PluginProbe ʕ •ᴥ•ʔ
Everest Forms – Contact Form, Payment Form, Quiz, Survey & Custom Form Builder with AI / 3.6.0
Everest Forms – Contact Form, Payment Form, Quiz, Survey & Custom Form Builder with AI v3.6.0
3.6.0 3.5.3 3.5.2 3.5.1 3.5.0 3.4.8 3.4.7 3.4.6 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.5.1 1.1.6 1.1.7 1.1.8 1.1.9 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4 1.3.0 1.3.1 1.3.2 1.3.3 1.3.4 1.4.0 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.4.6 1.4.7 1.4.8 1.4.9 1.5.0 1.5.1 1.5.10 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.5.9 1.6.0 1.6.1 1.6.2 1.6.3 1.6.4 1.6.5 1.6.6 1.6.6.1 1.6.7 1.7.0 1.7.0.1 1.7.0.2 1.7.0.3 1.7.1 1.7.2 1.7.2.1 1.7.2.2 1.7.3 1.7.4 1.7.5 1.7.5.1 1.7.5.2 1.7.6 1.7.7 1.7.7.1 1.7.7.2 1.7.8 1.7.9 1.8.0 1.8.0.1 1.8.1 1.8.2 1.8.2.1 1.8.2.2 1.8.2.3 1.8.3 1.8.4 1.8.5 1.8.6 1.8.7 1.8.8 1.8.9 1.9.0 1.9.0.1 1.9.1 1.9.2 1.9.3 1.9.4 1.9.4.1 1.9.5 1.9.6 1.9.7 1.9.8 1.9.9 2.0.0 2.0.0.1 2.0.1 2.0.2 2.0.3 2.0.3.1 2.0.4 2.0.4.1 2.0.5 2.0.6 2.0.7 2.0.8 2.0.8.1 2.0.9 3.0.0 3.0.0.1 3.0.1 3.0.2 3.0.3 3.0.3.1 3.0.4 3.0.4.1 3.0.4.2 3.0.5 3.0.5.1 3.0.5.2 3.0.6 3.0.6.1 3.0.7.1 3.0.8 3.0.8.1 3.0.9 3.0.9.1 3.0.9.2 3.0.9.3 3.0.9.4 3.0.9.5 3.1.0 3.1.1 3.1.2 3.2.0 3.2.1 3.2.2 3.2.3 3.2.4 3.2.5 3.2.6 3.3.0 3.4.0 3.4.1 3.4.2 3.4.2.1 3.4.3 3.4.4 3.4.5 trunk 1.0 1.0.1 1.0.2 1.0.3
everest-forms / includes / Integrations / AI / class-evf-ai-api.php
everest-forms / includes / Integrations / AI Last commit date
class-evf-ai-ajax.php 3 days ago class-evf-ai-api.php 3 days ago class-evf-ai-form-builder.php 3 days ago class-evf-ai-loader.php 2 months ago class-evf-ai-registration.php 3 days ago
class-evf-ai-api.php
569 lines
1 <?php
2 /**
3 * EVF AI API — HTTP client for the ThemeGrill AI Cloud gateway.
4 *
5 * Gateway URL is read from:
6 * 1. TG_AI_GATEWAY_URL constant (wp-config.php) — local dev override
7 * 2. 'evf_ai_gateway_url' option — settable from admin (future)
8 * 3. Hardcoded production URL as final fallback
9 *
10 * License pattern (follows WPForms): license key is sent inline with every
11 * generate request. The gateway verifies with wpeverest.com and caches for
12 * 1 week. No separate "activate" step needed.
13 */
14
15 defined( 'ABSPATH' ) || exit;
16
17 class EVF_AI_API {
18
19 const PRODUCTION_URL = 'https://ai.themegrill.com';
20 const PRODUCT = 'everest-forms';
21 const TIMEOUT = 90;
22
23 /**
24 * Daily-usage snapshot { remaining, limit, used } captured from the most recent gateway
25 * response, or null when the gateway didn't include one. The gateway now returns a `usage`
26 * object alongside every successful generate/style/update (and inside a daily_limit_reached
27 * 429's detail) — see themegrill-ai-cloud gateway/main.py::_usage_info(). Callers read it via
28 * get_last_usage() to surface "X requests left today" without a separate /ai/v1/usage request.
29 *
30 * @var array|null
31 */
32 private static $last_usage = null;
33
34 /**
35 * Generate a form from a plain-text prompt.
36 * Sends the EVF Pro license key inline — gateway verifies + caches (1 week).
37 *
38 * @param string $prompt
39 * @return array|WP_Error Decoded AI response on success.
40 */
41 public static function generate_form( string $prompt ) {
42 $token = EVF_AI_Registration::get_site_token();
43 if ( ! $token ) {
44 return new WP_Error( 'not_registered', __( 'AI features are not yet active on this site.', 'everest-forms' ) );
45 }
46
47 $logger = evf_get_logger();
48 $logger->info( sprintf( 'AI Form Generation started | prompt: %s', $prompt ), array( 'source' => 'evf-ai' ) );
49
50 // Send license key if EVF Pro is active — gateway verifies inline (WPForms pattern).
51 // If no license key, gateway treats site as free tier.
52 $license_key = self::get_license_key();
53
54 $response = self::request(
55 'POST',
56 '/ai/v1/generate',
57 array(
58 'prompt' => $prompt,
59 'license_key' => $license_key,
60 'available_fields' => implode( ',', evf()->form_fields->get_form_field_types() ),
61 'client_supports_style' => self::client_supports_style(),
62 ),
63 $token
64 );
65
66 if ( is_wp_error( $response ) ) {
67 $logger->error( sprintf( 'AI Form Generation failed | %s: %s', $response->get_error_code(), $response->get_error_message() ), array( 'source' => 'evf-ai' ) );
68 return $response;
69 }
70
71 if ( empty( $response['success'] ) || empty( $response['form'] ) ) {
72 $logger->error( 'AI Form Generation bad_response | missing success or form key', array( 'source' => 'evf-ai' ) );
73 return new WP_Error( 'bad_response', __( 'Unexpected response from AI service.', 'everest-forms' ) );
74 }
75
76 $logger->info(
77 sprintf( 'AI Form Generation succeeded | form_type: %s, fields: %d', $response['form']['form_type'] ?? 'standard', count( $response['form']['fields'] ?? array() ) ),
78 array( 'source' => 'evf-ai' )
79 );
80
81 return $response['form'];
82 }
83
84 /**
85 * Whether this site can use the gateway's create-time style capability.
86 *
87 * @return bool
88 */
89 private static function client_supports_style(): bool {
90 // Temporarily disabled for this release — the AI Form Builder chat (generate/update)
91 // should only build/edit fields for now, not also silently restyle the form. Re-enable
92 // by restoring the Engine::enabled() check below in a future release. This does NOT
93 // affect the Style Customizer's own separate "Style with AI" feature
94 // ({@see EVF_AI_API::style_form()}), which never reads this flag.
95 return false;
96 }
97
98 /**
99 * Generate or refine a Style Customizer v2 look from a plain-text prompt.
100 *
101 * Shares the site token / license / daily quota with form generation — only the
102 * gateway's `task=style` routing differs (a separate system prompt + validator,
103 * see themegrill-ai-cloud gateway/products/everest_forms_style.py). Returns the
104 * raw style intent `{ tokens, palette, summary }`; the CALLER is responsible for
105 * running it through `Sanitizer::sanitize_record()` before it ever touches a
106 * stored record — this class only talks to the gateway.
107 *
108 * @param string $prompt The style request ("sleek dark, rounded inputs").
109 * @param array $current_record Current v2 record (tokens/palette) — sent as context
110 * for a refine ("make the buttons bigger"); empty for
111 * a fresh request.
112 * @param string $refine_prompt Follow-up instruction; empty = fresh/regenerate.
113 * @param array $history Conversation turns so far, oldest first: [{role, text}].
114 * Lets a refine call see the actual dialogue instead of just
115 * the original prompt + a token dump.
116 * @param array $last_changed_keys Schema key(s) the AI's own previous turn changed — an
117 * explicit anchor so "increase it to 200px" continues that
118 * same property instead of the gateway re-guessing from state.
119 * @param array $context Extra site context: { field_labels, available_fonts }.
120 * @return array|WP_Error { tokens, palette, summary } on success.
121 */
122 public static function style_form( string $prompt, array $current_record = array(), string $refine_prompt = '', array $history = array(), array $last_changed_keys = array(), array $context = array() ) {
123 $token = EVF_AI_Registration::get_site_token();
124 if ( ! $token ) {
125 return new WP_Error( 'not_registered', __( 'AI features are not yet active on this site.', 'everest-forms' ) );
126 }
127
128 $logger = evf_get_logger();
129 $is_refine = '' !== $refine_prompt || ! empty( $current_record );
130 $logger->info(
131 sprintf( 'AI Style %s started | prompt: %s', $is_refine ? 'refine' : 'generate', $is_refine ? $refine_prompt : $prompt ),
132 array( 'source' => 'evf-ai' )
133 );
134
135 // Refine/regenerate an existing draft (task=style, same shape as update_form()) vs a
136 // fresh request (task=style, same shape as generate_form()) — resolved once so the
137 // retry-after-re-register below just replays the identical call.
138 $path = $is_refine ? '/ai/v1/update' : '/ai/v1/generate';
139 $body = $is_refine
140 ? array(
141 'prompt' => $prompt,
142 'refine_prompt' => $refine_prompt,
143 'license_key' => self::get_license_key(),
144 'current_form' => $current_record, // field name is generic on the gateway side.
145 'task' => 'style',
146 // Forward-compatible additions (see themegrill-ai-cloud gateway/products/
147 // everest_forms_style.py for the matching support) — history + last_changed_keys
148 // anchor a refine to what the previous turn actually did instead of the gateway
149 // re-guessing from a bare token dump; field_labels/available_fonts tell it what's
150 // genuinely settable (a specific field name, a real Google Font) versus not
151 // (e.g. a background image, which this site's media library it has no access to).
152 'history' => $history,
153 'last_changed_keys' => $last_changed_keys,
154 'field_labels' => $context['field_labels'] ?? array(),
155 'available_fonts' => $context['available_fonts'] ?? array(),
156 )
157 : array(
158 'prompt' => $prompt,
159 'license_key' => self::get_license_key(),
160 'task' => 'style',
161 'field_labels' => $context['field_labels'] ?? array(),
162 'available_fonts' => $context['available_fonts'] ?? array(),
163 );
164
165 $response = self::request( 'POST', $path, $body, $token );
166
167 // Auto-heal stale token — same pattern as generate_form/update_form.
168 if ( is_wp_error( $response ) && 'api_error' === $response->get_error_code()
169 && false !== strpos( $response->get_error_message(), 'Invalid token' ) ) {
170
171 $logger->warning( 'AI Style stale token — re-registering and retrying', array( 'source' => 'evf-ai' ) );
172 EVF_AI_Registration::clear_credentials();
173 EVF_AI_Registration::register();
174 $token = EVF_AI_Registration::get_site_token();
175 $response = self::request( 'POST', $path, $body, $token );
176 }
177
178 if ( is_wp_error( $response ) ) {
179 $logger->error( sprintf( 'AI Style failed | %s: %s', $response->get_error_code(), $response->get_error_message() ), array( 'source' => 'evf-ai' ) );
180 return $response;
181 }
182
183 if ( empty( $response['success'] ) || empty( $response['style'] ) ) {
184 $logger->error( 'AI Style bad_response | missing success or style key', array( 'source' => 'evf-ai' ) );
185 return new WP_Error( 'bad_response', __( 'Unexpected response from AI service.', 'everest-forms' ) );
186 }
187
188 $logger->info( 'AI Style succeeded', array( 'source' => 'evf-ai' ) );
189
190 return $response['style'];
191 }
192
193 /**
194 * Regenerate / refine an existing AI form from a follow-up prompt.
195 *
196 * NOTE: the gateway does not implement /ai/v1/update yet — this wires the call
197 * so it works the moment the Python endpoint ships. Until then it returns the
198 * gateway's error (surfaced to the user).
199 *
200 * @param string $prompt Refinement / follow-up prompt (or the original to regenerate).
201 * @param int $form_id The draft form being refined.
202 * @return array|WP_Error Decoded AI form schema on success.
203 */
204 public static function update_form( string $prompt, int $form_id = 0, string $refine_prompt = '' ) {
205 $token = EVF_AI_Registration::get_site_token();
206 if ( ! $token ) {
207 return new WP_Error( 'not_registered', __( 'AI features are not yet active on this site.', 'everest-forms' ) );
208 }
209
210 $logger = evf_get_logger();
211 $logger->info(
212 sprintf( 'AI Form Update started | form_id: %d, prompt: %s, refine_prompt: %s', $form_id, $prompt, $refine_prompt ),
213 array( 'source' => 'evf-ai' )
214 );
215
216 $body = array(
217 'prompt' => $prompt,
218 'refine_prompt' => $refine_prompt,
219 'form_id' => $form_id,
220 'license_key' => self::get_license_key(),
221 'current_form' => self::get_current_form_context( $form_id ),
222 'client_supports_style' => self::client_supports_style(),
223 );
224
225 $response = self::request( 'POST', '/ai/v1/update', $body, $token );
226
227 // Auto-heal stale token — same pattern as generate_form
228 if ( is_wp_error( $response ) && 'api_error' === $response->get_error_code()
229 && false !== strpos( $response->get_error_message(), 'Invalid token' ) ) {
230
231 $logger->warning( 'AI Form Update stale token — re-registering and retrying', array( 'source' => 'evf-ai' ) );
232 EVF_AI_Registration::clear_credentials();
233 EVF_AI_Registration::register();
234 $token = EVF_AI_Registration::get_site_token();
235 $response = self::request( 'POST', '/ai/v1/update', $body, $token );
236 }
237
238 if ( is_wp_error( $response ) ) {
239 $logger->error( sprintf( 'AI Form Update failed | %s: %s', $response->get_error_code(), $response->get_error_message() ), array( 'source' => 'evf-ai' ) );
240 return $response;
241 }
242
243 if ( empty( $response['success'] ) || empty( $response['form'] ) ) {
244 $logger->error( 'AI Form Update bad_response | missing success or form key', array( 'source' => 'evf-ai' ) );
245 return new WP_Error( 'bad_response', __( 'Unexpected response from AI service.', 'everest-forms' ) );
246 }
247
248 $logger->info(
249 sprintf( 'AI Form Update succeeded | form_id: %d, form_type: %s, fields: %d', $form_id, $response['form']['form_type'] ?? 'standard', count( $response['form']['fields'] ?? array() ) ),
250 array( 'source' => 'evf-ai' )
251 );
252
253 return $response['form'];
254 }
255
256 /**
257 * Extract a lightweight form context for the AI.
258 * Includes type, label, and any non-default field settings so that subsequent
259 * AI requests preserve changes made by earlier ones (e.g. label_hide, required).
260 *
261 * @param int $form_id
262 * @return array { form_title, fields: [ { type, label, ...settings } ] }
263 */
264 private static function get_current_form_context( int $form_id ): array {
265 if ( ! $form_id ) {
266 return [];
267 }
268
269 $post = get_post( $form_id );
270 if ( ! $post || 'everest_form' !== $post->post_type ) {
271 return [];
272 }
273
274 $data = evf_decode( $post->post_content );
275 $summary = [];
276
277 foreach ( ( $data['form_fields'] ?? [] ) as $field ) {
278 $type = $field['type'] ?? '';
279 if ( in_array( $type, [ 'hidden', 'html', 'divider' ], true ) ) {
280 continue;
281 }
282
283 $entry = [
284 'type' => $type,
285 'label' => $field['label'] ?? '',
286 ];
287
288 // Include non-default field settings so the AI can preserve them on
289 // subsequent requests without the user having to repeat their instructions.
290 if ( ! empty( $field['label_hide'] ) && '1' === $field['label_hide'] ) {
291 $entry['label_hide'] = true;
292 }
293 if ( ! empty( $field['required'] ) && '1' === $field['required'] ) {
294 $entry['required'] = true;
295 }
296 if ( ! empty( $field['description'] ) ) {
297 $entry['description'] = $field['description'];
298 }
299 if ( ! empty( $field['placeholder'] ) ) {
300 $entry['placeholder'] = $field['placeholder'];
301 }
302 if ( ! empty( $field['sublabel_hide'] ) && '1' === $field['sublabel_hide'] ) {
303 $entry['sublabel_hide'] = true;
304 }
305 if ( ! empty( $field['css'] ) ) {
306 $entry['css'] = $field['css'];
307 }
308
309 $summary[] = $entry;
310 }
311
312 // Detect form type so the gateway can preserve it during refine/regenerate
313 $form_type = 'standard';
314 if ( ! empty( $data['settings']['enable_multi_part'] ) && '1' === $data['settings']['enable_multi_part'] ) {
315 $form_type = 'multipart';
316 } elseif ( ! empty( $data['settings']['enable_conversational_forms'] ) && '1' === $data['settings']['enable_conversational_forms'] ) {
317 $form_type = 'conversational';
318 }
319
320 // Include multipart step titles so AI can preserve/extend them
321 $multipart_steps = [];
322 if ( 'multipart' === $form_type ) {
323 foreach ( ( $data['multi_part'] ?? [] ) as $part ) {
324 $multipart_steps[] = [
325 'title' => $part['name'] ?? '',
326 'field_count' => count( $part['fields'] ?? [] ),
327 ];
328 }
329 }
330
331 $email_conns = $data['settings']['email'] ?? [];
332 $conn1 = $email_conns['connection_1'] ?? [];
333
334 $context = [
335 'form_title' => $post->post_title,
336 'form_type' => $form_type,
337 'multipart_steps' => $multipart_steps,
338 'fields' => $summary,
339 // Settings context so the gateway preserves them on refine
340 'redirect_to' => $data['settings']['redirect_to'] ?? 'same',
341 'redirect_custom_page_id' => absint( $data['settings']['custom_page'] ?? 0 ),
342 'redirect_external_url' => $data['settings']['external_url'] ?? '',
343 'notification' => [
344 'from_name' => $conn1['evf_from_name'] ?? '',
345 'reply_to' => $conn1['evf_reply_to'] ?? 'auto',
346 'message' => $conn1['evf_email_message'] ?? '{all_fields}',
347 'subject' => $conn1['evf_email_subject'] ?? '',
348 ],
349 'user_confirmation' => ! empty( $email_conns['connection_2'] ) ? [
350 'from_name' => $email_conns['connection_2']['evf_from_name'] ?? '',
351 'reply_to' => $email_conns['connection_2']['evf_reply_to'] ?? 'auto',
352 ] : [],
353 ];
354
355 // Current Style Customizer v2 record, if any — lets a plain-form refine that implies
356 // a look/colour change (e.g. "make it feel more playful") adjust the existing style
357 // instead of the gateway guessing blind. Same gate maybe_apply_ai_style() uses to
358 // write this option, kept in sync deliberately.
359 if ( class_exists( '\EverestForms\Addons\StyleCustomizer\V2\Engine' )
360 && \EverestForms\Addons\StyleCustomizer\V2\Engine::enabled() ) {
361 $styles = get_option( 'everest_forms_styles', array() );
362 if ( ! empty( $styles[ $form_id ] ) ) {
363 $context['style'] = $styles[ $form_id ];
364 }
365 }
366
367 return $context;
368 }
369
370 /**
371 * Register this site with the ThemeGrill AI Cloud gateway (free tier).
372 * Called once on plugin activation — silent, no admin action required.
373 *
374 * @param string $verify_token One-time ownership token; gateway calls back to confirm.
375 * @return array|WP_Error { site_token, tier, product }
376 */
377 public static function register_site( string $verify_token = '' ) {
378 $payload = array(
379 'domain' => self::get_domain(),
380 'admin_email' => get_bloginfo( 'admin_email' ),
381 'wp_version' => get_bloginfo( 'version' ),
382 'product' => self::PRODUCT,
383 );
384
385 if ( $verify_token ) {
386 $payload['verify_token'] = $verify_token;
387 }
388
389 return self::request( 'POST', '/ai/v1/register', $payload );
390 }
391
392 /**
393 * Get current usage stats for display in the builder UI.
394 *
395 * @return array|WP_Error
396 */
397 public static function get_usage() {
398 $token = EVF_AI_Registration::get_site_token();
399 if ( ! $token ) {
400 return new WP_Error( 'not_registered', '' );
401 }
402 // Without this, the gateway had no way to tell Pro was removed/deactivated since it
403 // last saw a generate/update call — it just kept reporting whatever tier it last wrote
404 // to its own database, so a usage check could show a stale 300/day Pro limit indefinitely.
405 return self::request( 'GET', '/ai/v1/usage', array(), $token, self::get_license_key() );
406 }
407
408 /**
409 * Daily-usage snapshot { remaining, limit, used } from the last gateway call this request,
410 * or null when none was returned. Set inside request() from the gateway's `usage` object.
411 *
412 * @return array|null
413 */
414 public static function get_last_usage() {
415 return self::$last_usage;
416 }
417
418 /**
419 * Map the gateway's usage object ({ daily_count, daily_limit, daily_remaining }) to the
420 * compact { remaining, limit, used } shape the front-end reads. Public so the get_usage
421 * AJAX handler can normalize the /ai/v1/usage endpoint response the same way.
422 *
423 * @param array $usage Raw gateway usage object.
424 * @return array
425 */
426 public static function normalize_usage( array $usage ): array {
427 return array(
428 'remaining' => isset( $usage['daily_remaining'] ) ? max( 0, (int) $usage['daily_remaining'] ) : null,
429 'limit' => isset( $usage['daily_limit'] ) ? (int) $usage['daily_limit'] : null,
430 'used' => isset( $usage['daily_count'] ) ? (int) $usage['daily_count'] : null,
431 );
432 }
433
434 // ── Core HTTP request ─────────────────────────────────────────────────────
435
436 private static function request( string $method, string $path, array $body = array(), string $token = '', ?string $license_key = null ) {
437 $url = rtrim( self::gateway_url(), '/' ) . $path;
438 $headers = array( 'Content-Type' => 'application/json' );
439
440 if ( $token ) {
441 $headers['X-TG-Token'] = $token;
442 }
443 // Header, not a query param or body field: a GET query string is far more likely than a
444 // POST body to end up in access logs / proxy logs / request-tracing tools, and this is a
445 // real credential (an EVF Pro license key) — same treatment as $token above.
446 // Must distinguish "not passed" (null, callers that don't take a license key at all) from
447 // "passed but empty" (get_usage() always passes get_license_key(), which is '' once Pro is
448 // removed) — the gateway needs the empty header to know it should re-check and downgrade.
449 if ( null !== $license_key ) {
450 $headers['X-License-Key'] = $license_key;
451 }
452
453 $args = array(
454 'method' => strtoupper( $method ),
455 'headers' => $headers,
456 'timeout' => self::TIMEOUT,
457 );
458
459 if ( ! empty( $body ) && 'GET' !== strtoupper( $method ) ) {
460 $args['body'] = wp_json_encode( $body );
461 }
462
463 // Log outgoing request (license_key redacted).
464 $log_body = $body;
465 if ( isset( $log_body['license_key'] ) ) {
466 $log_body['license_key'] = $log_body['license_key'] ? '[redacted]' : '';
467 }
468 $logger = evf_get_logger();
469 $logger->debug(
470 sprintf( "AI Request: %s %s\n%s", strtoupper( $method ), $path, wp_json_encode( $log_body, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ) ),
471 array( 'source' => 'evf-ai' )
472 );
473
474 $wp_response = wp_remote_request( $url, $args );
475
476 if ( is_wp_error( $wp_response ) ) {
477 $logger->error(
478 sprintf( 'AI Request failed: %s', $wp_response->get_error_message() ),
479 array( 'source' => 'evf-ai' )
480 );
481 return new WP_Error(
482 'request_failed',
483 sprintf( __( 'Could not reach AI service: %s', 'everest-forms' ), $wp_response->get_error_message() )
484 );
485 }
486
487 $status = wp_remote_retrieve_response_code( $wp_response );
488 $body = json_decode( wp_remote_retrieve_body( $wp_response ), true );
489
490 // Log the raw response.
491 $logger->debug(
492 sprintf( "AI Response: HTTP %d\n%s", $status, wp_json_encode( $body, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ) ),
493 array( 'source' => 'evf-ai' )
494 );
495
496 // Capture the daily-usage snapshot the gateway now returns — top-level `usage` on a
497 // successful generate/style/update, or nested under `detail.usage` on a 429. Callers
498 // echo it to the UI via get_last_usage() so the panels can show "X requests left today".
499 if ( is_array( $body ) ) {
500 if ( isset( $body['usage'] ) && is_array( $body['usage'] ) ) {
501 self::$last_usage = self::normalize_usage( $body['usage'] );
502 } elseif ( isset( $body['detail']['usage'] ) && is_array( $body['detail']['usage'] ) ) {
503 self::$last_usage = self::normalize_usage( $body['detail']['usage'] );
504 }
505 }
506
507 if ( 429 === $status ) {
508 $detail = is_array( $body ) && isset( $body['detail'] ) && is_array( $body['detail'] ) ? $body['detail'] : array();
509 $msg = isset( $detail['message'] ) ? $detail['message'] : __( 'Request limit reached. Please try again later.', 'everest-forms' );
510 $code = isset( $detail['error'] ) ? $detail['error'] : 'rate_limited';
511 // "tier" (only present on a "daily_limit_reached" 429) lets the caller tell a Free
512 // user (show an upgrade CTA) apart from a Pro user who hit their own, much higher
513 // cap (don't show one — they're already Pro).
514 return new WP_Error( $code, $msg, array( 'tier' => isset( $detail['tier'] ) ? $detail['tier'] : '' ) );
515 }
516
517 if ( $status < 200 || $status >= 300 ) {
518 $detail = is_array( $body ) ? ( $body['detail'] ?? $body['message'] ?? '' ) : '';
519 // FastAPI 400s send detail as an object: {"error": "...", "message": "..."}.
520 $msg = is_array( $detail ) ? ( $detail['message'] ?? $detail['error'] ?? '' ) : $detail;
521 $code = ( is_array( $detail ) && ! empty( $detail['error'] ) ) ? $detail['error'] : 'api_error';
522 return new WP_Error(
523 $code,
524 $msg ?: sprintf( __( 'AI service returned an error (%d).', 'everest-forms' ), $status )
525 );
526 }
527
528 return $body;
529 }
530
531 // ── Helpers ───────────────────────────────────────────────────────────────
532
533 public static function gateway_url(): string {
534 if ( defined( 'TG_AI_GATEWAY_URL' ) ) {
535 return TG_AI_GATEWAY_URL;
536 }
537 return get_option( 'evf_ai_gateway_url', self::PRODUCTION_URL );
538 }
539
540 /**
541 * Get EVF Pro license key if the license is active — empty string otherwise.
542 * Gateway treats an empty key as free tier.
543 *
544 * Deliberately does NOT use evf_get_license_plan(): during AJAX/REST/Cron (which is
545 * every caller here — get_usage()/generate/update all run inside AJAX handlers) that
546 * function skips its own is_plugin_active() check and returns a cached
547 * 'evf_saved_license_plan' option instead, which stays truthy forever once set — even
548 * after Pro is deactivated or deleted. Checking is_plugin_active() directly here (same
549 * approach as EVF_AI_Ajax::has_active_license()) is what actually detects that.
550 */
551 private static function get_license_key(): string {
552 $license_key = get_option( 'everest-forms-pro_license_key', '' );
553 if ( ! $license_key ) {
554 return '';
555 }
556 if ( ! function_exists( 'is_plugin_active' ) ) {
557 include_once ABSPATH . 'wp-admin/includes/plugin.php';
558 }
559 if ( ! is_plugin_active( 'everest-forms-pro/everest-forms-pro.php' ) ) {
560 return '';
561 }
562 return (string) $license_key;
563 }
564
565 private static function get_domain(): string {
566 return preg_replace( '(^https?://)', '', rtrim( home_url(), '/' ) );
567 }
568 }
569