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-openai-client.php +49 -7 1.26.02.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
@@ -450,9 +487,14 @@
450 487
451 488 $is_transient = false;
452 489 $retry_after = 0;
453 490 if (is_wp_error($response)) {
454 - $is_transient = true;
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);
455 497 } else {
456 498 $status = wp_remote_retrieve_response_code($response);
457 499 if (429 === $status || $status >= 500) {
458 500 $is_transient = true;