PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.0
2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 All 51 releases
← All changes | includes/ai/class-manager.php +181 -75 2.0.2 → 2.10.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');
@@ -158,17 +165,63 @@
158 165 $this->client = new OpenRouter_Client($api_key, $model, $timeout);
159 166 }
160 167 break;
161 168
169 + case 'openai_compatible':
170 + // Any server speaking the OpenAI Chat Completions API:
171 + // Ollama, LM Studio, vLLM, Azure OpenAI, Groq, a company
172 + // gateway (#721). The key is optional — a local server
173 + // usually wants none — so the URL and the model id are what
174 + // decide whether this provider is configured.
175 + $base_url = (string) $this->settings->get('openai_compatible_base_url', '');
176 + $model = trim((string) $this->settings->get('openai_compatible_model', ''));
177 + if ('' !== $base_url && '' !== $model) {
178 + $compatible_client = new OpenAI_Client(
179 + (string) $this->settings->get('openai_compatible_api_key', ''),
180 + $model,
181 + (int) $this->settings->get('openai_compatible_timeout', Settings::DEFAULT_OPENAI_COMPATIBLE_TIMEOUT),
182 + $base_url
183 + );
184 + $compatible_client->set_json_mode((bool) $this->settings->get('openai_compatible_json_mode', false));
185 + $this->client = $compatible_client;
186 + }
187 + break;
188 +
162 189 default:
163 190 throw new \Exception("Unsupported AI provider: {$provider}");
164 191 }
165 192 } catch (\Exception $e) {
166 - // AI client initialization failed, will be handled later
193 + // Leave a trace. Swallowing this meant a misconfigured provider
194 + // produced a NULL client and every AI feature became a silent
195 + // no-op with nothing to diagnose from.
196 + if (defined('WP_DEBUG') && WP_DEBUG) {
197 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- diagnostic, WP_DEBUG only.
198 + error_log('ThinkRank [ai]: client initialization failed — ' . $e->getMessage());
199 + }
167 200 }
168 201 }
169 202
170 203 /**
204 + * Has the user configured enough for the selected provider to run?
205 + *
206 + * Every generator re-checks this before spending a request, because an
207 + * initialised client is not proof of configuration — the client is built
208 + * from whatever was stored. It used to be an inline OR over the four API
209 + * key settings, repeated at nine call sites; the OpenAI-compatible
210 + * provider broke that shape, since a local Ollama or LM Studio server
211 + * legitimately has no key and is configured by URL + model instead (#721).
212 + * Settings::has_ai_provider_configured() is now the single answer, shared
213 + * with the admin menu notice and the metabox.
214 + *
215 + * @since 2.8.0
216 + *
217 + * @return bool True when the selected provider has what it needs.
218 + */
219 + private function has_provider_credentials(): bool {
220 + return $this->settings->has_ai_provider_configured();
221 + }
222 +
223 + /**
171 224 * Get the display name of the currently selected AI provider
172 225 *
173 226 * @return string Provider display name (e.g. "OpenAI")
174 227 */
@@ -174,14 +227,17 @@
174 227 */
175 228 private function get_provider_label(): string {
176 229 $labels = [
177 230 'openai' => 'OpenAI',
178 - 'claude' => 'Claude',
231 + // The vendor, not the model family — matches the settings UI (#572).
232 + 'claude' => 'Anthropic',
179 233 'gemini' => 'Gemini',
180 234 'openrouter' => 'OpenRouter',
235 + // Named by what it is, not by OpenAI — the host is the customer's.
236 + 'openai_compatible' => 'OpenAI-compatible endpoint',
181 237 ];
182 238
183 - $provider = (string) $this->settings->get('ai_provider', 'openai');
239 + $provider = (string) $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
184 240
185 241 return $labels[$provider] ?? ucfirst($provider);
186 242 }
187 243
@@ -193,9 +249,9 @@
193 249 *
194 250 * @return string Actionable error message for end users
195 251 */
196 252 private function get_client_unavailable_message(): string {
197 - $provider = (string) $this->settings->get('ai_provider', 'openai');
253 + $provider = (string) $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
198 254
199 255 // The React admin renders this anchor as a real link via linkifyMessage().
200 256 $settings_link = sprintf(
201 257 '<a href="%s" target="_blank" rel="noopener noreferrer">%s</a>',
@@ -202,12 +258,50 @@
202 258 esc_url(admin_url('admin.php?page=thinkrank-settings')),
203 259 __('ThinkRank → Settings', 'thinkrank')
204 260 );
205 261
262 + // No provider chosen at all — asking for a key would put the cart before
263 + // the horse, so name the actual first step (#572).
264 + if (Settings::AI_PROVIDER_NONE === $provider) {
265 + return sprintf(
266 + /* translators: %s: link to the ThinkRank settings page. */
267 + __('AI features are not set up yet. Choose an AI provider and add its API key under %s.', 'thinkrank'),
268 + $settings_link
269 + );
270 + }
271 +
272 + // The OpenAI-compatible provider has no key requirement — a local
273 + // server usually wants none — so the generic "add your API key" copy
274 + // below would send the user looking for the wrong field (#721).
275 + if ('openai_compatible' === $provider) {
276 + if (empty($this->settings->get('openai_compatible_base_url'))) {
277 + return sprintf(
278 + /* translators: %s: link to the ThinkRank settings page. */
279 + __('AI features are not set up yet. Add the base URL of your OpenAI-compatible endpoint under %s.', 'thinkrank'),
280 + $settings_link
281 + );
282 + }
283 +
284 + if (empty(trim((string) $this->settings->get('openai_compatible_model', '')))) {
285 + return sprintf(
286 + /* translators: %s: link to the ThinkRank settings page. */
287 + __('AI features are not set up yet. Enter the model id your endpoint should use under %s.', 'thinkrank'),
288 + $settings_link
289 + );
290 + }
291 +
292 + return sprintf(
293 + /* translators: %s: link to the ThinkRank settings page. */
294 + __('ThinkRank could not reach your OpenAI-compatible endpoint. Check the base URL, model id and that the server is running under %s, then try again.', 'thinkrank'),
295 + $settings_link
296 + );
297 + }
298 +
206 299 if (empty($this->settings->get("{$provider}_api_key"))) {
207 300 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'),
301 + /* translators: 1: AI provider name (e.g. OpenAI), 2: link to the ThinkRank settings page. */
302 + __('AI features are not set up yet. To enable them, add your %1$s API key under %2$s.', 'thinkrank'),
303 + $this->get_provider_label(),
210 304 $settings_link
211 305 );
212 306 }
213 307
@@ -242,9 +336,9 @@
242 336 }
243 337
244 338 // If still not available, throw error
245 339 if (!$this->client) {
246 - throw new \Exception($this->get_client_unavailable_message());
340 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
247 341 }
248 342
249 343 return $this->client;
250 344 }
@@ -263,9 +357,9 @@
263 357 $this->initialize_client();
264 358
265 359 // If still not available, throw error
266 360 if (!$this->client) {
267 - throw new \Exception($this->get_client_unavailable_message());
361 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
268 362 }
269 363 }
270 364
271 365 // Check rate limits
@@ -289,12 +383,12 @@
289 383 // Generate metadata using AI
290 384 $metadata = $this->client->generate_seo_metadata($content, $options);
291 385
292 386 // Ensure user has configured their API key
293 - $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'));
387 + $user_has_api_key = $this->has_provider_credentials();
294 388
295 389 if (!$user_has_api_key) {
296 - throw new \Exception($this->get_client_unavailable_message());
390 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
297 391 }
298 392
299 393 // Cache the result
300 394 $this->cache->set($cache_key, $metadata);
@@ -338,16 +432,16 @@
338 432 public function improve_seo_title(string $content, array $options = []): array {
339 433 if (!$this->client) {
340 434 $this->initialize_client();
341 435 if (!$this->client) {
342 - throw new \Exception($this->get_client_unavailable_message());
436 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
343 437 }
344 438 }
345 439
346 440 // Ensure user has configured their API key.
347 - $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'));
441 + $user_has_api_key = $this->has_provider_credentials();
348 442 if (!$user_has_api_key) {
349 - throw new \Exception($this->get_client_unavailable_message());
443 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
350 444 }
351 445
352 446 // Check rate limits.
353 447 if (!$this->check_rate_limit()) {
@@ -509,15 +603,15 @@
509 603 public function improve_meta_description(string $content, array $options = []): array {
510 604 if (!$this->client) {
511 605 $this->initialize_client();
512 606 if (!$this->client) {
513 - throw new \Exception($this->get_client_unavailable_message());
607 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
514 608 }
515 609 }
516 610
517 - $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'));
611 + $user_has_api_key = $this->has_provider_credentials();
518 612 if (!$user_has_api_key) {
519 - throw new \Exception($this->get_client_unavailable_message());
613 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
520 614 }
521 615
522 616 if (!$this->check_rate_limit()) {
523 617 throw new \Exception('Rate limit exceeded. Please try again later.');
@@ -902,14 +996,14 @@
902 996 private function ensure_ready_for_ai(): void {
903 997 if (!$this->client) {
904 998 $this->initialize_client();
905 999 if (!$this->client) {
906 - throw new \Exception($this->get_client_unavailable_message());
1000 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
907 1001 }
908 1002 }
909 - $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'));
1003 + $user_has_api_key = $this->has_provider_credentials();
910 1004 if (!$user_has_api_key) {
911 - throw new \Exception($this->get_client_unavailable_message());
1005 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
912 1006 }
913 1007 if (!$this->check_rate_limit()) {
914 1008 throw new \Exception('Rate limit exceeded. Please try again later.');
915 1009 }
@@ -939,31 +1033,8 @@
939 1033 * @param string $prompt The prompt to send.
940 1034 * @return array{ai_text:string,tokens:int}
941 1035 */
942 1036 /**
943 - * Public plain-text completion against the configured provider.
944 - *
945 - * Thin gate over request_completion() for callers that need a free-form
946 - * answer rather than a structured SEO artifact (e.g. the brand-visibility
947 - * checker, which asks the model a user-style question and inspects the
948 - * reply). Provider differences are already normalized inside.
949 - *
950 - * @since 1.27.0
951 - *
952 - * @param string $prompt Prompt to send.
953 - * @param int $max_tokens Output token ceiling.
954 - * @return array{ai_text:string,tokens:int}
955 - * @throws \Exception When no AI client is configured/available.
956 - */
957 - public function answer_prompt(string $prompt, int $max_tokens = 1024, array $options = []): array {
958 - if (!$this->client) {
959 - throw new \Exception(esc_html($this->get_client_unavailable_message()));
960 - }
961 -
962 - return $this->request_completion($prompt, $max_tokens, $options);
963 - }
964 -
965 - /**
966 1037 * Detect a provider-side refusal or content-policy block and fail with
967 1038 * the real reason. Each provider signals these differently, and none of
968 1039 * the signals set the content field the extraction chain looks for — left
969 1040 * unchecked they read as an empty/unusable result with no explanation of
@@ -1045,10 +1116,10 @@
1045 1116 $tokens = $response['usage']['total_tokens']
1046 1117 ?? $response['usage']['output_tokens']
1047 1118 ?? ($response['usageMetadata']['totalTokenCount'] ?? 0);
1048 1119
1049 - // Diagnostics for callers that must explain an empty answer (e.g. the
1050 - // brand-visibility probe): why generation stopped, and how much of the
1120 + // Diagnostics for callers that must explain an empty answer: why
1121 + // generation stopped, and how much of the
1051 1122 // completion budget hidden reasoning consumed (OpenAI reasoning models).
1052 1123 // All three provider shapes are read — Gemini reports the stop reason
1053 1124 // per candidate, so without that arm the diagnostic was always blank
1054 1125 // for exactly the provider whose truncation it exists to explain.
@@ -1145,18 +1216,18 @@
1145 1216 * @throws \Exception If analysis fails
1146 1217 */
1147 1218 public function analyze_content(string $content, array $metadata = []): array {
1148 1219 if (!$this->client) {
1149 - throw new \Exception($this->get_client_unavailable_message());
1220 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1150 1221 }
1151 1222
1152 1223 $user_id = get_current_user_id();
1153 1224
1154 1225 // Ensure user has configured their API key
1155 - $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'));
1226 + $user_has_api_key = $this->has_provider_credentials();
1156 1227
1157 1228 if (!$user_has_api_key) {
1158 - throw new \Exception($this->get_client_unavailable_message());
1229 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1159 1230 }
1160 1231
1161 1232 // Check rate limits
1162 1233 if (!$this->check_rate_limit($user_id, 'content_analysis')) {
@@ -1241,12 +1312,12 @@
1241 1312
1242 1313 $user_id = get_current_user_id();
1243 1314
1244 1315 // Ensure user has configured their API key
1245 - $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'));
1316 + $user_has_api_key = $this->has_provider_credentials();
1246 1317
1247 1318 if (!$user_has_api_key) {
1248 - throw new \Exception($this->get_client_unavailable_message());
1319 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1249 1320 }
1250 1321
1251 1322 // Generate cache key using existing pattern
1252 1323 $cache_key = 'site_identity_' . md5(wp_json_encode($site_data) . wp_json_encode($options)) . '_' . $user_id;
@@ -1269,9 +1340,9 @@
1269 1340 // Get AI client
1270 1341 $client = $this->get_client();
1271 1342
1272 1343 if (!$client) {
1273 - throw new \Exception($this->get_client_unavailable_message());
1344 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1274 1345 }
1275 1346
1276 1347 // Perform AI optimization
1277 1348 $optimization_results = $client->optimize_site_identity($site_data, $options);
@@ -1282,9 +1353,9 @@
1282 1353 }
1283 1354
1284 1355 // Add metadata
1285 1356 $optimization_results['ai_model'] = $client->get_model();
1286 - $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1357 + $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1287 1358 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1288 1359 $optimization_results['user_id'] = $user_id;
1289 1360
1290 1361 // Cache the results (24 hours)
@@ -1319,12 +1390,12 @@
1319 1390
1320 1391 $user_id = get_current_user_id();
1321 1392
1322 1393 // Ensure user has configured their API key
1323 - $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'));
1394 + $user_has_api_key = $this->has_provider_credentials();
1324 1395
1325 1396 if (!$user_has_api_key) {
1326 - throw new \Exception($this->get_client_unavailable_message());
1397 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1327 1398 }
1328 1399
1329 1400 // Generate cache key
1330 1401 $cache_key = 'llms_txt_' . md5(wp_json_encode($website_data) . wp_json_encode($options)) . '_' . $user_id;
@@ -1347,9 +1418,9 @@
1347 1418 // Get AI client
1348 1419 $client = $this->get_client();
1349 1420
1350 1421 if (!$client) {
1351 - throw new \Exception($this->get_client_unavailable_message());
1422 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1352 1423 }
1353 1424
1354 1425 // Perform AI optimization
1355 1426 $optimization_results = $client->optimize_llms_txt($website_data, $options);
@@ -1360,9 +1431,9 @@
1360 1431 }
1361 1432
1362 1433 // Add metadata
1363 1434 $optimization_results['ai_model'] = $client->get_model();
1364 - $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1435 + $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1365 1436 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1366 1437 $optimization_results['user_id'] = $user_id;
1367 1438
1368 1439 // Cache the results (24 hours)
@@ -1392,25 +1463,42 @@
1392 1463 'models' => ['gpt-5-nano', 'gpt-5-mini', 'gpt-5', 'gpt-4o'],
1393 1464 'requires_key' => true,
1394 1465 ],
1395 1466 'claude' => [
1396 - 'name' => 'Claude (Anthropic)',
1397 - 'description' => 'Claude Opus 4.8, Sonnet 5, and Haiku 4.5',
1398 - 'models' => ['claude-opus-4-8', 'claude-sonnet-5', 'claude-haiku-4-5'],
1467 + // The vendor, not the model family: the other three entries name
1468 + // vendors, and a family name goes stale on every rename (#572).
1469 + 'name' => 'Anthropic',
1470 + 'description' => 'Claude Opus 5, Opus 4.8, Sonnet 5, and Haiku 4.5',
1471 + 'models' => ['claude-opus-5', 'claude-opus-4-8', 'claude-sonnet-5', 'claude-haiku-4-5'],
1399 1472 'requires_key' => true,
1400 1473 ],
1401 1474 'gemini' => [
1402 1475 'name' => 'Google Gemini',
1403 - 'description' => 'Gemini 3.x and 2.x models',
1404 - 'models' => ['gemini-3.1-pro', 'gemini-3.5-flash', 'gemini-3.1-flash-lite', 'gemini-2.5-flash-lite', 'gemini-2.5-pro'],
1476 + 'description' => 'Gemini 3.x models',
1477 + // 2.5 Pro / 2.5 Flash-Lite retire in Oct 2026 and 3.1 Pro only
1478 + // ships under its -preview id, so none of the three belong in a
1479 + // list users pick from (#572).
1480 + 'models' => ['gemini-3.5-flash', 'gemini-3.1-flash-lite', 'gemini-3.1-pro-preview'],
1405 1481 'requires_key' => true,
1406 1482 ],
1407 1483 'openrouter' => [
1408 1484 'name' => 'OpenRouter',
1409 1485 'description' => 'Unified access to many models via one key',
1410 - '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'],
1486 + // claude-3.5-sonnet is retired (Claude_Client::normalize_model
1487 + // already self-heals it on the direct path) and
1488 + // gemini-2.0-flash-001 was shut down on 1 Jun 2026 (#572).
1489 + 'models' => ['openai/gpt-4o-mini', 'anthropic/claude-sonnet-5', 'google/gemini-3.5-flash', 'meta-llama/llama-3.3-70b-instruct', 'deepseek/deepseek-chat'],
1411 1490 'requires_key' => true,
1412 1491 ],
1492 + 'openai_compatible' => [
1493 + 'name' => 'OpenAI-compatible endpoint',
1494 + 'description' => 'Any server speaking the OpenAI Chat Completions API: Ollama, LM Studio, vLLM, Azure OpenAI, Groq, Together, DeepSeek or your own gateway',
1495 + // Deliberately empty: the model list belongs to the server the
1496 + // user names, and is read from GET {base}/models at runtime.
1497 + 'models' => [],
1498 + 'requires_key' => false,
1499 + 'requires_base_url' => true,
1500 + ],
1413 1501 ];
1414 1502 }
1415 1503
1416 1504 /**
@@ -1418,14 +1506,20 @@
1418 1506 *
1419 1507 * @return array Provider status
1420 1508 */
1421 1509 public function get_provider_status(): array {
1422 - $provider = $this->settings->get('ai_provider', 'openai');
1423 - $api_key = $this->settings->get($provider . '_api_key');
1510 + $provider = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1424 1511
1512 + // "Configured" is per-provider: a key for the hosted three, a base URL
1513 + // plus a model id for an OpenAI-compatible endpoint, whose key is
1514 + // optional (#721).
1515 + $configured = Settings::AI_PROVIDER_NONE === $provider
1516 + ? false
1517 + : $this->has_provider_credentials();
1518 +
1425 1519 return [
1426 1520 'provider' => $provider,
1427 - 'configured' => !empty($api_key),
1521 + 'configured' => $configured,
1428 1522 'connected' => $this->client !== null,
1429 1523 ];
1430 1524 }
1431 1525
@@ -1508,9 +1602,9 @@
1508 1602 * @return bool True if within limits.
1509 1603 */
1510 1604 private function check_rate_limit(?int $user_id = null, string $context = 'ai'): bool {
1511 1605 $user_id = $user_id ?? get_current_user_id();
1512 - $max_requests = (int) $this->settings->get('max_requests_per_minute', 10);
1606 + $max_requests = (int) $this->settings->get('max_requests_per_minute', 0);
1513 1607
1514 1608 // A non-positive limit means "unlimited".
1515 1609 if ($max_requests <= 0) {
1516 1610 return true;
@@ -1562,14 +1656,26 @@
1562 1656 [
1563 1657 'user_id' => $user_id,
1564 1658 'action' => $action,
1565 1659 'tokens_used' => $tokens_used,
1566 - 'provider' => $this->settings->get('ai_provider', 'openai'),
1660 + 'provider' => $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE),
1567 1661 'metadata' => !empty($metadata) ? wp_json_encode($metadata) : null,
1568 1662 'created_at' => current_time('mysql'),
1569 1663 ],
1570 1664 ['%d', '%s', '%d', '%s', '%s', '%s']
1571 1665 );
1666 +
1667 + /**
1668 + * Fires after an AI usage row is recorded.
1669 + *
1670 + * Analytics listens to drop its cached overview, so the Usages page
1671 + * reflects this action immediately instead of after the 600s TTL.
1672 + *
1673 + * @since 2.2.1
1674 + *
1675 + * @param int $user_id User the usage was recorded against.
1676 + */
1677 + do_action('thinkrank_ai_usage_logged', $user_id);
1572 1678 }
1573 1679
1574 1680 /**
1575 1681 * Cleanup expired cache entries
@@ -1598,12 +1704,12 @@
1598 1704
1599 1705 $user_id = get_current_user_id();
1600 1706
1601 1707 // Ensure user has configured their API key (copying Site Identity pattern)
1602 - $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'));
1708 + $user_has_api_key = $this->has_provider_credentials();
1603 1709
1604 1710 if (!$user_has_api_key) {
1605 - throw new \Exception($this->get_client_unavailable_message());
1711 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1606 1712 }
1607 1713
1608 1714 // Generate cache key using existing pattern
1609 1715 $cache_key = 'homepage_meta_' . md5(wp_json_encode($content_data) . wp_json_encode($options)) . '_' . $user_id;
@@ -1626,9 +1732,9 @@
1626 1732 // Get AI client
1627 1733 $client = $this->get_client();
1628 1734
1629 1735 if (!$client) {
1630 - throw new \Exception($this->get_client_unavailable_message());
1736 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1631 1737 }
1632 1738
1633 1739 // Perform AI optimization
1634 1740 $optimization_results = $client->optimize_homepage_meta($content_data, $options);
@@ -1639,9 +1745,9 @@
1639 1745 }
1640 1746
1641 1747 // Add metadata
1642 1748 $optimization_results['ai_model'] = $client->get_model();
1643 - $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1749 + $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1644 1750 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1645 1751 $optimization_results['user_id'] = $user_id;
1646 1752
1647 1753 // Cache the results (24 hours)
@@ -1676,12 +1782,12 @@
1676 1782
1677 1783 $user_id = get_current_user_id();
1678 1784
1679 1785 // Ensure user has configured their API key (copying Site Identity pattern)
1680 - $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'));
1786 + $user_has_api_key = $this->has_provider_credentials();
1681 1787
1682 1788 if (!$user_has_api_key) {
1683 - throw new \Exception($this->get_client_unavailable_message());
1789 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1684 1790 }
1685 1791
1686 1792 // Generate cache key using existing pattern
1687 1793 $cache_key = 'homepage_hero_' . md5(wp_json_encode($hero_data) . wp_json_encode($options)) . '_' . $user_id;
@@ -1704,9 +1810,9 @@
1704 1810 // Get AI client
1705 1811 $client = $this->get_client();
1706 1812
1707 1813 if (!$client) {
1708 - throw new \Exception($this->get_client_unavailable_message());
1814 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1709 1815 }
1710 1816
1711 1817 // Perform AI optimization
1712 1818 $optimization_results = $client->optimize_homepage_hero($hero_data, $options);
@@ -1717,9 +1823,9 @@
1717 1823 }
1718 1824
1719 1825 // Add metadata
1720 1826 $optimization_results['ai_model'] = $client->get_model();
1721 - $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1827 + $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1722 1828 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1723 1829 $optimization_results['user_id'] = $user_id;
1724 1830
1725 1831 // Cache the results (24 hours)