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 +196 -56 1.27.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()) {
@@ -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 }
@@ -920,18 +963,75 @@
920 963 *
921 964 * @param string $prompt The prompt to send.
922 965 * @return array{ai_text:string,tokens:int}
923 966 */
924 - 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 {
925 1014 // "Thinking" providers (e.g. Gemini 2.5) spend output tokens on reasoning
926 1015 // before emitting text, so the cap must cover both the reasoning and the
927 1016 // visible JSON. Longer outputs (paragraphs) need a bigger budget. It's
928 1017 // only a ceiling — short replies cost no more.
929 - $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, [
930 1021 'max_tokens' => $max_tokens,
931 1022 'temperature' => 0.4,
932 - ]);
1023 + ]));
933 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 +
934 1034 $ai_text = '';
935 1035 if (isset($response['choices'][0]['message']['content'])) {
936 1036 $ai_text = is_array($response['choices'][0]['message']['content'])
937 1037 ? implode(' ', array_map(static fn($part) => is_array($part) ? ($part['text'] ?? '') : (string) $part, $response['choices'][0]['message']['content']))
@@ -947,9 +1047,25 @@
947 1047 $tokens = $response['usage']['total_tokens']
948 1048 ?? $response['usage']['output_tokens']
949 1049 ?? ($response['usageMetadata']['totalTokenCount'] ?? 0);
950 1050
951 - 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 + ];
952 1068 }
953 1069
954 1070 /**
955 1071 * Trim a meta description to at most 160 characters at a word boundary,
@@ -1031,9 +1147,9 @@
1031 1147 * @throws \Exception If analysis fails
1032 1148 */
1033 1149 public function analyze_content(string $content, array $metadata = []): array {
1034 1150 if (!$this->client) {
1035 - throw new \Exception($this->get_client_unavailable_message());
1151 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1036 1152 }
1037 1153
1038 1154 $user_id = get_current_user_id();
1039 1155
@@ -1040,9 +1156,9 @@
1040 1156 // Ensure user has configured their API key
1041 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'));
1042 1158
1043 1159 if (!$user_has_api_key) {
1044 - throw new \Exception($this->get_client_unavailable_message());
1160 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1045 1161 }
1046 1162
1047 1163 // Check rate limits
1048 1164 if (!$this->check_rate_limit($user_id, 'content_analysis')) {
@@ -1049,9 +1165,9 @@
1049 1165 throw new \Exception('Rate limit exceeded. Please try again later.');
1050 1166 }
1051 1167
1052 1168 // Check cache first
1053 - $cache_key = 'content_analysis_' . md5($content . serialize($metadata));
1169 + $cache_key = 'content_analysis_' . md5($content . wp_json_encode($metadata));
1054 1170 $cached_result = $this->cache->get($cache_key);
1055 1171 if ($cached_result) {
1056 1172 return $cached_result['data'] ?? $cached_result;
1057 1173 }
@@ -1130,13 +1246,13 @@
1130 1246 // Ensure user has configured their API key
1131 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'));
1132 1248
1133 1249 if (!$user_has_api_key) {
1134 - throw new \Exception($this->get_client_unavailable_message());
1250 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1135 1251 }
1136 1252
1137 1253 // Generate cache key using existing pattern
1138 - $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;
1139 1255
1140 1256 // Check existing cache infrastructure
1141 1257 // Cache_Manager::set() wraps entries as ['data' => …], so unwrap
1142 1258 // before inspecting — checking optimized_data on the wrapped array
@@ -1155,9 +1271,9 @@
1155 1271 // Get AI client
1156 1272 $client = $this->get_client();
1157 1273
1158 1274 if (!$client) {
1159 - throw new \Exception($this->get_client_unavailable_message());
1275 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1160 1276 }
1161 1277
1162 1278 // Perform AI optimization
1163 1279 $optimization_results = $client->optimize_site_identity($site_data, $options);
@@ -1168,9 +1284,9 @@
1168 1284 }
1169 1285
1170 1286 // Add metadata
1171 1287 $optimization_results['ai_model'] = $client->get_model();
1172 - $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1288 + $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1173 1289 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1174 1290 $optimization_results['user_id'] = $user_id;
1175 1291
1176 1292 // Cache the results (24 hours)
@@ -1208,13 +1324,13 @@
1208 1324 // Ensure user has configured their API key
1209 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'));
1210 1326
1211 1327 if (!$user_has_api_key) {
1212 - throw new \Exception($this->get_client_unavailable_message());
1328 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1213 1329 }
1214 1330
1215 1331 // Generate cache key
1216 - $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;
1217 1333
1218 1334 // Check cache first
1219 1335 // Cache_Manager::set() wraps entries as ['data' => …], so unwrap
1220 1336 // before inspecting — checking optimized_data on the wrapped array
@@ -1233,9 +1349,9 @@
1233 1349 // Get AI client
1234 1350 $client = $this->get_client();
1235 1351
1236 1352 if (!$client) {
1237 - throw new \Exception($this->get_client_unavailable_message());
1353 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1238 1354 }
1239 1355
1240 1356 // Perform AI optimization
1241 1357 $optimization_results = $client->optimize_llms_txt($website_data, $options);
@@ -1246,9 +1362,9 @@
1246 1362 }
1247 1363
1248 1364 // Add metadata
1249 1365 $optimization_results['ai_model'] = $client->get_model();
1250 - $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1366 + $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1251 1367 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1252 1368 $optimization_results['user_id'] = $user_id;
1253 1369
1254 1370 // Cache the results (24 hours)
@@ -1278,23 +1394,31 @@
1278 1394 'models' => ['gpt-5-nano', 'gpt-5-mini', 'gpt-5', 'gpt-4o'],
1279 1395 'requires_key' => true,
1280 1396 ],
1281 1397 'claude' => [
1282 - 'name' => 'Claude (Anthropic)',
1283 - 'description' => 'Claude Opus 4.8, Sonnet 5, and Haiku 4.5',
1284 - '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'],
1285 1403 'requires_key' => true,
1286 1404 ],
1287 1405 'gemini' => [
1288 1406 'name' => 'Google Gemini',
1289 - 'description' => 'Gemini 3.x and 2.x models',
1290 - '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'],
1291 1412 'requires_key' => true,
1292 1413 ],
1293 1414 'openrouter' => [
1294 1415 'name' => 'OpenRouter',
1295 1416 'description' => 'Unified access to many models via one key',
1296 - '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'],
1297 1421 'requires_key' => true,
1298 1422 ],
1299 1423 ];
1300 1424 }
@@ -1304,10 +1428,14 @@
1304 1428 *
1305 1429 * @return array Provider status
1306 1430 */
1307 1431 public function get_provider_status(): array {
1308 - $provider = $this->settings->get('ai_provider', 'openai');
1309 - $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');
1310 1438
1311 1439 return [
1312 1440 'provider' => $provider,
1313 1441 'configured' => !empty($api_key),
@@ -1448,14 +1576,26 @@
1448 1576 [
1449 1577 'user_id' => $user_id,
1450 1578 'action' => $action,
1451 1579 'tokens_used' => $tokens_used,
1452 - 'provider' => $this->settings->get('ai_provider', 'openai'),
1580 + 'provider' => $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE),
1453 1581 'metadata' => !empty($metadata) ? wp_json_encode($metadata) : null,
1454 1582 'created_at' => current_time('mysql'),
1455 1583 ],
1456 1584 ['%d', '%s', '%d', '%s', '%s', '%s']
1457 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);
1458 1598 }
1459 1599
1460 1600 /**
1461 1601 * Cleanup expired cache entries
@@ -1487,13 +1627,13 @@
1487 1627 // Ensure user has configured their API key (copying Site Identity pattern)
1488 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'));
1489 1629
1490 1630 if (!$user_has_api_key) {
1491 - throw new \Exception($this->get_client_unavailable_message());
1631 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1492 1632 }
1493 1633
1494 1634 // Generate cache key using existing pattern
1495 - $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;
1496 1636
1497 1637 // Check existing cache infrastructure
1498 1638 // Cache_Manager::set() wraps entries as ['data' => …], so unwrap
1499 1639 // before inspecting — checking optimized_data on the wrapped array
@@ -1512,9 +1652,9 @@
1512 1652 // Get AI client
1513 1653 $client = $this->get_client();
1514 1654
1515 1655 if (!$client) {
1516 - throw new \Exception($this->get_client_unavailable_message());
1656 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1517 1657 }
1518 1658
1519 1659 // Perform AI optimization
1520 1660 $optimization_results = $client->optimize_homepage_meta($content_data, $options);
@@ -1525,9 +1665,9 @@
1525 1665 }
1526 1666
1527 1667 // Add metadata
1528 1668 $optimization_results['ai_model'] = $client->get_model();
1529 - $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1669 + $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1530 1670 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1531 1671 $optimization_results['user_id'] = $user_id;
1532 1672
1533 1673 // Cache the results (24 hours)
@@ -1565,13 +1705,13 @@
1565 1705 // Ensure user has configured their API key (copying Site Identity pattern)
1566 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'));
1567 1707
1568 1708 if (!$user_has_api_key) {
1569 - throw new \Exception($this->get_client_unavailable_message());
1709 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1570 1710 }
1571 1711
1572 1712 // Generate cache key using existing pattern
1573 - $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;
1574 1714
1575 1715 // Check existing cache infrastructure
1576 1716 // Cache_Manager::set() wraps entries as ['data' => …], so unwrap
1577 1717 // before inspecting — checking optimized_data on the wrapped array
@@ -1590,9 +1730,9 @@
1590 1730 // Get AI client
1591 1731 $client = $this->get_client();
1592 1732
1593 1733 if (!$client) {
1594 - throw new \Exception($this->get_client_unavailable_message());
1734 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1595 1735 }
1596 1736
1597 1737 // Perform AI optimization
1598 1738 $optimization_results = $client->optimize_homepage_hero($hero_data, $options);
@@ -1603,9 +1743,9 @@
1603 1743 }
1604 1744
1605 1745 // Add metadata
1606 1746 $optimization_results['ai_model'] = $client->get_model();
1607 - $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1747 + $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1608 1748 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1609 1749 $optimization_results['user_id'] = $user_id;
1610 1750
1611 1751 // Cache the results (24 hours)