| @@ -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; |
| @@ -481,8 +482,13 @@ | ||
| 481 | 482 | 'required' => false, |
| 482 | 483 | 'default' => 'openai', |
| 483 | 484 | 'sanitize_callback' => 'sanitize_key', |
| 484 | 485 | ], |
| 486 | + 'model' => [ | |
| 487 | + 'type' => 'string', | |
| 488 | + 'required' => false, | |
| 489 | + 'sanitize_callback' => 'sanitize_text_field', | |
| 490 | + ], | |
| 485 | 491 | ], |
| 486 | 492 | ]); |
| 487 | 493 | |
| 488 | 494 | register_rest_route(self::NAMESPACE, '/ai/providers', [ |
| @@ -1412,8 +1418,13 @@ | ||
| 1412 | 1418 | |
| 1413 | 1419 | try { |
| 1414 | 1420 | $api_key = $request->get_param('api_key'); |
| 1415 | 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')); | |
| 1416 | 1427 | |
| 1417 | 1428 | // An unrecognised provider used to fall through to the Gemini arm |
| 1418 | 1429 | // below, so a typo silently tested the wrong provider's key. |
| 1419 | 1430 | if (!in_array($provider, \ThinkRank\Core\Settings::SUPPORTED_AI_PROVIDERS, true)) { |
| @@ -1446,15 +1457,15 @@ | ||
| 1446 | 1457 | } |
| 1447 | 1458 | |
| 1448 | 1459 | // Test the connection with a simple API call |
| 1449 | 1460 | if ($provider === 'openai') { |
| 1450 | - $result = $this->test_openai_connection($api_key); | |
| 1461 | + $result = $this->test_openai_connection($api_key, $model); | |
| 1451 | 1462 | } elseif ($provider === 'claude') { |
| 1452 | - $result = $this->test_claude_connection($api_key); | |
| 1463 | + $result = $this->test_claude_connection($api_key, $model); | |
| 1453 | 1464 | } elseif ($provider === 'openrouter') { |
| 1454 | - $result = $this->test_openrouter_connection($api_key); | |
| 1465 | + $result = $this->test_openrouter_connection($api_key, $model); | |
| 1455 | 1466 | } else { |
| 1456 | - $result = $this->test_gemini_connection($api_key); | |
| 1467 | + $result = $this->test_gemini_connection($api_key, $model); | |
| 1457 | 1468 | } |
| 1458 | 1469 | |
| 1459 | 1470 | return new \WP_REST_Response($result, $result['success'] ? 200 : 400); |
| 1460 | 1471 | } catch (\Exception $e) { |
| @@ -1467,12 +1478,21 @@ | ||
| 1467 | 1478 | |
| 1468 | 1479 | /** |
| 1469 | 1480 | * Test OpenAI API connection |
| 1470 | 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 | + * | |
| 1471 | 1486 | * @param string $api_key API key to test |
| 1487 | + * @param string $model Model id to verify, or '' to use the saved one | |
| 1472 | 1488 | * @return array Test result |
| 1473 | 1489 | */ |
| 1474 | - 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 | + | |
| 1475 | 1495 | $url = 'https://api.openai.com/v1/models'; |
| 1476 | 1496 | |
| 1477 | 1497 | $response = wp_remote_get($url, [ |
| 1478 | 1498 | 'headers' => [ |
| @@ -1494,11 +1514,28 @@ | ||
| 1494 | 1514 | |
| 1495 | 1515 | if ($status_code === 200) { |
| 1496 | 1516 | $data = json_decode($body, true); |
| 1497 | 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 | + | |
| 1498 | 1530 | return [ |
| 1499 | 1531 | 'success' => true, |
| 1500 | - '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'), | |
| 1501 | 1538 | 'models_count' => count($data['data']), |
| 1502 | 1539 | ]; |
| 1503 | 1540 | } |
| 1504 | 1541 | } |
| @@ -1516,11 +1553,16 @@ | ||
| 1516 | 1553 | /** |
| 1517 | 1554 | * Test OpenRouter API connection |
| 1518 | 1555 | * |
| 1519 | 1556 | * @param string $api_key API key to test |
| 1557 | + * @param string $model Model id to verify, or '' to use the saved one | |
| 1520 | 1558 | * @return array Test result |
| 1521 | 1559 | */ |
| 1522 | - 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 | + | |
| 1523 | 1565 | // Validate the key format first (OpenRouter keys start with "sk-or-"). |
| 1524 | 1566 | if (!str_starts_with($api_key, 'sk-or-')) { |
| 1525 | 1567 | return [ |
| 1526 | 1568 | 'success' => false, |
| @@ -1553,11 +1595,25 @@ | ||
| 1553 | 1595 | |
| 1554 | 1596 | if ($status_code === 200) { |
| 1555 | 1597 | $data = json_decode($body, true); |
| 1556 | 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 | + | |
| 1557 | 1608 | return [ |
| 1558 | 1609 | 'success' => true, |
| 1559 | - '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'), | |
| 1560 | 1616 | ]; |
| 1561 | 1617 | } |
| 1562 | 1618 | } |
| 1563 | 1619 | |
| @@ -1571,14 +1627,58 @@ | ||
| 1571 | 1627 | ]; |
| 1572 | 1628 | } |
| 1573 | 1629 | |
| 1574 | 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 | + /** | |
| 1575 | 1674 | * Test Claude API connection |
| 1576 | 1675 | * |
| 1577 | 1676 | * @param string $api_key API key to test |
| 1677 | + * @param string $model Model id to verify, or '' to use the saved one | |
| 1578 | 1678 | * @return array Test result |
| 1579 | 1679 | */ |
| 1580 | - private function test_claude_connection(string $api_key): array { | |
| 1680 | + private function test_claude_connection(string $api_key, string $model = ''): array { | |
| 1581 | 1681 | // First validate the key format |
| 1582 | 1682 | if (!str_starts_with($api_key, 'sk-ant-')) { |
| 1583 | 1683 | return [ |
| 1584 | 1684 | 'success' => false, |
| @@ -1588,12 +1688,18 @@ | ||
| 1588 | 1688 | |
| 1589 | 1689 | // Test with a simple API call |
| 1590 | 1690 | $url = 'https://api.anthropic.com/v1/messages'; |
| 1591 | 1691 | |
| 1592 | - // Get the configured Claude model, with fallback to a current model. | |
| 1593 | - // Self-heal retired/unavailable IDs saved by earlier versions. | |
| 1594 | - $claude_model = \ThinkRank\Core\Settings::instance()->get('claude_model', \ThinkRank\Core\Settings::DEFAULT_CLAUDE_MODEL); | |
| 1595 | - $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 | + } | |
| 1596 | 1702 | |
| 1597 | 1703 | $body = [ |
| 1598 | 1704 | 'model' => $claude_model, |
| 1599 | 1705 | 'max_tokens' => 10, |
| @@ -1627,16 +1733,32 @@ | ||
| 1627 | 1733 | |
| 1628 | 1734 | if ($status_code === 200) { |
| 1629 | 1735 | return [ |
| 1630 | 1736 | 'success' => true, |
| 1631 | - '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), | |
| 1632 | 1741 | ]; |
| 1633 | 1742 | } else { |
| 1634 | 1743 | $error_data = json_decode($response_body, true); |
| 1635 | 1744 | $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank'); |
| 1636 | 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 | + | |
| 1637 | 1758 | return [ |
| 1638 | 1759 | 'success' => false, |
| 1760 | + 'model' => $claude_model, | |
| 1639 | 1761 | /* translators: %1$d: HTTP status code, %2$s: error message from Claude API */ |
| 1640 | 1762 | 'message' => sprintf(__('Claude API error (%1$d): %2$s', 'thinkrank'), $status_code, $error_message), |
| 1641 | 1763 | ]; |
| 1642 | 1764 | } |
| @@ -1645,15 +1767,26 @@ | ||
| 1645 | 1767 | /** |
| 1646 | 1768 | * Test Gemini API connection |
| 1647 | 1769 | * |
| 1648 | 1770 | * @param string $api_key API key to test |
| 1771 | + * @param string $model Model id to verify, or '' to use the saved one | |
| 1649 | 1772 | * @return array Test result |
| 1650 | 1773 | */ |
| 1651 | - private function test_gemini_connection(string $api_key): array { | |
| 1774 | + private function test_gemini_connection(string $api_key, string $model = ''): array { | |
| 1652 | 1775 | // Test with a simple API call |
| 1653 | - $gemini_model = \ThinkRank\Core\Settings::instance()->get('gemini_model', \ThinkRank\Core\Settings::DEFAULT_GEMINI_MODEL); | |
| 1654 | - $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); | |
| 1655 | 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 | + | |
| 1656 | 1789 | $body = [ |
| 1657 | 1790 | 'contents' => [ |
| 1658 | 1791 | [ |
| 1659 | 1792 | 'parts' => [ |
| @@ -1687,16 +1820,32 @@ | ||
| 1687 | 1820 | |
| 1688 | 1821 | if ($status_code === 200) { |
| 1689 | 1822 | return [ |
| 1690 | 1823 | 'success' => true, |
| 1691 | - '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), | |
| 1692 | 1828 | ]; |
| 1693 | 1829 | } else { |
| 1694 | 1830 | $error_data = json_decode($response_body, true); |
| 1695 | 1831 | $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank'); |
| 1696 | 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 | + | |
| 1697 | 1845 | return [ |
| 1698 | 1846 | 'success' => false, |
| 1847 | + 'model' => $gemini_model, | |
| 1699 | 1848 | /* translators: %1$d: HTTP status code, %2$s: error message from Gemini API */ |
| 1700 | 1849 | 'message' => sprintf(__('Gemini API error (%1$d): %2$s', 'thinkrank'), $status_code, $error_message), |
| 1701 | 1850 | ]; |
| 1702 | 1851 | } |
| @@ -1826,15 +1975,8 @@ | ||
| 1826 | 1975 | // Failed to register AI Insights endpoint |
| 1827 | 1976 | } |
| 1828 | 1977 | |
| 1829 | 1978 | try { |
| 1830 | - $brand_visibility_endpoint = new Brand_Visibility_Endpoint(); | |
| 1831 | - $brand_visibility_endpoint->register_routes(); | |
| 1832 | - } catch (\Exception $e) { | |
| 1833 | - // Failed to register Brand Visibility endpoint | |
| 1834 | - } | |
| 1835 | - | |
| 1836 | - try { | |
| 1837 | 1979 | $performance_endpoint = new Performance_Endpoint(); |
| 1838 | 1980 | $performance_endpoint->register_routes(); |
| 1839 | 1981 | } catch (\Exception $e) { |
| 1840 | 1982 | // Failed to register Performance endpoint |
| @@ -1903,12 +2045,26 @@ | ||
| 1903 | 2045 | // Failed to register Global SEO endpoint |
| 1904 | 2046 | } |
| 1905 | 2047 | |
| 1906 | 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 { | |
| 1907 | 2056 | $image_seo_endpoint = new Image_SEO_Endpoint(); |
| 1908 | 2057 | $image_seo_endpoint->register_routes(); |
| 1909 | 2058 | } catch (\Exception $e) { |
| 1910 | 2059 | // Failed to register Image SEO endpoint |
| 2060 | + } | |
| 2061 | + | |
| 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 | |
| 1911 | 2067 | } |
| 1912 | 2068 | |
| 1913 | 2069 | // Import_Controller is deliberately NOT gated on enable_migration_tools. |
| 1914 | 2070 | // /import/detect backs the setup wizard's migration step and the record |