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 +181 -25 2.4.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;
@@ -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