PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.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 1.0.2 1.1.0 All 49 releases
← All changes | includes/ai/class-gemini-client.php +188 -17 1.0.22.7.0 View file →
@@ -13,8 +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 +
19 +// Prevent direct access
20 +if (!defined('ABSPATH')) {
21 + exit;
22 +}
23 +
24 +require_once __DIR__ . '/traits/trait-request-timeout.php';
25 +
17 26 /**
18 27 * Gemini AI Client Class
19 28 *
20 29 * Provides interface to Google Gemini API for SEO optimization,
@@ -23,8 +32,11 @@
23 32 * @since 1.0.0
24 33 */
25 34 class Gemini_Client {
26 35
36 + use Request_Timeout;
37 +
38 +
27 39 /**
28 40 * API key for Gemini
29 41 *
30 42 * @since 1.0.0
@@ -62,9 +74,9 @@
62 74 * @param string $api_key Gemini API key
63 75 * @param string $model Default model to use
64 76 * @param int $timeout Request timeout
65 77 */
66 - 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) {
67 79 $this->api_key = $api_key;
68 80 $this->model = $model;
69 81 $this->timeout = $timeout;
70 82 }
@@ -111,9 +123,10 @@
111 123 $content_type = $options['content_type'] ?? 'blog_post';
112 124 $tone = $options['tone'] ?? 'professional';
113 125
114 126 $prompt_builder = $this->get_prompt_builder();
115 - $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);
116 129
117 130 $response = $this->generate_completion($prompt, [
118 131 'max_tokens' => 500,
119 132 'temperature' => 0.3,
@@ -172,12 +185,12 @@
172 185 'parts' => [
173 186 ['text' => 'You are an expert SEO consultant specializing in site identity optimization. Provide actionable, specific recommendations in JSON format.']
174 187 ]
175 188 ],
176 - 'generationConfig' => [
189 + 'generationConfig' => $this->build_generation_config([
177 190 'maxOutputTokens' => 2000, // Increased based on actual usage (1499 tokens used)
178 191 'temperature' => 0.4,
179 - ]
192 + ])
180 193 ]);
181 194
182 195 return $this->parse_site_identity_response($response);
183 196 }
@@ -213,12 +226,12 @@
213 226 'parts' => [
214 227 ['text' => 'You are an expert SEO consultant specializing in homepage meta optimization. Provide actionable, specific recommendations in JSON format.']
215 228 ]
216 229 ],
217 - 'generationConfig' => [
230 + 'generationConfig' => $this->build_generation_config([
218 231 'maxOutputTokens' => 1200, // Higher limit for Gemini homepage meta
219 232 'temperature' => 0.4,
220 - ]
233 + ])
221 234 ]);
222 235
223 236 return $this->parse_homepage_meta_response($response);
224 237 }
@@ -254,12 +267,12 @@
254 267 'parts' => [
255 268 ['text' => 'You are an expert SEO consultant specializing in homepage hero optimization. Provide actionable, specific recommendations in JSON format.']
256 269 ]
257 270 ],
258 - 'generationConfig' => [
271 + 'generationConfig' => $this->build_generation_config([
259 272 'maxOutputTokens' => 1200, // Higher limit for Gemini homepage hero
260 273 'temperature' => 0.4,
261 - ]
274 + ])
262 275 ]);
263 276
264 277 return $this->parse_homepage_hero_response($response);
265 278 }
@@ -286,12 +299,12 @@
286 299 ['text' => $prompt]
287 300 ]
288 301 ]
289 302 ],
290 - 'generationConfig' => [
303 + 'generationConfig' => $this->build_generation_config([
291 304 'maxOutputTokens' => 2000, // Increased for Gemini 2.5 Flash compatibility
292 305 'temperature' => 0.4,
293 - ]
306 + ])
294 307 ]);
295 308
296 309 return $this->parse_llms_txt_response($response);
297 310 }
@@ -315,18 +328,90 @@
315 328 ['text' => $prompt]
316 329 ]
317 330 ]
318 331 ],
319 - 'generationConfig' => [
332 + 'generationConfig' => $this->build_generation_config([
320 333 'maxOutputTokens' => $max_tokens,
321 334 'temperature' => $temperature,
322 - ]
335 + ])
323 336 ]);
324 337 }
325 338
326 339 /**
340 + * Build the generationConfig for a request, disabling "thinking" on
341 + * Gemini 2.5 Flash models.
342 + *
343 + * Gemini Flash models (2.5 and 3.x) enable an internal "thinking" phase by
344 + * default, and those thoughts are billed against maxOutputTokens. On smaller
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.
350 + *
351 + * Only Flash models accept thinkingBudget=0; Pro models require a minimum
352 + * budget and pre-2.5 models reject thinkingConfig outright, so the override
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.
356 + *
357 + * @param array $config Caller-supplied generationConfig
358 + * @return array generationConfig with thinking disabled where supported
359 + */
360 + private function build_generation_config(array $config): array {
361 + if (preg_match('/^gemini-(2\.5|3(?:\.\d+)?)-flash/', $this->model) === 1) {
362 + $config['thinkingConfig'] = ['thinkingBudget' => 0];
363 + }
364 +
365 + if (isset($config['maxOutputTokens'])) {
366 + $config['maxOutputTokens'] = min((int) $config['maxOutputTokens'], $this->get_max_output_tokens());
367 + }
368 +
369 + return $config;
370 + }
371 +
372 + /**
373 + * Maximum output tokens the current model accepts
374 + *
375 + * @since 1.21.0
376 + *
377 + * @return int Output token ceiling
378 + */
379 + private function get_max_output_tokens(): int {
380 + // Gemini 2.5 and newer allow 64k output; 1.5/2.0 cap at 8k.
381 + return preg_match('/^gemini-(2\.5|3)/', $this->model) ? 65536 : 8192;
382 + }
383 +
384 + /**
385 + * Get recommended token limit for specific use cases
386 + *
387 + * Mirrors the OpenAI/OpenRouter clients so callers can size a request
388 + * without knowing which provider is active. Without this method callers
389 + * fall back to a 4000-token budget, which a full content brief overruns
390 + * (the reply is then cut off mid-JSON and fails to parse).
391 + *
392 + * @since 1.21.0
393 + *
394 + * @param string $use_case Use case (e.g., 'content_brief', 'seo_metadata', 'analysis')
395 + * @return int Recommended token limit
396 + */
397 + public function get_recommended_tokens(string $use_case): int {
398 + $recommendations = [
399 + // Higher budget: the brief now also returns a full article body,
400 + // so the reply is much longer than the structured fields alone.
401 + 'content_brief' => 16384,
402 + 'seo_metadata' => 800,
403 + 'analysis' => 1500,
404 + 'llms_txt' => 2000,
405 + 'optimization' => 2000,
406 + ];
407 +
408 + return min($recommendations[$use_case] ?? 1000, $this->get_max_output_tokens());
409 + }
410 +
411 + /**
327 412 * Make request to Gemini API
328 - *
413 + *
329 414 * @param string $endpoint API endpoint
330 415 * @param array $data Request data
331 416 * @return array Response data
332 417 * @throws \Exception If request fails
@@ -331,17 +416,19 @@
331 416 * @return array Response data
332 417 * @throws \Exception If request fails
333 418 */
334 419 private function make_request(string $endpoint, array $data): array {
335 - $url = "https://generativelanguage.googleapis.com/v1beta/models/{$this->model}:{$endpoint}?key={$this->api_key}";
420 + // Send the API key in the x-goog-api-key header rather than the URL
421 + // query string, which is logged by servers, proxies and referrers.
422 + $url = "https://generativelanguage.googleapis.com/v1beta/models/{$this->model}:{$endpoint}";
336 423
337 -
338 -
339 - $response = wp_remote_post($url, [
424 + $response = $this->request_with_retry($url, [
340 425 'timeout' => $this->timeout,
341 426 'headers' => [
342 427 'Content-Type' => 'application/json',
428 + 'x-goog-api-key' => $this->api_key,
343 429 ],
430 + 'method' => 'POST',
344 431 'body' => wp_json_encode($data),
345 432 ]);
346 433
347 434 if (is_wp_error($response)) {
@@ -353,8 +440,18 @@
353 440
354 441 if ($status_code !== 200) {
355 442 $error_data = json_decode($body, true);
356 443 $error_message = $error_data['error']['message'] ?? 'Unknown error';
444 + // A 404 almost always means the configured model has been retired or
445 + // is not available to this API key. Surface an actionable message
446 + // pointing at the model setting instead of the provider's raw error.
447 + if ($status_code === 404) {
448 + throw new \Exception(sprintf(
449 + 'The selected Gemini model "%s" is unavailable (404). Choose a different model in ThinkRank → Settings → AI. (Provider message: %s)',
450 + esc_html($this->model),
451 + esc_html($error_message)
452 + ));
453 + }
357 454 throw new \Exception('Gemini API error (' . esc_html($status_code) . '): ' . esc_html($error_message));
358 455 }
359 456
360 457 $decoded = json_decode($body, true);
@@ -361,8 +458,15 @@
361 458 if (json_last_error() !== JSON_ERROR_NONE) {
362 459 throw new \Exception('Invalid JSON response from Gemini API');
363 460 }
364 461
462 + // A valid-but-scalar body (null/number/string from a proxy/gateway on a
463 + // 2xx) would violate this method's : array return type; reject it here so
464 + // it surfaces as a catchable \Exception, not an uncatchable TypeError.
465 + if (!is_array($decoded)) {
466 + throw new \Exception('Unexpected non-array response from Gemini API');
467 + }
468 +
365 469 // Debug: Log token usage information
366 470 if (isset($decoded['usageMetadata'])) {
367 471 $usage = $decoded['usageMetadata'];
368 472 $prompt_tokens = $usage['promptTokenCount'] ?? 0;
@@ -372,8 +476,75 @@
372 476
373 477 }
374 478
375 479 return $decoded;
480 + }
481 +
482 + /**
483 + * Perform an HTTP request, retrying transient failures (429 / 5xx / network)
484 + * per the plugin's retry settings, honoring a Retry-After header when given.
485 + *
486 + * @param string $url Request URL
487 + * @param array $args wp_remote_request arguments
488 + * @return array|\WP_Error Final response (or last error after retries)
489 + */
490 + private function request_with_retry(string $url, array $args) {
491 + $settings = \ThinkRank\Core\Settings::instance();
492 + $retry_enabled = (bool) $settings->get('retry_failed_requests', true);
493 + $max_attempts = $retry_enabled ? max(1, (int) $settings->get('retry_attempts', 3)) : 1;
494 +
495 + $response = null;
496 + for ($attempt = 1; $attempt <= $max_attempts; $attempt++) {
497 + // Keep PHP alive for the whole blocking call (see method docblock).
498 + $this->raise_request_time_limit();
499 +
500 + $response = wp_remote_request($url, $args);
501 +
502 + $is_transient = false;
503 + $retry_after = 0;
504 + if (is_wp_error($response)) {
505 + // A client-side timeout means the work genuinely needs longer
506 + // than the budget we allowed; re-running the identical prompt,
507 + // model and budget just times out again and multiplies the
508 + // wait (issue #288). Do not retry a timeout. Other WP_Error
509 + // results — DNS, connection refused, TLS — stay retryable.
510 + $is_transient = !$this->is_timeout_error($response);
511 + } else {
512 + $status = wp_remote_retrieve_response_code($response);
513 + if (429 === $status || $status >= 500) {
514 + $is_transient = true;
515 + $retry_after = (int) wp_remote_retrieve_header($response, 'retry-after');
516 + }
517 + }
518 +
519 + if (!$is_transient || $attempt === $max_attempts) {
520 + break;
521 + }
522 +
523 + $delay = $retry_after > 0 ? min($retry_after, 30) : min(2 ** ($attempt - 1), 8);
524 + sleep($delay);
525 + }
526 +
527 + return $response;
528 + }
529 +
530 + /**
531 + * Give PHP enough execution time to outlive a blocking AI HTTP request.
532 + *
533 + * The provider call blocks for up to $this->timeout seconds, but the web
534 + * SAPI's default max_execution_time (commonly 30s) is shorter — so PHP
535 + * fatally terminates the script mid-request (inside the cURL transport),
536 + * which the web server surfaces as a 502 Bad Gateway. Resetting the limit
537 + * before each attempt keeps the script alive for the full call; PHP-FPM's
538 + * request_terminate_timeout still caps the absolute maximum. No-op when
539 + * set_time_limit() is disabled (e.g. via disable_functions or safe mode).
540 + *
541 + * @return void
542 + */
543 + private function raise_request_time_limit(): void {
544 + if (function_exists('set_time_limit')) {
545 + @set_time_limit($this->timeout + 45); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- set_time_limit() warns when disabled by host policy; the guard is intentional.
546 + }
376 547 }
377 548
378 549 /**
379 550 * Parse SEO response from Gemini