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
← All changes | includes/ai/class-openai-client.php +390 -26 1.26.0 → 2.10.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,8 +326,9 @@
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 }
@@ -246,8 +394,31 @@
246 394 return $limit;
247 395 }
248 396 }
249 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 +
250 421 // Default fallback for unknown models
251 422 return 4096;
252 423 }
253 424
@@ -380,21 +551,53 @@
380 551 * @return array Response data
381 552 * @throws \Exception If request fails
382 553 */
383 554 private function make_request(string $endpoint, array $body = []): array {
384 - $url = self::API_BASE_URL . '/' . ltrim($endpoint, '/');
385 -
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 +
386 580 $args = [
387 581 'timeout' => $this->timeout,
388 - 'headers' => [
389 - 'Authorization' => 'Bearer ' . $this->api_key,
390 - 'Content-Type' => 'application/json',
391 - 'User-Agent' => 'ThinkRank/' . THINKRANK_VERSION,
392 - ],
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,
393 592 ];
394 593
594 + if (null === $args['limit_response_size']) {
595 + unset($args['limit_response_size']);
596 + }
597 +
598 + $args['method'] = empty($body) ? 'GET' : 'POST';
395 599 if (!empty($body)) {
396 - $args['method'] = 'POST';
397 600 $args['body'] = wp_json_encode($body);
398 601 }
399 602
400 603 $response = $this->request_with_retry($url, $args);
@@ -405,25 +608,108 @@
405 608
406 609 $status_code = wp_remote_retrieve_response_code($response);
407 610 $response_body = wp_remote_retrieve_body($response);
408 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 +
409 648 if ($status_code >= 400) {
410 649 $error_data = json_decode($response_body, true);
411 - $error_message = $error_data['error']['message'] ?? 'Unknown API error';
412 - 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 + ));
413 658 }
414 659
415 660 $data = json_decode($response_body, true);
416 661
417 662 if (json_last_error() !== JSON_ERROR_NONE) {
418 - 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 + ));
419 668 }
420 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 +
421 703 // A valid-but-scalar body (null/number/string from a proxy/gateway on a
422 704 // 2xx) would violate this method's : array return type; reject it here so
423 705 // it surfaces as a catchable \Exception, not an uncatchable TypeError.
424 706 if (!is_array($data)) {
425 - 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 + ));
426 712 }
427 713
428 714 return $data;
429 715 }
@@ -428,8 +714,76 @@
428 714 return $data;
429 715 }
430 716
431 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 + /**
432 786 * Perform an HTTP request, retrying transient failures (429 / 5xx / network)
433 787 * per the plugin's retry settings, honoring a Retry-After header when given.
434 788 *
435 789 * @param string $url Request URL
@@ -445,14 +799,24 @@
445 799 for ($attempt = 1; $attempt <= $max_attempts; $attempt++) {
446 800 // Keep PHP alive for the whole blocking call (see method docblock).
447 801 $this->raise_request_time_limit();
448 802
449 - $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);
450 809
451 810 $is_transient = false;
452 811 $retry_after = 0;
453 812 if (is_wp_error($response)) {
454 - $is_transient = true;
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);
455 819 } else {
456 820 $status = wp_remote_retrieve_response_code($response);
457 821 if (429 === $status || $status >= 500) {
458 822 $is_transient = true;
@@ -789,9 +1153,9 @@
789 1153 return [
790 1154 'optimized_data' => [
791 1155 'site_name' => sanitize_text_field($optimization['optimized_data']['site_name'] ?? ''),
792 1156 'project_overview' => sanitize_textarea_field($optimization['optimized_data']['project_overview'] ?? ''),
793 - 'key_features' => sanitize_textarea_field($optimization['optimized_data']['key_features'] ?? ''),
1157 + 'key_features' => \ThinkRank\SEO\LLMs_Txt_Manager::normalize_ai_key_features($optimization['optimized_data']['key_features'] ?? ''),
794 1158 'architecture' => sanitize_textarea_field($optimization['optimized_data']['architecture'] ?? ''),
795 1159 'development_guidelines' => sanitize_textarea_field($optimization['optimized_data']['development_guidelines'] ?? ''),
796 1160 'ai_context' => sanitize_textarea_field($optimization['optimized_data']['ai_context'] ?? ''),
797 1161 ],