| @@ -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 |