PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.0
2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 All 51 releases
← All changes | includes/ai/class-gemini-client.php +44 -14 1.25.0 → 2.10.0 View file →
@@ -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 ],