PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.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 +463 -60 1.0.1 → 2.9.0 View file →
@@ -11,28 +11,64 @@
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 +require_once __DIR__ . '/class-endpoint-url-validator.php';
24 +
20 25 /**
21 26 * OpenAI Client Class
22 - *
27 + *
23 28 * Single Responsibility: Handle OpenAI API communication
24 - *
29 + *
25 30 * @since 1.0.0
26 31 */
27 32 class OpenAI_Client {
33 +
34 + use Request_Timeout;
35 +
28 36
29 37 /**
30 - * OpenAI API base URL
38 + * OpenAI's own API base URL — the default when no other is given.
31 39 */
32 - private const API_BASE_URL = 'https://api.openai.com/v1';
40 + public const API_BASE_URL = 'https://api.openai.com/v1';
41 +
42 + /**
43 + * Base URL every request is built on.
44 + *
45 + * Not always OpenAI's: the `openai_compatible` provider (#721) points this
46 + * at any server speaking the Chat Completions API — Ollama, LM Studio,
47 + * vLLM, Azure OpenAI, a company gateway — so the rest of this client, the
48 + * retry policy and the response parsing are shared verbatim.
49 + *
50 + * @since 2.8.0
51 + * @var string
52 + */
53 + private string $base_url;
33 54
34 55 /**
56 + * Ceiling for a response body from a user-named endpoint.
57 + *
58 + * Mirrors Endpoint_URL_Validator::MAX_RESPONSE_BYTES; kept here too so the
59 + * truncation message and the transport limit cannot drift apart.
60 + */
61 + private const MAX_RESPONSE_BYTES = 2097152; // 2 MB.
62 +
63 + /**
64 + * Output-token ceiling for an unrecognised model on a user-named endpoint.
65 + *
66 + * See get_max_completion_tokens().
67 + */
68 + private const COMPATIBLE_DEFAULT_MAX_TOKENS = 8192;
69 +
70 + /**
35 71 * API key
36 72 *
37 73 * @var string
38 74 */
@@ -52,8 +88,19 @@
52 88 */
53 89 private int $timeout;
54 90
55 91 /**
92 + * Ask a custom endpoint to constrain JSON answers with response_format.
93 + *
94 + * Off by default because not every compatible server takes it (LM Studio
95 + * accepts only json_schema). See set_json_mode().
96 + *
97 + * @since 2.8.0
98 + * @var bool
99 + */
100 + private bool $json_mode = false;
101 +
102 + /**
56 103 * Prompt Builder instance
57 104 *
58 105 * @since 1.0.0
59 106 * @var Prompt_Builder|null
@@ -62,19 +109,83 @@
62 109
63 110 /**
64 111 * Constructor
65 112 *
66 - * @param string $api_key OpenAI API key
67 - * @param string $model Default model to use
68 - * @param int $timeout Request timeout
113 + * @param string $api_key OpenAI API key (may be empty for a local server that wants none).
114 + * @param string $model Default model to use
115 + * @param int $timeout Request timeout
116 + * @param string $base_url API base URL without a trailing slash; defaults to OpenAI's.
69 117 */
70 - public function __construct(string $api_key, string $model = 'gpt-5-nano', int $timeout = 30) {
118 + public function __construct(string $api_key, string $model = \ThinkRank\Core\Settings::DEFAULT_OPENAI_MODEL, int $timeout = 30, string $base_url = self::API_BASE_URL) {
71 119 $this->api_key = $api_key;
72 120 $this->model = $model;
73 121 $this->timeout = $timeout;
122 + $base_url = rtrim(trim($base_url), '/');
123 + $this->base_url = '' !== $base_url ? $base_url : self::API_BASE_URL;
74 124 }
75 125
76 126 /**
127 + * Turn on response_format: json_object for calls that want a JSON object.
128 + *
129 + * ThinkRank otherwise enforces JSON through the prompt alone, and a small
130 + * local model then writes an unescaped quote inside a string (DeepSeek-R1
131 + * 7B quoting a phrase in the brief's HTML body) and the whole brief fails
132 + * to parse. Ollama, vLLM and llama.cpp turn this field into a grammar, so
133 + * the reply cannot be malformed JSON. Only calls passing the `json_object`
134 + * option get it: a plain-text caller would be forced into JSON too.
135 + *
136 + * @since 2.8.0
137 + *
138 + * @param bool $enabled Whether the endpoint accepts response_format json_object.
139 + * @return void
140 + */
141 + public function set_json_mode(bool $enabled): void {
142 + $this->json_mode = $enabled;
143 + }
144 +
145 + /**
146 + * Get the base URL this client talks to.
147 + *
148 + * @since 2.8.0
149 + *
150 + * @return string Base URL without a trailing slash.
151 + */
152 + public function get_base_url(): string {
153 + return $this->base_url;
154 + }
155 +
156 + /**
157 + * Is this client pointed at a server other than OpenAI's?
158 + *
159 + * Used for error copy: naming "OpenAI" in a failure from someone's local
160 + * Ollama box sends them to the wrong place to debug it.
161 + *
162 + * @since 2.8.0
163 + *
164 + * @return bool
165 + */
166 + private function is_custom_endpoint(): bool {
167 + return self::API_BASE_URL !== $this->base_url;
168 + }
169 +
170 + /**
171 + * Name to use for this endpoint in user-facing messages.
172 + *
173 + * @since 2.8.0
174 + *
175 + * @return string
176 + */
177 + private function get_endpoint_label(): string {
178 + if (!$this->is_custom_endpoint()) {
179 + return 'OpenAI';
180 + }
181 +
182 + $host = wp_parse_url($this->base_url, PHP_URL_HOST);
183 +
184 + return is_string($host) && '' !== $host ? $host : __('the AI endpoint', 'thinkrank');
185 + }
186 +
187 + /**
77 188 * Get Prompt Builder instance
78 189 *
79 190 * @since 1.0.0
80 191 *
@@ -109,9 +220,30 @@
109 220 'presence_penalty' => 0,
110 221 ];
111 222
112 223 $options = array_merge($default_options, $options);
113 -
224 +
225 + $body = $this->build_chat_completion_body($prompt, $options);
226 +
227 + return $this->make_request('chat/completions', $body);
228 + }
229 +
230 + /**
231 + * Build the chat/completions request body for the given (merged) options.
232 + *
233 + * Extracted so the per-model-family parameter handling is unit-testable:
234 + * reasoning models take max_completion_tokens (and only the GPT-5 family
235 + * accepts reasoning_effort — o1/o3 reject it), while standard models take
236 + * temperature/top_p/penalties/max_tokens. Keeping this in one place stops a
237 + * future refactor from silently regressing the GPT-5-only guard (issue #286).
238 + *
239 + * @param string $prompt User prompt.
240 + * @param array $options Merged options (must include model, max_tokens, and
241 + * the sampling defaults; reasoning_effort and
242 + * json_object optional).
243 + * @return array Request body for the chat/completions endpoint.
244 + */
245 + private function build_chat_completion_body(string $prompt, array $options): array {
114 246 $body = [
115 247 'model' => $options['model'],
116 248 'messages' => [
117 249 [
@@ -128,8 +260,17 @@
128 260 if ($this->is_reasoning_model($options['model'])) {
129 261 // Reasoning models (o1/o3) have fixed parameters and restricted support
130 262 // temperature, top_p, frequency_penalty, presence_penalty are not supported
131 263 $body['max_completion_tokens'] = $safe_tokens;
264 +
265 + // GPT-5 models accept reasoning_effort ('minimal'…'high'). Callers
266 + // wanting a quick answer pass a low level so hidden reasoning
267 + // can't consume the whole completion budget and return empty
268 + // text. Only the GPT-5
269 + // family gets it: o1 rejects the parameter outright.
270 + if (isset($options['reasoning_effort']) && str_starts_with($options['model'], 'gpt-5')) {
271 + $body['reasoning_effort'] = (string) $options['reasoning_effort'];
272 + }
132 273 } else {
133 274 // Standard models support all parameters
134 275 $body['temperature'] = $options['temperature'];
135 276 $body['top_p'] = $options['top_p'];
@@ -137,9 +278,13 @@
137 278 $body['presence_penalty'] = $options['presence_penalty'];
138 279 $body['max_tokens'] = $safe_tokens;
139 280 }
140 281
141 - return $this->make_request('chat/completions', $body);
282 + if ($this->json_mode && $this->is_custom_endpoint() && !empty($options['json_object'])) {
283 + $body['response_format'] = ['type' => 'json_object'];
284 + }
285 +
286 + return $body;
142 287 }
143 288
144 289 /**
145 290 * Generate SEO metadata
@@ -154,13 +299,15 @@
154 299 $content_type = $options['content_type'] ?? 'blog_post';
155 300 $tone = $options['tone'] ?? 'professional';
156 301
157 302 $prompt_builder = $this->get_prompt_builder();
158 - $prompt = $prompt_builder->build_seo_prompt($content, $target_keyword, $content_type, $tone, 'openai');
303 + $language = is_string($options['language'] ?? null) ? $options['language'] : '';
304 + $prompt = $prompt_builder->build_seo_prompt($content, $target_keyword, $content_type, $tone, 'openai', $language);
159 305
160 306 $response = $this->generate_completion($prompt, [
161 307 'max_tokens' => $this->get_recommended_tokens('seo_metadata'),
162 308 'temperature' => 0.3, // Lower temperature for more consistent SEO output
309 + 'json_object' => true,
163 310 ]);
164 311
165 312 return $this->parse_seo_response($response);
166 313 }
@@ -179,29 +326,13 @@
179 326
180 327 $response = $this->generate_completion($prompt, [
181 328 'max_tokens' => $this->get_recommended_tokens('analysis'),
182 329 'temperature' => 0.3, // Lower temperature for more consistent analysis
330 + 'json_object' => true,
183 331 ]);
184 332
185 333 return $this->parse_analysis_response($response);
186 334 }
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 335 private function is_reasoning_model(string $model): bool {
205 336 // Models that require max_completion_tokens and restrict parameters (no temperature/top_p)
206 337 // Includes OpenAI o1/o3 series and GPT-5 family
207 338 $reasoning_models = [
@@ -263,8 +394,31 @@
263 394 return $limit;
264 395 }
265 396 }
266 397
398 + // A model we have never heard of on OpenAI's own API gets a
399 + // conservative ceiling. On a user-named server nearly every model is
400 + // unknown, and 4096 left a brief ~2.9K tokens after length scaling,
401 + // which a reasoning model (DeepSeek-R1, Qwen3) spends half of thinking
402 + // before the JSON starts. max_tokens is a cap, not a reservation, so a
403 + // higher one costs nothing on a model that finishes early.
404 + if ($this->is_custom_endpoint()) {
405 + /**
406 + * Filters the output-token ceiling for an unrecognised model on an
407 + * OpenAI-compatible endpoint.
408 + *
409 + * Lower it for a server that rejects max_tokens beyond its context
410 + * window (vLLM does); raise it for a large-context local model.
411 + *
412 + * @since 2.8.0
413 + *
414 + * @param int $limit Ceiling in tokens. Default 8192.
415 + * @param string $model Model id sent to the endpoint.
416 + */
417 + $limit = (int) apply_filters('thinkrank_openai_compatible_max_output_tokens', self::COMPATIBLE_DEFAULT_MAX_TOKENS, $model);
418 + return $limit > 0 ? $limit : self::COMPATIBLE_DEFAULT_MAX_TOKENS;
419 + }
420 +
267 421 // Default fallback for unknown models
268 422 return 4096;
269 423 }
270 424
@@ -397,51 +551,314 @@
397 551 * @return array Response data
398 552 * @throws \Exception If request fails
399 553 */
400 554 private function make_request(string $endpoint, array $body = []): array {
401 - $url = self::API_BASE_URL . '/' . ltrim($endpoint, '/');
402 -
555 + // The user's daily ceiling and kill switch are enforced here, at the
556 + // one place every outbound OpenAI call passes through, so no feature
557 + // path can bypass them by forgetting to ask first (#448).
558 + Spend_Guard::guard();
559 + Spend_Guard::record();
560 +
561 + // Not string concatenation: a base URL may carry a query string (Azure
562 + // requires ?api-version=…), and the route has to land before it (#721).
563 + $url = Endpoint_URL_Validator::route($this->base_url, $endpoint);
564 +
565 + $headers = [
566 + 'Content-Type' => 'application/json',
567 + 'User-Agent' => 'ThinkRank/' . THINKRANK_VERSION,
568 + ];
569 +
570 + // A local Ollama or LM Studio server wants no key at all; sending
571 + // "Bearer " with nothing after it makes some gateways 401.
572 + if ('' !== $this->api_key) {
573 + $headers['Authorization'] = 'Bearer ' . $this->api_key;
574 + // Azure OpenAI reads the key from its own header and ignores
575 + // Authorization. Sending both costs nothing and makes an Azure
576 + // deployment URL work without a separate provider.
577 + $headers['api-key'] = $this->api_key;
578 + }
579 +
403 580 $args = [
404 581 'timeout' => $this->timeout,
405 - 'headers' => [
406 - 'Authorization' => 'Bearer ' . $this->api_key,
407 - 'Content-Type' => 'application/json',
408 - 'User-Agent' => 'ThinkRank/' . THINKRANK_VERSION,
409 - ],
582 + 'headers' => $headers,
583 + // Only for an endpoint we do not control: a ceiling generous enough
584 + // for the largest thing we ask for (a content brief as JSON), so a
585 + // server that ignores its token limit still cannot spend the
586 + // worker's memory. Truncation is reported below, not parsed (#721).
587 + 'limit_response_size' => $this->is_custom_endpoint() ? self::MAX_RESPONSE_BYTES : null,
588 + // The key travels in these headers. A redirect to another host
589 + // would hand it to whoever controls that host, so never follow one
590 + // (#721) — a moved endpoint is the administrator's URL to fix.
591 + 'redirection' => 0,
410 592 ];
411 593
594 + if (null === $args['limit_response_size']) {
595 + unset($args['limit_response_size']);
596 + }
597 +
598 + $args['method'] = empty($body) ? 'GET' : 'POST';
412 599 if (!empty($body)) {
413 - $args['method'] = 'POST';
414 600 $args['body'] = wp_json_encode($body);
415 601 }
416 -
417 - $response = wp_remote_request($url, $args);
418 -
602 +
603 + $response = $this->request_with_retry($url, $args);
604 +
419 605 if (is_wp_error($response)) {
420 606 throw new \Exception('API request failed: ' . esc_html($response->get_error_message()));
421 607 }
422 -
608 +
423 609 $status_code = wp_remote_retrieve_response_code($response);
424 610 $response_body = wp_remote_retrieve_body($response);
425 -
611 +
612 + // JSON mode is the administrator's claim that this server takes
613 + // response_format. A server that does not (LM Studio wants json_schema)
614 + // answers 400 and names the field; drop it and ask once more, so a
615 + // wrong toggle costs a round trip rather than the feature. The rest of
616 + // this request's calls skip the field instead of failing first.
617 + if ($this->should_retry_without_response_format($body, (int) $status_code, (string) $response_body)) {
618 + $this->json_mode = false;
619 + unset($body['response_format']);
620 +
621 + if (defined('WP_DEBUG') && WP_DEBUG) {
622 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- diagnostic, WP_DEBUG only.
623 + error_log('[ThinkRank] ' . $this->get_endpoint_label() . ' rejected response_format; retrying without JSON mode.');
624 + }
625 +
626 + $args['body'] = wp_json_encode($body);
627 + $response = $this->request_with_retry($url, $args);
628 +
629 + if (is_wp_error($response)) {
630 + throw new \Exception('API request failed: ' . esc_html($response->get_error_message()));
631 + }
632 +
633 + $status_code = wp_remote_retrieve_response_code($response);
634 + $response_body = wp_remote_retrieve_body($response);
635 + }
636 +
637 + // A body that reached the ceiling was cut mid-JSON. Say so, rather than
638 + // letting it fail as "invalid JSON" — the cause and the fix are
639 + // different things.
640 + if ($this->is_custom_endpoint() && strlen($response_body) >= self::MAX_RESPONSE_BYTES) {
641 + throw new \Exception(sprintf(
642 + /* translators: %s: endpoint name or host. */
643 + esc_html__('%s sent more than ThinkRank will read (2 MB). The endpoint is misconfigured or is not answering with a chat completion.', 'thinkrank'),
644 + esc_html($this->get_endpoint_label())
645 + ));
646 + }
647 +
426 648 if ($status_code >= 400) {
427 649 $error_data = json_decode($response_body, true);
428 - $error_message = $error_data['error']['message'] ?? 'Unknown API error';
429 - throw new \Exception(sprintf('OpenAI API error (%d): %s', (int) $status_code, esc_html($error_message)));
650 + $error_message = $error_data['error']['message'] ?? ($error_data['message'] ?? 'Unknown API error');
651 + throw new \Exception(sprintf(
652 + /* translators: 1: endpoint name or host, 2: HTTP status code, 3: error message from the server. */
653 + esc_html__('%1$s API error (%2$d): %3$s', 'thinkrank'),
654 + esc_html($this->get_endpoint_label()),
655 + (int) $status_code,
656 + esc_html((string) $error_message)
657 + ));
430 658 }
431 659
432 660 $data = json_decode($response_body, true);
433 -
661 +
434 662 if (json_last_error() !== JSON_ERROR_NONE) {
435 - throw new \Exception('Invalid JSON response from OpenAI API');
663 + throw new \Exception(sprintf(
664 + /* translators: %s: endpoint name or host. */
665 + esc_html__('Invalid JSON response from %s', 'thinkrank'),
666 + esc_html($this->get_endpoint_label())
667 + ));
436 668 }
437 -
669 +
670 + // A local reasoning model (deepseek-r1, a qwen3 thinking build) spends
671 + // its token budget on hidden reasoning before writing anything, and
672 + // Ollama and vLLM bill that against max_tokens — so the budget runs out
673 + // mid-thought and the reply comes back 200 with empty content and
674 + // finish_reason "length". Every generator then fails with "no valid
675 + // JSON", which blames the model for a budget problem. Ask once more
676 + // with reasoning turned off, which those servers accept as
677 + // reasoning_effort: none (#721).
678 + if (is_array($data) && $this->should_retry_without_reasoning($endpoint, $body, $data)) {
679 + $retry_body = $body;
680 + $retry_body['reasoning_effort'] = 'none';
681 +
682 + $retry_args = $args;
683 + $retry_args['body'] = wp_json_encode($retry_body);
684 + $retry = $this->request_with_retry($url, $retry_args);
685 +
686 + if (!is_wp_error($retry) && wp_remote_retrieve_response_code($retry) < 400) {
687 + $retry_data = json_decode(wp_remote_retrieve_body($retry), true);
688 + // Keep the first answer when the retry is no better — a server
689 + // that ignores the parameter answers exactly the same way, and
690 + // the error below is then the honest one.
691 + if (is_array($retry_data) && '' !== trim((string) ($retry_data['choices'][0]['message']['content'] ?? ''))) {
692 + return $retry_data;
693 + }
694 + }
695 +
696 + throw new \Exception(sprintf(
697 + /* translators: %s: endpoint name or host. */
698 + esc_html__('%s answered with no text: the model spent its whole token budget on hidden reasoning. Use a non-reasoning model, or raise the token budget for this endpoint.', 'thinkrank'),
699 + esc_html($this->get_endpoint_label())
700 + ));
701 + }
702 +
703 + // A valid-but-scalar body (null/number/string from a proxy/gateway on a
704 + // 2xx) would violate this method's : array return type; reject it here so
705 + // it surfaces as a catchable \Exception, not an uncatchable TypeError.
706 + if (!is_array($data)) {
707 + throw new \Exception(sprintf(
708 + /* translators: %s: endpoint name or host. */
709 + esc_html__('Unexpected non-array response from %s', 'thinkrank'),
710 + esc_html($this->get_endpoint_label())
711 + ));
712 + }
713 +
438 714 return $data;
439 715 }
440 716
717 + /**
718 + * Did the server refuse the request because of response_format?
719 + *
720 + * Only when we sent the field, the server answered 400 or 422 (vLLM and
721 + * FastAPI-based servers use 422 for a schema error), and the error text
722 + * names the field or a JSON-mode type. Any other 400 (wrong model id, a
723 + * prompt over the context window) is a real error, and resending without
724 + * the field would only repeat it.
725 + *
726 + * @since 2.8.0
727 + *
728 + * @param array $body Request body that was sent.
729 + * @param int $status_code HTTP status of the answer.
730 + * @param string $response_body Raw answer body.
731 + * @return bool
732 + */
733 + private function should_retry_without_response_format(array $body, int $status_code, string $response_body): bool {
734 + if (!isset($body['response_format']) || !in_array($status_code, [400, 422], true)) {
735 + return false;
736 + }
441 737
738 + $error = strtolower($response_body);
739 + foreach (['response_format', 'json_object', 'json_schema'] as $needle) {
740 + if (false !== strpos($error, $needle)) {
741 + return true;
742 + }
743 + }
442 744
745 + return false;
746 + }
747 +
443 748 /**
749 + * Did a chat completion come back empty because the model was still thinking?
750 + *
751 + * Three things have to be true: this is a chat completion against a custom
752 + * endpoint (OpenAI's own reasoning models manage their own budget through
753 + * max_completion_tokens), the content is empty, and the server stopped on
754 + * "length" or reported reasoning it never got to use.
755 + *
756 + * @since 2.8.0
757 + *
758 + * @param string $endpoint Endpoint path that was called.
759 + * @param array $body Request body that was sent.
760 + * @param array $data Decoded response.
761 + * @return bool
762 + */
763 + private function should_retry_without_reasoning(string $endpoint, array $body, array $data): bool {
764 + if (!$this->is_custom_endpoint() || 'chat/completions' !== ltrim($endpoint, '/')) {
765 + return false;
766 + }
767 +
768 + // Already asked without reasoning — a second identical attempt would
769 + // only cost the user another slow generation.
770 + if (isset($body['reasoning_effort'])) {
771 + return false;
772 + }
773 +
774 + $message = is_array($data['choices'][0]['message'] ?? null) ? $data['choices'][0]['message'] : null;
775 + if (null === $message || '' !== trim((string) ($message['content'] ?? ''))) {
776 + return false;
777 + }
778 +
779 + $finish = (string) ($data['choices'][0]['finish_reason'] ?? '');
780 + $reasoning = trim((string) ($message['reasoning'] ?? ($message['reasoning_content'] ?? '')));
781 +
782 + return 'length' === $finish || '' !== $reasoning;
783 + }
784 +
785 + /**
786 + * Perform an HTTP request, retrying transient failures (429 / 5xx / network)
787 + * per the plugin's retry settings, honoring a Retry-After header when given.
788 + *
789 + * @param string $url Request URL
790 + * @param array $args wp_remote_request arguments
791 + * @return array|\WP_Error Final response (or last error after retries)
792 + */
793 + private function request_with_retry(string $url, array $args) {
794 + $settings = \ThinkRank\Core\Settings::instance();
795 + $retry_enabled = (bool) $settings->get('retry_failed_requests', true);
796 + $max_attempts = $retry_enabled ? max(1, (int) $settings->get('retry_attempts', 3)) : 1;
797 +
798 + $response = null;
799 + for ($attempt = 1; $attempt <= $max_attempts; $attempt++) {
800 + // Keep PHP alive for the whole blocking call (see method docblock).
801 + $this->raise_request_time_limit();
802 +
803 + // A custom endpoint is a host the site owner named, not one of
804 + // ours: check where it actually resolves, pin the connection there
805 + // and cap the body before any of it is buffered (#721).
806 + $response = $this->is_custom_endpoint()
807 + ? Endpoint_URL_Validator::guarded_request($url, $args)
808 + : wp_remote_request($url, $args);
809 +
810 + $is_transient = false;
811 + $retry_after = 0;
812 + if (is_wp_error($response)) {
813 + // A client-side timeout means the work genuinely needs longer
814 + // than the budget we allowed; re-running the identical prompt,
815 + // model and budget just times out again and multiplies the
816 + // wait (issue #288). Do not retry a timeout. Other WP_Error
817 + // results — DNS, connection refused, TLS — stay retryable.
818 + $is_transient = !$this->is_timeout_error($response);
819 + } else {
820 + $status = wp_remote_retrieve_response_code($response);
821 + if (429 === $status || $status >= 500) {
822 + $is_transient = true;
823 + $retry_after = (int) wp_remote_retrieve_header($response, 'retry-after');
824 + }
825 + }
826 +
827 + if (!$is_transient || $attempt === $max_attempts) {
828 + break;
829 + }
830 +
831 + // Honor Retry-After, else exponential backoff (1s, 2s, 4s…), capped.
832 + $delay = $retry_after > 0 ? min($retry_after, 30) : min(2 ** ($attempt - 1), 8);
833 + sleep($delay);
834 + }
835 +
836 + return $response;
837 + }
838 +
839 + /**
840 + * Give PHP enough execution time to outlive a blocking AI HTTP request.
841 + *
842 + * The provider call blocks for up to $this->timeout seconds, but the web
843 + * SAPI's default max_execution_time (commonly 30s) is shorter — so PHP
844 + * fatally terminates the script mid-request (inside the cURL transport),
845 + * which the web server surfaces as a 502 Bad Gateway. Resetting the limit
846 + * before each attempt keeps the script alive for the full call; PHP-FPM's
847 + * request_terminate_timeout still caps the absolute maximum. No-op when
848 + * set_time_limit() is disabled (e.g. via disable_functions or safe mode).
849 + *
850 + * @return void
851 + */
852 + private function raise_request_time_limit(): void {
853 + if (function_exists('set_time_limit')) {
854 + // Cover the request timeout plus a small buffer for connection
855 + // setup and response handling.
856 + @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.
857 + }
858 + }
859 +
860 + /**
444 861 * Parse SEO response from OpenAI
445 862 *
446 863 * @param array $response OpenAI response
447 864 * @return array Parsed metadata
@@ -454,10 +871,8 @@
454 871
455 872 $content = $response['choices'][0]['message']['content'];
456 873 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
457 874
458 -
459 -
460 875 // Try to extract JSON from the response
461 876 $json_start = strpos($content, '{');
462 877 $json_end = strrpos($content, '}');
463 878
@@ -565,15 +980,11 @@
565 980 );
566 981
567 982 $response = $this->make_request('chat/completions', $body);
568 983
569 -
570 -
571 984 return $this->parse_site_identity_response($response);
572 985 }
573 986
574 -
575 -
576 987 /**
577 988 * Parse site identity optimization response
578 989 *
579 990 * @param array $response OpenAI API response
@@ -707,10 +1118,8 @@
707 1118
708 1119 return $this->parse_llms_txt_response($response);
709 1120 }
710 1121
711 -
712 -
713 1122 /**
714 1123 * Parse LLMs.txt optimization response
715 1124 *
716 1125 * @param array $response OpenAI API response
@@ -724,10 +1133,8 @@
724 1133
725 1134 $content = trim($response['choices'][0]['message']['content']);
726 1135 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
727 1136
728 -
729 -
730 1137 // Extract JSON from response
731 1138 $json_start = strpos($content, '{');
732 1139 $json_end = strrpos($content, '}');
733 1140
@@ -756,12 +1163,8 @@
756 1163 'tokens_used' => $response['usage']['total_tokens'] ?? 0,
757 1164 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
758 1165 ];
759 1166 }
760 -
761 -
762 -
763 -
764 1167
765 1168 /**
766 1169 * Parse homepage meta optimization response
767 1170 *