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 +167 -63 1.28.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');
@@ -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()) {
@@ -398,8 +425,9 @@
398 425 $generated = $this->request_title($prompt);
399 426 $title = $generated['title'];
400 427 $total_tokens = $generated['tokens'];
401 428 $ai_text = $generated['ai_text'];
429 + $finish_reason = $generated['finish_reason'];
402 430
403 431 // A reasoning model can still return an empty/truncated title on the
404 432 // first pass; retry once before giving up so the "Apply" action reliably
405 433 // produces a title.
@@ -408,8 +436,9 @@
408 436 $total_tokens += $retry['tokens'];
409 437 if ($retry['ai_text'] !== '') {
410 438 $ai_text = $retry['ai_text'];
411 439 }
440 + $finish_reason = $retry['finish_reason'];
412 441 if ($retry['title'] !== '') {
413 442 $title = $retry['title'];
414 443 }
415 444 }
@@ -423,8 +452,9 @@
423 452 $total_tokens += $retry['tokens'];
424 453 if ($retry['ai_text'] !== '') {
425 454 $ai_text = $retry['ai_text'];
426 455 }
456 + $finish_reason = $retry['finish_reason'];
427 457 if ($retry['title'] !== '' && $this->title_contains_word($retry['title'], $sentiment_words)) {
428 458 $title = $retry['title'];
429 459 }
430 460 }
@@ -429,8 +459,20 @@
429 459 }
430 460 }
431 461
432 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 + }
433 475 throw new \Exception('The AI did not return a usable title. Please try again.');
434 476 }
435 477
436 478 // Log usage.
@@ -464,8 +506,9 @@
464 506 return [
465 507 'title' => $title,
466 508 'ai_text' => $completion['ai_text'],
467 509 'tokens' => $completion['tokens'],
510 + 'finish_reason' => $completion['finish_reason'],
468 511 ];
469 512 }
470 513
471 514 /**
@@ -491,15 +534,15 @@
491 534 public function improve_meta_description(string $content, array $options = []): array {
492 535 if (!$this->client) {
493 536 $this->initialize_client();
494 537 if (!$this->client) {
495 - throw new \Exception($this->get_client_unavailable_message());
538 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
496 539 }
497 540 }
498 541
499 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'));
500 543 if (!$user_has_api_key) {
501 - throw new \Exception($this->get_client_unavailable_message());
544 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
502 545 }
503 546
504 547 if (!$this->check_rate_limit()) {
505 548 throw new \Exception('Rate limit exceeded. Please try again later.');
@@ -884,14 +927,14 @@
884 927 private function ensure_ready_for_ai(): void {
885 928 if (!$this->client) {
886 929 $this->initialize_client();
887 930 if (!$this->client) {
888 - throw new \Exception($this->get_client_unavailable_message());
931 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
889 932 }
890 933 }
891 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'));
892 935 if (!$user_has_api_key) {
893 - throw new \Exception($this->get_client_unavailable_message());
936 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
894 937 }
895 938 if (!$this->check_rate_limit()) {
896 939 throw new \Exception('Rate limit exceeded. Please try again later.');
897 940 }
@@ -921,28 +964,51 @@
921 964 * @param string $prompt The prompt to send.
922 965 * @return array{ai_text:string,tokens:int}
923 966 */
924 967 /**
925 - * Public plain-text completion against the configured provider.
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.
926 974 *
927 - * Thin gate over request_completion() for callers that need a free-form
928 - * answer rather than a structured SEO artifact (e.g. the brand-visibility
929 - * checker, which asks the model a user-style question and inspects the
930 - * reply). Provider differences are already normalized inside.
931 - *
932 - * @since 1.27.0
933 - *
934 - * @param string $prompt Prompt to send.
935 - * @param int $max_tokens Output token ceiling.
936 - * @return array{ai_text:string,tokens:int}
937 - * @throws \Exception When no AI client is configured/available.
975 + * @param array $response Raw response from the AI client.
976 + * @throws \Exception If the response is a refusal or policy block.
938 977 */
939 - public function answer_prompt(string $prompt, int $max_tokens = 1024, array $options = []): array {
940 - if (!$this->client) {
941 - throw new \Exception(esc_html($this->get_client_unavailable_message()));
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 + }
942 992 }
943 993
944 - return $this->request_completion($prompt, $max_tokens, $options);
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 + }
945 1011 }
946 1012
947 1013 private function request_completion(string $prompt, int $max_tokens = 2048, array $options = []): array {
948 1014 // "Thinking" providers (e.g. Gemini 2.5) spend output tokens on reasoning
@@ -955,8 +1021,17 @@
955 1021 'max_tokens' => $max_tokens,
956 1022 'temperature' => 0.4,
957 1023 ]));
958 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 +
959 1034 $ai_text = '';
960 1035 if (isset($response['choices'][0]['message']['content'])) {
961 1036 $ai_text = is_array($response['choices'][0]['message']['content'])
962 1037 ? implode(' ', array_map(static fn($part) => is_array($part) ? ($part['text'] ?? '') : (string) $part, $response['choices'][0]['message']['content']))
@@ -972,12 +1047,17 @@
972 1047 $tokens = $response['usage']['total_tokens']
973 1048 ?? $response['usage']['output_tokens']
974 1049 ?? ($response['usageMetadata']['totalTokenCount'] ?? 0);
975 1050
976 - // Diagnostics for callers that must explain an empty answer (e.g. the
977 - // brand-visibility probe): why generation stopped, and how much of the
1051 + // Diagnostics for callers that must explain an empty answer: why
1052 + // generation stopped, and how much of the
978 1053 // completion budget hidden reasoning consumed (OpenAI reasoning models).
979 - $finish_reason = (string) ($response['choices'][0]['finish_reason'] ?? ($response['stop_reason'] ?? ''));
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'] ?? '')));
980 1060 $reasoning_tokens = (int) ($response['usage']['completion_tokens_details']['reasoning_tokens'] ?? 0);
981 1061
982 1062 return [
983 1063 'ai_text' => $ai_text,
@@ -1067,9 +1147,9 @@
1067 1147 * @throws \Exception If analysis fails
1068 1148 */
1069 1149 public function analyze_content(string $content, array $metadata = []): array {
1070 1150 if (!$this->client) {
1071 - throw new \Exception($this->get_client_unavailable_message());
1151 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1072 1152 }
1073 1153
1074 1154 $user_id = get_current_user_id();
1075 1155
@@ -1076,9 +1156,9 @@
1076 1156 // Ensure user has configured their API key
1077 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'));
1078 1158
1079 1159 if (!$user_has_api_key) {
1080 - throw new \Exception($this->get_client_unavailable_message());
1160 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1081 1161 }
1082 1162
1083 1163 // Check rate limits
1084 1164 if (!$this->check_rate_limit($user_id, 'content_analysis')) {
@@ -1085,9 +1165,9 @@
1085 1165 throw new \Exception('Rate limit exceeded. Please try again later.');
1086 1166 }
1087 1167
1088 1168 // Check cache first
1089 - $cache_key = 'content_analysis_' . md5($content . serialize($metadata));
1169 + $cache_key = 'content_analysis_' . md5($content . wp_json_encode($metadata));
1090 1170 $cached_result = $this->cache->get($cache_key);
1091 1171 if ($cached_result) {
1092 1172 return $cached_result['data'] ?? $cached_result;
1093 1173 }
@@ -1166,13 +1246,13 @@
1166 1246 // Ensure user has configured their API key
1167 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'));
1168 1248
1169 1249 if (!$user_has_api_key) {
1170 - throw new \Exception($this->get_client_unavailable_message());
1250 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1171 1251 }
1172 1252
1173 1253 // Generate cache key using existing pattern
1174 - $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;
1175 1255
1176 1256 // Check existing cache infrastructure
1177 1257 // Cache_Manager::set() wraps entries as ['data' => …], so unwrap
1178 1258 // before inspecting — checking optimized_data on the wrapped array
@@ -1191,9 +1271,9 @@
1191 1271 // Get AI client
1192 1272 $client = $this->get_client();
1193 1273
1194 1274 if (!$client) {
1195 - throw new \Exception($this->get_client_unavailable_message());
1275 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1196 1276 }
1197 1277
1198 1278 // Perform AI optimization
1199 1279 $optimization_results = $client->optimize_site_identity($site_data, $options);
@@ -1204,9 +1284,9 @@
1204 1284 }
1205 1285
1206 1286 // Add metadata
1207 1287 $optimization_results['ai_model'] = $client->get_model();
1208 - $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1288 + $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1209 1289 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1210 1290 $optimization_results['user_id'] = $user_id;
1211 1291
1212 1292 // Cache the results (24 hours)
@@ -1244,13 +1324,13 @@
1244 1324 // Ensure user has configured their API key
1245 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'));
1246 1326
1247 1327 if (!$user_has_api_key) {
1248 - throw new \Exception($this->get_client_unavailable_message());
1328 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1249 1329 }
1250 1330
1251 1331 // Generate cache key
1252 - $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;
1253 1333
1254 1334 // Check cache first
1255 1335 // Cache_Manager::set() wraps entries as ['data' => …], so unwrap
1256 1336 // before inspecting — checking optimized_data on the wrapped array
@@ -1269,9 +1349,9 @@
1269 1349 // Get AI client
1270 1350 $client = $this->get_client();
1271 1351
1272 1352 if (!$client) {
1273 - throw new \Exception($this->get_client_unavailable_message());
1353 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1274 1354 }
1275 1355
1276 1356 // Perform AI optimization
1277 1357 $optimization_results = $client->optimize_llms_txt($website_data, $options);
@@ -1282,9 +1362,9 @@
1282 1362 }
1283 1363
1284 1364 // Add metadata
1285 1365 $optimization_results['ai_model'] = $client->get_model();
1286 - $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1366 + $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1287 1367 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1288 1368 $optimization_results['user_id'] = $user_id;
1289 1369
1290 1370 // Cache the results (24 hours)
@@ -1314,23 +1394,31 @@
1314 1394 'models' => ['gpt-5-nano', 'gpt-5-mini', 'gpt-5', 'gpt-4o'],
1315 1395 'requires_key' => true,
1316 1396 ],
1317 1397 'claude' => [
1318 - 'name' => 'Claude (Anthropic)',
1319 - 'description' => 'Claude Opus 4.8, Sonnet 5, and Haiku 4.5',
1320 - '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'],
1321 1403 'requires_key' => true,
1322 1404 ],
1323 1405 'gemini' => [
1324 1406 'name' => 'Google Gemini',
1325 - 'description' => 'Gemini 3.x and 2.x models',
1326 - 'models' => ['gemini-3.1-pro', 'gemini-3.5-flash', 'gemini-3.1-flash-lite', 'gemini-2.5-flash-lite', 'gemini-2.5-pro'],
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'],
1327 1412 'requires_key' => true,
1328 1413 ],
1329 1414 'openrouter' => [
1330 1415 'name' => 'OpenRouter',
1331 1416 'description' => 'Unified access to many models via one key',
1332 - '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'],
1333 1421 'requires_key' => true,
1334 1422 ],
1335 1423 ];
1336 1424 }
@@ -1340,10 +1428,14 @@
1340 1428 *
1341 1429 * @return array Provider status
1342 1430 */
1343 1431 public function get_provider_status(): array {
1344 - $provider = $this->settings->get('ai_provider', 'openai');
1345 - $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');
1346 1438
1347 1439 return [
1348 1440 'provider' => $provider,
1349 1441 'configured' => !empty($api_key),
@@ -1484,14 +1576,26 @@
1484 1576 [
1485 1577 'user_id' => $user_id,
1486 1578 'action' => $action,
1487 1579 'tokens_used' => $tokens_used,
1488 - 'provider' => $this->settings->get('ai_provider', 'openai'),
1580 + 'provider' => $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE),
1489 1581 'metadata' => !empty($metadata) ? wp_json_encode($metadata) : null,
1490 1582 'created_at' => current_time('mysql'),
1491 1583 ],
1492 1584 ['%d', '%s', '%d', '%s', '%s', '%s']
1493 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);
1494 1598 }
1495 1599
1496 1600 /**
1497 1601 * Cleanup expired cache entries
@@ -1523,13 +1627,13 @@
1523 1627 // Ensure user has configured their API key (copying Site Identity pattern)
1524 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'));
1525 1629
1526 1630 if (!$user_has_api_key) {
1527 - throw new \Exception($this->get_client_unavailable_message());
1631 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1528 1632 }
1529 1633
1530 1634 // Generate cache key using existing pattern
1531 - $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;
1532 1636
1533 1637 // Check existing cache infrastructure
1534 1638 // Cache_Manager::set() wraps entries as ['data' => …], so unwrap
1535 1639 // before inspecting — checking optimized_data on the wrapped array
@@ -1548,9 +1652,9 @@
1548 1652 // Get AI client
1549 1653 $client = $this->get_client();
1550 1654
1551 1655 if (!$client) {
1552 - throw new \Exception($this->get_client_unavailable_message());
1656 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1553 1657 }
1554 1658
1555 1659 // Perform AI optimization
1556 1660 $optimization_results = $client->optimize_homepage_meta($content_data, $options);
@@ -1561,9 +1665,9 @@
1561 1665 }
1562 1666
1563 1667 // Add metadata
1564 1668 $optimization_results['ai_model'] = $client->get_model();
1565 - $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1669 + $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1566 1670 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1567 1671 $optimization_results['user_id'] = $user_id;
1568 1672
1569 1673 // Cache the results (24 hours)
@@ -1601,13 +1705,13 @@
1601 1705 // Ensure user has configured their API key (copying Site Identity pattern)
1602 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'));
1603 1707
1604 1708 if (!$user_has_api_key) {
1605 - throw new \Exception($this->get_client_unavailable_message());
1709 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1606 1710 }
1607 1711
1608 1712 // Generate cache key using existing pattern
1609 - $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;
1610 1714
1611 1715 // Check existing cache infrastructure
1612 1716 // Cache_Manager::set() wraps entries as ['data' => …], so unwrap
1613 1717 // before inspecting — checking optimized_data on the wrapped array
@@ -1626,9 +1730,9 @@
1626 1730 // Get AI client
1627 1731 $client = $this->get_client();
1628 1732
1629 1733 if (!$client) {
1630 - throw new \Exception($this->get_client_unavailable_message());
1734 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1631 1735 }
1632 1736
1633 1737 // Perform AI optimization
1634 1738 $optimization_results = $client->optimize_homepage_hero($hero_data, $options);
@@ -1639,9 +1743,9 @@
1639 1743 }
1640 1744
1641 1745 // Add metadata
1642 1746 $optimization_results['ai_model'] = $client->get_model();
1643 - $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1747 + $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1644 1748 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1645 1749 $optimization_results['user_id'] = $user_id;
1646 1750
1647 1751 // Cache the results (24 hours)