| @@ -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 | |
| @@ -438,8 +482,13 @@ | ||
| 438 | 482 | 'required' => false, |
| 439 | 483 | 'default' => 'openai', |
| 440 | 484 | 'sanitize_callback' => 'sanitize_key', |
| 441 | 485 | ], |
| 486 | + 'model' => [ | |
| 487 | + 'type' => 'string', | |
| 488 | + 'required' => false, | |
| 489 | + 'sanitize_callback' => 'sanitize_text_field', | |
| 490 | + ], | |
| 442 | 491 | ], |
| 443 | 492 | ]); |
| 444 | 493 | |
| 445 | 494 | register_rest_route(self::NAMESPACE, '/ai/providers', [ |
| @@ -757,9 +806,9 @@ | ||
| 757 | 806 | // Use Settings class for consistent access (handles decryption automatically) |
| 758 | 807 | $settings_instance = \ThinkRank\Core\Settings::instance(); |
| 759 | 808 | |
| 760 | 809 | $settings = [ |
| 761 | - 'ai_provider' => $settings_instance->get('ai_provider', 'openai'), | |
| 810 | + 'ai_provider' => $settings_instance->get('ai_provider', \ThinkRank\Core\Settings::AI_PROVIDER_NONE), | |
| 762 | 811 | 'openai_api_key' => $settings_instance->get('openai_api_key', ''), |
| 763 | 812 | 'openai_model' => $settings_instance->get('openai_model', \ThinkRank\Core\Settings::DEFAULT_OPENAI_MODEL), |
| 764 | 813 | 'claude_api_key' => $settings_instance->get('claude_api_key', ''), |
| 765 | 814 | 'claude_model' => $settings_instance->get('claude_model', \ThinkRank\Core\Settings::DEFAULT_CLAUDE_MODEL), |
| @@ -771,8 +820,9 @@ | ||
| 771 | 820 | 'temperature' => $settings_instance->get('temperature', 0.7), |
| 772 | 821 | 'cache_duration' => $settings_instance->get('cache_duration', 3600), |
| 773 | 822 | 'keep_data_on_uninstall' => (bool) $settings_instance->get('keep_data_on_uninstall', true), |
| 774 | 823 | 'enable_migration_tools' => (bool) $settings_instance->get('enable_migration_tools', false), |
| 824 | + 'enable_import_export' => (bool) $settings_instance->get('enable_import_export', false), | |
| 775 | 825 | 'google_account_connected' => (bool) $settings_instance->get('google_account_connected', false), |
| 776 | 826 | 'enable_mcp' => (bool) $settings_instance->get('enable_mcp', false), |
| 777 | 827 | ]; |
| 778 | 828 | |
| @@ -847,8 +897,9 @@ | ||
| 847 | 897 | 'cache_duration' => 'cache_duration', |
| 848 | 898 | 'keep_data_on_uninstall' => 'keep_data_on_uninstall', |
| 849 | 899 | 'enable_mcp' => 'enable_mcp', |
| 850 | 900 | 'enable_migration_tools' => 'enable_migration_tools', |
| 901 | + 'enable_import_export' => 'enable_import_export', | |
| 851 | 902 | ]; |
| 852 | 903 | |
| 853 | 904 | // Processing settings save request |
| 854 | 905 | |
| @@ -856,9 +907,9 @@ | ||
| 856 | 907 | if (isset($params[$param_key])) { |
| 857 | 908 | $value = $params[$param_key]; |
| 858 | 909 | |
| 859 | 910 | // Handle API keys specially - check for masked values |
| 860 | - 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)) { | |
| 861 | 912 | // Don't update if the value carries the mask sentinel (the |
| 862 | 913 | // preview now keeps real head/tail chars around it, so match |
| 863 | 914 | // anywhere rather than only at the start). Empty still clears. |
| 864 | 915 | if (strpos($value, '••••••••') !== false) { |
| @@ -866,11 +917,11 @@ | ||
| 866 | 917 | } |
| 867 | 918 | } |
| 868 | 919 | |
| 869 | 920 | // Use Settings class for all operations (handles encryption automatically) |
| 870 | - if (!$settings->set($setting_key, $value)) { | |
| 871 | - // Settings save failed, continue with other settings | |
| 872 | - } | |
| 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); | |
| 873 | 924 | } |
| 874 | 925 | } |
| 875 | 926 | |
| 876 | 927 | // MCP is a single master switch (see #244): enabling it auto-mints a |
| @@ -955,8 +1006,15 @@ | ||
| 955 | 1006 | if (!current_user_can('edit_post', $post_id)) { |
| 956 | 1007 | return new \WP_REST_Response(['message' => 'You are not allowed to view this metadata.'], 403); |
| 957 | 1008 | } |
| 958 | 1009 | |
| 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 | + | |
| 959 | 1017 | // Get existing metadata. These must read the same canonical meta keys |
| 960 | 1018 | // the rest of the plugin writes/reads (frontend, metabox, scoring), |
| 961 | 1019 | // otherwise the response is always empty: |
| 962 | 1020 | // title/description → _thinkrank_seo_title / _thinkrank_meta_description |
| @@ -967,8 +1025,12 @@ | ||
| 967 | 1025 | 'description' => get_post_meta($post_id, '_thinkrank_meta_description', true), |
| 968 | 1026 | 'keywords' => \ThinkRank\SEO\Focus_Keywords::get($post_id), |
| 969 | 1027 | 'seo_score' => get_post_meta($post_id, '_thinkrank_seo_score', true) ?: 0, |
| 970 | 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, | |
| 971 | 1033 | ]; |
| 972 | 1034 | |
| 973 | 1035 | return new \WP_REST_Response($metadata); |
| 974 | 1036 | } |
| @@ -1356,9 +1418,24 @@ | ||
| 1356 | 1418 | |
| 1357 | 1419 | try { |
| 1358 | 1420 | $api_key = $request->get_param('api_key'); |
| 1359 | 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')); | |
| 1360 | 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 | + | |
| 1361 | 1438 | // If no API key provided in request, try to get from saved settings |
| 1362 | 1439 | if (empty($api_key)) { |
| 1363 | 1440 | $settings = \ThinkRank\Core\Settings::instance(); |
| 1364 | 1441 | if ($provider === 'openai') { |
| @@ -1380,15 +1457,15 @@ | ||
| 1380 | 1457 | } |
| 1381 | 1458 | |
| 1382 | 1459 | // Test the connection with a simple API call |
| 1383 | 1460 | if ($provider === 'openai') { |
| 1384 | - $result = $this->test_openai_connection($api_key); | |
| 1461 | + $result = $this->test_openai_connection($api_key, $model); | |
| 1385 | 1462 | } elseif ($provider === 'claude') { |
| 1386 | - $result = $this->test_claude_connection($api_key); | |
| 1463 | + $result = $this->test_claude_connection($api_key, $model); | |
| 1387 | 1464 | } elseif ($provider === 'openrouter') { |
| 1388 | - $result = $this->test_openrouter_connection($api_key); | |
| 1465 | + $result = $this->test_openrouter_connection($api_key, $model); | |
| 1389 | 1466 | } else { |
| 1390 | - $result = $this->test_gemini_connection($api_key); | |
| 1467 | + $result = $this->test_gemini_connection($api_key, $model); | |
| 1391 | 1468 | } |
| 1392 | 1469 | |
| 1393 | 1470 | return new \WP_REST_Response($result, $result['success'] ? 200 : 400); |
| 1394 | 1471 | } catch (\Exception $e) { |
| @@ -1401,12 +1478,21 @@ | ||
| 1401 | 1478 | |
| 1402 | 1479 | /** |
| 1403 | 1480 | * Test OpenAI API connection |
| 1404 | 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 | + * | |
| 1405 | 1486 | * @param string $api_key API key to test |
| 1487 | + * @param string $model Model id to verify, or '' to use the saved one | |
| 1406 | 1488 | * @return array Test result |
| 1407 | 1489 | */ |
| 1408 | - 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 | + | |
| 1409 | 1495 | $url = 'https://api.openai.com/v1/models'; |
| 1410 | 1496 | |
| 1411 | 1497 | $response = wp_remote_get($url, [ |
| 1412 | 1498 | 'headers' => [ |
| @@ -1428,11 +1514,28 @@ | ||
| 1428 | 1514 | |
| 1429 | 1515 | if ($status_code === 200) { |
| 1430 | 1516 | $data = json_decode($body, true); |
| 1431 | 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 | + | |
| 1432 | 1530 | return [ |
| 1433 | 1531 | 'success' => true, |
| 1434 | - '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'), | |
| 1435 | 1538 | 'models_count' => count($data['data']), |
| 1436 | 1539 | ]; |
| 1437 | 1540 | } |
| 1438 | 1541 | } |
| @@ -1450,11 +1553,16 @@ | ||
| 1450 | 1553 | /** |
| 1451 | 1554 | * Test OpenRouter API connection |
| 1452 | 1555 | * |
| 1453 | 1556 | * @param string $api_key API key to test |
| 1557 | + * @param string $model Model id to verify, or '' to use the saved one | |
| 1454 | 1558 | * @return array Test result |
| 1455 | 1559 | */ |
| 1456 | - 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 | + | |
| 1457 | 1565 | // Validate the key format first (OpenRouter keys start with "sk-or-"). |
| 1458 | 1566 | if (!str_starts_with($api_key, 'sk-or-')) { |
| 1459 | 1567 | return [ |
| 1460 | 1568 | 'success' => false, |
| @@ -1487,11 +1595,25 @@ | ||
| 1487 | 1595 | |
| 1488 | 1596 | if ($status_code === 200) { |
| 1489 | 1597 | $data = json_decode($body, true); |
| 1490 | 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 | + | |
| 1491 | 1608 | return [ |
| 1492 | 1609 | 'success' => true, |
| 1493 | - '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'), | |
| 1494 | 1616 | ]; |
| 1495 | 1617 | } |
| 1496 | 1618 | } |
| 1497 | 1619 | |
| @@ -1505,14 +1627,58 @@ | ||
| 1505 | 1627 | ]; |
| 1506 | 1628 | } |
| 1507 | 1629 | |
| 1508 | 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 | + /** | |
| 1509 | 1674 | * Test Claude API connection |
| 1510 | 1675 | * |
| 1511 | 1676 | * @param string $api_key API key to test |
| 1677 | + * @param string $model Model id to verify, or '' to use the saved one | |
| 1512 | 1678 | * @return array Test result |
| 1513 | 1679 | */ |
| 1514 | - private function test_claude_connection(string $api_key): array { | |
| 1680 | + private function test_claude_connection(string $api_key, string $model = ''): array { | |
| 1515 | 1681 | // First validate the key format |
| 1516 | 1682 | if (!str_starts_with($api_key, 'sk-ant-')) { |
| 1517 | 1683 | return [ |
| 1518 | 1684 | 'success' => false, |
| @@ -1522,12 +1688,18 @@ | ||
| 1522 | 1688 | |
| 1523 | 1689 | // Test with a simple API call |
| 1524 | 1690 | $url = 'https://api.anthropic.com/v1/messages'; |
| 1525 | 1691 | |
| 1526 | - // Get the configured Claude model, with fallback to a current model. | |
| 1527 | - // Self-heal retired/unavailable IDs saved by earlier versions. | |
| 1528 | - $claude_model = \ThinkRank\Core\Settings::instance()->get('claude_model', \ThinkRank\Core\Settings::DEFAULT_CLAUDE_MODEL); | |
| 1529 | - $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 | + } | |
| 1530 | 1702 | |
| 1531 | 1703 | $body = [ |
| 1532 | 1704 | 'model' => $claude_model, |
| 1533 | 1705 | 'max_tokens' => 10, |
| @@ -1561,16 +1733,32 @@ | ||
| 1561 | 1733 | |
| 1562 | 1734 | if ($status_code === 200) { |
| 1563 | 1735 | return [ |
| 1564 | 1736 | 'success' => true, |
| 1565 | - '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), | |
| 1566 | 1741 | ]; |
| 1567 | 1742 | } else { |
| 1568 | 1743 | $error_data = json_decode($response_body, true); |
| 1569 | 1744 | $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank'); |
| 1570 | 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 | + | |
| 1571 | 1758 | return [ |
| 1572 | 1759 | 'success' => false, |
| 1760 | + 'model' => $claude_model, | |
| 1573 | 1761 | /* translators: %1$d: HTTP status code, %2$s: error message from Claude API */ |
| 1574 | 1762 | 'message' => sprintf(__('Claude API error (%1$d): %2$s', 'thinkrank'), $status_code, $error_message), |
| 1575 | 1763 | ]; |
| 1576 | 1764 | } |
| @@ -1579,15 +1767,26 @@ | ||
| 1579 | 1767 | /** |
| 1580 | 1768 | * Test Gemini API connection |
| 1581 | 1769 | * |
| 1582 | 1770 | * @param string $api_key API key to test |
| 1771 | + * @param string $model Model id to verify, or '' to use the saved one | |
| 1583 | 1772 | * @return array Test result |
| 1584 | 1773 | */ |
| 1585 | - private function test_gemini_connection(string $api_key): array { | |
| 1774 | + private function test_gemini_connection(string $api_key, string $model = ''): array { | |
| 1586 | 1775 | // Test with a simple API call |
| 1587 | - $gemini_model = \ThinkRank\Core\Settings::instance()->get('gemini_model', \ThinkRank\Core\Settings::DEFAULT_GEMINI_MODEL); | |
| 1588 | - $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); | |
| 1589 | 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 | + | |
| 1590 | 1789 | $body = [ |
| 1591 | 1790 | 'contents' => [ |
| 1592 | 1791 | [ |
| 1593 | 1792 | 'parts' => [ |
| @@ -1621,16 +1820,32 @@ | ||
| 1621 | 1820 | |
| 1622 | 1821 | if ($status_code === 200) { |
| 1623 | 1822 | return [ |
| 1624 | 1823 | 'success' => true, |
| 1625 | - '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), | |
| 1626 | 1828 | ]; |
| 1627 | 1829 | } else { |
| 1628 | 1830 | $error_data = json_decode($response_body, true); |
| 1629 | 1831 | $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank'); |
| 1630 | 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 | + | |
| 1631 | 1845 | return [ |
| 1632 | 1846 | 'success' => false, |
| 1847 | + 'model' => $gemini_model, | |
| 1633 | 1848 | /* translators: %1$d: HTTP status code, %2$s: error message from Gemini API */ |
| 1634 | 1849 | 'message' => sprintf(__('Gemini API error (%1$d): %2$s', 'thinkrank'), $status_code, $error_message), |
| 1635 | 1850 | ]; |
| 1636 | 1851 | } |
| @@ -1760,15 +1975,8 @@ | ||
| 1760 | 1975 | // Failed to register AI Insights endpoint |
| 1761 | 1976 | } |
| 1762 | 1977 | |
| 1763 | 1978 | try { |
| 1764 | - $brand_visibility_endpoint = new Brand_Visibility_Endpoint(); | |
| 1765 | - $brand_visibility_endpoint->register_routes(); | |
| 1766 | - } catch (\Exception $e) { | |
| 1767 | - // Failed to register Brand Visibility endpoint | |
| 1768 | - } | |
| 1769 | - | |
| 1770 | - try { | |
| 1771 | 1979 | $performance_endpoint = new Performance_Endpoint(); |
| 1772 | 1980 | $performance_endpoint->register_routes(); |
| 1773 | 1981 | } catch (\Exception $e) { |
| 1774 | 1982 | // Failed to register Performance endpoint |
| @@ -1837,8 +2045,15 @@ | ||
| 1837 | 2045 | // Failed to register Global SEO endpoint |
| 1838 | 2046 | } |
| 1839 | 2047 | |
| 1840 | 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 { | |
| 1841 | 2056 | $image_seo_endpoint = new Image_SEO_Endpoint(); |
| 1842 | 2057 | $image_seo_endpoint->register_routes(); |
| 1843 | 2058 | } catch (\Exception $e) { |
| 1844 | 2059 | // Failed to register Image SEO endpoint |
| @@ -1844,12 +2059,40 @@ | ||
| 1844 | 2059 | // Failed to register Image SEO endpoint |
| 1845 | 2060 | } |
| 1846 | 2061 | |
| 1847 | 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 { | |
| 1848 | 2076 | $import_controller = new Import_Controller(); |
| 1849 | 2077 | $import_controller->register_routes(); |
| 1850 | 2078 | } catch (\Exception $e) { |
| 1851 | 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 | + } | |
| 1852 | 2095 | } |
| 1853 | 2096 | |
| 1854 | 2097 | try { |
| 1855 | 2098 | $setup_wizard_endpoint = new Setup_Wizard_Endpoint(); |