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 +71 -45 2.1.12.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
@@ -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(wp_kses_post($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.
@@ -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)
@@ -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
@@ -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)
@@ -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)