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 +345 -23 2.1.1 → 2.9.0 View file →
@@ -19,8 +19,9 @@
19 19 exit;
20 20 }
21 21
22 22 require_once __DIR__ . '/traits/trait-request-timeout.php';
23 +require_once __DIR__ . '/class-endpoint-url-validator.php';
23 24
24 25 /**
25 26 * OpenAI Client Class
26 27 *
@@ -33,13 +34,41 @@
33 34 use Request_Timeout;
34 35
35 36
36 37 /**
37 - * OpenAI API base URL
38 + * OpenAI's own API base URL — the default when no other is given.
38 39 */
39 - 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;
40 54
41 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 + /**
42 71 * API key
43 72 *
44 73 * @var string
45 74 */
@@ -59,8 +88,19 @@
59 88 */
60 89 private int $timeout;
61 90
62 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 + /**
63 103 * Prompt Builder instance
64 104 *
65 105 * @since 1.0.0
66 106 * @var Prompt_Builder|null
@@ -69,19 +109,83 @@
69 109
70 110 /**
71 111 * Constructor
72 112 *
73 - * @param string $api_key OpenAI API key
74 - * @param string $model Default model to use
75 - * @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.
76 117 */
77 - public function __construct(string $api_key, string $model = \ThinkRank\Core\Settings::DEFAULT_OPENAI_MODEL, 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) {
78 119 $this->api_key = $api_key;
79 120 $this->model = $model;
80 121 $this->timeout = $timeout;
122 + $base_url = rtrim(trim($base_url), '/');
123 + $this->base_url = '' !== $base_url ? $base_url : self::API_BASE_URL;
81 124 }
82 125
83 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 + /**
84 188 * Get Prompt Builder instance
85 189 *
86 190 * @since 1.0.0
87 191 *
@@ -133,9 +237,10 @@
133 237 * future refactor from silently regressing the GPT-5-only guard (issue #286).
134 238 *
135 239 * @param string $prompt User prompt.
136 240 * @param array $options Merged options (must include model, max_tokens, and
137 - * the sampling defaults; reasoning_effort optional).
241 + * the sampling defaults; reasoning_effort and
242 + * json_object optional).
138 243 * @return array Request body for the chat/completions endpoint.
139 244 */
140 245 private function build_chat_completion_body(string $prompt, array $options): array {
141 246 $body = [
@@ -157,11 +262,11 @@
157 262 // temperature, top_p, frequency_penalty, presence_penalty are not supported
158 263 $body['max_completion_tokens'] = $safe_tokens;
159 264
160 265 // GPT-5 models accept reasoning_effort ('minimal'…'high'). Callers
161 - // wanting a quick consumer-style answer (e.g. brand-visibility
162 - // probes) pass 'minimal' so hidden reasoning can't consume the
163 - // whole completion budget and return empty text. Only the GPT-5
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
164 269 // family gets it: o1 rejects the parameter outright.
165 270 if (isset($options['reasoning_effort']) && str_starts_with($options['model'], 'gpt-5')) {
166 271 $body['reasoning_effort'] = (string) $options['reasoning_effort'];
167 272 }
@@ -173,8 +278,12 @@
173 278 $body['presence_penalty'] = $options['presence_penalty'];
174 279 $body['max_tokens'] = $safe_tokens;
175 280 }
176 281
282 + if ($this->json_mode && $this->is_custom_endpoint() && !empty($options['json_object'])) {
283 + $body['response_format'] = ['type' => 'json_object'];
284 + }
285 +
177 286 return $body;
178 287 }
179 288
180 289 /**
@@ -196,8 +305,9 @@
196 305
197 306 $response = $this->generate_completion($prompt, [
198 307 'max_tokens' => $this->get_recommended_tokens('seo_metadata'),
199 308 'temperature' => 0.3, // Lower temperature for more consistent SEO output
309 + 'json_object' => true,
200 310 ]);
201 311
202 312 return $this->parse_seo_response($response);
203 313 }
@@ -216,8 +326,9 @@
216 326
217 327 $response = $this->generate_completion($prompt, [
218 328 'max_tokens' => $this->get_recommended_tokens('analysis'),
219 329 'temperature' => 0.3, // Lower temperature for more consistent analysis
330 + 'json_object' => true,
220 331 ]);
221 332
222 333 return $this->parse_analysis_response($response);
223 334 }
@@ -283,8 +394,31 @@
283 394 return $limit;
284 395 }
285 396 }
286 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 +
287 421 // Default fallback for unknown models
288 422 return 4096;
289 423 }
290 424
@@ -417,21 +551,53 @@
417 551 * @return array Response data
418 552 * @throws \Exception If request fails
419 553 */
420 554 private function make_request(string $endpoint, array $body = []): array {
421 - $url = self::API_BASE_URL . '/' . ltrim($endpoint, '/');
422 -
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 +
423 580 $args = [
424 581 'timeout' => $this->timeout,
425 - 'headers' => [
426 - 'Authorization' => 'Bearer ' . $this->api_key,
427 - 'Content-Type' => 'application/json',
428 - 'User-Agent' => 'ThinkRank/' . THINKRANK_VERSION,
429 - ],
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,
430 592 ];
431 593
594 + if (null === $args['limit_response_size']) {
595 + unset($args['limit_response_size']);
596 + }
597 +
598 + $args['method'] = empty($body) ? 'GET' : 'POST';
432 599 if (!empty($body)) {
433 - $args['method'] = 'POST';
434 600 $args['body'] = wp_json_encode($body);
435 601 }
436 602
437 603 $response = $this->request_with_retry($url, $args);
@@ -442,25 +608,108 @@
442 608
443 609 $status_code = wp_remote_retrieve_response_code($response);
444 610 $response_body = wp_remote_retrieve_body($response);
445 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 +
446 648 if ($status_code >= 400) {
447 649 $error_data = json_decode($response_body, true);
448 - $error_message = $error_data['error']['message'] ?? 'Unknown API error';
449 - 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 + ));
450 658 }
451 659
452 660 $data = json_decode($response_body, true);
453 661
454 662 if (json_last_error() !== JSON_ERROR_NONE) {
455 - 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 + ));
456 668 }
457 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 +
458 703 // A valid-but-scalar body (null/number/string from a proxy/gateway on a
459 704 // 2xx) would violate this method's : array return type; reject it here so
460 705 // it surfaces as a catchable \Exception, not an uncatchable TypeError.
461 706 if (!is_array($data)) {
462 - throw new \Exception('Unexpected non-array response from OpenAI API');
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 + ));
463 712 }
464 713
465 714 return $data;
466 715 }
@@ -465,8 +714,76 @@
465 714 return $data;
466 715 }
467 716
468 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 + }
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 + }
744 +
745 + return false;
746 + }
747 +
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 + /**
469 786 * Perform an HTTP request, retrying transient failures (429 / 5xx / network)
470 787 * per the plugin's retry settings, honoring a Retry-After header when given.
471 788 *
472 789 * @param string $url Request URL
@@ -482,9 +799,14 @@
482 799 for ($attempt = 1; $attempt <= $max_attempts; $attempt++) {
483 800 // Keep PHP alive for the whole blocking call (see method docblock).
484 801 $this->raise_request_time_limit();
485 802
486 - $response = wp_remote_request($url, $args);
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);
487 809
488 810 $is_transient = false;
489 811 $retry_after = 0;
490 812 if (is_wp_error($response)) {