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 +160 -58 2.0.02.7.0 View file →
@@ -87,10 +87,17 @@
87 87 *
88 88 * @throws \Exception On failure.
89 89 */
90 90 public function initialize_client(): void {
91 - $provider = $this->settings->get('ai_provider', 'openai');
91 + $provider = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
92 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 +
93 100 try {
94 101 switch ($provider) {
95 102 case 'openai':
96 103 $api_key = $this->settings->get('openai_api_key');
@@ -162,9 +169,15 @@
162 169 default:
163 170 throw new \Exception("Unsupported AI provider: {$provider}");
164 171 }
165 172 } catch (\Exception $e) {
166 - // 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 + }
167 180 }
168 181 }
169 182
170 183 /**
@@ -174,14 +187,15 @@
174 187 */
175 188 private function get_provider_label(): string {
176 189 $labels = [
177 190 'openai' => 'OpenAI',
178 - 'claude' => 'Claude',
191 + // The vendor, not the model family — matches the settings UI (#572).
192 + 'claude' => 'Anthropic',
179 193 'gemini' => 'Gemini',
180 194 'openrouter' => 'OpenRouter',
181 195 ];
182 196
183 - $provider = (string) $this->settings->get('ai_provider', 'openai');
197 + $provider = (string) $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
184 198
185 199 return $labels[$provider] ?? ucfirst($provider);
186 200 }
187 201
@@ -193,9 +207,9 @@
193 207 *
194 208 * @return string Actionable error message for end users
195 209 */
196 210 private function get_client_unavailable_message(): string {
197 - $provider = (string) $this->settings->get('ai_provider', 'openai');
211 + $provider = (string) $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
198 212
199 213 // The React admin renders this anchor as a real link via linkifyMessage().
200 214 $settings_link = sprintf(
201 215 '<a href="%s" target="_blank" rel="noopener noreferrer">%s</a>',
@@ -202,12 +216,23 @@
202 216 esc_url(admin_url('admin.php?page=thinkrank-settings')),
203 217 __('ThinkRank → Settings', 'thinkrank')
204 218 );
205 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 +
206 230 if (empty($this->settings->get("{$provider}_api_key"))) {
207 231 return sprintf(
208 - /* translators: %s: link to the ThinkRank settings page. */
209 - __('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(),
210 235 $settings_link
211 236 );
212 237 }
213 238
@@ -242,9 +267,9 @@
242 267 }
243 268
244 269 // If still not available, throw error
245 270 if (!$this->client) {
246 - throw new \Exception($this->get_client_unavailable_message());
271 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
247 272 }
248 273
249 274 return $this->client;
250 275 }
@@ -263,9 +288,9 @@
263 288 $this->initialize_client();
264 289
265 290 // If still not available, throw error
266 291 if (!$this->client) {
267 - throw new \Exception($this->get_client_unavailable_message());
292 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
268 293 }
269 294 }
270 295
271 296 // Check rate limits
@@ -292,9 +317,9 @@
292 317 // Ensure user has configured their API key
293 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'));
294 319
295 320 if (!$user_has_api_key) {
296 - throw new \Exception($this->get_client_unavailable_message());
321 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
297 322 }
298 323
299 324 // Cache the result
300 325 $this->cache->set($cache_key, $metadata);
@@ -338,9 +363,9 @@
338 363 public function improve_seo_title(string $content, array $options = []): array {
339 364 if (!$this->client) {
340 365 $this->initialize_client();
341 366 if (!$this->client) {
342 - throw new \Exception($this->get_client_unavailable_message());
367 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
343 368 }
344 369 }
345 370
346 371 // Ensure user has configured their API key.
@@ -345,9 +370,9 @@
345 370
346 371 // Ensure user has configured their API key.
347 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'));
348 373 if (!$user_has_api_key) {
349 - throw new \Exception($this->get_client_unavailable_message());
374 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
350 375 }
351 376
352 377 // Check rate limits.
353 378 if (!$this->check_rate_limit()) {
@@ -400,8 +425,9 @@
400 425 $generated = $this->request_title($prompt);
401 426 $title = $generated['title'];
402 427 $total_tokens = $generated['tokens'];
403 428 $ai_text = $generated['ai_text'];
429 + $finish_reason = $generated['finish_reason'];
404 430
405 431 // A reasoning model can still return an empty/truncated title on the
406 432 // first pass; retry once before giving up so the "Apply" action reliably
407 433 // produces a title.
@@ -410,8 +436,9 @@
410 436 $total_tokens += $retry['tokens'];
411 437 if ($retry['ai_text'] !== '') {
412 438 $ai_text = $retry['ai_text'];
413 439 }
440 + $finish_reason = $retry['finish_reason'];
414 441 if ($retry['title'] !== '') {
415 442 $title = $retry['title'];
416 443 }
417 444 }
@@ -425,8 +452,9 @@
425 452 $total_tokens += $retry['tokens'];
426 453 if ($retry['ai_text'] !== '') {
427 454 $ai_text = $retry['ai_text'];
428 455 }
456 + $finish_reason = $retry['finish_reason'];
429 457 if ($retry['title'] !== '' && $this->title_contains_word($retry['title'], $sentiment_words)) {
430 458 $title = $retry['title'];
431 459 }
432 460 }
@@ -431,8 +459,20 @@
431 459 }
432 460 }
433 461
434 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 + }
435 475 throw new \Exception('The AI did not return a usable title. Please try again.');
436 476 }
437 477
438 478 // Log usage.
@@ -466,8 +506,9 @@
466 506 return [
467 507 'title' => $title,
468 508 'ai_text' => $completion['ai_text'],
469 509 'tokens' => $completion['tokens'],
510 + 'finish_reason' => $completion['finish_reason'],
470 511 ];
471 512 }
472 513
473 514 /**
@@ -493,15 +534,15 @@
493 534 public function improve_meta_description(string $content, array $options = []): array {
494 535 if (!$this->client) {
495 536 $this->initialize_client();
496 537 if (!$this->client) {
497 - throw new \Exception($this->get_client_unavailable_message());
538 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
498 539 }
499 540 }
500 541
501 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'));
502 543 if (!$user_has_api_key) {
503 - throw new \Exception($this->get_client_unavailable_message());
544 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
504 545 }
505 546
506 547 if (!$this->check_rate_limit()) {
507 548 throw new \Exception('Rate limit exceeded. Please try again later.');
@@ -886,14 +927,14 @@
886 927 private function ensure_ready_for_ai(): void {
887 928 if (!$this->client) {
888 929 $this->initialize_client();
889 930 if (!$this->client) {
890 - throw new \Exception($this->get_client_unavailable_message());
931 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
891 932 }
892 933 }
893 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'));
894 935 if (!$user_has_api_key) {
895 - throw new \Exception($this->get_client_unavailable_message());
936 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
896 937 }
897 938 if (!$this->check_rate_limit()) {
898 939 throw new \Exception('Rate limit exceeded. Please try again later.');
899 940 }
@@ -923,28 +964,51 @@
923 964 * @param string $prompt The prompt to send.
924 965 * @return array{ai_text:string,tokens:int}
925 966 */
926 967 /**
927 - * 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.
928 974 *
929 - * Thin gate over request_completion() for callers that need a free-form
930 - * answer rather than a structured SEO artifact (e.g. the brand-visibility
931 - * checker, which asks the model a user-style question and inspects the
932 - * reply). Provider differences are already normalized inside.
933 - *
934 - * @since 1.27.0
935 - *
936 - * @param string $prompt Prompt to send.
937 - * @param int $max_tokens Output token ceiling.
938 - * @return array{ai_text:string,tokens:int}
939 - * @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.
940 977 */
941 - public function answer_prompt(string $prompt, int $max_tokens = 1024, array $options = []): array {
942 - if (!$this->client) {
943 - 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 + }
944 992 }
945 993
946 - 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 + }
947 1011 }
948 1012
949 1013 private function request_completion(string $prompt, int $max_tokens = 2048, array $options = []): array {
950 1014 // "Thinking" providers (e.g. Gemini 2.5) spend output tokens on reasoning
@@ -957,8 +1021,17 @@
957 1021 'max_tokens' => $max_tokens,
958 1022 'temperature' => 0.4,
959 1023 ]));
960 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 +
961 1034 $ai_text = '';
962 1035 if (isset($response['choices'][0]['message']['content'])) {
963 1036 $ai_text = is_array($response['choices'][0]['message']['content'])
964 1037 ? implode(' ', array_map(static fn($part) => is_array($part) ? ($part['text'] ?? '') : (string) $part, $response['choices'][0]['message']['content']))
@@ -974,12 +1047,17 @@
974 1047 $tokens = $response['usage']['total_tokens']
975 1048 ?? $response['usage']['output_tokens']
976 1049 ?? ($response['usageMetadata']['totalTokenCount'] ?? 0);
977 1050
978 - // Diagnostics for callers that must explain an empty answer (e.g. the
979 - // 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
980 1053 // completion budget hidden reasoning consumed (OpenAI reasoning models).
981 - $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'] ?? '')));
982 1060 $reasoning_tokens = (int) ($response['usage']['completion_tokens_details']['reasoning_tokens'] ?? 0);
983 1061
984 1062 return [
985 1063 'ai_text' => $ai_text,
@@ -1069,9 +1147,9 @@
1069 1147 * @throws \Exception If analysis fails
1070 1148 */
1071 1149 public function analyze_content(string $content, array $metadata = []): array {
1072 1150 if (!$this->client) {
1073 - throw new \Exception($this->get_client_unavailable_message());
1151 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1074 1152 }
1075 1153
1076 1154 $user_id = get_current_user_id();
1077 1155
@@ -1078,9 +1156,9 @@
1078 1156 // Ensure user has configured their API key
1079 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'));
1080 1158
1081 1159 if (!$user_has_api_key) {
1082 - throw new \Exception($this->get_client_unavailable_message());
1160 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1083 1161 }
1084 1162
1085 1163 // Check rate limits
1086 1164 if (!$this->check_rate_limit($user_id, 'content_analysis')) {
@@ -1168,9 +1246,9 @@
1168 1246 // Ensure user has configured their API key
1169 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'));
1170 1248
1171 1249 if (!$user_has_api_key) {
1172 - throw new \Exception($this->get_client_unavailable_message());
1250 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1173 1251 }
1174 1252
1175 1253 // Generate cache key using existing pattern
1176 1254 $cache_key = 'site_identity_' . md5(wp_json_encode($site_data) . wp_json_encode($options)) . '_' . $user_id;
@@ -1193,9 +1271,9 @@
1193 1271 // Get AI client
1194 1272 $client = $this->get_client();
1195 1273
1196 1274 if (!$client) {
1197 - throw new \Exception($this->get_client_unavailable_message());
1275 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1198 1276 }
1199 1277
1200 1278 // Perform AI optimization
1201 1279 $optimization_results = $client->optimize_site_identity($site_data, $options);
@@ -1206,9 +1284,9 @@
1206 1284 }
1207 1285
1208 1286 // Add metadata
1209 1287 $optimization_results['ai_model'] = $client->get_model();
1210 - $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1288 + $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1211 1289 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1212 1290 $optimization_results['user_id'] = $user_id;
1213 1291
1214 1292 // Cache the results (24 hours)
@@ -1246,9 +1324,9 @@
1246 1324 // Ensure user has configured their API key
1247 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'));
1248 1326
1249 1327 if (!$user_has_api_key) {
1250 - throw new \Exception($this->get_client_unavailable_message());
1328 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1251 1329 }
1252 1330
1253 1331 // Generate cache key
1254 1332 $cache_key = 'llms_txt_' . md5(wp_json_encode($website_data) . wp_json_encode($options)) . '_' . $user_id;
@@ -1271,9 +1349,9 @@
1271 1349 // Get AI client
1272 1350 $client = $this->get_client();
1273 1351
1274 1352 if (!$client) {
1275 - throw new \Exception($this->get_client_unavailable_message());
1353 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1276 1354 }
1277 1355
1278 1356 // Perform AI optimization
1279 1357 $optimization_results = $client->optimize_llms_txt($website_data, $options);
@@ -1284,9 +1362,9 @@
1284 1362 }
1285 1363
1286 1364 // Add metadata
1287 1365 $optimization_results['ai_model'] = $client->get_model();
1288 - $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1366 + $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1289 1367 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1290 1368 $optimization_results['user_id'] = $user_id;
1291 1369
1292 1370 // Cache the results (24 hours)
@@ -1316,23 +1394,31 @@
1316 1394 'models' => ['gpt-5-nano', 'gpt-5-mini', 'gpt-5', 'gpt-4o'],
1317 1395 'requires_key' => true,
1318 1396 ],
1319 1397 'claude' => [
1320 - 'name' => 'Claude (Anthropic)',
1321 - 'description' => 'Claude Opus 4.8, Sonnet 5, and Haiku 4.5',
1322 - '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'],
1323 1403 'requires_key' => true,
1324 1404 ],
1325 1405 'gemini' => [
1326 1406 'name' => 'Google Gemini',
1327 - 'description' => 'Gemini 3.x and 2.x models',
1328 - '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'],
1329 1412 'requires_key' => true,
1330 1413 ],
1331 1414 'openrouter' => [
1332 1415 'name' => 'OpenRouter',
1333 1416 'description' => 'Unified access to many models via one key',
1334 - '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'],
1335 1421 'requires_key' => true,
1336 1422 ],
1337 1423 ];
1338 1424 }
@@ -1342,10 +1428,14 @@
1342 1428 *
1343 1429 * @return array Provider status
1344 1430 */
1345 1431 public function get_provider_status(): array {
1346 - $provider = $this->settings->get('ai_provider', 'openai');
1347 - $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');
1348 1438
1349 1439 return [
1350 1440 'provider' => $provider,
1351 1441 'configured' => !empty($api_key),
@@ -1486,14 +1576,26 @@
1486 1576 [
1487 1577 'user_id' => $user_id,
1488 1578 'action' => $action,
1489 1579 'tokens_used' => $tokens_used,
1490 - 'provider' => $this->settings->get('ai_provider', 'openai'),
1580 + 'provider' => $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE),
1491 1581 'metadata' => !empty($metadata) ? wp_json_encode($metadata) : null,
1492 1582 'created_at' => current_time('mysql'),
1493 1583 ],
1494 1584 ['%d', '%s', '%d', '%s', '%s', '%s']
1495 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);
1496 1598 }
1497 1599
1498 1600 /**
1499 1601 * Cleanup expired cache entries
@@ -1525,9 +1627,9 @@
1525 1627 // Ensure user has configured their API key (copying Site Identity pattern)
1526 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'));
1527 1629
1528 1630 if (!$user_has_api_key) {
1529 - throw new \Exception($this->get_client_unavailable_message());
1631 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1530 1632 }
1531 1633
1532 1634 // Generate cache key using existing pattern
1533 1635 $cache_key = 'homepage_meta_' . md5(wp_json_encode($content_data) . wp_json_encode($options)) . '_' . $user_id;
@@ -1550,9 +1652,9 @@
1550 1652 // Get AI client
1551 1653 $client = $this->get_client();
1552 1654
1553 1655 if (!$client) {
1554 - throw new \Exception($this->get_client_unavailable_message());
1656 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1555 1657 }
1556 1658
1557 1659 // Perform AI optimization
1558 1660 $optimization_results = $client->optimize_homepage_meta($content_data, $options);
@@ -1563,9 +1665,9 @@
1563 1665 }
1564 1666
1565 1667 // Add metadata
1566 1668 $optimization_results['ai_model'] = $client->get_model();
1567 - $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1669 + $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1568 1670 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1569 1671 $optimization_results['user_id'] = $user_id;
1570 1672
1571 1673 // Cache the results (24 hours)
@@ -1603,9 +1705,9 @@
1603 1705 // Ensure user has configured their API key (copying Site Identity pattern)
1604 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'));
1605 1707
1606 1708 if (!$user_has_api_key) {
1607 - throw new \Exception($this->get_client_unavailable_message());
1709 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1608 1710 }
1609 1711
1610 1712 // Generate cache key using existing pattern
1611 1713 $cache_key = 'homepage_hero_' . md5(wp_json_encode($hero_data) . wp_json_encode($options)) . '_' . $user_id;
@@ -1628,9 +1730,9 @@
1628 1730 // Get AI client
1629 1731 $client = $this->get_client();
1630 1732
1631 1733 if (!$client) {
1632 - throw new \Exception($this->get_client_unavailable_message());
1734 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1633 1735 }
1634 1736
1635 1737 // Perform AI optimization
1636 1738 $optimization_results = $client->optimize_homepage_hero($hero_data, $options);
@@ -1641,9 +1743,9 @@
1641 1743 }
1642 1744
1643 1745 // Add metadata
1644 1746 $optimization_results['ai_model'] = $client->get_model();
1645 - $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1747 + $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1646 1748 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1647 1749 $optimization_results['user_id'] = $user_id;
1648 1750
1649 1751 // Cache the results (24 hours)