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 +186 -25 2.2.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;
@@ -89,8 +90,13 @@
89 90 public function init(): void {
90 91 add_action('rest_api_init', [$this, 'register_routes']);
91 92 add_action('rest_api_init', [$this, 'register_endpoint_classes']);
92 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 +
93 99 // Make declared schema constraints mean something. Applied once over
94 100 // the whole namespace rather than at 70-odd call sites, because that is
95 101 // exactly how the enum on /setup-wizard/migrated-plugins and the one on
96 102 // /seo-analytics/dashboard came to be inert while the route next door
@@ -476,8 +482,13 @@
476 482 'required' => false,
477 483 'default' => 'openai',
478 484 'sanitize_callback' => 'sanitize_key',
479 485 ],
486 + 'model' => [
487 + 'type' => 'string',
488 + 'required' => false,
489 + 'sanitize_callback' => 'sanitize_text_field',
490 + ],
480 491 ],
481 492 ]);
482 493
483 494 register_rest_route(self::NAMESPACE, '/ai/providers', [
@@ -1407,8 +1418,13 @@
1407 1418
1408 1419 try {
1409 1420 $api_key = $request->get_param('api_key');
1410 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'));
1411 1427
1412 1428 // An unrecognised provider used to fall through to the Gemini arm
1413 1429 // below, so a typo silently tested the wrong provider's key.
1414 1430 if (!in_array($provider, \ThinkRank\Core\Settings::SUPPORTED_AI_PROVIDERS, true)) {
@@ -1441,15 +1457,15 @@
1441 1457 }
1442 1458
1443 1459 // Test the connection with a simple API call
1444 1460 if ($provider === 'openai') {
1445 - $result = $this->test_openai_connection($api_key);
1461 + $result = $this->test_openai_connection($api_key, $model);
1446 1462 } elseif ($provider === 'claude') {
1447 - $result = $this->test_claude_connection($api_key);
1463 + $result = $this->test_claude_connection($api_key, $model);
1448 1464 } elseif ($provider === 'openrouter') {
1449 - $result = $this->test_openrouter_connection($api_key);
1465 + $result = $this->test_openrouter_connection($api_key, $model);
1450 1466 } else {
1451 - $result = $this->test_gemini_connection($api_key);
1467 + $result = $this->test_gemini_connection($api_key, $model);
1452 1468 }
1453 1469
1454 1470 return new \WP_REST_Response($result, $result['success'] ? 200 : 400);
1455 1471 } catch (\Exception $e) {
@@ -1462,12 +1478,21 @@
1462 1478
1463 1479 /**
1464 1480 * Test OpenAI API connection
1465 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 + *
1466 1486 * @param string $api_key API key to test
1487 + * @param string $model Model id to verify, or '' to use the saved one
1467 1488 * @return array Test result
1468 1489 */
1469 - 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 +
1470 1495 $url = 'https://api.openai.com/v1/models';
1471 1496
1472 1497 $response = wp_remote_get($url, [
1473 1498 'headers' => [
@@ -1489,11 +1514,28 @@
1489 1514
1490 1515 if ($status_code === 200) {
1491 1516 $data = json_decode($body, true);
1492 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 +
1493 1530 return [
1494 1531 'success' => true,
1495 - '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'),
1496 1538 'models_count' => count($data['data']),
1497 1539 ];
1498 1540 }
1499 1541 }
@@ -1511,11 +1553,16 @@
1511 1553 /**
1512 1554 * Test OpenRouter API connection
1513 1555 *
1514 1556 * @param string $api_key API key to test
1557 + * @param string $model Model id to verify, or '' to use the saved one
1515 1558 * @return array Test result
1516 1559 */
1517 - 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 +
1518 1565 // Validate the key format first (OpenRouter keys start with "sk-or-").
1519 1566 if (!str_starts_with($api_key, 'sk-or-')) {
1520 1567 return [
1521 1568 'success' => false,
@@ -1548,11 +1595,25 @@
1548 1595
1549 1596 if ($status_code === 200) {
1550 1597 $data = json_decode($body, true);
1551 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 +
1552 1608 return [
1553 1609 'success' => true,
1554 - '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'),
1555 1616 ];
1556 1617 }
1557 1618 }
1558 1619
@@ -1566,14 +1627,58 @@
1566 1627 ];
1567 1628 }
1568 1629
1569 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 + /**
1570 1674 * Test Claude API connection
1571 1675 *
1572 1676 * @param string $api_key API key to test
1677 + * @param string $model Model id to verify, or '' to use the saved one
1573 1678 * @return array Test result
1574 1679 */
1575 - private function test_claude_connection(string $api_key): array {
1680 + private function test_claude_connection(string $api_key, string $model = ''): array {
1576 1681 // First validate the key format
1577 1682 if (!str_starts_with($api_key, 'sk-ant-')) {
1578 1683 return [
1579 1684 'success' => false,
@@ -1583,12 +1688,18 @@
1583 1688
1584 1689 // Test with a simple API call
1585 1690 $url = 'https://api.anthropic.com/v1/messages';
1586 1691
1587 - // Get the configured Claude model, with fallback to a current model.
1588 - // Self-heal retired/unavailable IDs saved by earlier versions.
1589 - $claude_model = \ThinkRank\Core\Settings::instance()->get('claude_model', \ThinkRank\Core\Settings::DEFAULT_CLAUDE_MODEL);
1590 - $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 + }
1591 1702
1592 1703 $body = [
1593 1704 'model' => $claude_model,
1594 1705 'max_tokens' => 10,
@@ -1622,16 +1733,32 @@
1622 1733
1623 1734 if ($status_code === 200) {
1624 1735 return [
1625 1736 'success' => true,
1626 - '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),
1627 1741 ];
1628 1742 } else {
1629 1743 $error_data = json_decode($response_body, true);
1630 1744 $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
1631 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 +
1632 1758 return [
1633 1759 'success' => false,
1760 + 'model' => $claude_model,
1634 1761 /* translators: %1$d: HTTP status code, %2$s: error message from Claude API */
1635 1762 'message' => sprintf(__('Claude API error (%1$d): %2$s', 'thinkrank'), $status_code, $error_message),
1636 1763 ];
1637 1764 }
@@ -1640,15 +1767,26 @@
1640 1767 /**
1641 1768 * Test Gemini API connection
1642 1769 *
1643 1770 * @param string $api_key API key to test
1771 + * @param string $model Model id to verify, or '' to use the saved one
1644 1772 * @return array Test result
1645 1773 */
1646 - private function test_gemini_connection(string $api_key): array {
1774 + private function test_gemini_connection(string $api_key, string $model = ''): array {
1647 1775 // Test with a simple API call
1648 - $gemini_model = \ThinkRank\Core\Settings::instance()->get('gemini_model', \ThinkRank\Core\Settings::DEFAULT_GEMINI_MODEL);
1649 - $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);
1650 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 +
1651 1789 $body = [
1652 1790 'contents' => [
1653 1791 [
1654 1792 'parts' => [
@@ -1682,16 +1820,32 @@
1682 1820
1683 1821 if ($status_code === 200) {
1684 1822 return [
1685 1823 'success' => true,
1686 - '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),
1687 1828 ];
1688 1829 } else {
1689 1830 $error_data = json_decode($response_body, true);
1690 1831 $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
1691 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 +
1692 1845 return [
1693 1846 'success' => false,
1847 + 'model' => $gemini_model,
1694 1848 /* translators: %1$d: HTTP status code, %2$s: error message from Gemini API */
1695 1849 'message' => sprintf(__('Gemini API error (%1$d): %2$s', 'thinkrank'), $status_code, $error_message),
1696 1850 ];
1697 1851 }
@@ -1821,15 +1975,8 @@
1821 1975 // Failed to register AI Insights endpoint
1822 1976 }
1823 1977
1824 1978 try {
1825 - $brand_visibility_endpoint = new Brand_Visibility_Endpoint();
1826 - $brand_visibility_endpoint->register_routes();
1827 - } catch (\Exception $e) {
1828 - // Failed to register Brand Visibility endpoint
1829 - }
1830 -
1831 - try {
1832 1979 $performance_endpoint = new Performance_Endpoint();
1833 1980 $performance_endpoint->register_routes();
1834 1981 } catch (\Exception $e) {
1835 1982 // Failed to register Performance endpoint
@@ -1898,12 +2045,26 @@
1898 2045 // Failed to register Global SEO endpoint
1899 2046 }
1900 2047
1901 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 {
1902 2056 $image_seo_endpoint = new Image_SEO_Endpoint();
1903 2057 $image_seo_endpoint->register_routes();
1904 2058 } catch (\Exception $e) {
1905 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
1906 2067 }
1907 2068
1908 2069 // Import_Controller is deliberately NOT gated on enable_migration_tools.
1909 2070 // /import/detect backs the setup wizard's migration step and the record