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.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 1.0.2 All 50 releases
← All changes | includes/ai/class-openai-client.php +125 -44 1.0.0 → 2.7.0 View file →
@@ -11,21 +11,28 @@
11 11 declare(strict_types=1);
12 12
13 13 namespace ThinkRank\AI;
14 14
15 +use ThinkRank\AI\Traits\Request_Timeout;
16 +
15 17 // Prevent direct access
16 18 if (!defined('ABSPATH')) {
17 19 exit;
18 20 }
19 21
22 +require_once __DIR__ . '/traits/trait-request-timeout.php';
23 +
20 24 /**
21 25 * OpenAI Client Class
22 - *
26 + *
23 27 * Single Responsibility: Handle OpenAI API communication
24 - *
28 + *
25 29 * @since 1.0.0
26 30 */
27 31 class OpenAI_Client {
32 +
33 + use Request_Timeout;
34 +
28 35
29 36 /**
30 37 * OpenAI API base URL
31 38 */
@@ -66,9 +73,9 @@
66 73 * @param string $api_key OpenAI API key
67 74 * @param string $model Default model to use
68 75 * @param int $timeout Request timeout
69 76 */
70 - public function __construct(string $api_key, string $model = 'gpt-5-nano', int $timeout = 30) {
77 + public function __construct(string $api_key, string $model = \ThinkRank\Core\Settings::DEFAULT_OPENAI_MODEL, int $timeout = 30) {
71 78 $this->api_key = $api_key;
72 79 $this->model = $model;
73 80 $this->timeout = $timeout;
74 81 }
@@ -109,9 +116,29 @@
109 116 'presence_penalty' => 0,
110 117 ];
111 118
112 119 $options = array_merge($default_options, $options);
113 -
120 +
121 + $body = $this->build_chat_completion_body($prompt, $options);
122 +
123 + return $this->make_request('chat/completions', $body);
124 + }
125 +
126 + /**
127 + * Build the chat/completions request body for the given (merged) options.
128 + *
129 + * Extracted so the per-model-family parameter handling is unit-testable:
130 + * reasoning models take max_completion_tokens (and only the GPT-5 family
131 + * accepts reasoning_effort — o1/o3 reject it), while standard models take
132 + * temperature/top_p/penalties/max_tokens. Keeping this in one place stops a
133 + * future refactor from silently regressing the GPT-5-only guard (issue #286).
134 + *
135 + * @param string $prompt User prompt.
136 + * @param array $options Merged options (must include model, max_tokens, and
137 + * the sampling defaults; reasoning_effort optional).
138 + * @return array Request body for the chat/completions endpoint.
139 + */
140 + private function build_chat_completion_body(string $prompt, array $options): array {
114 141 $body = [
115 142 'model' => $options['model'],
116 143 'messages' => [
117 144 [
@@ -128,8 +155,17 @@
128 155 if ($this->is_reasoning_model($options['model'])) {
129 156 // Reasoning models (o1/o3) have fixed parameters and restricted support
130 157 // temperature, top_p, frequency_penalty, presence_penalty are not supported
131 158 $body['max_completion_tokens'] = $safe_tokens;
159 +
160 + // GPT-5 models accept reasoning_effort ('minimal'…'high'). Callers
161 + // wanting a quick answer pass a low level so hidden reasoning
162 + // can't consume the whole completion budget and return empty
163 + // text. Only the GPT-5
164 + // family gets it: o1 rejects the parameter outright.
165 + if (isset($options['reasoning_effort']) && str_starts_with($options['model'], 'gpt-5')) {
166 + $body['reasoning_effort'] = (string) $options['reasoning_effort'];
167 + }
132 168 } else {
133 169 // Standard models support all parameters
134 170 $body['temperature'] = $options['temperature'];
135 171 $body['top_p'] = $options['top_p'];
@@ -137,9 +173,9 @@
137 173 $body['presence_penalty'] = $options['presence_penalty'];
138 174 $body['max_tokens'] = $safe_tokens;
139 175 }
140 176
141 - return $this->make_request('chat/completions', $body);
177 + return $body;
142 178 }
143 179
144 180 /**
145 181 * Generate SEO metadata
@@ -154,9 +190,10 @@
154 190 $content_type = $options['content_type'] ?? 'blog_post';
155 191 $tone = $options['tone'] ?? 'professional';
156 192
157 193 $prompt_builder = $this->get_prompt_builder();
158 - $prompt = $prompt_builder->build_seo_prompt($content, $target_keyword, $content_type, $tone, 'openai');
194 + $language = is_string($options['language'] ?? null) ? $options['language'] : '';
195 + $prompt = $prompt_builder->build_seo_prompt($content, $target_keyword, $content_type, $tone, 'openai', $language);
159 196
160 197 $response = $this->generate_completion($prompt, [
161 198 'max_tokens' => $this->get_recommended_tokens('seo_metadata'),
162 199 'temperature' => 0.3, // Lower temperature for more consistent SEO output
@@ -183,25 +220,8 @@
183 220 ]);
184 221
185 222 return $this->parse_analysis_response($response);
186 223 }
187 -
188 - /**
189 - * Check if model uses max_completion_tokens parameter
190 - *
191 - * @param string $model Model name
192 - * @return bool True if model uses max_completion_tokens
193 - */
194 - private function uses_max_completion_tokens(string $model): bool {
195 - return $this->is_reasoning_model($model);
196 - }
197 -
198 - /**
199 - * Check if model is a reasoning model (o1/o3 series)
200 - *
201 - * @param string $model Model name
202 - * @return bool True if model is a reasoning model
203 - */
204 224 private function is_reasoning_model(string $model): bool {
205 225 // Models that require max_completion_tokens and restrict parameters (no temperature/top_p)
206 226 // Includes OpenAI o1/o3 series and GPT-5 family
207 227 $reasoning_models = [
@@ -412,18 +432,18 @@
412 432 if (!empty($body)) {
413 433 $args['method'] = 'POST';
414 434 $args['body'] = wp_json_encode($body);
415 435 }
416 -
417 - $response = wp_remote_request($url, $args);
418 -
436 +
437 + $response = $this->request_with_retry($url, $args);
438 +
419 439 if (is_wp_error($response)) {
420 440 throw new \Exception('API request failed: ' . esc_html($response->get_error_message()));
421 441 }
422 -
442 +
423 443 $status_code = wp_remote_retrieve_response_code($response);
424 444 $response_body = wp_remote_retrieve_body($response);
425 -
445 +
426 446 if ($status_code >= 400) {
427 447 $error_data = json_decode($response_body, true);
428 448 $error_message = $error_data['error']['message'] ?? 'Unknown API error';
429 449 throw new \Exception(sprintf('OpenAI API error (%d): %s', (int) $status_code, esc_html($error_message)));
@@ -429,19 +449,94 @@
429 449 throw new \Exception(sprintf('OpenAI API error (%d): %s', (int) $status_code, esc_html($error_message)));
430 450 }
431 451
432 452 $data = json_decode($response_body, true);
433 -
453 +
434 454 if (json_last_error() !== JSON_ERROR_NONE) {
435 455 throw new \Exception('Invalid JSON response from OpenAI API');
436 456 }
437 -
457 +
458 + // A valid-but-scalar body (null/number/string from a proxy/gateway on a
459 + // 2xx) would violate this method's : array return type; reject it here so
460 + // it surfaces as a catchable \Exception, not an uncatchable TypeError.
461 + if (!is_array($data)) {
462 + throw new \Exception('Unexpected non-array response from OpenAI API');
463 + }
464 +
438 465 return $data;
439 466 }
440 467
468 + /**
469 + * Perform an HTTP request, retrying transient failures (429 / 5xx / network)
470 + * per the plugin's retry settings, honoring a Retry-After header when given.
471 + *
472 + * @param string $url Request URL
473 + * @param array $args wp_remote_request arguments
474 + * @return array|\WP_Error Final response (or last error after retries)
475 + */
476 + private function request_with_retry(string $url, array $args) {
477 + $settings = \ThinkRank\Core\Settings::instance();
478 + $retry_enabled = (bool) $settings->get('retry_failed_requests', true);
479 + $max_attempts = $retry_enabled ? max(1, (int) $settings->get('retry_attempts', 3)) : 1;
441 480
481 + $response = null;
482 + for ($attempt = 1; $attempt <= $max_attempts; $attempt++) {
483 + // Keep PHP alive for the whole blocking call (see method docblock).
484 + $this->raise_request_time_limit();
442 485
486 + $response = wp_remote_request($url, $args);
487 +
488 + $is_transient = false;
489 + $retry_after = 0;
490 + if (is_wp_error($response)) {
491 + // A client-side timeout means the work genuinely needs longer
492 + // than the budget we allowed; re-running the identical prompt,
493 + // model and budget just times out again and multiplies the
494 + // wait (issue #288). Do not retry a timeout. Other WP_Error
495 + // results — DNS, connection refused, TLS — stay retryable.
496 + $is_transient = !$this->is_timeout_error($response);
497 + } else {
498 + $status = wp_remote_retrieve_response_code($response);
499 + if (429 === $status || $status >= 500) {
500 + $is_transient = true;
501 + $retry_after = (int) wp_remote_retrieve_header($response, 'retry-after');
502 + }
503 + }
504 +
505 + if (!$is_transient || $attempt === $max_attempts) {
506 + break;
507 + }
508 +
509 + // Honor Retry-After, else exponential backoff (1s, 2s, 4s…), capped.
510 + $delay = $retry_after > 0 ? min($retry_after, 30) : min(2 ** ($attempt - 1), 8);
511 + sleep($delay);
512 + }
513 +
514 + return $response;
515 + }
516 +
443 517 /**
518 + * Give PHP enough execution time to outlive a blocking AI HTTP request.
519 + *
520 + * The provider call blocks for up to $this->timeout seconds, but the web
521 + * SAPI's default max_execution_time (commonly 30s) is shorter — so PHP
522 + * fatally terminates the script mid-request (inside the cURL transport),
523 + * which the web server surfaces as a 502 Bad Gateway. Resetting the limit
524 + * before each attempt keeps the script alive for the full call; PHP-FPM's
525 + * request_terminate_timeout still caps the absolute maximum. No-op when
526 + * set_time_limit() is disabled (e.g. via disable_functions or safe mode).
527 + *
528 + * @return void
529 + */
530 + private function raise_request_time_limit(): void {
531 + if (function_exists('set_time_limit')) {
532 + // Cover the request timeout plus a small buffer for connection
533 + // setup and response handling.
534 + @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.
535 + }
536 + }
537 +
538 + /**
444 539 * Parse SEO response from OpenAI
445 540 *
446 541 * @param array $response OpenAI response
447 542 * @return array Parsed metadata
@@ -454,10 +549,8 @@
454 549
455 550 $content = $response['choices'][0]['message']['content'];
456 551 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
457 552
458 -
459 -
460 553 // Try to extract JSON from the response
461 554 $json_start = strpos($content, '{');
462 555 $json_end = strrpos($content, '}');
463 556
@@ -565,15 +658,11 @@
565 658 );
566 659
567 660 $response = $this->make_request('chat/completions', $body);
568 661
569 -
570 -
571 662 return $this->parse_site_identity_response($response);
572 663 }
573 664
574 -
575 -
576 665 /**
577 666 * Parse site identity optimization response
578 667 *
579 668 * @param array $response OpenAI API response
@@ -707,10 +796,8 @@
707 796
708 797 return $this->parse_llms_txt_response($response);
709 798 }
710 799
711 -
712 -
713 800 /**
714 801 * Parse LLMs.txt optimization response
715 802 *
716 803 * @param array $response OpenAI API response
@@ -724,10 +811,8 @@
724 811
725 812 $content = trim($response['choices'][0]['message']['content']);
726 813 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
727 814
728 -
729 -
730 815 // Extract JSON from response
731 816 $json_start = strpos($content, '{');
732 817 $json_end = strrpos($content, '}');
733 818
@@ -756,12 +841,8 @@
756 841 'tokens_used' => $response['usage']['total_tokens'] ?? 0,
757 842 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
758 843 ];
759 844 }
760 -
761 -
762 -
763 -
764 845
765 846 /**
766 847 * Parse homepage meta optimization response
767 848 *