PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.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 1.1.0 1.10.0 All 48 releases
← All changes | includes/ai/class-manager.php +202 -58 1.25.02.7.0 View file →
@@ -83,12 +83,21 @@
83 83 /**
84 84 * Initialize AI client
85 85 *
86 86 * @return void
87 + *
88 + * @throws \Exception On failure.
87 89 */
88 90 public function initialize_client(): void {
89 - $provider = $this->settings->get('ai_provider', 'openai');
91 + $provider = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
90 92
93 + // No provider chosen yet (a fresh install, or the user cleared it). That
94 + // is a normal unconfigured state, not a failure — leave $this->client
95 + // null and let get_client_unavailable_message() explain it (#572).
96 + if (Settings::AI_PROVIDER_NONE === $provider) {
97 + return;
98 + }
99 +
91 100 try {
92 101 switch ($provider) {
93 102 case 'openai':
94 103 $api_key = $this->settings->get('openai_api_key');
@@ -94,11 +103,11 @@
94 103 $api_key = $this->settings->get('openai_api_key');
95 104 if ($api_key) {
96 105 // Allow any model id (incl. user-entered custom models);
97 106 // only fall back to the default when none is set.
98 - $model = $this->settings->get('openai_model', 'gpt-5-nano');
107 + $model = $this->settings->get('openai_model', Settings::DEFAULT_OPENAI_MODEL);
99 108 if (empty($model)) {
100 - $model = 'gpt-5-nano';
109 + $model = Settings::DEFAULT_OPENAI_MODEL;
101 110 }
102 111 // OpenAI's reasoning models (GPT-5/o-series) spend a long
103 112 // time on reasoning tokens before emitting content, so
104 113 // large completions (content briefs) regularly outlive the
@@ -114,11 +123,11 @@
114 123 $api_key = $this->settings->get('claude_api_key');
115 124 if ($api_key) {
116 125 // Allow any model id (incl. user-entered custom models);
117 126 // only fall back to the default when none is set.
118 - $model = $this->settings->get('claude_model', 'claude-sonnet-5');
127 + $model = $this->settings->get('claude_model', Settings::DEFAULT_CLAUDE_MODEL);
119 128 if (empty($model)) {
120 - $model = 'claude-sonnet-5';
129 + $model = Settings::DEFAULT_CLAUDE_MODEL;
121 130 }
122 131 // Use 120-second timeout for complex AI operations
123 132 $timeout = 120;
124 133 $this->client = new Claude_Client($api_key, $model, $timeout);
@@ -131,11 +140,11 @@
131 140 $api_key = $this->settings->get('gemini_api_key');
132 141 if ($api_key) {
133 142 // Allow any model id (incl. user-entered custom models);
134 143 // only fall back to the default when none is set.
135 - $model = $this->settings->get('gemini_model', 'gemini-2.5-flash');
144 + $model = $this->settings->get('gemini_model', Settings::DEFAULT_GEMINI_MODEL);
136 145 if (empty($model)) {
137 - $model = 'gemini-2.5-flash';
146 + $model = Settings::DEFAULT_GEMINI_MODEL;
138 147 }
139 148 // Use 120-second timeout for complex AI operations
140 149 $timeout = 120;
141 150 $this->client = new Gemini_Client($api_key, $model, $timeout);
@@ -146,11 +155,11 @@
146 155 $api_key = $this->settings->get('openrouter_api_key');
147 156 if ($api_key) {
148 157 // Allow any model id (incl. user-entered custom models);
149 158 // only fall back to the default when none is set.
150 - $model = $this->settings->get('openrouter_model', 'openai/gpt-4o-mini');
159 + $model = $this->settings->get('openrouter_model', Settings::DEFAULT_OPENROUTER_MODEL);
151 160 if (empty($model)) {
152 - $model = 'openai/gpt-4o-mini';
161 + $model = Settings::DEFAULT_OPENROUTER_MODEL;
153 162 }
154 163 // Use 120-second timeout for complex AI operations
155 164 $timeout = 120;
156 165 $this->client = new OpenRouter_Client($api_key, $model, $timeout);
@@ -160,9 +169,15 @@
160 169 default:
161 170 throw new \Exception("Unsupported AI provider: {$provider}");
162 171 }
163 172 } catch (\Exception $e) {
164 - // AI client initialization failed, will be handled later
173 + // Leave a trace. Swallowing this meant a misconfigured provider
174 + // produced a NULL client and every AI feature became a silent
175 + // no-op with nothing to diagnose from.
176 + if (defined('WP_DEBUG') && WP_DEBUG) {
177 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- diagnostic, WP_DEBUG only.
178 + error_log('ThinkRank [ai]: client initialization failed — ' . $e->getMessage());
179 + }
165 180 }
166 181 }
167 182
168 183 /**
@@ -172,14 +187,15 @@
172 187 */
173 188 private function get_provider_label(): string {
174 189 $labels = [
175 190 'openai' => 'OpenAI',
176 - 'claude' => 'Claude',
191 + // The vendor, not the model family — matches the settings UI (#572).
192 + 'claude' => 'Anthropic',
177 193 'gemini' => 'Gemini',
178 194 'openrouter' => 'OpenRouter',
179 195 ];
180 196
181 - $provider = (string) $this->settings->get('ai_provider', 'openai');
197 + $provider = (string) $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
182 198
183 199 return $labels[$provider] ?? ucfirst($provider);
184 200 }
185 201
@@ -191,9 +207,9 @@
191 207 *
192 208 * @return string Actionable error message for end users
193 209 */
194 210 private function get_client_unavailable_message(): string {
195 - $provider = (string) $this->settings->get('ai_provider', 'openai');
211 + $provider = (string) $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
196 212
197 213 // The React admin renders this anchor as a real link via linkifyMessage().
198 214 $settings_link = sprintf(
199 215 '<a href="%s" target="_blank" rel="noopener noreferrer">%s</a>',
@@ -200,12 +216,23 @@
200 216 esc_url(admin_url('admin.php?page=thinkrank-settings')),
201 217 __('ThinkRank → Settings', 'thinkrank')
202 218 );
203 219
220 + // No provider chosen at all — asking for a key would put the cart before
221 + // the horse, so name the actual first step (#572).
222 + if (Settings::AI_PROVIDER_NONE === $provider) {
223 + return sprintf(
224 + /* translators: %s: link to the ThinkRank settings page. */
225 + __('AI features are not set up yet. Choose an AI provider and add its API key under %s.', 'thinkrank'),
226 + $settings_link
227 + );
228 + }
229 +
204 230 if (empty($this->settings->get("{$provider}_api_key"))) {
205 231 return sprintf(
206 - /* translators: %s: link to the ThinkRank settings page. */
207 - __('AI features are not set up yet. To enable them, add your API key under %s.', 'thinkrank'),
232 + /* translators: 1: AI provider name (e.g. OpenAI), 2: link to the ThinkRank settings page. */
233 + __('AI features are not set up yet. To enable them, add your %1$s API key under %2$s.', 'thinkrank'),
234 + $this->get_provider_label(),
208 235 $settings_link
209 236 );
210 237 }
211 238
@@ -240,9 +267,9 @@
240 267 }
241 268
242 269 // If still not available, throw error
243 270 if (!$this->client) {
244 - throw new \Exception($this->get_client_unavailable_message());
271 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
245 272 }
246 273
247 274 return $this->client;
248 275 }
@@ -261,9 +288,9 @@
261 288 $this->initialize_client();
262 289
263 290 // If still not available, throw error
264 291 if (!$this->client) {
265 - throw new \Exception($this->get_client_unavailable_message());
292 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
266 293 }
267 294 }
268 295
269 296 // Check rate limits
@@ -290,9 +317,9 @@
290 317 // Ensure user has configured their API key
291 318 $user_has_api_key = !empty($this->settings->get('openai_api_key')) || !empty($this->settings->get('claude_api_key')) || !empty($this->settings->get('gemini_api_key')) || !empty($this->settings->get('openrouter_api_key'));
292 319
293 320 if (!$user_has_api_key) {
294 - throw new \Exception($this->get_client_unavailable_message());
321 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
295 322 }
296 323
297 324 // Cache the result
298 325 $this->cache->set($cache_key, $metadata);
@@ -336,9 +363,9 @@
336 363 public function improve_seo_title(string $content, array $options = []): array {
337 364 if (!$this->client) {
338 365 $this->initialize_client();
339 366 if (!$this->client) {
340 - throw new \Exception($this->get_client_unavailable_message());
367 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
341 368 }
342 369 }
343 370
344 371 // Ensure user has configured their API key.
@@ -343,9 +370,9 @@
343 370
344 371 // Ensure user has configured their API key.
345 372 $user_has_api_key = !empty($this->settings->get('openai_api_key')) || !empty($this->settings->get('claude_api_key')) || !empty($this->settings->get('gemini_api_key')) || !empty($this->settings->get('openrouter_api_key'));
346 373 if (!$user_has_api_key) {
347 - throw new \Exception($this->get_client_unavailable_message());
374 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
348 375 }
349 376
350 377 // Check rate limits.
351 378 if (!$this->check_rate_limit()) {
@@ -356,8 +383,9 @@
356 383 $target_keyword = (string) ($options['target_keyword'] ?? '');
357 384 $content_type = (string) ($options['content_type'] ?? 'blog_post');
358 385 $tone = (string) ($options['tone'] ?? 'professional');
359 386 $suggestion = (string) ($options['suggestion'] ?? '');
387 + $language = (string) ($options['language'] ?? '');
360 388
361 389 // Cache identical requests (same content + inputs) to avoid duplicate calls.
362 390 // Cap content server-side (mirror the frontend 5000-char trim) so a
363 391 // direct REST caller can't force oversized prompt/cache/AI work.
@@ -389,9 +417,10 @@
389 417 $tone,
390 418 $suggestion,
391 419 $provider,
392 420 $sentiment_words,
393 - $power_words
421 + $power_words,
422 + $language
394 423 );
395 424
396 425 $generated = $this->request_title($prompt);
397 426 $title = $generated['title'];
@@ -396,8 +425,9 @@
396 425 $generated = $this->request_title($prompt);
397 426 $title = $generated['title'];
398 427 $total_tokens = $generated['tokens'];
399 428 $ai_text = $generated['ai_text'];
429 + $finish_reason = $generated['finish_reason'];
400 430
401 431 // A reasoning model can still return an empty/truncated title on the
402 432 // first pass; retry once before giving up so the "Apply" action reliably
403 433 // produces a title.
@@ -406,8 +436,9 @@
406 436 $total_tokens += $retry['tokens'];
407 437 if ($retry['ai_text'] !== '') {
408 438 $ai_text = $retry['ai_text'];
409 439 }
440 + $finish_reason = $retry['finish_reason'];
410 441 if ($retry['title'] !== '') {
411 442 $title = $retry['title'];
412 443 }
413 444 }
@@ -421,8 +452,9 @@
421 452 $total_tokens += $retry['tokens'];
422 453 if ($retry['ai_text'] !== '') {
423 454 $ai_text = $retry['ai_text'];
424 455 }
456 + $finish_reason = $retry['finish_reason'];
425 457 if ($retry['title'] !== '' && $this->title_contains_word($retry['title'], $sentiment_words)) {
426 458 $title = $retry['title'];
427 459 }
428 460 }
@@ -427,8 +459,20 @@
427 459 }
428 460 }
429 461
430 462 if ($title === '') {
463 + // Nothing about a raw JSON-parse failure is visible to support
464 + // otherwise — log_ai_usage() below only runs on success, so a
465 + // failed attempt left no trace of what the model actually sent
466 + // back or why generation stopped.
467 + if (defined('WP_DEBUG') && WP_DEBUG) {
468 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Debug logging only when WP_DEBUG is enabled.
469 + error_log(sprintf(
470 + '[ThinkRank] Title improvement failed to extract a title. finish_reason=%s ai_text=%s',
471 + $finish_reason !== '' ? $finish_reason : '(none)',
472 + mb_substr($ai_text, 0, 500)
473 + ));
474 + }
431 475 throw new \Exception('The AI did not return a usable title. Please try again.');
432 476 }
433 477
434 478 // Log usage.
@@ -462,8 +506,9 @@
462 506 return [
463 507 'title' => $title,
464 508 'ai_text' => $completion['ai_text'],
465 509 'tokens' => $completion['tokens'],
510 + 'finish_reason' => $completion['finish_reason'],
466 511 ];
467 512 }
468 513
469 514 /**
@@ -489,15 +534,15 @@
489 534 public function improve_meta_description(string $content, array $options = []): array {
490 535 if (!$this->client) {
491 536 $this->initialize_client();
492 537 if (!$this->client) {
493 - throw new \Exception($this->get_client_unavailable_message());
538 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
494 539 }
495 540 }
496 541
497 542 $user_has_api_key = !empty($this->settings->get('openai_api_key')) || !empty($this->settings->get('claude_api_key')) || !empty($this->settings->get('gemini_api_key')) || !empty($this->settings->get('openrouter_api_key'));
498 543 if (!$user_has_api_key) {
499 - throw new \Exception($this->get_client_unavailable_message());
544 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
500 545 }
501 546
502 547 if (!$this->check_rate_limit()) {
503 548 throw new \Exception('Rate limit exceeded. Please try again later.');
@@ -507,8 +552,9 @@
507 552 $target_keyword = (string) ($options['target_keyword'] ?? '');
508 553 $content_type = (string) ($options['content_type'] ?? 'blog_post');
509 554 $tone = (string) ($options['tone'] ?? 'professional');
510 555 $suggestion = (string) ($options['suggestion'] ?? '');
556 + $language = (string) ($options['language'] ?? '');
511 557
512 558 // Cap content server-side (mirror the frontend 5000-char trim) so a
513 559 // direct REST caller can't force oversized prompt/cache/AI work.
514 560 $content = mb_substr($content, 0, 5000);
@@ -532,9 +578,10 @@
532 578 $target_keyword,
533 579 $content_type,
534 580 $tone,
535 581 $suggestion,
536 - $provider
582 + $provider,
583 + $language
537 584 );
538 585
539 586 $valid = function (string $desc) use ($needs_keyword, $target_keyword): bool {
540 587 $len = mb_strlen($desc);
@@ -880,14 +927,14 @@
880 927 private function ensure_ready_for_ai(): void {
881 928 if (!$this->client) {
882 929 $this->initialize_client();
883 930 if (!$this->client) {
884 - throw new \Exception($this->get_client_unavailable_message());
931 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
885 932 }
886 933 }
887 934 $user_has_api_key = !empty($this->settings->get('openai_api_key')) || !empty($this->settings->get('claude_api_key')) || !empty($this->settings->get('gemini_api_key')) || !empty($this->settings->get('openrouter_api_key'));
888 935 if (!$user_has_api_key) {
889 - throw new \Exception($this->get_client_unavailable_message());
936 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
890 937 }
891 938 if (!$this->check_rate_limit()) {
892 939 throw new \Exception('Rate limit exceeded. Please try again later.');
893 940 }
@@ -916,18 +963,75 @@
916 963 *
917 964 * @param string $prompt The prompt to send.
918 965 * @return array{ai_text:string,tokens:int}
919 966 */
920 - private function request_completion(string $prompt, int $max_tokens = 2048): array {
967 + /**
968 + * Detect a provider-side refusal or content-policy block and fail with
969 + * the real reason. Each provider signals these differently, and none of
970 + * the signals set the content field the extraction chain looks for — left
971 + * unchecked they read as an empty/unusable result with no explanation of
972 + * why, and every caller here retries an empty result once, which just
973 + * repeats the same refusal at the cost of more tokens.
974 + *
975 + * @param array $response Raw response from the AI client.
976 + * @throws \Exception If the response is a refusal or policy block.
977 + */
978 + private function guard_against_refusal(array $response): void {
979 + // --- OpenAI (Chat Completions) ---
980 + // A structured refusal is HTTP 200 with message.content=null and the
981 + // stated reason carried in message.refusal.
982 + if (isset($response['choices'][0]['message'])) {
983 + $message = $response['choices'][0]['message'];
984 + $finish = (string) ($response['choices'][0]['finish_reason'] ?? '');
985 +
986 + if (!empty($message['refusal'])) {
987 + throw new \Exception(esc_html('The AI declined this request: ' . (string) $message['refusal']));
988 + }
989 + if ('content_filter' === $finish) {
990 + throw new \Exception('The AI blocked this request under its content policy. Try different wording.');
991 + }
992 + }
993 +
994 + // --- Claude (Messages) ---
995 + if (isset($response['stop_reason']) && 'refusal' === (string) $response['stop_reason']) {
996 + throw new \Exception('The AI declined this request. Try different wording.');
997 + }
998 +
999 + // --- Gemini ---
1000 + // A prompt rejected outright returns no candidate at all, only
1001 + // promptFeedback.blockReason; a candidate can also finish on SAFETY or
1002 + // PROHIBITED_CONTENT.
1003 + $block_reason = (string) ($response['promptFeedback']['blockReason'] ?? '');
1004 + if ('' !== $block_reason) {
1005 + throw new \Exception(esc_html(sprintf('The AI blocked this request under its content policy (%s). Try different wording.', $block_reason)));
1006 + }
1007 + $gemini_finish = (string) ($response['candidates'][0]['finishReason'] ?? '');
1008 + if (in_array($gemini_finish, ['SAFETY', 'PROHIBITED_CONTENT'], true)) {
1009 + throw new \Exception('The AI blocked this request under its content policy. Try different wording.');
1010 + }
1011 + }
1012 +
1013 + private function request_completion(string $prompt, int $max_tokens = 2048, array $options = []): array {
921 1014 // "Thinking" providers (e.g. Gemini 2.5) spend output tokens on reasoning
922 1015 // before emitting text, so the cap must cover both the reasoning and the
923 1016 // visible JSON. Longer outputs (paragraphs) need a bigger budget. It's
924 1017 // only a ceiling — short replies cost no more.
925 - $response = $this->client->generate_completion($prompt, [
1018 + // Extra options (e.g. reasoning_effort) pass through; every client
1019 + // cherry-picks the keys it understands and ignores the rest.
1020 + $response = $this->client->generate_completion($prompt, array_merge($options, [
926 1021 'max_tokens' => $max_tokens,
927 1022 'temperature' => 0.4,
928 - ]);
1023 + ]));
929 1024
1025 + // Fail fast on a genuine refusal/policy block instead of retrying the
1026 + // same prompt (every caller retries on an empty result) and burning
1027 + // more tokens on a request the model has already declined. Truncation
1028 + // (finish_reason length/max_tokens) is deliberately NOT treated as a
1029 + // hard failure here — callers' existing empty-result retries already
1030 + // recover from that, and a retry can succeed where the first attempt
1031 + // spent its budget on hidden reasoning.
1032 + $this->guard_against_refusal($response);
1033 +
930 1034 $ai_text = '';
931 1035 if (isset($response['choices'][0]['message']['content'])) {
932 1036 $ai_text = is_array($response['choices'][0]['message']['content'])
933 1037 ? implode(' ', array_map(static fn($part) => is_array($part) ? ($part['text'] ?? '') : (string) $part, $response['choices'][0]['message']['content']))
@@ -943,9 +1047,25 @@
943 1047 $tokens = $response['usage']['total_tokens']
944 1048 ?? $response['usage']['output_tokens']
945 1049 ?? ($response['usageMetadata']['totalTokenCount'] ?? 0);
946 1050
947 - return ['ai_text' => $ai_text, 'tokens' => (int) $tokens];
1051 + // Diagnostics for callers that must explain an empty answer: why
1052 + // generation stopped, and how much of the
1053 + // completion budget hidden reasoning consumed (OpenAI reasoning models).
1054 + // All three provider shapes are read — Gemini reports the stop reason
1055 + // per candidate, so without that arm the diagnostic was always blank
1056 + // for exactly the provider whose truncation it exists to explain.
1057 + $finish_reason = (string) ($response['choices'][0]['finish_reason']
1058 + ?? ($response['stop_reason']
1059 + ?? ($response['candidates'][0]['finishReason'] ?? '')));
1060 + $reasoning_tokens = (int) ($response['usage']['completion_tokens_details']['reasoning_tokens'] ?? 0);
1061 +
1062 + return [
1063 + 'ai_text' => $ai_text,
1064 + 'tokens' => (int) $tokens,
1065 + 'finish_reason' => $finish_reason,
1066 + 'reasoning_tokens' => $reasoning_tokens,
1067 + ];
948 1068 }
949 1069
950 1070 /**
951 1071 * Trim a meta description to at most 160 characters at a word boundary,
@@ -1027,9 +1147,9 @@
1027 1147 * @throws \Exception If analysis fails
1028 1148 */
1029 1149 public function analyze_content(string $content, array $metadata = []): array {
1030 1150 if (!$this->client) {
1031 - throw new \Exception($this->get_client_unavailable_message());
1151 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1032 1152 }
1033 1153
1034 1154 $user_id = get_current_user_id();
1035 1155
@@ -1036,9 +1156,9 @@
1036 1156 // Ensure user has configured their API key
1037 1157 $user_has_api_key = !empty($this->settings->get('openai_api_key')) || !empty($this->settings->get('claude_api_key')) || !empty($this->settings->get('gemini_api_key')) || !empty($this->settings->get('openrouter_api_key'));
1038 1158
1039 1159 if (!$user_has_api_key) {
1040 - throw new \Exception($this->get_client_unavailable_message());
1160 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1041 1161 }
1042 1162
1043 1163 // Check rate limits
1044 1164 if (!$this->check_rate_limit($user_id, 'content_analysis')) {
@@ -1045,9 +1165,9 @@
1045 1165 throw new \Exception('Rate limit exceeded. Please try again later.');
1046 1166 }
1047 1167
1048 1168 // Check cache first
1049 - $cache_key = 'content_analysis_' . md5($content . serialize($metadata));
1169 + $cache_key = 'content_analysis_' . md5($content . wp_json_encode($metadata));
1050 1170 $cached_result = $this->cache->get($cache_key);
1051 1171 if ($cached_result) {
1052 1172 return $cached_result['data'] ?? $cached_result;
1053 1173 }
@@ -1126,13 +1246,13 @@
1126 1246 // Ensure user has configured their API key
1127 1247 $user_has_api_key = !empty($this->settings->get('openai_api_key')) || !empty($this->settings->get('claude_api_key')) || !empty($this->settings->get('gemini_api_key')) || !empty($this->settings->get('openrouter_api_key'));
1128 1248
1129 1249 if (!$user_has_api_key) {
1130 - throw new \Exception($this->get_client_unavailable_message());
1250 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1131 1251 }
1132 1252
1133 1253 // Generate cache key using existing pattern
1134 - $cache_key = 'site_identity_' . md5(serialize($site_data) . serialize($options)) . '_' . $user_id;
1254 + $cache_key = 'site_identity_' . md5(wp_json_encode($site_data) . wp_json_encode($options)) . '_' . $user_id;
1135 1255
1136 1256 // Check existing cache infrastructure
1137 1257 // Cache_Manager::set() wraps entries as ['data' => …], so unwrap
1138 1258 // before inspecting — checking optimized_data on the wrapped array
@@ -1151,9 +1271,9 @@
1151 1271 // Get AI client
1152 1272 $client = $this->get_client();
1153 1273
1154 1274 if (!$client) {
1155 - throw new \Exception($this->get_client_unavailable_message());
1275 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1156 1276 }
1157 1277
1158 1278 // Perform AI optimization
1159 1279 $optimization_results = $client->optimize_site_identity($site_data, $options);
@@ -1164,9 +1284,9 @@
1164 1284 }
1165 1285
1166 1286 // Add metadata
1167 1287 $optimization_results['ai_model'] = $client->get_model();
1168 - $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1288 + $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1169 1289 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1170 1290 $optimization_results['user_id'] = $user_id;
1171 1291
1172 1292 // Cache the results (24 hours)
@@ -1204,13 +1324,13 @@
1204 1324 // Ensure user has configured their API key
1205 1325 $user_has_api_key = !empty($this->settings->get('openai_api_key')) || !empty($this->settings->get('claude_api_key')) || !empty($this->settings->get('gemini_api_key')) || !empty($this->settings->get('openrouter_api_key'));
1206 1326
1207 1327 if (!$user_has_api_key) {
1208 - throw new \Exception($this->get_client_unavailable_message());
1328 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1209 1329 }
1210 1330
1211 1331 // Generate cache key
1212 - $cache_key = 'llms_txt_' . md5(serialize($website_data) . serialize($options)) . '_' . $user_id;
1332 + $cache_key = 'llms_txt_' . md5(wp_json_encode($website_data) . wp_json_encode($options)) . '_' . $user_id;
1213 1333
1214 1334 // Check cache first
1215 1335 // Cache_Manager::set() wraps entries as ['data' => …], so unwrap
1216 1336 // before inspecting — checking optimized_data on the wrapped array
@@ -1229,9 +1349,9 @@
1229 1349 // Get AI client
1230 1350 $client = $this->get_client();
1231 1351
1232 1352 if (!$client) {
1233 - throw new \Exception($this->get_client_unavailable_message());
1353 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1234 1354 }
1235 1355
1236 1356 // Perform AI optimization
1237 1357 $optimization_results = $client->optimize_llms_txt($website_data, $options);
@@ -1242,9 +1362,9 @@
1242 1362 }
1243 1363
1244 1364 // Add metadata
1245 1365 $optimization_results['ai_model'] = $client->get_model();
1246 - $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1366 + $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1247 1367 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1248 1368 $optimization_results['user_id'] = $user_id;
1249 1369
1250 1370 // Cache the results (24 hours)
@@ -1274,23 +1394,31 @@
1274 1394 'models' => ['gpt-5-nano', 'gpt-5-mini', 'gpt-5', 'gpt-4o'],
1275 1395 'requires_key' => true,
1276 1396 ],
1277 1397 'claude' => [
1278 - 'name' => 'Claude (Anthropic)',
1279 - 'description' => 'Claude Opus 4.8, Sonnet 5, and Haiku 4.5',
1280 - 'models' => ['claude-opus-4-8', 'claude-sonnet-5', 'claude-haiku-4-5'],
1398 + // The vendor, not the model family: the other three entries name
1399 + // vendors, and a family name goes stale on every rename (#572).
1400 + 'name' => 'Anthropic',
1401 + 'description' => 'Claude Opus 5, Opus 4.8, Sonnet 5, and Haiku 4.5',
1402 + 'models' => ['claude-opus-5', 'claude-opus-4-8', 'claude-sonnet-5', 'claude-haiku-4-5'],
1281 1403 'requires_key' => true,
1282 1404 ],
1283 1405 'gemini' => [
1284 1406 'name' => 'Google Gemini',
1285 - 'description' => 'Gemini 3.x and 2.x models',
1286 - 'models' => ['gemini-3.1-pro', 'gemini-3.5-flash', 'gemini-3.1-flash-lite', 'gemini-2.5-flash', 'gemini-2.5-flash-lite', 'gemini-2.5-pro', 'gemini-2.0-flash', 'gemini-1.5-flash'],
1407 + 'description' => 'Gemini 3.x models',
1408 + // 2.5 Pro / 2.5 Flash-Lite retire in Oct 2026 and 3.1 Pro only
1409 + // ships under its -preview id, so none of the three belong in a
1410 + // list users pick from (#572).
1411 + 'models' => ['gemini-3.5-flash', 'gemini-3.1-flash-lite', 'gemini-3.1-pro-preview'],
1287 1412 'requires_key' => true,
1288 1413 ],
1289 1414 'openrouter' => [
1290 1415 'name' => 'OpenRouter',
1291 1416 'description' => 'Unified access to many models via one key',
1292 - 'models' => ['openai/gpt-4o-mini', 'anthropic/claude-3.5-sonnet', 'google/gemini-2.0-flash-001', 'meta-llama/llama-3.3-70b-instruct', 'deepseek/deepseek-chat'],
1417 + // claude-3.5-sonnet is retired (Claude_Client::normalize_model
1418 + // already self-heals it on the direct path) and
1419 + // gemini-2.0-flash-001 was shut down on 1 Jun 2026 (#572).
1420 + 'models' => ['openai/gpt-4o-mini', 'anthropic/claude-sonnet-5', 'google/gemini-3.5-flash', 'meta-llama/llama-3.3-70b-instruct', 'deepseek/deepseek-chat'],
1293 1421 'requires_key' => true,
1294 1422 ],
1295 1423 ];
1296 1424 }
@@ -1300,10 +1428,14 @@
1300 1428 *
1301 1429 * @return array Provider status
1302 1430 */
1303 1431 public function get_provider_status(): array {
1304 - $provider = $this->settings->get('ai_provider', 'openai');
1305 - $api_key = $this->settings->get($provider . '_api_key');
1432 + $provider = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1433 + // With no provider chosen there is no "<provider>_api_key" to read;
1434 + // asking for '_api_key' would be a nonsense lookup.
1435 + $api_key = Settings::AI_PROVIDER_NONE === $provider
1436 + ? ''
1437 + : $this->settings->get($provider . '_api_key');
1306 1438
1307 1439 return [
1308 1440 'provider' => $provider,
1309 1441 'configured' => !empty($api_key),
@@ -1444,14 +1576,26 @@
1444 1576 [
1445 1577 'user_id' => $user_id,
1446 1578 'action' => $action,
1447 1579 'tokens_used' => $tokens_used,
1448 - 'provider' => $this->settings->get('ai_provider', 'openai'),
1580 + 'provider' => $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE),
1449 1581 'metadata' => !empty($metadata) ? wp_json_encode($metadata) : null,
1450 1582 'created_at' => current_time('mysql'),
1451 1583 ],
1452 1584 ['%d', '%s', '%d', '%s', '%s', '%s']
1453 1585 );
1586 +
1587 + /**
1588 + * Fires after an AI usage row is recorded.
1589 + *
1590 + * Analytics listens to drop its cached overview, so the Usages page
1591 + * reflects this action immediately instead of after the 600s TTL.
1592 + *
1593 + * @since 2.2.1
1594 + *
1595 + * @param int $user_id User the usage was recorded against.
1596 + */
1597 + do_action('thinkrank_ai_usage_logged', $user_id);
1454 1598 }
1455 1599
1456 1600 /**
1457 1601 * Cleanup expired cache entries
@@ -1483,13 +1627,13 @@
1483 1627 // Ensure user has configured their API key (copying Site Identity pattern)
1484 1628 $user_has_api_key = !empty($this->settings->get('openai_api_key')) || !empty($this->settings->get('claude_api_key')) || !empty($this->settings->get('gemini_api_key')) || !empty($this->settings->get('openrouter_api_key'));
1485 1629
1486 1630 if (!$user_has_api_key) {
1487 - throw new \Exception($this->get_client_unavailable_message());
1631 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1488 1632 }
1489 1633
1490 1634 // Generate cache key using existing pattern
1491 - $cache_key = 'homepage_meta_' . md5(serialize($content_data) . serialize($options)) . '_' . $user_id;
1635 + $cache_key = 'homepage_meta_' . md5(wp_json_encode($content_data) . wp_json_encode($options)) . '_' . $user_id;
1492 1636
1493 1637 // Check existing cache infrastructure
1494 1638 // Cache_Manager::set() wraps entries as ['data' => …], so unwrap
1495 1639 // before inspecting — checking optimized_data on the wrapped array
@@ -1508,9 +1652,9 @@
1508 1652 // Get AI client
1509 1653 $client = $this->get_client();
1510 1654
1511 1655 if (!$client) {
1512 - throw new \Exception($this->get_client_unavailable_message());
1656 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1513 1657 }
1514 1658
1515 1659 // Perform AI optimization
1516 1660 $optimization_results = $client->optimize_homepage_meta($content_data, $options);
@@ -1521,9 +1665,9 @@
1521 1665 }
1522 1666
1523 1667 // Add metadata
1524 1668 $optimization_results['ai_model'] = $client->get_model();
1525 - $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1669 + $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1526 1670 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1527 1671 $optimization_results['user_id'] = $user_id;
1528 1672
1529 1673 // Cache the results (24 hours)
@@ -1561,13 +1705,13 @@
1561 1705 // Ensure user has configured their API key (copying Site Identity pattern)
1562 1706 $user_has_api_key = !empty($this->settings->get('openai_api_key')) || !empty($this->settings->get('claude_api_key')) || !empty($this->settings->get('gemini_api_key')) || !empty($this->settings->get('openrouter_api_key'));
1563 1707
1564 1708 if (!$user_has_api_key) {
1565 - throw new \Exception($this->get_client_unavailable_message());
1709 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1566 1710 }
1567 1711
1568 1712 // Generate cache key using existing pattern
1569 - $cache_key = 'homepage_hero_' . md5(serialize($hero_data) . serialize($options)) . '_' . $user_id;
1713 + $cache_key = 'homepage_hero_' . md5(wp_json_encode($hero_data) . wp_json_encode($options)) . '_' . $user_id;
1570 1714
1571 1715 // Check existing cache infrastructure
1572 1716 // Cache_Manager::set() wraps entries as ['data' => …], so unwrap
1573 1717 // before inspecting — checking optimized_data on the wrapped array
@@ -1586,9 +1730,9 @@
1586 1730 // Get AI client
1587 1731 $client = $this->get_client();
1588 1732
1589 1733 if (!$client) {
1590 - throw new \Exception($this->get_client_unavailable_message());
1734 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1591 1735 }
1592 1736
1593 1737 // Perform AI optimization
1594 1738 $optimization_results = $client->optimize_homepage_hero($hero_data, $options);
@@ -1599,9 +1743,9 @@
1599 1743 }
1600 1744
1601 1745 // Add metadata
1602 1746 $optimization_results['ai_model'] = $client->get_model();
1603 - $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1747 + $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1604 1748 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1605 1749 $optimization_results['user_id'] = $user_id;
1606 1750
1607 1751 // Cache the results (24 hours)