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 |