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 +90 -64 2.0.22.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()) {
@@ -509,15 +534,15 @@
509 534 public function improve_meta_description(string $content, array $options = []): array {
510 535 if (!$this->client) {
511 536 $this->initialize_client();
512 537 if (!$this->client) {
513 - throw new \Exception($this->get_client_unavailable_message());
538 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
514 539 }
515 540 }
516 541
517 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'));
518 543 if (!$user_has_api_key) {
519 - throw new \Exception($this->get_client_unavailable_message());
544 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
520 545 }
521 546
522 547 if (!$this->check_rate_limit()) {
523 548 throw new \Exception('Rate limit exceeded. Please try again later.');
@@ -902,14 +927,14 @@
902 927 private function ensure_ready_for_ai(): void {
903 928 if (!$this->client) {
904 929 $this->initialize_client();
905 930 if (!$this->client) {
906 - throw new \Exception($this->get_client_unavailable_message());
931 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
907 932 }
908 933 }
909 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'));
910 935 if (!$user_has_api_key) {
911 - throw new \Exception($this->get_client_unavailable_message());
936 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
912 937 }
913 938 if (!$this->check_rate_limit()) {
914 939 throw new \Exception('Rate limit exceeded. Please try again later.');
915 940 }
@@ -939,31 +964,8 @@
939 964 * @param string $prompt The prompt to send.
940 965 * @return array{ai_text:string,tokens:int}
941 966 */
942 967 /**
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 968 * Detect a provider-side refusal or content-policy block and fail with
967 969 * the real reason. Each provider signals these differently, and none of
968 970 * the signals set the content field the extraction chain looks for — left
969 971 * unchecked they read as an empty/unusable result with no explanation of
@@ -1045,10 +1047,10 @@
1045 1047 $tokens = $response['usage']['total_tokens']
1046 1048 ?? $response['usage']['output_tokens']
1047 1049 ?? ($response['usageMetadata']['totalTokenCount'] ?? 0);
1048 1050
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
1051 + // Diagnostics for callers that must explain an empty answer: why
1052 + // generation stopped, and how much of the
1051 1053 // completion budget hidden reasoning consumed (OpenAI reasoning models).
1052 1054 // All three provider shapes are read — Gemini reports the stop reason
1053 1055 // per candidate, so without that arm the diagnostic was always blank
1054 1056 // for exactly the provider whose truncation it exists to explain.
@@ -1145,9 +1147,9 @@
1145 1147 * @throws \Exception If analysis fails
1146 1148 */
1147 1149 public function analyze_content(string $content, array $metadata = []): array {
1148 1150 if (!$this->client) {
1149 - throw new \Exception($this->get_client_unavailable_message());
1151 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1150 1152 }
1151 1153
1152 1154 $user_id = get_current_user_id();
1153 1155
@@ -1154,9 +1156,9 @@
1154 1156 // Ensure user has configured their API key
1155 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'));
1156 1158
1157 1159 if (!$user_has_api_key) {
1158 - throw new \Exception($this->get_client_unavailable_message());
1160 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1159 1161 }
1160 1162
1161 1163 // Check rate limits
1162 1164 if (!$this->check_rate_limit($user_id, 'content_analysis')) {
@@ -1244,9 +1246,9 @@
1244 1246 // Ensure user has configured their API key
1245 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'));
1246 1248
1247 1249 if (!$user_has_api_key) {
1248 - throw new \Exception($this->get_client_unavailable_message());
1250 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1249 1251 }
1250 1252
1251 1253 // Generate cache key using existing pattern
1252 1254 $cache_key = 'site_identity_' . md5(wp_json_encode($site_data) . wp_json_encode($options)) . '_' . $user_id;
@@ -1269,9 +1271,9 @@
1269 1271 // Get AI client
1270 1272 $client = $this->get_client();
1271 1273
1272 1274 if (!$client) {
1273 - throw new \Exception($this->get_client_unavailable_message());
1275 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1274 1276 }
1275 1277
1276 1278 // Perform AI optimization
1277 1279 $optimization_results = $client->optimize_site_identity($site_data, $options);
@@ -1282,9 +1284,9 @@
1282 1284 }
1283 1285
1284 1286 // Add metadata
1285 1287 $optimization_results['ai_model'] = $client->get_model();
1286 - $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1288 + $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1287 1289 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1288 1290 $optimization_results['user_id'] = $user_id;
1289 1291
1290 1292 // Cache the results (24 hours)
@@ -1322,9 +1324,9 @@
1322 1324 // Ensure user has configured their API key
1323 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'));
1324 1326
1325 1327 if (!$user_has_api_key) {
1326 - throw new \Exception($this->get_client_unavailable_message());
1328 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1327 1329 }
1328 1330
1329 1331 // Generate cache key
1330 1332 $cache_key = 'llms_txt_' . md5(wp_json_encode($website_data) . wp_json_encode($options)) . '_' . $user_id;
@@ -1347,9 +1349,9 @@
1347 1349 // Get AI client
1348 1350 $client = $this->get_client();
1349 1351
1350 1352 if (!$client) {
1351 - throw new \Exception($this->get_client_unavailable_message());
1353 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1352 1354 }
1353 1355
1354 1356 // Perform AI optimization
1355 1357 $optimization_results = $client->optimize_llms_txt($website_data, $options);
@@ -1360,9 +1362,9 @@
1360 1362 }
1361 1363
1362 1364 // Add metadata
1363 1365 $optimization_results['ai_model'] = $client->get_model();
1364 - $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1366 + $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1365 1367 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1366 1368 $optimization_results['user_id'] = $user_id;
1367 1369
1368 1370 // Cache the results (24 hours)
@@ -1392,23 +1394,31 @@
1392 1394 'models' => ['gpt-5-nano', 'gpt-5-mini', 'gpt-5', 'gpt-4o'],
1393 1395 'requires_key' => true,
1394 1396 ],
1395 1397 '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'],
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'],
1399 1403 'requires_key' => true,
1400 1404 ],
1401 1405 'gemini' => [
1402 1406 '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'],
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'],
1405 1412 'requires_key' => true,
1406 1413 ],
1407 1414 'openrouter' => [
1408 1415 'name' => 'OpenRouter',
1409 1416 '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'],
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'],
1411 1421 'requires_key' => true,
1412 1422 ],
1413 1423 ];
1414 1424 }
@@ -1418,10 +1428,14 @@
1418 1428 *
1419 1429 * @return array Provider status
1420 1430 */
1421 1431 public function get_provider_status(): array {
1422 - $provider = $this->settings->get('ai_provider', 'openai');
1423 - $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');
1424 1438
1425 1439 return [
1426 1440 'provider' => $provider,
1427 1441 'configured' => !empty($api_key),
@@ -1562,14 +1576,26 @@
1562 1576 [
1563 1577 'user_id' => $user_id,
1564 1578 'action' => $action,
1565 1579 'tokens_used' => $tokens_used,
1566 - 'provider' => $this->settings->get('ai_provider', 'openai'),
1580 + 'provider' => $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE),
1567 1581 'metadata' => !empty($metadata) ? wp_json_encode($metadata) : null,
1568 1582 'created_at' => current_time('mysql'),
1569 1583 ],
1570 1584 ['%d', '%s', '%d', '%s', '%s', '%s']
1571 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);
1572 1598 }
1573 1599
1574 1600 /**
1575 1601 * Cleanup expired cache entries
@@ -1601,9 +1627,9 @@
1601 1627 // Ensure user has configured their API key (copying Site Identity pattern)
1602 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'));
1603 1629
1604 1630 if (!$user_has_api_key) {
1605 - throw new \Exception($this->get_client_unavailable_message());
1631 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1606 1632 }
1607 1633
1608 1634 // Generate cache key using existing pattern
1609 1635 $cache_key = 'homepage_meta_' . md5(wp_json_encode($content_data) . wp_json_encode($options)) . '_' . $user_id;
@@ -1626,9 +1652,9 @@
1626 1652 // Get AI client
1627 1653 $client = $this->get_client();
1628 1654
1629 1655 if (!$client) {
1630 - throw new \Exception($this->get_client_unavailable_message());
1656 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1631 1657 }
1632 1658
1633 1659 // Perform AI optimization
1634 1660 $optimization_results = $client->optimize_homepage_meta($content_data, $options);
@@ -1639,9 +1665,9 @@
1639 1665 }
1640 1666
1641 1667 // Add metadata
1642 1668 $optimization_results['ai_model'] = $client->get_model();
1643 - $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1669 + $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1644 1670 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1645 1671 $optimization_results['user_id'] = $user_id;
1646 1672
1647 1673 // Cache the results (24 hours)
@@ -1679,9 +1705,9 @@
1679 1705 // Ensure user has configured their API key (copying Site Identity pattern)
1680 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'));
1681 1707
1682 1708 if (!$user_has_api_key) {
1683 - throw new \Exception($this->get_client_unavailable_message());
1709 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1684 1710 }
1685 1711
1686 1712 // Generate cache key using existing pattern
1687 1713 $cache_key = 'homepage_hero_' . md5(wp_json_encode($hero_data) . wp_json_encode($options)) . '_' . $user_id;
@@ -1704,9 +1730,9 @@
1704 1730 // Get AI client
1705 1731 $client = $this->get_client();
1706 1732
1707 1733 if (!$client) {
1708 - throw new \Exception($this->get_client_unavailable_message());
1734 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1709 1735 }
1710 1736
1711 1737 // Perform AI optimization
1712 1738 $optimization_results = $client->optimize_homepage_hero($hero_data, $options);
@@ -1717,9 +1743,9 @@
1717 1743 }
1718 1744
1719 1745 // Add metadata
1720 1746 $optimization_results['ai_model'] = $client->get_model();
1721 - $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1747 + $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1722 1748 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1723 1749 $optimization_results['user_id'] = $user_id;
1724 1750
1725 1751 // Cache the results (24 hours)