PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.0
2.11.0 2.10.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 All 52 releases
thinkrank / includes / ai / class-openai-client.php

class-openai-client.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.10.0, at includes/ai/class-openai-client.php

1,257 lines 50.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenAI API Client
4 *
5 * Handles communication with OpenAI API
6 *
7 * @package ThinkRank\AI
8 * @since 1.0.0
9 */
10
11 declare(strict_types=1);
12
13 namespace ThinkRank\AI;
14
15 use ThinkRank\AI\Traits\Request_Timeout;
16
17 // Prevent direct access
18 if (!defined('ABSPATH')) {
19 exit;
20 }
21
22 require_once __DIR__ . '/traits/trait-request-timeout.php';
23 require_once __DIR__ . '/class-endpoint-url-validator.php';
24
25 /**
26 * OpenAI Client Class
27 *
28 * Single Responsibility: Handle OpenAI API communication
29 *
30 * @since 1.0.0
31 */
32 class OpenAI_Client {
33
34 use Request_Timeout;
35
36
37 /**
38 * OpenAI's own API base URL — the default when no other is given.
39 */
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;
54
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 /**
71 * API key
72 *
73 * @var string
74 */
75 private string $api_key;
76
77 /**
78 * Default model
79 *
80 * @var string
81 */
82 private string $model;
83
84 /**
85 * Request timeout in seconds
86 *
87 * @var int
88 */
89 private int $timeout;
90
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 /**
103 * Prompt Builder instance
104 *
105 * @since 1.0.0
106 * @var Prompt_Builder|null
107 */
108 private ?Prompt_Builder $prompt_builder = null;
109
110 /**
111 * Constructor
112 *
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.
117 */
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) {
119 $this->api_key = $api_key;
120 $this->model = $model;
121 $this->timeout = $timeout;
122 $base_url = rtrim(trim($base_url), '/');
123 $this->base_url = '' !== $base_url ? $base_url : self::API_BASE_URL;
124 }
125
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 /**
188 * Get Prompt Builder instance
189 *
190 * @since 1.0.0
191 *
192 * @return Prompt_Builder Prompt Builder instance
193 */
194 private function get_prompt_builder(): Prompt_Builder {
195 if (!$this->prompt_builder) {
196 // Ensure Prompt Builder is loaded
197 if (!class_exists('ThinkRank\\AI\\Prompt_Builder')) {
198 require_once THINKRANK_PLUGIN_DIR . 'includes/ai/class-prompt-builder.php';
199 }
200 $this->prompt_builder = new Prompt_Builder();
201 }
202 return $this->prompt_builder;
203 }
204
205 /**
206 * Generate completion using OpenAI
207 *
208 * @param string $prompt The prompt to send
209 * @param array $options Additional options
210 * @return array Response data
211 * @throws \Exception If API request fails
212 */
213 public function generate_completion(string $prompt, array $options = []): array {
214 $default_options = [
215 'model' => $this->model,
216 'max_tokens' => 1000,
217 'temperature' => 0.7,
218 'top_p' => 1,
219 'frequency_penalty' => 0,
220 'presence_penalty' => 0,
221 ];
222
223 $options = array_merge($default_options, $options);
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 {
246 $body = [
247 'model' => $options['model'],
248 'messages' => [
249 [
250 'role' => 'user',
251 'content' => $prompt,
252 ]
253 ],
254 ];
255
256 // Get safe token limit for this model
257 $safe_tokens = $this->get_safe_token_limit($options['model'], $options['max_tokens']);
258
259 // Add parameters based on model type
260 if ($this->is_reasoning_model($options['model'])) {
261 // Reasoning models (o1/o3) have fixed parameters and restricted support
262 // temperature, top_p, frequency_penalty, presence_penalty are not supported
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 }
273 } else {
274 // Standard models support all parameters
275 $body['temperature'] = $options['temperature'];
276 $body['top_p'] = $options['top_p'];
277 $body['frequency_penalty'] = $options['frequency_penalty'];
278 $body['presence_penalty'] = $options['presence_penalty'];
279 $body['max_tokens'] = $safe_tokens;
280 }
281
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;
287 }
288
289 /**
290 * Generate SEO metadata
291 *
292 * @param string $content Content to analyze
293 * @param array $options Generation options
294 * @return array Generated metadata
295 * @throws \Exception If generation fails
296 */
297 public function generate_seo_metadata(string $content, array $options = []): array {
298 $target_keyword = $options['target_keyword'] ?? '';
299 $content_type = $options['content_type'] ?? 'blog_post';
300 $tone = $options['tone'] ?? 'professional';
301
302 $prompt_builder = $this->get_prompt_builder();
303 $language = is_string($options['language'] ?? null) ? $options['language'] : '';
304 $prompt = $prompt_builder->build_seo_prompt($content, $target_keyword, $content_type, $tone, 'openai', $language);
305
306 $response = $this->generate_completion($prompt, [
307 'max_tokens' => $this->get_recommended_tokens('seo_metadata'),
308 'temperature' => 0.3, // Lower temperature for more consistent SEO output
309 'json_object' => true,
310 ]);
311
312 return $this->parse_seo_response($response);
313 }
314
315 /**
316 * Analyze content for SEO optimization
317 *
318 * @param string $content Content to analyze
319 * @param array $metadata Existing metadata
320 * @return array Analysis results
321 * @throws \Exception If analysis fails
322 */
323 public function analyze_content(string $content, array $metadata = []): array {
324 $prompt_builder = $this->get_prompt_builder();
325 $prompt = $prompt_builder->build_analysis_prompt($content, $metadata, 'openai');
326
327 $response = $this->generate_completion($prompt, [
328 'max_tokens' => $this->get_recommended_tokens('analysis'),
329 'temperature' => 0.3, // Lower temperature for more consistent analysis
330 'json_object' => true,
331 ]);
332
333 return $this->parse_analysis_response($response);
334 }
335 private function is_reasoning_model(string $model): bool {
336 // Models that require max_completion_tokens and restrict parameters (no temperature/top_p)
337 // Includes OpenAI o1/o3 series and GPT-5 family
338 $reasoning_models = [
339 'o1-preview',
340 'o1-mini',
341 'o3-mini',
342 'o3-2024-12-17',
343 'gpt-5',
344 'gpt-5-mini',
345 'gpt-5-nano',
346 ];
347
348 // Check for exact matches or model prefixes
349 foreach ($reasoning_models as $reasoning_model) {
350 if ($model === $reasoning_model || strpos($model, $reasoning_model) === 0) {
351 return true;
352 }
353 }
354
355 return false;
356 }
357
358 /**
359 * Get maximum completion tokens for a model
360 *
361 * @param string $model Model name
362 * @return int Maximum completion tokens
363 */
364 private function get_max_completion_tokens(string $model): int {
365 // Model-specific token limits (completion tokens, not context)
366 $token_limits = [
367 // GPT-5 series (optimized for reasoning + content tokens based on usage data)
368 'gpt-5' => 20480, // 20K tokens for full GPT-5
369 'gpt-5-mini' => 15360, // 15K tokens for mini variant (increased)
370 'gpt-5-nano' => 12288, // 12K tokens for nano (increased from 10K for better buffer)
371
372 // GPT-4o series
373 'gpt-4o' => 4096,
374 'gpt-4o-2024-08-06' => 4096,
375 'gpt-4o-2024-05-13' => 4096,
376 'gpt-4o-mini' => 16384,
377 'gpt-4o-mini-2024-07-18' => 16384,
378
379 // o1/o3 reasoning models (higher limits)
380 'o1-preview' => 32768,
381 'o1-mini' => 65536,
382 'o3-mini' => 65536,
383 'o3-2024-12-17' => 65536,
384 ];
385
386 // Check for exact match first
387 if (isset($token_limits[$model])) {
388 return $token_limits[$model];
389 }
390
391 // Check for partial matches (for versioned models)
392 foreach ($token_limits as $known_model => $limit) {
393 if (strpos($model, $known_model) === 0) {
394 return $limit;
395 }
396 }
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
421 // Default fallback for unknown models
422 return 4096;
423 }
424
425 /**
426 * Get safe token limit for a request
427 *
428 * @param string $model Model name
429 * @param int $requested_tokens Requested token count
430 * @return int Safe token count (capped at model limit)
431 */
432 public function get_safe_token_limit(string $model, int $requested_tokens): int {
433 $max_tokens = $this->get_max_completion_tokens($model);
434 return min($requested_tokens, $max_tokens);
435 }
436
437 /**
438 * Get recommended token limit for specific use cases
439 *
440 * @param string $use_case Use case (e.g., 'content_brief', 'seo_metadata', 'analysis')
441 * @return int Recommended token limit
442 */
443 public function get_recommended_tokens(string $use_case): int {
444 $max_tokens = $this->get_max_completion_tokens($this->model);
445
446 // For reasoning models (GPT-5, o1, o3), we need much higher token limits
447 // because they use reasoning tokens + content tokens
448 if ($this->is_reasoning_model($this->model)) {
449 $reasoning_recommendations = [
450 'content_brief' => 0.95, // 95% of max tokens (reasoning + content)
451 'seo_metadata' => 0.8, // 80% for reasoning models (safe buffer)
452 'analysis' => 0.85, // 85% for reasoning models (safe buffer)
453 'llms_txt' => 0.8, // 80% for reasoning models (safe buffer)
454 'optimization' => 0.9, // 90% for reasoning models (based on 5.9K usage + 50% buffer)
455 ];
456 $percentage = $reasoning_recommendations[$use_case] ?? 0.3;
457 } else {
458 // Standard models - original percentages
459 $standard_recommendations = [
460 'content_brief' => 0.9, // 90% of max tokens for comprehensive briefs
461 'seo_metadata' => 0.15, // 15% of max tokens for metadata
462 'analysis' => 0.25, // 25% of max tokens for analysis
463 'llms_txt' => 0.5, // 50% of max tokens for llms.txt
464 'optimization' => 0.15, // 15% of max tokens for optimization
465 ];
466 $percentage = $standard_recommendations[$use_case] ?? 0.15;
467 }
468
469 return (int) ($max_tokens * $percentage);
470 }
471
472 /**
473 * Build request body for chat completions with model-specific parameters
474 *
475 * @param string $user_prompt User prompt
476 * @param string|null $system_prompt System prompt (ignored for reasoning models)
477 * @param int $max_tokens Maximum tokens
478 * @param float $temperature Temperature (ignored for reasoning models)
479 * @return array Request body
480 */
481 private function build_chat_request(string $user_prompt, ?string $system_prompt = null, int $max_tokens = 600, float $temperature = 0.4): array {
482 $body = [
483 'model' => $this->model,
484 ];
485
486 // Get safe token limit for this model
487 $safe_tokens = $this->get_safe_token_limit($this->model, $max_tokens);
488
489 // Build messages based on model type
490 if ($this->is_reasoning_model($this->model)) {
491 // Reasoning/GPT‑5 family: use max_completion_tokens and omit temperature
492 $content = $user_prompt;
493 // Some GPT‑5 responses may expect array content parts; we'll send string
494 $body['messages'] = [
495 [
496 'role' => 'user',
497 'content' => $content
498 ]
499 ];
500 $body['max_completion_tokens'] = $safe_tokens;
501 } else {
502 // Standard models support system messages and temperature
503 $messages = [];
504 if ($system_prompt) {
505 $messages[] = [
506 'role' => 'system',
507 'content' => $system_prompt
508 ];
509 }
510 $messages[] = [
511 'role' => 'user',
512 'content' => $user_prompt
513 ];
514
515 $body['messages'] = $messages;
516 $body['temperature'] = $temperature;
517 $body['max_tokens'] = $safe_tokens;
518 }
519
520 return $body;
521 }
522
523 /**
524 * Get current model
525 *
526 * @return string Current model name
527 */
528 public function get_model(): string {
529 return $this->model;
530 }
531
532 /**
533 * Test API connection
534 *
535 * @return bool True if connection successful
536 */
537 public function test_connection(): bool {
538 try {
539 $response = $this->make_request('models');
540 return isset($response['data']) && is_array($response['data']);
541 } catch (\Exception $e) {
542 return false;
543 }
544 }
545
546 /**
547 * Make API request to OpenAI
548 *
549 * @param string $endpoint API endpoint
550 * @param array $body Request body
551 * @return array Response data
552 * @throws \Exception If request fails
553 */
554 private function make_request(string $endpoint, array $body = []): array {
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
580 $args = [
581 'timeout' => $this->timeout,
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,
592 ];
593
594 if (null === $args['limit_response_size']) {
595 unset($args['limit_response_size']);
596 }
597
598 $args['method'] = empty($body) ? 'GET' : 'POST';
599 if (!empty($body)) {
600 $args['body'] = wp_json_encode($body);
601 }
602
603 $response = $this->request_with_retry($url, $args);
604
605 if (is_wp_error($response)) {
606 throw new \Exception('API request failed: ' . esc_html($response->get_error_message()));
607 }
608
609 $status_code = wp_remote_retrieve_response_code($response);
610 $response_body = wp_remote_retrieve_body($response);
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
648 if ($status_code >= 400) {
649 $error_data = json_decode($response_body, true);
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 ));
658 }
659
660 $data = json_decode($response_body, true);
661
662 if (json_last_error() !== JSON_ERROR_NONE) {
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 ));
668 }
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
714 return $data;
715 }
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 }
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 /**
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 /**
861 * Parse SEO response from OpenAI
862 *
863 * @param array $response OpenAI response
864 * @return array Parsed metadata
865 * @throws \Exception If parsing fails
866 */
867 private function parse_seo_response(array $response): array {
868 if (!isset($response['choices'][0]['message']['content'])) {
869 throw new \Exception('Invalid response format from OpenAI');
870 }
871
872 $content = $response['choices'][0]['message']['content'];
873 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
874
875 // Try to extract JSON from the response
876 $json_start = strpos($content, '{');
877 $json_end = strrpos($content, '}');
878
879 if (false === $json_start || false === $json_end) {
880 throw new \Exception('No valid JSON found in OpenAI response');
881 }
882
883 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
884 $metadata = json_decode($json_content, true);
885
886 if (json_last_error() !== JSON_ERROR_NONE) {
887 throw new \Exception('Failed to parse JSON from OpenAI response');
888 }
889
890 // Validate required fields
891 $required_fields = ['title', 'description', 'focus_keyword'];
892 foreach ($required_fields as $field) {
893 if (!isset($metadata[$field])) {
894 throw new \Exception(sprintf('Missing required field: %s', esc_html($field)));
895 }
896 }
897
898 return [
899 'title' => sanitize_text_field($metadata['title']),
900 'description' => sanitize_text_field($metadata['description']),
901 'focus_keyword' => sanitize_text_field($metadata['focus_keyword']),
902 'suggestions' => array_map('sanitize_text_field', $metadata['suggestions'] ?? []),
903 'generated_at' => current_time('mysql'),
904 'tokens_used' => $response['usage']['total_tokens'] ?? 0,
905 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
906 ];
907 }
908
909 /**
910 * Parse analysis response from OpenAI
911 *
912 * @param array $response OpenAI API response
913 * @return array Parsed analysis data
914 * @throws \Exception If parsing fails
915 */
916 private function parse_analysis_response(array $response): array {
917 if (!isset($response['choices'][0]['message']['content'])) {
918 throw new \Exception('Invalid response format from OpenAI');
919 }
920
921 $content = trim($response['choices'][0]['message']['content']);
922 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
923
924 // Extract JSON from response
925 $json_start = strpos($content, '{');
926 $json_end = strrpos($content, '}');
927
928 if (false === $json_start || false === $json_end) {
929 throw new \Exception('No valid JSON found in response');
930 }
931
932 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
933 $analysis = json_decode($json_content, true);
934
935 if (json_last_error() !== JSON_ERROR_NONE) {
936 throw new \Exception('Failed to parse JSON response: ' . esc_html(json_last_error_msg()));
937 }
938
939 // Validate and sanitize response
940 return [
941 'seo_score' => min(100, max(0, (int) ($analysis['seo_score'] ?? 0))),
942 'content_analysis' => [
943 'word_count' => (int) ($analysis['content_analysis']['word_count'] ?? 0),
944 'readability' => sanitize_text_field($analysis['content_analysis']['readability'] ?? 'unknown'),
945 'keyword_density' => sanitize_text_field($analysis['content_analysis']['keyword_density'] ?? 'unknown'),
946 'structure' => sanitize_text_field($analysis['content_analysis']['structure'] ?? 'unknown'),
947 ],
948 'suggestions' => array_map('sanitize_text_field', $analysis['suggestions'] ?? []),
949 'strengths' => array_map('sanitize_text_field', $analysis['strengths'] ?? []),
950 'weaknesses' => array_map('sanitize_text_field', $analysis['weaknesses'] ?? []),
951 'analyzed_at' => current_time('mysql'),
952 'tokens_used' => $response['usage']['total_tokens'] ?? 0,
953 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
954 ];
955 }
956
957 /**
958 * Optimize site identity using OpenAI
959 *
960 * @since 1.0.0
961 *
962 * @param array $site_data Site data to optimize
963 * @param array $options Optimization options
964 * @return array Optimization results
965 * @throws \Exception If optimization fails
966 */
967 public function optimize_site_identity(array $site_data, array $options = []): array {
968 $business_type = $options['business_type'] ?? 'website';
969 $target_audience = $options['target_audience'] ?? 'general';
970 $tone = $options['tone'] ?? 'professional';
971
972 $prompt_builder = $this->get_prompt_builder();
973 $prompt = $prompt_builder->build_site_identity_prompt($site_data, $business_type, $target_audience, $tone, 'openai');
974
975 $body = $this->build_chat_request(
976 $prompt,
977 'You are an expert SEO consultant specializing in site identity optimization. Provide actionable, specific recommendations in JSON format.',
978 $this->get_recommended_tokens('optimization'),
979 0.4
980 );
981
982 $response = $this->make_request('chat/completions', $body);
983
984 return $this->parse_site_identity_response($response);
985 }
986
987 /**
988 * Parse site identity optimization response
989 *
990 * @param array $response OpenAI API response
991 * @return array Parsed optimization data
992 * @throws \Exception If parsing fails
993 */
994 private function parse_site_identity_response(array $response): array {
995 if (!isset($response['choices'][0]['message']['content'])) {
996 throw new \Exception('Invalid response format from OpenAI');
997 }
998
999 $content = trim($response['choices'][0]['message']['content']);
1000 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
1001
1002 // Extract JSON from response
1003 $json_start = strpos($content, '{');
1004 $json_end = strrpos($content, '}');
1005
1006 if (false === $json_start || false === $json_end) {
1007 throw new \Exception('No valid JSON found in response');
1008 }
1009
1010 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
1011 $optimization = json_decode($json_content, true);
1012
1013 if (json_last_error() !== JSON_ERROR_NONE) {
1014 throw new \Exception('Failed to parse JSON response: ' . esc_html(json_last_error_msg()));
1015 }
1016
1017 // Validate and sanitize response
1018 return [
1019 'optimized_data' => [
1020 'site_name' => sanitize_text_field($optimization['optimized_data']['site_name'] ?? ''),
1021 'site_description' => sanitize_text_field($optimization['optimized_data']['site_description'] ?? ''),
1022 'tagline' => sanitize_text_field($optimization['optimized_data']['tagline'] ?? ''),
1023 'default_meta_description' => sanitize_text_field($optimization['optimized_data']['default_meta_description'] ?? ''),
1024 ],
1025 'analysis' => sanitize_textarea_field($optimization['analysis'] ?? ''),
1026 'suggestions' => array_map('sanitize_text_field', $optimization['suggestions'] ?? []),
1027 'score' => min(100, max(0, (int) ($optimization['score'] ?? 0))),
1028 'tokens_used' => $response['usage']['total_tokens'] ?? 0,
1029 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
1030 ];
1031 }
1032
1033 /**
1034 * Optimize homepage meta content using AI (copying Site Identity pattern exactly)
1035 *
1036 * @since 1.0.0
1037 *
1038 * @param array $content_data Meta content data to optimize
1039 * @param array $options Optimization options
1040 * @return array Optimization results
1041 * @throws \Exception If optimization fails
1042 */
1043 public function optimize_homepage_meta(array $content_data, array $options = []): array {
1044 $business_type = $options['business_type'] ?? 'website';
1045 $target_audience = $options['target_audience'] ?? 'general';
1046 $tone = $options['tone'] ?? 'professional';
1047 $context = $options['context'] ?? [];
1048
1049 $prompt_builder = $this->get_prompt_builder();
1050 $prompt = $prompt_builder->build_homepage_meta_prompt($content_data, $business_type, $target_audience, $tone, $context, 'openai');
1051
1052 $body = $this->build_chat_request(
1053 $prompt,
1054 'You are an expert SEO consultant specializing in homepage meta optimization. Provide actionable, specific recommendations in JSON format.',
1055 $this->get_recommended_tokens('optimization'),
1056 0.4
1057 );
1058
1059 $response = $this->make_request('chat/completions', $body);
1060
1061 return $this->parse_homepage_meta_response($response);
1062 }
1063
1064 /**
1065 * Optimize homepage hero content using AI (copying Site Identity pattern exactly)
1066 *
1067 * @since 1.0.0
1068 *
1069 * @param array $hero_data Hero content data to optimize
1070 * @param array $options Optimization options
1071 * @return array Optimization results
1072 * @throws \Exception If optimization fails
1073 */
1074 public function optimize_homepage_hero(array $hero_data, array $options = []): array {
1075 $business_type = $options['business_type'] ?? 'website';
1076 $target_audience = $options['target_audience'] ?? 'general';
1077 $tone = $options['tone'] ?? 'professional';
1078 $context = $options['context'] ?? [];
1079
1080 $prompt_builder = $this->get_prompt_builder();
1081 $prompt = $prompt_builder->build_homepage_hero_prompt($hero_data, $business_type, $target_audience, $tone, $context, 'openai');
1082
1083 $body = $this->build_chat_request(
1084 $prompt,
1085 'You are an expert conversion optimization specialist specializing in homepage hero sections. Provide actionable, specific recommendations in JSON format.',
1086 $this->get_recommended_tokens('optimization'),
1087 0.4
1088 );
1089
1090 $response = $this->make_request('chat/completions', $body);
1091
1092 return $this->parse_homepage_hero_response($response);
1093 }
1094
1095 /**
1096 * Optimize LLMs.txt content using OpenAI
1097 *
1098 * @since 1.0.0
1099 *
1100 * @param array $website_data Website data to optimize
1101 * @param array $options Optimization options
1102 * @return array Optimization results
1103 * @throws \Exception If optimization fails
1104 */
1105 public function optimize_llms_txt(array $website_data, array $options = []): array {
1106 // Use shared prompt builder for consistent prompts across all AI providers
1107 $prompt_builder = $this->get_prompt_builder();
1108 $prompt = $prompt_builder->build_llms_txt_prompt($website_data, $options, 'openai');
1109
1110 $body = $this->build_chat_request(
1111 $prompt,
1112 'You are an expert technical writer specializing in creating llms.txt files for AI assistants. Provide structured, comprehensive content in JSON format.',
1113 $this->get_recommended_tokens('llms_txt'),
1114 0.4
1115 );
1116
1117 $response = $this->make_request('chat/completions', $body);
1118
1119 return $this->parse_llms_txt_response($response);
1120 }
1121
1122 /**
1123 * Parse LLMs.txt optimization response
1124 *
1125 * @param array $response OpenAI API response
1126 * @return array Parsed optimization data
1127 * @throws \Exception If parsing fails
1128 */
1129 private function parse_llms_txt_response(array $response): array {
1130 if (!isset($response['choices'][0]['message']['content'])) {
1131 throw new \Exception('Invalid response format from OpenAI');
1132 }
1133
1134 $content = trim($response['choices'][0]['message']['content']);
1135 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
1136
1137 // Extract JSON from response
1138 $json_start = strpos($content, '{');
1139 $json_end = strrpos($content, '}');
1140
1141 if (false === $json_start || false === $json_end) {
1142 throw new \Exception('No valid JSON found in response');
1143 }
1144
1145 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
1146 $optimization = json_decode($json_content, true);
1147
1148 if (json_last_error() !== JSON_ERROR_NONE) {
1149 throw new \Exception('Invalid JSON in response: ' . esc_html(json_last_error_msg()));
1150 }
1151
1152 // Validate and sanitize response
1153 return [
1154 'optimized_data' => [
1155 'site_name' => sanitize_text_field($optimization['optimized_data']['site_name'] ?? ''),
1156 'project_overview' => sanitize_textarea_field($optimization['optimized_data']['project_overview'] ?? ''),
1157 'key_features' => \ThinkRank\SEO\LLMs_Txt_Manager::normalize_ai_key_features($optimization['optimized_data']['key_features'] ?? ''),
1158 'architecture' => sanitize_textarea_field($optimization['optimized_data']['architecture'] ?? ''),
1159 'development_guidelines' => sanitize_textarea_field($optimization['optimized_data']['development_guidelines'] ?? ''),
1160 'ai_context' => sanitize_textarea_field($optimization['optimized_data']['ai_context'] ?? ''),
1161 ],
1162 'suggestions' => array_map('sanitize_text_field', $optimization['suggestions'] ?? []),
1163 'tokens_used' => $response['usage']['total_tokens'] ?? 0,
1164 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
1165 ];
1166 }
1167
1168 /**
1169 * Parse homepage meta optimization response
1170 *
1171 * @param array $response OpenAI API response
1172 * @return array Parsed optimization data
1173 * @throws \Exception If parsing fails
1174 */
1175 private function parse_homepage_meta_response(array $response): array {
1176 if (!isset($response['choices'][0]['message']['content'])) {
1177 throw new \Exception('Invalid response format from OpenAI');
1178 }
1179
1180 $content = trim($response['choices'][0]['message']['content']);
1181 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
1182
1183 // Extract JSON from response
1184 $json_start = strpos($content, '{');
1185 $json_end = strrpos($content, '}');
1186
1187 if (false === $json_start || false === $json_end) {
1188 throw new \Exception('No valid JSON found in response');
1189 }
1190
1191 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
1192 $optimization = json_decode($json_content, true);
1193
1194 if (json_last_error() !== JSON_ERROR_NONE) {
1195 throw new \Exception('Failed to parse JSON response: ' . esc_html(json_last_error_msg()));
1196 }
1197
1198 // Validate and sanitize response
1199 return [
1200 'optimized_data' => [
1201 'title' => sanitize_text_field($optimization['optimized_data']['title'] ?? ''),
1202 'meta_description' => sanitize_text_field($optimization['optimized_data']['meta_description'] ?? ''),
1203 ],
1204 'analysis' => sanitize_textarea_field($optimization['analysis'] ?? ''),
1205 'suggestions' => array_map('sanitize_text_field', $optimization['suggestions'] ?? []),
1206 'score' => min(100, max(0, (int) ($optimization['score'] ?? 0))),
1207 'tokens_used' => $response['usage']['total_tokens'] ?? 0,
1208 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
1209 ];
1210 }
1211
1212 /**
1213 * Parse homepage hero optimization response
1214 *
1215 * @param array $response OpenAI API response
1216 * @return array Parsed optimization data
1217 * @throws \Exception If parsing fails
1218 */
1219 private function parse_homepage_hero_response(array $response): array {
1220 if (!isset($response['choices'][0]['message']['content'])) {
1221 throw new \Exception('Invalid response format from OpenAI');
1222 }
1223
1224 $content = trim($response['choices'][0]['message']['content']);
1225 $ai_text = $content; // Store the raw AI-generated text (Content Brief pattern)
1226
1227 // Extract JSON from response
1228 $json_start = strpos($content, '{');
1229 $json_end = strrpos($content, '}');
1230
1231 if (false === $json_start || false === $json_end) {
1232 throw new \Exception('No valid JSON found in response');
1233 }
1234
1235 $json_content = substr($content, $json_start, $json_end - $json_start + 1);
1236 $optimization = json_decode($json_content, true);
1237
1238 if (json_last_error() !== JSON_ERROR_NONE) {
1239 throw new \Exception('Failed to parse JSON response: ' . esc_html(json_last_error_msg()));
1240 }
1241
1242 // Validate and sanitize response
1243 return [
1244 'optimized_data' => [
1245 'hero_title' => sanitize_text_field($optimization['optimized_data']['hero_title'] ?? ''),
1246 'hero_subtitle' => sanitize_text_field($optimization['optimized_data']['hero_subtitle'] ?? ''),
1247 'hero_cta_text' => sanitize_text_field($optimization['optimized_data']['hero_cta_text'] ?? '')
1248 ],
1249 'analysis' => sanitize_textarea_field($optimization['analysis'] ?? ''),
1250 'suggestions' => array_map('sanitize_text_field', $optimization['suggestions'] ?? []),
1251 'score' => min(100, max(0, (int) ($optimization['score'] ?? 0))),
1252 'tokens_used' => $response['usage']['total_tokens'] ?? 0,
1253 '_ai_text' => $ai_text, // Store the raw AI-generated text (Content Brief pattern)
1254 ];
1255 }
1256 }
1257