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/api/class-manager.php +340 -32 1.26.02.7.0 View file →
@@ -27,8 +27,9 @@
27 27 use ThinkRank\API\Social_Platforms_Endpoint;
28 28 use ThinkRank\API\LLMs_Txt_Endpoint;
29 29 use ThinkRank\API\Global_SEO_Endpoint;
30 30 use ThinkRank\API\Image_SEO_Endpoint;
31 +use ThinkRank\API\External_Links_Endpoint;
31 32 use ThinkRank\API\Instant_Indexing_Endpoint;
32 33 use ThinkRank\API\Pillar_Content_Endpoint;
33 34 use ThinkRank\API\Global_Robot_Meta_Endpoint;
34 35 use ThinkRank\API\Author_Archives_Endpoint;
@@ -33,8 +34,9 @@
33 34 use ThinkRank\API\Global_Robot_Meta_Endpoint;
34 35 use ThinkRank\API\Author_Archives_Endpoint;
35 36 use ThinkRank\API\Email_Report_Endpoint;
36 37 use ThinkRank\Admin\Importers\Import_Controller;
38 +use ThinkRank\Admin\Importers\Export_Controller;
37 39 use ThinkRank\API\Setup_Wizard_Endpoint;
38 40
39 41
40 42 // Prevent direct access
@@ -87,8 +89,21 @@
87 89 */
88 90 public function init(): void {
89 91 add_action('rest_api_init', [$this, 'register_routes']);
90 92 add_action('rest_api_init', [$this, 'register_endpoint_classes']);
93 +
94 + // Analytics cache invalidation must listen on every request, not only
95 + // REST ones — AI usage is logged from cron and WP-CLI too, and a
96 + // listener bound on rest_api_init never hears those.
97 + Usage_Analytics_Endpoint::boot_cache_invalidation();
98 +
99 + // Make declared schema constraints mean something. Applied once over
100 + // the whole namespace rather than at 70-odd call sites, because that is
101 + // exactly how the enum on /setup-wizard/migrated-plugins and the one on
102 + // /seo-analytics/dashboard came to be inert while the route next door
103 + // was fine (#394). Late priority so it sees every route, including any
104 + // an add-on registered.
105 + add_filter('rest_endpoints', [Rest_Args::class, 'enforce_namespace'], 99);
91 106 }
92 107
93 108 /**
94 109 * Register REST API routes
@@ -138,9 +153,15 @@
138 153 'permission_callback' => [$this, 'check_settings_permissions'],
139 154 'args' => [
140 155 'ai_provider' => [
141 156 'type' => 'string',
157 + // Includes '' (Settings::AI_PROVIDER_NONE) so a client can
158 + // clear the selection, not just switch between providers.
159 + 'enum' => \ThinkRank\Core\Settings::selectable_ai_providers(),
142 160 'sanitize_callback' => 'sanitize_key',
161 + // The enum is inert without this: has_valid_params() skips
162 + // an arg entirely unless a validate_callback is set (#394).
163 + 'validate_callback' => 'rest_validate_request_arg',
143 164 ],
144 165 'openai_api_key' => [
145 166 'type' => 'string',
146 167 'sanitize_callback' => 'sanitize_text_field',
@@ -172,8 +193,27 @@
172 193 'openrouter_model' => [
173 194 'type' => 'string',
174 195 'sanitize_callback' => 'sanitize_text_field',
175 196 ],
197 + 'max_tokens' => [
198 + 'type' => 'integer',
199 + 'minimum' => 1,
200 + 'maximum' => 32000,
201 + 'sanitize_callback' => 'absint',
202 + 'validate_callback' => 'rest_validate_request_arg',
203 + ],
204 + 'temperature' => [
205 + 'type' => 'number',
206 + 'minimum' => 0,
207 + 'maximum' => 2,
208 + 'validate_callback' => 'rest_validate_request_arg',
209 + ],
210 + 'cache_duration' => [
211 + 'type' => 'integer',
212 + 'minimum' => 0,
213 + 'sanitize_callback' => 'absint',
214 + 'validate_callback' => 'rest_validate_request_arg',
215 + ],
176 216 'keep_data_on_uninstall' => [
177 217 'type' => 'boolean',
178 218 'sanitize_callback' => 'rest_sanitize_boolean',
179 219 ],
@@ -184,8 +224,12 @@
184 224 'enable_migration_tools' => [
185 225 'type' => 'boolean',
186 226 'sanitize_callback' => 'rest_sanitize_boolean',
187 227 ],
228 + 'enable_import_export' => [
229 + 'type' => 'boolean',
230 + 'sanitize_callback' => 'rest_sanitize_boolean',
231 + ],
188 232 ],
189 233 ]);
190 234
191 235
@@ -227,8 +271,14 @@
227 271 'type' => 'string',
228 272 'default' => 'professional',
229 273 'sanitize_callback' => 'sanitize_text_field',
230 274 ],
275 + 'post_id' => [
276 + 'type' => 'integer',
277 + 'required' => false,
278 + 'default' => 0,
279 + 'sanitize_callback' => 'absint',
280 + ],
231 281 ],
232 282 ]);
233 283
234 284 register_rest_route(self::NAMESPACE, '/ai/improve-title', [
@@ -262,8 +312,14 @@
262 312 'suggestion' => [
263 313 'type' => 'string',
264 314 'sanitize_callback' => 'sanitize_text_field',
265 315 ],
316 + 'post_id' => [
317 + 'type' => 'integer',
318 + 'required' => false,
319 + 'default' => 0,
320 + 'sanitize_callback' => 'absint',
321 + ],
266 322 ],
267 323 ]);
268 324
269 325 register_rest_route(self::NAMESPACE, '/ai/improve-meta-description', [
@@ -297,8 +353,14 @@
297 353 'suggestion' => [
298 354 'type' => 'string',
299 355 'sanitize_callback' => 'sanitize_text_field',
300 356 ],
357 + 'post_id' => [
358 + 'type' => 'integer',
359 + 'required' => false,
360 + 'default' => 0,
361 + 'sanitize_callback' => 'absint',
362 + ],
301 363 ],
302 364 ]);
303 365
304 366 register_rest_route(self::NAMESPACE, '/ai/explain-suggestion', [
@@ -420,8 +482,13 @@
420 482 'required' => false,
421 483 'default' => 'openai',
422 484 'sanitize_callback' => 'sanitize_key',
423 485 ],
486 + 'model' => [
487 + 'type' => 'string',
488 + 'required' => false,
489 + 'sanitize_callback' => 'sanitize_text_field',
490 + ],
424 491 ],
425 492 ]);
426 493
427 494 register_rest_route(self::NAMESPACE, '/ai/providers', [
@@ -739,22 +806,23 @@
739 806 // Use Settings class for consistent access (handles decryption automatically)
740 807 $settings_instance = \ThinkRank\Core\Settings::instance();
741 808
742 809 $settings = [
743 - 'ai_provider' => $settings_instance->get('ai_provider', 'openai'),
810 + 'ai_provider' => $settings_instance->get('ai_provider', \ThinkRank\Core\Settings::AI_PROVIDER_NONE),
744 811 'openai_api_key' => $settings_instance->get('openai_api_key', ''),
745 - 'openai_model' => $settings_instance->get('openai_model', 'gpt-5-nano'),
812 + 'openai_model' => $settings_instance->get('openai_model', \ThinkRank\Core\Settings::DEFAULT_OPENAI_MODEL),
746 813 'claude_api_key' => $settings_instance->get('claude_api_key', ''),
747 - 'claude_model' => $settings_instance->get('claude_model', 'claude-sonnet-5'),
814 + 'claude_model' => $settings_instance->get('claude_model', \ThinkRank\Core\Settings::DEFAULT_CLAUDE_MODEL),
748 815 'gemini_api_key' => $settings_instance->get('gemini_api_key', ''),
749 - 'gemini_model' => $settings_instance->get('gemini_model', 'gemini-2.5-flash'),
816 + 'gemini_model' => $settings_instance->get('gemini_model', \ThinkRank\Core\Settings::DEFAULT_GEMINI_MODEL),
750 817 'openrouter_api_key' => $settings_instance->get('openrouter_api_key', ''),
751 - 'openrouter_model' => $settings_instance->get('openrouter_model', 'openai/gpt-4o-mini'),
818 + 'openrouter_model' => $settings_instance->get('openrouter_model', \ThinkRank\Core\Settings::DEFAULT_OPENROUTER_MODEL),
752 819 'max_tokens' => $settings_instance->get('max_tokens', 1000),
753 820 'temperature' => $settings_instance->get('temperature', 0.7),
754 821 'cache_duration' => $settings_instance->get('cache_duration', 3600),
755 822 'keep_data_on_uninstall' => (bool) $settings_instance->get('keep_data_on_uninstall', true),
756 823 'enable_migration_tools' => (bool) $settings_instance->get('enable_migration_tools', false),
824 + 'enable_import_export' => (bool) $settings_instance->get('enable_import_export', false),
757 825 'google_account_connected' => (bool) $settings_instance->get('google_account_connected', false),
758 826 'enable_mcp' => (bool) $settings_instance->get('enable_mcp', false),
759 827 ];
760 828
@@ -808,8 +876,12 @@
808 876
809 877 // Get Settings instance for proper encryption handling
810 878 $settings = \ThinkRank\Core\Settings::instance();
811 879
880 + // Capture the pre-save MCP state so we can detect an on/off transition
881 + // below and mint/revoke the connection token to match (see #244).
882 + $mcp_was_enabled = (bool) $settings->get('enable_mcp', false);
883 +
812 884 // Map frontend parameter names to setting keys
813 885 $settings_map = [
814 886 'ai_provider' => 'ai_provider',
815 887 'openai_api_key' => 'openai_api_key',
@@ -825,8 +897,9 @@
825 897 'cache_duration' => 'cache_duration',
826 898 'keep_data_on_uninstall' => 'keep_data_on_uninstall',
827 899 'enable_mcp' => 'enable_mcp',
828 900 'enable_migration_tools' => 'enable_migration_tools',
901 + 'enable_import_export' => 'enable_import_export',
829 902 ];
830 903
831 904 // Processing settings save request
832 905
@@ -834,9 +907,9 @@
834 907 if (isset($params[$param_key])) {
835 908 $value = $params[$param_key];
836 909
837 910 // Handle API keys specially - check for masked values
838 - if (in_array($param_key, ['openai_api_key', 'claude_api_key', 'gemini_api_key', 'openrouter_api_key'])) {
911 + if (in_array($param_key, ['openai_api_key', 'claude_api_key', 'gemini_api_key', 'openrouter_api_key'], true)) {
839 912 // Don't update if the value carries the mask sentinel (the
840 913 // preview now keeps real head/tail chars around it, so match
841 914 // anywhere rather than only at the start). Empty still clears.
842 915 if (strpos($value, '••••••••') !== false) {
@@ -844,14 +917,33 @@
844 917 }
845 918 }
846 919
847 920 // Use Settings class for all operations (handles encryption automatically)
848 - if (!$settings->set($setting_key, $value)) {
849 - // Settings save failed, continue with other settings
850 - }
921 + // A failed write is skipped rather than aborting the batch, so
922 + // one bad setting cannot block the rest of the save.
923 + $settings->set($setting_key, $value);
851 924 }
852 925 }
853 926
927 + // MCP is a single master switch (see #244): enabling it auto-mints a
928 + // read/write connection token so the connect recipes are ready without
929 + // a separate "Generate token" step.
930 + //
931 + // Disabling is a PAUSE, not a wipe: the switch alone already denies all
932 + // access (Mcp_Server 403s and the OAuth/discovery endpoints refuse while
933 + // off), so stored tokens and OAuth grants are inert. We keep them so
934 + // re-enabling restores every previously connected app with no
935 + // re-approval. Explicit revocation stays available per-app (the
936 + // Connected AI apps trash button) and for the shared token (Reset
937 + // token / rotate). Only act on an actual on->off->on transition so
938 + // saving unrelated settings never touches the connection.
939 + if (isset($params['enable_mcp'])) {
940 + $mcp_now_enabled = (bool) $settings->get('enable_mcp', false);
941 + if ($mcp_now_enabled && !$mcp_was_enabled) {
942 + \ThinkRank\Mcp\Mcp_Pairing::connect();
943 + }
944 + }
945 +
854 946 // Auto-dismiss welcome notice if API key was saved
855 947 $this->maybe_dismiss_welcome_notice($params);
856 948
857 949 // Force AI Manager to re-initialize client with new settings
@@ -914,15 +1006,31 @@
914 1006 if (!current_user_can('edit_post', $post_id)) {
915 1007 return new \WP_REST_Response(['message' => 'You are not allowed to view this metadata.'], 403);
916 1008 }
917 1009
918 - // Get existing metadata
1010 + // Read the pending flag BEFORE the meta below, never after. A writer
1011 + // that finishes mid-request writes the meta and *then* clears the
1012 + // flag; reading the flag last could therefore observe "no value" and
1013 + // "not pending" for the same run and stop the editor panel polling one
1014 + // tick before the value it was waiting for lands (#329).
1015 + $pending = \ThinkRank\SEO\Metadata_Pending::is_pending($post_id);
1016 +
1017 + // Get existing metadata. These must read the same canonical meta keys
1018 + // the rest of the plugin writes/reads (frontend, metabox, scoring),
1019 + // otherwise the response is always empty:
1020 + // title/description → _thinkrank_seo_title / _thinkrank_meta_description
1021 + // keywords → Focus_Keywords (stored as _thinkrank_focus_keywords)
1022 + // last_generated → _thinkrank_generated_at (written by Metadata_Generator)
919 1023 $metadata = [
920 - 'title' => get_post_meta($post_id, '_thinkrank_title', true),
921 - 'description' => get_post_meta($post_id, '_thinkrank_description', true),
922 - 'keywords' => get_post_meta($post_id, '_thinkrank_keywords', true),
1024 + 'title' => get_post_meta($post_id, '_thinkrank_seo_title', true),
1025 + 'description' => get_post_meta($post_id, '_thinkrank_meta_description', true),
1026 + 'keywords' => \ThinkRank\SEO\Focus_Keywords::get($post_id),
923 1027 'seo_score' => get_post_meta($post_id, '_thinkrank_seo_score', true) ?: 0,
924 - 'last_generated' => get_post_meta($post_id, '_thinkrank_last_generated', true),
1028 + 'last_generated' => get_post_meta($post_id, '_thinkrank_generated_at', true),
1029 + // Whether a background writer (Auto AI on publish, bulk
1030 + // optimization, imports) is about to fill these fields. The editor
1031 + // panel polls only while this is true.
1032 + 'pending' => $pending,
925 1033 ];
926 1034
927 1035 return new \WP_REST_Response($metadata);
928 1036 }
@@ -939,8 +1047,11 @@
939 1047 $options = [
940 1048 'target_keyword' => $request->get_param('target_keyword'),
941 1049 'content_type' => $request->get_param('content_type'),
942 1050 'tone' => $request->get_param('tone'),
1051 + // Instruct the model to write in the post/site language instead of
1052 + // defaulting to English on non-English sites (issue #234).
1053 + 'language' => \ThinkRank\AI\Language_Resolver::resolve((int) $request->get_param('post_id')),
943 1054 ];
944 1055
945 1056 // Rate limiting: per user/IP per route
946 1057 $user_id = get_current_user_id();
@@ -992,8 +1103,9 @@
992 1103 'target_keyword' => $request->get_param('target_keyword'),
993 1104 'content_type' => $request->get_param('content_type'),
994 1105 'tone' => $request->get_param('tone'),
995 1106 'suggestion' => $request->get_param('suggestion'),
1107 + 'language' => \ThinkRank\AI\Language_Resolver::resolve((int) $request->get_param('post_id')),
996 1108 ];
997 1109
998 1110 // Rate limiting: shares the AI generation bucket.
999 1111 $user_id = get_current_user_id();
@@ -1041,8 +1153,9 @@
1041 1153 'target_keyword' => $request->get_param('target_keyword'),
1042 1154 'content_type' => $request->get_param('content_type'),
1043 1155 'tone' => $request->get_param('tone'),
1044 1156 'suggestion' => $request->get_param('suggestion'),
1157 + 'language' => \ThinkRank\AI\Language_Resolver::resolve((int) $request->get_param('post_id')),
1045 1158 ];
1046 1159
1047 1160 // Rate limiting: shares the AI generation bucket.
1048 1161 $user_id = get_current_user_id();
@@ -1305,9 +1418,24 @@
1305 1418
1306 1419 try {
1307 1420 $api_key = $request->get_param('api_key');
1308 1421 $provider = $request->get_param('provider') ?: 'openai';
1422 + // The model the caller is asking about. Empty means "whatever is
1423 + // saved" — the settings screen sends the model currently on screen
1424 + // so an unsaved pick or a hand-typed id is what actually gets
1425 + // tested, rather than the last saved one.
1426 + $model = trim((string) $request->get_param('model'));
1309 1427
1428 + // An unrecognised provider used to fall through to the Gemini arm
1429 + // below, so a typo silently tested the wrong provider's key.
1430 + if (!in_array($provider, \ThinkRank\Core\Settings::SUPPORTED_AI_PROVIDERS, true)) {
1431 + return new \WP_REST_Response([
1432 + 'success' => false,
1433 + /* translators: %s: the unrecognised provider value. */
1434 + 'message' => sprintf(__('Unknown AI provider: %s', 'thinkrank'), $provider),
1435 + ], 400);
1436 + }
1437 +
1310 1438 // If no API key provided in request, try to get from saved settings
1311 1439 if (empty($api_key)) {
1312 1440 $settings = \ThinkRank\Core\Settings::instance();
1313 1441 if ($provider === 'openai') {
@@ -1329,15 +1457,15 @@
1329 1457 }
1330 1458
1331 1459 // Test the connection with a simple API call
1332 1460 if ($provider === 'openai') {
1333 - $result = $this->test_openai_connection($api_key);
1461 + $result = $this->test_openai_connection($api_key, $model);
1334 1462 } elseif ($provider === 'claude') {
1335 - $result = $this->test_claude_connection($api_key);
1463 + $result = $this->test_claude_connection($api_key, $model);
1336 1464 } elseif ($provider === 'openrouter') {
1337 - $result = $this->test_openrouter_connection($api_key);
1465 + $result = $this->test_openrouter_connection($api_key, $model);
1338 1466 } else {
1339 - $result = $this->test_gemini_connection($api_key);
1467 + $result = $this->test_gemini_connection($api_key, $model);
1340 1468 }
1341 1469
1342 1470 return new \WP_REST_Response($result, $result['success'] ? 200 : 400);
1343 1471 } catch (\Exception $e) {
@@ -1350,12 +1478,21 @@
1350 1478
1351 1479 /**
1352 1480 * Test OpenAI API connection
1353 1481 *
1482 + * The models endpoint doubles as the model check: it answers with every id
1483 + * this key may call, so an unknown or unentitled model is caught here
1484 + * instead of at the first real generation.
1485 + *
1354 1486 * @param string $api_key API key to test
1487 + * @param string $model Model id to verify, or '' to use the saved one
1355 1488 * @return array Test result
1356 1489 */
1357 - private function test_openai_connection(string $api_key): array {
1490 + private function test_openai_connection(string $api_key, string $model = ''): array {
1491 + $model = $model !== ''
1492 + ? $model
1493 + : (string) \ThinkRank\Core\Settings::instance()->get('openai_model', \ThinkRank\Core\Settings::DEFAULT_OPENAI_MODEL);
1494 +
1358 1495 $url = 'https://api.openai.com/v1/models';
1359 1496
1360 1497 $response = wp_remote_get($url, [
1361 1498 'headers' => [
@@ -1377,11 +1514,28 @@
1377 1514
1378 1515 if ($status_code === 200) {
1379 1516 $data = json_decode($body, true);
1380 1517 if (isset($data['data']) && is_array($data['data'])) {
1518 + $ids = array_column($data['data'], 'id');
1519 +
1520 + if ($model !== '' && !in_array($model, $ids, true)) {
1521 + return [
1522 + 'success' => false,
1523 + 'model' => $model,
1524 + 'model_available' => false,
1525 + /* translators: %s: the model id that was tested. */
1526 + 'message' => sprintf(__('API key works, but the model "%s" is not available to this account.', 'thinkrank'), $model),
1527 + ];
1528 + }
1529 +
1381 1530 return [
1382 1531 'success' => true,
1383 - 'message' => __('OpenAI API connection successful!', 'thinkrank'),
1532 + 'model' => $model,
1533 + 'model_available' => $model !== '',
1534 + 'message' => $model !== ''
1535 + /* translators: %s: the model id that was tested. */
1536 + ? sprintf(__('OpenAI API connection successful — model "%s" is available.', 'thinkrank'), $model)
1537 + : __('OpenAI API connection successful!', 'thinkrank'),
1384 1538 'models_count' => count($data['data']),
1385 1539 ];
1386 1540 }
1387 1541 }
@@ -1399,11 +1553,16 @@
1399 1553 /**
1400 1554 * Test OpenRouter API connection
1401 1555 *
1402 1556 * @param string $api_key API key to test
1557 + * @param string $model Model id to verify, or '' to use the saved one
1403 1558 * @return array Test result
1404 1559 */
1405 - private function test_openrouter_connection(string $api_key): array {
1560 + private function test_openrouter_connection(string $api_key, string $model = ''): array {
1561 + $model = $model !== ''
1562 + ? $model
1563 + : (string) \ThinkRank\Core\Settings::instance()->get('openrouter_model', \ThinkRank\Core\Settings::DEFAULT_OPENROUTER_MODEL);
1564 +
1406 1565 // Validate the key format first (OpenRouter keys start with "sk-or-").
1407 1566 if (!str_starts_with($api_key, 'sk-or-')) {
1408 1567 return [
1409 1568 'success' => false,
@@ -1436,11 +1595,25 @@
1436 1595
1437 1596 if ($status_code === 200) {
1438 1597 $data = json_decode($body, true);
1439 1598 if (isset($data['data']) && is_array($data['data'])) {
1599 + // The key is good; the catalogue is a separate document, so
1600 + // the model needs its own lookup.
1601 + if ($model !== '') {
1602 + $model_check = $this->check_openrouter_model($api_key, $model);
1603 + if ($model_check !== null) {
1604 + return $model_check;
1605 + }
1606 + }
1607 +
1440 1608 return [
1441 1609 'success' => true,
1442 - 'message' => __('OpenRouter API connection successful!', 'thinkrank'),
1610 + 'model' => $model,
1611 + 'model_available' => $model !== '',
1612 + 'message' => $model !== ''
1613 + /* translators: %s: the model id that was tested. */
1614 + ? sprintf(__('OpenRouter API connection successful — model "%s" is available.', 'thinkrank'), $model)
1615 + : __('OpenRouter API connection successful!', 'thinkrank'),
1443 1616 ];
1444 1617 }
1445 1618 }
1446 1619
@@ -1454,14 +1627,58 @@
1454 1627 ];
1455 1628 }
1456 1629
1457 1630 /**
1631 + * Verify a model id against OpenRouter's public catalogue.
1632 + *
1633 + * @param string $api_key API key to authenticate the lookup
1634 + * @param string $model Model id to look for
1635 + * @return array|null Failure payload when the model is unknown, null when it
1636 + * is available or when the catalogue could not be read —
1637 + * a listing hiccup must not fail an otherwise good key.
1638 + */
1639 + private function check_openrouter_model(string $api_key, string $model): ?array {
1640 + $response = wp_remote_get('https://openrouter.ai/api/v1/models', [
1641 + 'headers' => [
1642 + 'Authorization' => 'Bearer ' . $api_key,
1643 + 'Content-Type' => 'application/json',
1644 + 'HTTP-Referer' => home_url('/'),
1645 + 'X-Title' => 'ThinkRank',
1646 + ],
1647 + 'timeout' => 10,
1648 + ]);
1649 +
1650 + if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
1651 + return null;
1652 + }
1653 +
1654 + $data = json_decode(wp_remote_retrieve_body($response), true);
1655 + if (!isset($data['data']) || !is_array($data['data'])) {
1656 + return null;
1657 + }
1658 +
1659 + $ids = array_column($data['data'], 'id');
1660 + if (in_array($model, $ids, true)) {
1661 + return null;
1662 + }
1663 +
1664 + return [
1665 + 'success' => false,
1666 + 'model' => $model,
1667 + 'model_available' => false,
1668 + /* translators: %s: the model id that was tested. */
1669 + 'message' => sprintf(__('API key works, but "%s" is not a model OpenRouter offers.', 'thinkrank'), $model),
1670 + ];
1671 + }
1672 +
1673 + /**
1458 1674 * Test Claude API connection
1459 1675 *
1460 1676 * @param string $api_key API key to test
1677 + * @param string $model Model id to verify, or '' to use the saved one
1461 1678 * @return array Test result
1462 1679 */
1463 - private function test_claude_connection(string $api_key): array {
1680 + private function test_claude_connection(string $api_key, string $model = ''): array {
1464 1681 // First validate the key format
1465 1682 if (!str_starts_with($api_key, 'sk-ant-')) {
1466 1683 return [
1467 1684 'success' => false,
@@ -1471,12 +1688,18 @@
1471 1688
1472 1689 // Test with a simple API call
1473 1690 $url = 'https://api.anthropic.com/v1/messages';
1474 1691
1475 - // Get the configured Claude model, with fallback to a current model.
1476 - // Self-heal retired/unavailable IDs saved by earlier versions.
1477 - $claude_model = \ThinkRank\Core\Settings::instance()->get('claude_model', 'claude-sonnet-5');
1478 - $claude_model = \ThinkRank\AI\Claude_Client::normalize_model($claude_model);
1692 + // A model sent with the request is tested verbatim: normalizing it would
1693 + // quietly swap a typo for a working id and report success for a model
1694 + // the user never asked for. Only the saved fallback is self-healed, as
1695 + // that is the path where a retired id from an older release shows up.
1696 + if ($model !== '') {
1697 + $claude_model = $model;
1698 + } else {
1699 + $claude_model = \ThinkRank\Core\Settings::instance()->get('claude_model', \ThinkRank\Core\Settings::DEFAULT_CLAUDE_MODEL);
1700 + $claude_model = \ThinkRank\AI\Claude_Client::normalize_model($claude_model);
1701 + }
1479 1702
1480 1703 $body = [
1481 1704 'model' => $claude_model,
1482 1705 'max_tokens' => 10,
@@ -1510,16 +1733,32 @@
1510 1733
1511 1734 if ($status_code === 200) {
1512 1735 return [
1513 1736 'success' => true,
1514 - 'message' => __('Claude API connection successful!', 'thinkrank'),
1737 + 'model' => $claude_model,
1738 + 'model_available' => true,
1739 + /* translators: %s: the model id that was tested. */
1740 + 'message' => sprintf(__('Claude API connection successful — model "%s" is available.', 'thinkrank'), $claude_model),
1515 1741 ];
1516 1742 } else {
1517 1743 $error_data = json_decode($response_body, true);
1518 1744 $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
1519 1745
1746 + // 404 on /v1/messages means the key authenticated but the model id
1747 + // does not exist — say so, instead of blaming the key.
1748 + if ($status_code === 404) {
1749 + return [
1750 + 'success' => false,
1751 + 'model' => $claude_model,
1752 + 'model_available' => false,
1753 + /* translators: %s: the model id that was tested. */
1754 + 'message' => sprintf(__('API key works, but the model "%s" was not found.', 'thinkrank'), $claude_model),
1755 + ];
1756 + }
1757 +
1520 1758 return [
1521 1759 'success' => false,
1760 + 'model' => $claude_model,
1522 1761 /* translators: %1$d: HTTP status code, %2$s: error message from Claude API */
1523 1762 'message' => sprintf(__('Claude API error (%1$d): %2$s', 'thinkrank'), $status_code, $error_message),
1524 1763 ];
1525 1764 }
@@ -1528,15 +1767,26 @@
1528 1767 /**
1529 1768 * Test Gemini API connection
1530 1769 *
1531 1770 * @param string $api_key API key to test
1771 + * @param string $model Model id to verify, or '' to use the saved one
1532 1772 * @return array Test result
1533 1773 */
1534 - private function test_gemini_connection(string $api_key): array {
1774 + private function test_gemini_connection(string $api_key, string $model = ''): array {
1535 1775 // Test with a simple API call
1536 - $gemini_model = \ThinkRank\Core\Settings::instance()->get('gemini_model', 'gemini-2.5-flash');
1537 - $url = "https://generativelanguage.googleapis.com/v1beta/models/{$gemini_model}:generateContent?key={$api_key}";
1776 + $gemini_model = $model !== ''
1777 + ? $model
1778 + : (string) \ThinkRank\Core\Settings::instance()->get('gemini_model', \ThinkRank\Core\Settings::DEFAULT_GEMINI_MODEL);
1538 1779
1780 + // The model is a path segment, and ids may arrive with the "models/"
1781 + // prefix Google's own docs use.
1782 + $gemini_model = ltrim($gemini_model, '/');
1783 + $gemini_model = preg_replace('#^models/#', '', $gemini_model);
1784 +
1785 + $url = 'https://generativelanguage.googleapis.com/v1beta/models/'
1786 + . rawurlencode($gemini_model)
1787 + . ':generateContent?key=' . rawurlencode($api_key);
1788 +
1539 1789 $body = [
1540 1790 'contents' => [
1541 1791 [
1542 1792 'parts' => [
@@ -1570,16 +1820,32 @@
1570 1820
1571 1821 if ($status_code === 200) {
1572 1822 return [
1573 1823 'success' => true,
1574 - 'message' => __('Gemini API connection successful!', 'thinkrank'),
1824 + 'model' => $gemini_model,
1825 + 'model_available' => true,
1826 + /* translators: %s: the model id that was tested. */
1827 + 'message' => sprintf(__('Gemini API connection successful — model "%s" is available.', 'thinkrank'), $gemini_model),
1575 1828 ];
1576 1829 } else {
1577 1830 $error_data = json_decode($response_body, true);
1578 1831 $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
1579 1832
1833 + // Gemini answers 404 for a model id it does not serve; the key
1834 + // itself authenticated fine, so name the real problem.
1835 + if ($status_code === 404) {
1836 + return [
1837 + 'success' => false,
1838 + 'model' => $gemini_model,
1839 + 'model_available' => false,
1840 + /* translators: %s: the model id that was tested. */
1841 + 'message' => sprintf(__('API key works, but the model "%s" was not found.', 'thinkrank'), $gemini_model),
1842 + ];
1843 + }
1844 +
1580 1845 return [
1581 1846 'success' => false,
1847 + 'model' => $gemini_model,
1582 1848 /* translators: %1$d: HTTP status code, %2$s: error message from Gemini API */
1583 1849 'message' => sprintf(__('Gemini API error (%1$d): %2$s', 'thinkrank'), $status_code, $error_message),
1584 1850 ];
1585 1851 }
@@ -1702,8 +1968,15 @@
1702 1968 // Failed to register Site Identity endpoint
1703 1969 }
1704 1970
1705 1971 try {
1972 + $ai_insights_endpoint = new Ai_Insights_Endpoint();
1973 + $ai_insights_endpoint->register_routes();
1974 + } catch (\Exception $e) {
1975 + // Failed to register AI Insights endpoint
1976 + }
1977 +
1978 + try {
1706 1979 $performance_endpoint = new Performance_Endpoint();
1707 1980 $performance_endpoint->register_routes();
1708 1981 } catch (\Exception $e) {
1709 1982 // Failed to register Performance endpoint
@@ -1772,8 +2045,15 @@
1772 2045 // Failed to register Global SEO endpoint
1773 2046 }
1774 2047
1775 2048 try {
2049 + $content_type_matrix_endpoint = new \ThinkRank\API\Content_Type_Matrix_Endpoint();
2050 + $content_type_matrix_endpoint->register_routes();
2051 + } catch (\Exception $e) {
2052 + // Failed to register Content Type Matrix endpoint
2053 + }
2054 +
2055 + try {
1776 2056 $image_seo_endpoint = new Image_SEO_Endpoint();
1777 2057 $image_seo_endpoint->register_routes();
1778 2058 } catch (\Exception $e) {
1779 2059 // Failed to register Image SEO endpoint
@@ -1779,12 +2059,40 @@
1779 2059 // Failed to register Image SEO endpoint
1780 2060 }
1781 2061
1782 2062 try {
2063 + $external_links_endpoint = new External_Links_Endpoint();
2064 + $external_links_endpoint->register_routes();
2065 + } catch (\Exception $e) {
2066 + // Failed to register External Links endpoint
2067 + }
2068 +
2069 + // Import_Controller is deliberately NOT gated on enable_migration_tools.
2070 + // /import/detect backs the setup wizard's migration step and the record
2071 + // count on Settings > Import / Export, and /import/snapshot + /migrate
2072 + // run the wizard's actual import — all on a fresh install, where the
2073 + // setting is off. Gating them would break onboarding, which is a worse
2074 + // bug than the one #583 reports.
2075 + try {
1783 2076 $import_controller = new Import_Controller();
1784 2077 $import_controller->register_routes();
1785 2078 } catch (\Exception $e) {
1786 2079 // Failed to register Import endpoint
2080 + }
2081 +
2082 + // Export/restore is gated on the setting that gates its admin screen,
2083 + // so turning Import / Export off removes its REST surface along with
2084 + // its menu item (#583). Nothing in the setup wizard calls these:
2085 + // MigrationPluginRow takes startExport/startMigration/cancel from
2086 + // useImportWorkflow and never uploadFile. rest_api_init runs per
2087 + // request, so a toggle takes effect on the next one — no flush.
2088 + if ((bool) \ThinkRank\Core\Settings::instance()->get('enable_import_export', false)) {
2089 + try {
2090 + $export_controller = new Export_Controller();
2091 + $export_controller->register_routes();
2092 + } catch (\Exception $e) {
2093 + // Failed to register Export endpoint
2094 + }
1787 2095 }
1788 2096
1789 2097 try {
1790 2098 $setup_wizard_endpoint = new Setup_Wizard_Endpoint();