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