| @@ -13,13 +13,17 @@ | ||
| 13 | 13 | declare(strict_types=1); |
| 14 | 14 | |
| 15 | 15 | namespace ThinkRank\AI; |
| 16 | 16 | |
| 17 | +use ThinkRank\AI\Traits\Request_Timeout; | |
| 18 | + | |
| 17 | 19 | // Prevent direct access |
| 18 | 20 | if (!defined('ABSPATH')) { |
| 19 | 21 | exit; |
| 20 | 22 | } |
| 21 | 23 | |
| 24 | +require_once __DIR__ . '/traits/trait-request-timeout.php'; | |
| 25 | + | |
| 22 | 26 | /** |
| 23 | 27 | * Gemini AI Client Class |
| 24 | 28 | * |
| 25 | 29 | * Provides interface to Google Gemini API for SEO optimization, |
| @@ -28,8 +32,11 @@ | ||
| 28 | 32 | * @since 1.0.0 |
| 29 | 33 | */ |
| 30 | 34 | class Gemini_Client { |
| 31 | 35 | |
| 36 | + use Request_Timeout; | |
| 37 | + | |
| 38 | + | |
| 32 | 39 | /** |
| 33 | 40 | * API key for Gemini |
| 34 | 41 | * |
| 35 | 42 | * @since 1.0.0 |
| @@ -67,9 +74,9 @@ | ||
| 67 | 74 | * @param string $api_key Gemini API key |
| 68 | 75 | * @param string $model Default model to use |
| 69 | 76 | * @param int $timeout Request timeout |
| 70 | 77 | */ |
| 71 | - public function __construct(string $api_key, string $model = 'gemini-2.5-flash', int $timeout = 30) { | |
| 78 | + public function __construct(string $api_key, string $model = \ThinkRank\Core\Settings::DEFAULT_GEMINI_MODEL, int $timeout = 30) { | |
| 72 | 79 | $this->api_key = $api_key; |
| 73 | 80 | $this->model = $model; |
| 74 | 81 | $this->timeout = $timeout; |
| 75 | 82 | } |
| @@ -116,9 +123,10 @@ | ||
| 116 | 123 | $content_type = $options['content_type'] ?? 'blog_post'; |
| 117 | 124 | $tone = $options['tone'] ?? 'professional'; |
| 118 | 125 | |
| 119 | 126 | $prompt_builder = $this->get_prompt_builder(); |
| 120 | - $prompt = $prompt_builder->build_seo_prompt($content, $target_keyword, $content_type, $tone, 'gemini'); | |
| 127 | + $language = is_string($options['language'] ?? null) ? $options['language'] : ''; | |
| 128 | + $prompt = $prompt_builder->build_seo_prompt($content, $target_keyword, $content_type, $tone, 'gemini', $language); | |
| 121 | 129 | |
| 122 | 130 | $response = $this->generate_completion($prompt, [ |
| 123 | 131 | 'max_tokens' => 500, |
| 124 | 132 | 'temperature' => 0.3, |
| @@ -331,26 +339,27 @@ | ||
| 331 | 339 | /** |
| 332 | 340 | * Build the generationConfig for a request, disabling "thinking" on |
| 333 | 341 | * Gemini 2.5 Flash models. |
| 334 | 342 | * |
| 335 | - * Gemini 2.5 Flash / Flash-Lite enable an internal "thinking" phase by | |
| 343 | + * Gemini Flash models (2.5 and 3.x) enable an internal "thinking" phase by | |
| 336 | 344 | * default, and those thoughts are billed against maxOutputTokens. On smaller |
| 337 | - * budgets — especially the free API tier used with the default | |
| 338 | - * gemini-2.5-flash model — thinking can consume most of the budget, leaving | |
| 339 | - * the visible answer truncated (finishReason=MAX_TOKENS) with incomplete | |
| 340 | - * JSON. Downstream parsers then fail with "parsing failed". Setting | |
| 341 | - * thinkingBudget to 0 disables thinking so the entire budget is spent on the | |
| 342 | - * JSON answer. | |
| 345 | + * budgets — especially the free API tier used with the default Flash model — | |
| 346 | + * thinking can consume most of the budget, leaving the visible answer | |
| 347 | + * truncated (finishReason=MAX_TOKENS) with incomplete JSON. Downstream | |
| 348 | + * parsers then fail with "parsing failed". Setting thinkingBudget to 0 | |
| 349 | + * disables thinking so the entire budget is spent on the JSON answer. | |
| 343 | 350 | * |
| 344 | - * Only 2.5 Flash models accept thinkingBudget=0; 2.5 Pro requires a minimum | |
| 351 | + * Only Flash models accept thinkingBudget=0; Pro models require a minimum | |
| 345 | 352 | * budget and pre-2.5 models reject thinkingConfig outright, so the override |
| 346 | - * is scoped to Flash models to avoid 400 errors. | |
| 353 | + * is scoped to 2.5/3.x Flash models to avoid 400 errors. This deliberately | |
| 354 | + * covers the current default (gemini-3.5-flash) as well as legacy | |
| 355 | + * gemini-2.5-flash installs. | |
| 347 | 356 | * |
| 348 | 357 | * @param array $config Caller-supplied generationConfig |
| 349 | 358 | * @return array generationConfig with thinking disabled where supported |
| 350 | 359 | */ |
| 351 | 360 | private function build_generation_config(array $config): array { |
| 352 | - if (strpos($this->model, 'gemini-2.5-flash') === 0) { | |
| 361 | + if (preg_match('/^gemini-(2\.5|3(?:\.\d+)?)-flash/', $this->model) === 1) { | |
| 353 | 362 | $config['thinkingConfig'] = ['thinkingBudget' => 0]; |
| 354 | 363 | } |
| 355 | 364 | |
| 356 | 365 | if (isset($config['maxOutputTokens'])) { |
| @@ -407,8 +416,14 @@ | ||
| 407 | 416 | * @return array Response data |
| 408 | 417 | * @throws \Exception If request fails |
| 409 | 418 | */ |
| 410 | 419 | private function make_request(string $endpoint, array $data): array { |
| 420 | + // The user's daily ceiling and kill switch are enforced here, at the | |
| 421 | + // one place every outbound Gemini call passes through, so no feature | |
| 422 | + // path can bypass them by forgetting to ask first (#448). | |
| 423 | + Spend_Guard::guard(); | |
| 424 | + Spend_Guard::record(); | |
| 425 | + | |
| 411 | 426 | // Send the API key in the x-goog-api-key header rather than the URL |
| 412 | 427 | // query string, which is logged by servers, proxies and referrers. |
| 413 | 428 | $url = "https://generativelanguage.googleapis.com/v1beta/models/{$this->model}:{$endpoint}"; |
| 414 | 429 | |
| @@ -431,8 +446,18 @@ | ||
| 431 | 446 | |
| 432 | 447 | if ($status_code !== 200) { |
| 433 | 448 | $error_data = json_decode($body, true); |
| 434 | 449 | $error_message = $error_data['error']['message'] ?? 'Unknown error'; |
| 450 | + // A 404 almost always means the configured model has been retired or | |
| 451 | + // is not available to this API key. Surface an actionable message | |
| 452 | + // pointing at the model setting instead of the provider's raw error. | |
| 453 | + if ($status_code === 404) { | |
| 454 | + throw new \Exception(sprintf( | |
| 455 | + 'The selected Gemini model "%s" is unavailable (404). Choose a different model in ThinkRank → Settings → AI. (Provider message: %s)', | |
| 456 | + esc_html($this->model), | |
| 457 | + esc_html($error_message) | |
| 458 | + )); | |
| 459 | + } | |
| 435 | 460 | throw new \Exception('Gemini API error (' . esc_html($status_code) . '): ' . esc_html($error_message)); |
| 436 | 461 | } |
| 437 | 462 | |
| 438 | 463 | $decoded = json_decode($body, true); |
| @@ -482,9 +507,14 @@ | ||
| 482 | 507 | |
| 483 | 508 | $is_transient = false; |
| 484 | 509 | $retry_after = 0; |
| 485 | 510 | if (is_wp_error($response)) { |
| 486 | - $is_transient = true; | |
| 511 | + // A client-side timeout means the work genuinely needs longer | |
| 512 | + // than the budget we allowed; re-running the identical prompt, | |
| 513 | + // model and budget just times out again and multiplies the | |
| 514 | + // wait (issue #288). Do not retry a timeout. Other WP_Error | |
| 515 | + // results — DNS, connection refused, TLS — stay retryable. | |
| 516 | + $is_transient = !$this->is_timeout_error($response); | |
| 487 | 517 | } else { |
| 488 | 518 | $status = wp_remote_retrieve_response_code($response); |
| 489 | 519 | if (429 === $status || $status >= 500) { |
| 490 | 520 | $is_transient = true; |
| @@ -846,9 +876,9 @@ | ||
| 846 | 876 | return [ |
| 847 | 877 | 'optimized_data' => [ |
| 848 | 878 | 'site_name' => sanitize_text_field($data['optimized_data']['site_name'] ?? ''), |
| 849 | 879 | 'project_overview' => sanitize_textarea_field($data['optimized_data']['project_overview'] ?? ''), |
| 850 | - 'key_features' => sanitize_textarea_field($data['optimized_data']['key_features'] ?? ''), | |
| 880 | + 'key_features' => \ThinkRank\SEO\LLMs_Txt_Manager::normalize_ai_key_features($data['optimized_data']['key_features'] ?? ''), | |
| 851 | 881 | 'architecture' => sanitize_textarea_field($data['optimized_data']['architecture'] ?? ''), |
| 852 | 882 | 'development_guidelines' => sanitize_textarea_field($data['optimized_data']['development_guidelines'] ?? ''), |
| 853 | 883 | 'ai_context' => sanitize_textarea_field($data['optimized_data']['ai_context'] ?? ''), |
| 854 | 884 | ], |