| @@ -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 |
| @@ -88,8 +90,13 @@ | ||
| 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']); |
| 91 | 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 | + | |
| 92 | 99 | // Make declared schema constraints mean something. Applied once over |
| 93 | 100 | // the whole namespace rather than at 70-odd call sites, because that is |
| 94 | 101 | // exactly how the enum on /setup-wizard/migrated-plugins and the one on |
| 95 | 102 | // /seo-analytics/dashboard came to be inert while the route next door |
| @@ -146,9 +153,15 @@ | ||
| 146 | 153 | 'permission_callback' => [$this, 'check_settings_permissions'], |
| 147 | 154 | 'args' => [ |
| 148 | 155 | 'ai_provider' => [ |
| 149 | 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(), | |
| 150 | 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', | |
| 151 | 164 | ], |
| 152 | 165 | 'openai_api_key' => [ |
| 153 | 166 | 'type' => 'string', |
| 154 | 167 | 'sanitize_callback' => 'sanitize_text_field', |
| @@ -180,8 +193,27 @@ | ||
| 180 | 193 | 'openrouter_model' => [ |
| 181 | 194 | 'type' => 'string', |
| 182 | 195 | 'sanitize_callback' => 'sanitize_text_field', |
| 183 | 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 | + ], | |
| 184 | 216 | 'keep_data_on_uninstall' => [ |
| 185 | 217 | 'type' => 'boolean', |
| 186 | 218 | 'sanitize_callback' => 'rest_sanitize_boolean', |
| 187 | 219 | ], |
| @@ -192,8 +224,12 @@ | ||
| 192 | 224 | 'enable_migration_tools' => [ |
| 193 | 225 | 'type' => 'boolean', |
| 194 | 226 | 'sanitize_callback' => 'rest_sanitize_boolean', |
| 195 | 227 | ], |
| 228 | + 'enable_import_export' => [ | |
| 229 | + 'type' => 'boolean', | |
| 230 | + 'sanitize_callback' => 'rest_sanitize_boolean', | |
| 231 | + ], | |
| 196 | 232 | ], |
| 197 | 233 | ]); |
| 198 | 234 | |
| 199 | 235 | |
| @@ -446,8 +482,13 @@ | ||
| 446 | 482 | 'required' => false, |
| 447 | 483 | 'default' => 'openai', |
| 448 | 484 | 'sanitize_callback' => 'sanitize_key', |
| 449 | 485 | ], |
| 486 | + 'model' => [ | |
| 487 | + 'type' => 'string', | |
| 488 | + 'required' => false, | |
| 489 | + 'sanitize_callback' => 'sanitize_text_field', | |
| 490 | + ], | |
| 450 | 491 | ], |
| 451 | 492 | ]); |
| 452 | 493 | |
| 453 | 494 | register_rest_route(self::NAMESPACE, '/ai/providers', [ |
| @@ -765,9 +806,9 @@ | ||
| 765 | 806 | // Use Settings class for consistent access (handles decryption automatically) |
| 766 | 807 | $settings_instance = \ThinkRank\Core\Settings::instance(); |
| 767 | 808 | |
| 768 | 809 | $settings = [ |
| 769 | - 'ai_provider' => $settings_instance->get('ai_provider', 'openai'), | |
| 810 | + 'ai_provider' => $settings_instance->get('ai_provider', \ThinkRank\Core\Settings::AI_PROVIDER_NONE), | |
| 770 | 811 | 'openai_api_key' => $settings_instance->get('openai_api_key', ''), |
| 771 | 812 | 'openai_model' => $settings_instance->get('openai_model', \ThinkRank\Core\Settings::DEFAULT_OPENAI_MODEL), |
| 772 | 813 | 'claude_api_key' => $settings_instance->get('claude_api_key', ''), |
| 773 | 814 | 'claude_model' => $settings_instance->get('claude_model', \ThinkRank\Core\Settings::DEFAULT_CLAUDE_MODEL), |
| @@ -779,8 +820,9 @@ | ||
| 779 | 820 | 'temperature' => $settings_instance->get('temperature', 0.7), |
| 780 | 821 | 'cache_duration' => $settings_instance->get('cache_duration', 3600), |
| 781 | 822 | 'keep_data_on_uninstall' => (bool) $settings_instance->get('keep_data_on_uninstall', true), |
| 782 | 823 | 'enable_migration_tools' => (bool) $settings_instance->get('enable_migration_tools', false), |
| 824 | + 'enable_import_export' => (bool) $settings_instance->get('enable_import_export', false), | |
| 783 | 825 | 'google_account_connected' => (bool) $settings_instance->get('google_account_connected', false), |
| 784 | 826 | 'enable_mcp' => (bool) $settings_instance->get('enable_mcp', false), |
| 785 | 827 | ]; |
| 786 | 828 | |
| @@ -855,8 +897,9 @@ | ||
| 855 | 897 | 'cache_duration' => 'cache_duration', |
| 856 | 898 | 'keep_data_on_uninstall' => 'keep_data_on_uninstall', |
| 857 | 899 | 'enable_mcp' => 'enable_mcp', |
| 858 | 900 | 'enable_migration_tools' => 'enable_migration_tools', |
| 901 | + 'enable_import_export' => 'enable_import_export', | |
| 859 | 902 | ]; |
| 860 | 903 | |
| 861 | 904 | // Processing settings save request |
| 862 | 905 | |
| @@ -1375,9 +1418,24 @@ | ||
| 1375 | 1418 | |
| 1376 | 1419 | try { |
| 1377 | 1420 | $api_key = $request->get_param('api_key'); |
| 1378 | 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')); | |
| 1379 | 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 | + | |
| 1380 | 1438 | // If no API key provided in request, try to get from saved settings |
| 1381 | 1439 | if (empty($api_key)) { |
| 1382 | 1440 | $settings = \ThinkRank\Core\Settings::instance(); |
| 1383 | 1441 | if ($provider === 'openai') { |
| @@ -1399,15 +1457,15 @@ | ||
| 1399 | 1457 | } |
| 1400 | 1458 | |
| 1401 | 1459 | // Test the connection with a simple API call |
| 1402 | 1460 | if ($provider === 'openai') { |
| 1403 | - $result = $this->test_openai_connection($api_key); | |
| 1461 | + $result = $this->test_openai_connection($api_key, $model); | |
| 1404 | 1462 | } elseif ($provider === 'claude') { |
| 1405 | - $result = $this->test_claude_connection($api_key); | |
| 1463 | + $result = $this->test_claude_connection($api_key, $model); | |
| 1406 | 1464 | } elseif ($provider === 'openrouter') { |
| 1407 | - $result = $this->test_openrouter_connection($api_key); | |
| 1465 | + $result = $this->test_openrouter_connection($api_key, $model); | |
| 1408 | 1466 | } else { |
| 1409 | - $result = $this->test_gemini_connection($api_key); | |
| 1467 | + $result = $this->test_gemini_connection($api_key, $model); | |
| 1410 | 1468 | } |
| 1411 | 1469 | |
| 1412 | 1470 | return new \WP_REST_Response($result, $result['success'] ? 200 : 400); |
| 1413 | 1471 | } catch (\Exception $e) { |
| @@ -1420,12 +1478,21 @@ | ||
| 1420 | 1478 | |
| 1421 | 1479 | /** |
| 1422 | 1480 | * Test OpenAI API connection |
| 1423 | 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 | + * | |
| 1424 | 1486 | * @param string $api_key API key to test |
| 1487 | + * @param string $model Model id to verify, or '' to use the saved one | |
| 1425 | 1488 | * @return array Test result |
| 1426 | 1489 | */ |
| 1427 | - 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 | + | |
| 1428 | 1495 | $url = 'https://api.openai.com/v1/models'; |
| 1429 | 1496 | |
| 1430 | 1497 | $response = wp_remote_get($url, [ |
| 1431 | 1498 | 'headers' => [ |
| @@ -1447,11 +1514,28 @@ | ||
| 1447 | 1514 | |
| 1448 | 1515 | if ($status_code === 200) { |
| 1449 | 1516 | $data = json_decode($body, true); |
| 1450 | 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 | + | |
| 1451 | 1530 | return [ |
| 1452 | 1531 | 'success' => true, |
| 1453 | - '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'), | |
| 1454 | 1538 | 'models_count' => count($data['data']), |
| 1455 | 1539 | ]; |
| 1456 | 1540 | } |
| 1457 | 1541 | } |
| @@ -1469,11 +1553,16 @@ | ||
| 1469 | 1553 | /** |
| 1470 | 1554 | * Test OpenRouter API connection |
| 1471 | 1555 | * |
| 1472 | 1556 | * @param string $api_key API key to test |
| 1557 | + * @param string $model Model id to verify, or '' to use the saved one | |
| 1473 | 1558 | * @return array Test result |
| 1474 | 1559 | */ |
| 1475 | - 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 | + | |
| 1476 | 1565 | // Validate the key format first (OpenRouter keys start with "sk-or-"). |
| 1477 | 1566 | if (!str_starts_with($api_key, 'sk-or-')) { |
| 1478 | 1567 | return [ |
| 1479 | 1568 | 'success' => false, |
| @@ -1506,11 +1595,25 @@ | ||
| 1506 | 1595 | |
| 1507 | 1596 | if ($status_code === 200) { |
| 1508 | 1597 | $data = json_decode($body, true); |
| 1509 | 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 | + | |
| 1510 | 1608 | return [ |
| 1511 | 1609 | 'success' => true, |
| 1512 | - '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'), | |
| 1513 | 1616 | ]; |
| 1514 | 1617 | } |
| 1515 | 1618 | } |
| 1516 | 1619 | |
| @@ -1524,14 +1627,58 @@ | ||
| 1524 | 1627 | ]; |
| 1525 | 1628 | } |
| 1526 | 1629 | |
| 1527 | 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 | + /** | |
| 1528 | 1674 | * Test Claude API connection |
| 1529 | 1675 | * |
| 1530 | 1676 | * @param string $api_key API key to test |
| 1677 | + * @param string $model Model id to verify, or '' to use the saved one | |
| 1531 | 1678 | * @return array Test result |
| 1532 | 1679 | */ |
| 1533 | - private function test_claude_connection(string $api_key): array { | |
| 1680 | + private function test_claude_connection(string $api_key, string $model = ''): array { | |
| 1534 | 1681 | // First validate the key format |
| 1535 | 1682 | if (!str_starts_with($api_key, 'sk-ant-')) { |
| 1536 | 1683 | return [ |
| 1537 | 1684 | 'success' => false, |
| @@ -1541,12 +1688,18 @@ | ||
| 1541 | 1688 | |
| 1542 | 1689 | // Test with a simple API call |
| 1543 | 1690 | $url = 'https://api.anthropic.com/v1/messages'; |
| 1544 | 1691 | |
| 1545 | - // Get the configured Claude model, with fallback to a current model. | |
| 1546 | - // Self-heal retired/unavailable IDs saved by earlier versions. | |
| 1547 | - $claude_model = \ThinkRank\Core\Settings::instance()->get('claude_model', \ThinkRank\Core\Settings::DEFAULT_CLAUDE_MODEL); | |
| 1548 | - $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 | + } | |
| 1549 | 1702 | |
| 1550 | 1703 | $body = [ |
| 1551 | 1704 | 'model' => $claude_model, |
| 1552 | 1705 | 'max_tokens' => 10, |
| @@ -1580,16 +1733,32 @@ | ||
| 1580 | 1733 | |
| 1581 | 1734 | if ($status_code === 200) { |
| 1582 | 1735 | return [ |
| 1583 | 1736 | 'success' => true, |
| 1584 | - '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), | |
| 1585 | 1741 | ]; |
| 1586 | 1742 | } else { |
| 1587 | 1743 | $error_data = json_decode($response_body, true); |
| 1588 | 1744 | $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank'); |
| 1589 | 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 | + | |
| 1590 | 1758 | return [ |
| 1591 | 1759 | 'success' => false, |
| 1760 | + 'model' => $claude_model, | |
| 1592 | 1761 | /* translators: %1$d: HTTP status code, %2$s: error message from Claude API */ |
| 1593 | 1762 | 'message' => sprintf(__('Claude API error (%1$d): %2$s', 'thinkrank'), $status_code, $error_message), |
| 1594 | 1763 | ]; |
| 1595 | 1764 | } |
| @@ -1598,15 +1767,26 @@ | ||
| 1598 | 1767 | /** |
| 1599 | 1768 | * Test Gemini API connection |
| 1600 | 1769 | * |
| 1601 | 1770 | * @param string $api_key API key to test |
| 1771 | + * @param string $model Model id to verify, or '' to use the saved one | |
| 1602 | 1772 | * @return array Test result |
| 1603 | 1773 | */ |
| 1604 | - private function test_gemini_connection(string $api_key): array { | |
| 1774 | + private function test_gemini_connection(string $api_key, string $model = ''): array { | |
| 1605 | 1775 | // Test with a simple API call |
| 1606 | - $gemini_model = \ThinkRank\Core\Settings::instance()->get('gemini_model', \ThinkRank\Core\Settings::DEFAULT_GEMINI_MODEL); | |
| 1607 | - $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); | |
| 1608 | 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 | + | |
| 1609 | 1789 | $body = [ |
| 1610 | 1790 | 'contents' => [ |
| 1611 | 1791 | [ |
| 1612 | 1792 | 'parts' => [ |
| @@ -1640,16 +1820,32 @@ | ||
| 1640 | 1820 | |
| 1641 | 1821 | if ($status_code === 200) { |
| 1642 | 1822 | return [ |
| 1643 | 1823 | 'success' => true, |
| 1644 | - '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), | |
| 1645 | 1828 | ]; |
| 1646 | 1829 | } else { |
| 1647 | 1830 | $error_data = json_decode($response_body, true); |
| 1648 | 1831 | $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank'); |
| 1649 | 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 | + | |
| 1650 | 1845 | return [ |
| 1651 | 1846 | 'success' => false, |
| 1847 | + 'model' => $gemini_model, | |
| 1652 | 1848 | /* translators: %1$d: HTTP status code, %2$s: error message from Gemini API */ |
| 1653 | 1849 | 'message' => sprintf(__('Gemini API error (%1$d): %2$s', 'thinkrank'), $status_code, $error_message), |
| 1654 | 1850 | ]; |
| 1655 | 1851 | } |
| @@ -1779,15 +1975,8 @@ | ||
| 1779 | 1975 | // Failed to register AI Insights endpoint |
| 1780 | 1976 | } |
| 1781 | 1977 | |
| 1782 | 1978 | try { |
| 1783 | - $brand_visibility_endpoint = new Brand_Visibility_Endpoint(); | |
| 1784 | - $brand_visibility_endpoint->register_routes(); | |
| 1785 | - } catch (\Exception $e) { | |
| 1786 | - // Failed to register Brand Visibility endpoint | |
| 1787 | - } | |
| 1788 | - | |
| 1789 | - try { | |
| 1790 | 1979 | $performance_endpoint = new Performance_Endpoint(); |
| 1791 | 1980 | $performance_endpoint->register_routes(); |
| 1792 | 1981 | } catch (\Exception $e) { |
| 1793 | 1982 | // Failed to register Performance endpoint |
| @@ -1856,8 +2045,15 @@ | ||
| 1856 | 2045 | // Failed to register Global SEO endpoint |
| 1857 | 2046 | } |
| 1858 | 2047 | |
| 1859 | 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 { | |
| 1860 | 2056 | $image_seo_endpoint = new Image_SEO_Endpoint(); |
| 1861 | 2057 | $image_seo_endpoint->register_routes(); |
| 1862 | 2058 | } catch (\Exception $e) { |
| 1863 | 2059 | // Failed to register Image SEO endpoint |
| @@ -1863,12 +2059,40 @@ | ||
| 1863 | 2059 | // Failed to register Image SEO endpoint |
| 1864 | 2060 | } |
| 1865 | 2061 | |
| 1866 | 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 { | |
| 1867 | 2076 | $import_controller = new Import_Controller(); |
| 1868 | 2077 | $import_controller->register_routes(); |
| 1869 | 2078 | } catch (\Exception $e) { |
| 1870 | 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 | + } | |
| 1871 | 2095 | } |
| 1872 | 2096 | |
| 1873 | 2097 | try { |
| 1874 | 2098 | $setup_wizard_endpoint = new Setup_Wizard_Endpoint(); |