PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.0
2.9.0 2.8.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 All 50 releases
← All changes | includes/api/class-manager.php +815 -36 2.3.02.9.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;
@@ -67,8 +68,19 @@
67 68 */
68 69 private const AI_CONTENT_MAX_LENGTH = 5000;
69 70
70 71 /**
72 + * Ceilings for the OpenAI-compatible endpoint's model listing (#721).
73 + *
74 + * The endpoint is a host the site owner named, not one we trust: a
75 + * misconfigured or hostile server can answer `GET /models` with an
76 + * unbounded body or a catalogue of thousands. Both are bounded here — the
77 + * field this feeds is a suggestion list, and the UI shows the first few.
78 + */
79 + private const MAX_MODELS_RESPONSE_BYTES = 262144; // 256 KB.
80 + private const MAX_ENDPOINT_MODELS = 200;
81 +
82 + /**
71 83 * Sanitize and hard-cap an AI `content` request parameter.
72 84 *
73 85 * Used as the `sanitize_callback` for every AI endpoint's `content` arg so
74 86 * the server enforces its own maximum regardless of what a direct REST
@@ -192,8 +204,41 @@
192 204 'openrouter_model' => [
193 205 'type' => 'string',
194 206 'sanitize_callback' => 'sanitize_text_field',
195 207 ],
208 + // OpenAI-compatible endpoint (#721). The URL is not run through
209 + // esc_url_raw here: Settings::sanitize_setting() validates it
210 + // (scheme, SSRF guard) and save_settings() reports the reason
211 + // when it refuses, which a sanitize callback cannot do.
212 + 'openai_compatible_base_url' => [
213 + 'type' => 'string',
214 + 'sanitize_callback' => 'sanitize_text_field',
215 + ],
216 + 'openai_compatible_api_key' => [
217 + 'type' => 'string',
218 + 'sanitize_callback' => 'sanitize_text_field',
219 + ],
220 + 'openai_compatible_model' => [
221 + 'type' => 'string',
222 + 'sanitize_callback' => 'sanitize_text_field',
223 + ],
224 + 'openai_compatible_timeout' => [
225 + 'type' => 'integer',
226 + 'minimum' => 10,
227 + 'maximum' => 600,
228 + 'sanitize_callback' => 'absint',
229 + 'validate_callback' => 'rest_validate_request_arg',
230 + ],
231 + 'openai_compatible_supports_images' => [
232 + 'type' => 'boolean',
233 + ],
234 + 'openai_compatible_json_mode' => [
235 + 'type' => 'boolean',
236 + ],
237 + 'openai_compatible_price_per_million' => [
238 + 'type' => 'number',
239 + 'minimum' => 0,
240 + ],
196 241 'max_tokens' => [
197 242 'type' => 'integer',
198 243 'minimum' => 1,
199 244 'maximum' => 32000,
@@ -227,8 +272,28 @@
227 272 'enable_import_export' => [
228 273 'type' => 'boolean',
229 274 'sanitize_callback' => 'rest_sanitize_boolean',
230 275 ],
276 + // AI spend controls (#448). `max_requests_per_minute` is not
277 + // new, but it was never reachable: registered since 1.0 and
278 + // rendered nowhere, so no user could see the throttle that was
279 + // limiting them.
280 + 'max_requests_per_minute' => [
281 + 'type' => 'integer',
282 + 'minimum' => 0,
283 + 'sanitize_callback' => 'absint',
284 + 'validate_callback' => 'rest_validate_request_arg',
285 + ],
286 + 'ai_daily_request_limit' => [
287 + 'type' => 'integer',
288 + 'minimum' => 0,
289 + 'sanitize_callback' => 'absint',
290 + 'validate_callback' => 'rest_validate_request_arg',
291 + ],
292 + 'ai_paused' => [
293 + 'type' => 'boolean',
294 + 'sanitize_callback' => 'rest_sanitize_boolean',
295 + ],
231 296 ],
232 297 ]);
233 298
234 299
@@ -481,11 +546,50 @@
481 546 'required' => false,
482 547 'default' => 'openai',
483 548 'sanitize_callback' => 'sanitize_key',
484 549 ],
550 + 'model' => [
551 + 'type' => 'string',
552 + 'required' => false,
553 + 'sanitize_callback' => 'sanitize_text_field',
554 + ],
555 + // Only used by the openai_compatible provider: the URL on
556 + // screen, so an unsaved endpoint can be tested before saving.
557 + 'base_url' => [
558 + 'type' => 'string',
559 + 'required' => false,
560 + 'sanitize_callback' => 'sanitize_text_field',
561 + ],
562 + // Only used by the openai_compatible provider: the JSON mode
563 + // toggle on screen. Omitted, the saved setting decides.
564 + 'json_mode' => [
565 + 'type' => 'boolean',
566 + 'required' => false,
567 + ],
485 568 ],
486 569 ]);
487 570
571 + // Ask an OpenAI-compatible endpoint what models it serves. Ollama, LM
572 + // Studio and vLLM all answer GET {base}/models; a gateway that does not
573 + // simply leaves the user typing the id by hand (#721).
574 + register_rest_route(self::NAMESPACE, '/ai/models', [
575 + 'methods' => 'POST',
576 + 'callback' => [$this, 'list_endpoint_models'],
577 + 'permission_callback' => [$this, 'check_admin_permissions'],
578 + 'args' => [
579 + 'base_url' => [
580 + 'type' => 'string',
581 + 'required' => false,
582 + 'sanitize_callback' => 'sanitize_text_field',
583 + ],
584 + 'api_key' => [
585 + 'type' => 'string',
586 + 'required' => false,
587 + 'sanitize_callback' => 'sanitize_text_field',
588 + ],
589 + ],
590 + ]);
591 +
488 592 register_rest_route(self::NAMESPACE, '/ai/providers', [
489 593 'methods' => 'GET',
490 594 'callback' => [$this, 'get_ai_providers'],
491 595 'permission_callback' => [$this, 'check_basic_permissions'],
@@ -655,8 +759,17 @@
655 759 $enabled = (bool) $settings->get('enable_rate_limiting', true);
656 760 if (!$enabled) {
657 761 return true;
658 762 }
763 + // A non-positive limit means unlimited, matching AI\Manager and the
764 + // label on the control (#448). This used to fall through to
765 + // max(1, $limit) below, which turned a 0 into the most restrictive
766 + // setting available rather than the least: the first request of each
767 + // minute was allowed and every other one got a 429. Harmless while the
768 + // field was rendered nowhere, user-facing the moment it was surfaced.
769 + if ($limit <= 0) {
770 + return true;
771 + }
659 772 $now = time();
660 773 $window = 60;
661 774 $key = 'thinkrank_rl_' . md5($bucket_id);
662 775 $bucket = get_transient($key);
@@ -665,9 +778,9 @@
665 778 }
666 779 if ($now - ($bucket['start'] ?? 0) >= $window) {
667 780 $bucket = ['start' => $now, 'count' => 0];
668 781 }
669 - if (($bucket['count'] ?? 0) >= max(1, $limit)) {
782 + if (($bucket['count'] ?? 0) >= $limit) {
670 783 return new \WP_Error('rate_limited', __('Rate limit exceeded. Please wait a moment and try again.', 'thinkrank'), ['status' => 429]);
671 784 }
672 785 $bucket['count']++;
673 786 set_transient($key, $bucket, $window);
@@ -809,8 +922,15 @@
809 922 'gemini_api_key' => $settings_instance->get('gemini_api_key', ''),
810 923 'gemini_model' => $settings_instance->get('gemini_model', \ThinkRank\Core\Settings::DEFAULT_GEMINI_MODEL),
811 924 'openrouter_api_key' => $settings_instance->get('openrouter_api_key', ''),
812 925 'openrouter_model' => $settings_instance->get('openrouter_model', \ThinkRank\Core\Settings::DEFAULT_OPENROUTER_MODEL),
926 + 'openai_compatible_base_url' => $settings_instance->get('openai_compatible_base_url', ''),
927 + 'openai_compatible_api_key' => $settings_instance->get('openai_compatible_api_key', ''),
928 + 'openai_compatible_model' => $settings_instance->get('openai_compatible_model', ''),
929 + 'openai_compatible_timeout' => (int) $settings_instance->get('openai_compatible_timeout', \ThinkRank\Core\Settings::DEFAULT_OPENAI_COMPATIBLE_TIMEOUT),
930 + 'openai_compatible_supports_images' => (bool) $settings_instance->get('openai_compatible_supports_images', false),
931 + 'openai_compatible_json_mode' => (bool) $settings_instance->get('openai_compatible_json_mode', false),
932 + 'openai_compatible_price_per_million' => (float) $settings_instance->get('openai_compatible_price_per_million', 0),
813 933 'max_tokens' => $settings_instance->get('max_tokens', 1000),
814 934 'temperature' => $settings_instance->get('temperature', 0.7),
815 935 'cache_duration' => $settings_instance->get('cache_duration', 3600),
816 936 'keep_data_on_uninstall' => (bool) $settings_instance->get('keep_data_on_uninstall', true),
@@ -817,8 +937,11 @@
817 937 'enable_migration_tools' => (bool) $settings_instance->get('enable_migration_tools', false),
818 938 'enable_import_export' => (bool) $settings_instance->get('enable_import_export', false),
819 939 'google_account_connected' => (bool) $settings_instance->get('google_account_connected', false),
820 940 'enable_mcp' => (bool) $settings_instance->get('enable_mcp', false),
941 + 'max_requests_per_minute' => (int) $settings_instance->get('max_requests_per_minute', 0),
942 + 'ai_daily_request_limit' => (int) $settings_instance->get('ai_daily_request_limit', 0),
943 + 'ai_paused' => (bool) $settings_instance->get('ai_paused', false),
821 944 ];
822 945
823 946
824 947
@@ -835,8 +958,11 @@
835 958 }
836 959 if (!empty($settings['openrouter_api_key'])) {
837 960 $settings['openrouter_api_key'] = $this->mask_ai_api_key($settings['openrouter_api_key']);
838 961 }
962 + if (!empty($settings['openai_compatible_api_key'])) {
963 + $settings['openai_compatible_api_key'] = $this->mask_ai_api_key($settings['openai_compatible_api_key']);
964 + }
839 965
840 966 return new \WP_REST_Response($settings);
841 967 }
842 968
@@ -874,8 +1000,105 @@
874 1000 // Capture the pre-save MCP state so we can detect an on/off transition
875 1001 // below and mint/revoke the connection token to match (see #244).
876 1002 $mcp_was_enabled = (bool) $settings->get('enable_mcp', false);
877 1003
1004 + // Pointing the site's AI at an arbitrary host — including loopback and
1005 + // LAN addresses, which this provider deliberately allows — is an
1006 + // administrator's decision, not a delegated one. The settings route
1007 + // itself is delegable through the Role Manager's `thinkrank_settings`
1008 + // capability, so an editor granted "manage ThinkRank settings" could
1009 + // otherwise aim server-side requests (with an Authorization header of
1010 + // their choosing) at internal services. Every other field on this route
1011 + // stays delegable; only these are held back (#721).
1012 + $endpoint_fields = [
1013 + 'openai_compatible_base_url',
1014 + 'openai_compatible_api_key',
1015 + 'openai_compatible_model',
1016 + 'openai_compatible_timeout',
1017 + 'openai_compatible_supports_images',
1018 + 'openai_compatible_json_mode',
1019 + 'openai_compatible_price_per_million',
1020 + ];
1021 +
1022 + foreach ($endpoint_fields as $endpoint_field) {
1023 + if (!isset($params[$endpoint_field])) {
1024 + continue;
1025 + }
1026 +
1027 + // Only an actual change needs the capability: a client that echoes
1028 + // the whole settings payload back unchanged is not reconfiguring
1029 + // anything, and failing that save would break the Settings screen
1030 + // for delegated users editing an unrelated field.
1031 + //
1032 + // The key needs the mask rule the persistence loop below already
1033 + // uses. GET /settings returns it masked ("sk-pr••••••••abc"), so
1034 + // comparing that against the stored plaintext always differs, and
1035 + // every echoed payload would read as "an administrator changed the
1036 + // key" — locking delegated users out of saving anything at all.
1037 + $submitted = $params[$endpoint_field];
1038 + if (is_string($submitted) && false !== strpos($submitted, '••••••••')) {
1039 + continue;
1040 + }
1041 +
1042 + $stored = $settings->get($endpoint_field);
1043 +
1044 + // Booleans and numbers arrive typed from the REST layer but are
1045 + // stored as '1'/'' and '120'; compare them as the values they are.
1046 + if (is_bool($submitted) || is_bool($stored)) {
1047 + if ((bool) $stored === (bool) $submitted) {
1048 + continue;
1049 + }
1050 + } elseif (is_numeric($submitted) && is_numeric($stored)) {
1051 + if ((float) $stored === (float) $submitted) {
1052 + continue;
1053 + }
1054 + } elseif ((string) $stored === (string) $submitted) {
1055 + continue;
1056 + }
1057 +
1058 + if (!current_user_can('manage_options')) {
1059 + return new \WP_REST_Response([
1060 + 'success' => false,
1061 + 'message' => __('Only an administrator can configure a custom AI endpoint.', 'thinkrank'),
1062 + 'field' => $endpoint_field,
1063 + ], 403);
1064 + }
1065 +
1066 + break;
1067 + }
1068 +
1069 + // Selecting the provider is the same decision by another name.
1070 + if (isset($params['ai_provider'])
1071 + && 'openai_compatible' === $params['ai_provider']
1072 + && 'openai_compatible' !== (string) $settings->get('ai_provider', \ThinkRank\Core\Settings::AI_PROVIDER_NONE)
1073 + && !current_user_can('manage_options')
1074 + ) {
1075 + return new \WP_REST_Response([
1076 + 'success' => false,
1077 + 'message' => __('Only an administrator can configure a custom AI endpoint.', 'thinkrank'),
1078 + 'field' => 'ai_provider',
1079 + ], 403);
1080 + }
1081 +
1082 + // A refused endpoint URL has to say why. Settings::sanitize_setting()
1083 + // stores '' for one that fails validation — right, since an unvalidated
1084 + // URL must never become a URL we fetch — but silent, so the user would
1085 + // see "Settings saved" and an endpoint that vanished. Validate here,
1086 + // where the reason can be returned, and reject the whole save: a
1087 + // half-applied AI provider is worse than none (#721).
1088 + if (!empty($params['openai_compatible_base_url'])) {
1089 + $validated_base_url = \ThinkRank\AI\Endpoint_URL_Validator::validate((string) $params['openai_compatible_base_url']);
1090 + if (is_wp_error($validated_base_url)) {
1091 + return new \WP_REST_Response([
1092 + 'success' => false,
1093 + 'message' => $validated_base_url->get_error_message(),
1094 + 'field' => 'openai_compatible_base_url',
1095 + ], 400);
1096 + }
1097 +
1098 + $params['openai_compatible_base_url'] = $validated_base_url;
1099 + }
1100 +
878 1101 // Map frontend parameter names to setting keys
879 1102 $settings_map = [
880 1103 'ai_provider' => 'ai_provider',
881 1104 'openai_api_key' => 'openai_api_key',
@@ -885,8 +1108,15 @@
885 1108 'gemini_api_key' => 'gemini_api_key',
886 1109 'gemini_model' => 'gemini_model',
887 1110 'openrouter_api_key' => 'openrouter_api_key',
888 1111 'openrouter_model' => 'openrouter_model',
1112 + 'openai_compatible_base_url' => 'openai_compatible_base_url',
1113 + 'openai_compatible_api_key' => 'openai_compatible_api_key',
1114 + 'openai_compatible_model' => 'openai_compatible_model',
1115 + 'openai_compatible_timeout' => 'openai_compatible_timeout',
1116 + 'openai_compatible_supports_images' => 'openai_compatible_supports_images',
1117 + 'openai_compatible_json_mode' => 'openai_compatible_json_mode',
1118 + 'openai_compatible_price_per_million' => 'openai_compatible_price_per_million',
889 1119 'max_tokens' => 'max_tokens',
890 1120 'temperature' => 'temperature',
891 1121 'cache_duration' => 'cache_duration',
892 1122 'keep_data_on_uninstall' => 'keep_data_on_uninstall',
@@ -892,8 +1122,11 @@
892 1122 'keep_data_on_uninstall' => 'keep_data_on_uninstall',
893 1123 'enable_mcp' => 'enable_mcp',
894 1124 'enable_migration_tools' => 'enable_migration_tools',
895 1125 'enable_import_export' => 'enable_import_export',
1126 + 'max_requests_per_minute' => 'max_requests_per_minute',
1127 + 'ai_daily_request_limit' => 'ai_daily_request_limit',
1128 + 'ai_paused' => 'ai_paused',
896 1129 ];
897 1130
898 1131 // Processing settings save request
899 1132
@@ -901,9 +1134,9 @@
901 1134 if (isset($params[$param_key])) {
902 1135 $value = $params[$param_key];
903 1136
904 1137 // Handle API keys specially - check for masked values
905 - if (in_array($param_key, ['openai_api_key', 'claude_api_key', 'gemini_api_key', 'openrouter_api_key'], true)) {
1138 + if (in_array($param_key, ['openai_api_key', 'claude_api_key', 'gemini_api_key', 'openrouter_api_key', 'openai_compatible_api_key'], true)) {
906 1139 // Don't update if the value carries the mask sentinel (the
907 1140 // preview now keeps real head/tail chars around it, so match
908 1141 // anywhere rather than only at the start). Empty still clears.
909 1142 if (strpos($value, '••••••••') !== false) {
@@ -940,9 +1173,9 @@
940 1173 // Auto-dismiss welcome notice if API key was saved
941 1174 $this->maybe_dismiss_welcome_notice($params);
942 1175
943 1176 // Force AI Manager to re-initialize client with new settings
944 - if (isset($params['ai_provider']) || isset($params['openai_api_key']) || isset($params['claude_api_key']) || isset($params['gemini_api_key']) || isset($params['openrouter_api_key'])) {
1177 + if (isset($params['ai_provider']) || isset($params['openai_api_key']) || isset($params['claude_api_key']) || isset($params['gemini_api_key']) || isset($params['openrouter_api_key']) || isset($params['openai_compatible_base_url']) || isset($params['openai_compatible_api_key']) || isset($params['openai_compatible_model'])) {
945 1178 // Clear any cached AI Manager instances to force re-initialization
946 1179 wp_cache_delete('thinkrank_ai_manager', 'thinkrank');
947 1180
948 1181 // If we have an AI Manager instance, force it to re-initialize
@@ -1050,9 +1283,9 @@
1050 1283 // Rate limiting: per user/IP per route
1051 1284 $user_id = get_current_user_id();
1052 1285 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1053 1286 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1054 - $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10);
1287 + $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 0);
1055 1288 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1056 1289 if (is_wp_error($allowed)) {
1057 1290 return new \WP_REST_Response([
1058 1291 'success' => false,
@@ -1104,9 +1337,9 @@
1104 1337 // Rate limiting: shares the AI generation bucket.
1105 1338 $user_id = get_current_user_id();
1106 1339 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1107 1340 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1108 - $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10);
1341 + $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 0);
1109 1342 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1110 1343 if (is_wp_error($allowed)) {
1111 1344 return new \WP_REST_Response([
1112 1345 'success' => false,
@@ -1154,9 +1387,9 @@
1154 1387 // Rate limiting: shares the AI generation bucket.
1155 1388 $user_id = get_current_user_id();
1156 1389 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1157 1390 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1158 - $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10);
1391 + $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 0);
1159 1392 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1160 1393 if (is_wp_error($allowed)) {
1161 1394 return new \WP_REST_Response([
1162 1395 'success' => false,
@@ -1206,9 +1439,9 @@
1206 1439 // Rate limiting: shares the AI generation bucket.
1207 1440 $user_id = get_current_user_id();
1208 1441 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1209 1442 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1210 - $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10);
1443 + $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 0);
1211 1444 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1212 1445 if (is_wp_error($allowed)) {
1213 1446 return new \WP_REST_Response([
1214 1447 'success' => false,
@@ -1252,9 +1485,9 @@
1252 1485 // Rate limiting: shares the AI generation bucket.
1253 1486 $user_id = get_current_user_id();
1254 1487 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1255 1488 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1256 - $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10);
1489 + $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 0);
1257 1490 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1258 1491 if (is_wp_error($allowed)) {
1259 1492 return new \WP_REST_Response([
1260 1493 'success' => false,
@@ -1301,9 +1534,9 @@
1301 1534 // Rate limiting: shares the AI generation bucket.
1302 1535 $user_id = get_current_user_id();
1303 1536 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1304 1537 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1305 - $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10);
1538 + $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 0);
1306 1539 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1307 1540 if (is_wp_error($allowed)) {
1308 1541 return new \WP_REST_Response([
1309 1542 'success' => false,
@@ -1400,9 +1633,9 @@
1400 1633 // Rate limiting: per user/IP per route
1401 1634 $user_id = get_current_user_id();
1402 1635 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1403 1636 $bucket_id = 'ai_test|' . ($user_id ?: $ip);
1404 - $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10);
1637 + $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 0);
1405 1638 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1406 1639 if (is_wp_error($allowed)) {
1407 1640 return new \WP_REST_Response([
1408 1641 'success' => false,
@@ -1412,8 +1645,13 @@
1412 1645
1413 1646 try {
1414 1647 $api_key = $request->get_param('api_key');
1415 1648 $provider = $request->get_param('provider') ?: 'openai';
1649 + // The model the caller is asking about. Empty means "whatever is
1650 + // saved" — the settings screen sends the model currently on screen
1651 + // so an unsaved pick or a hand-typed id is what actually gets
1652 + // tested, rather than the last saved one.
1653 + $model = trim((string) $request->get_param('model'));
1416 1654
1417 1655 // An unrecognised provider used to fall through to the Gemini arm
1418 1656 // below, so a typo silently tested the wrong provider's key.
1419 1657 if (!in_array($provider, \ThinkRank\Core\Settings::SUPPORTED_AI_PROVIDERS, true)) {
@@ -1423,8 +1661,34 @@
1423 1661 'message' => sprintf(__('Unknown AI provider: %s', 'thinkrank'), $provider),
1424 1662 ], 400);
1425 1663 }
1426 1664
1665 + // The OpenAI-compatible endpoint is tested by URL, not by key: a
1666 + // local Ollama or LM Studio server wants no key, so the key checks
1667 + // below would refuse to test a perfectly good endpoint (#721).
1668 + if ('openai_compatible' === $provider) {
1669 + $base_url = trim((string) $request->get_param('base_url'));
1670 + if ('' === $base_url) {
1671 + $base_url = (string) \ThinkRank\Core\Settings::instance()->get('openai_compatible_base_url', '');
1672 + }
1673 +
1674 + if (empty($api_key)) {
1675 + $api_key = (string) \ThinkRank\Core\Settings::instance()->get('openai_compatible_api_key', '');
1676 + }
1677 +
1678 + if ('' === $model) {
1679 + $model = trim((string) \ThinkRank\Core\Settings::instance()->get('openai_compatible_model', ''));
1680 + }
1681 +
1682 + $json_mode = $request->has_param('json_mode')
1683 + ? (bool) $request->get_param('json_mode')
1684 + : (bool) \ThinkRank\Core\Settings::instance()->get('openai_compatible_json_mode', false);
1685 +
1686 + $result = $this->test_openai_compatible_connection($base_url, $api_key, $model, $json_mode);
1687 +
1688 + return new \WP_REST_Response($result, $result['success'] ? 200 : 400);
1689 + }
1690 +
1427 1691 // If no API key provided in request, try to get from saved settings
1428 1692 if (empty($api_key)) {
1429 1693 $settings = \ThinkRank\Core\Settings::instance();
1430 1694 if ($provider === 'openai') {
@@ -1446,15 +1710,15 @@
1446 1710 }
1447 1711
1448 1712 // Test the connection with a simple API call
1449 1713 if ($provider === 'openai') {
1450 - $result = $this->test_openai_connection($api_key);
1714 + $result = $this->test_openai_connection($api_key, $model);
1451 1715 } elseif ($provider === 'claude') {
1452 - $result = $this->test_claude_connection($api_key);
1716 + $result = $this->test_claude_connection($api_key, $model);
1453 1717 } elseif ($provider === 'openrouter') {
1454 - $result = $this->test_openrouter_connection($api_key);
1718 + $result = $this->test_openrouter_connection($api_key, $model);
1455 1719 } else {
1456 - $result = $this->test_gemini_connection($api_key);
1720 + $result = $this->test_gemini_connection($api_key, $model);
1457 1721 }
1458 1722
1459 1723 return new \WP_REST_Response($result, $result['success'] ? 200 : 400);
1460 1724 } catch (\Exception $e) {
@@ -1465,14 +1729,378 @@
1465 1729 }
1466 1730 }
1467 1731
1468 1732 /**
1733 + * Test an OpenAI-compatible endpoint with a real (tiny) completion.
1734 + *
1735 + * Deliberately not a GET /models probe: a server can list models and still
1736 + * fail to complete (wrong model id, model not pulled, gateway that only
1737 + * proxies /models). The one-token chat completion answers the question the
1738 + * user is actually asking — "can ThinkRank generate with this?" — and its
1739 + * reply plus latency is what the settings screen shows (#721).
1740 + *
1741 + * @since 2.8.0
1742 + *
1743 + * @param string $base_url Base URL as typed (validated here).
1744 + * @param string $api_key Optional API key.
1745 + * @param string $model Model id to complete with.
1746 + * @param bool $json_mode Also check the server accepts response_format json_object.
1747 + * @return array Test result.
1748 + */
1749 + private function test_openai_compatible_connection(string $base_url, string $api_key, string $model, bool $json_mode = false): array {
1750 + $validated = \ThinkRank\AI\Endpoint_URL_Validator::validate($base_url);
1751 + if (is_wp_error($validated)) {
1752 + return [
1753 + 'success' => false,
1754 + 'message' => $validated->get_error_message(),
1755 + ];
1756 + }
1757 +
1758 + if ('' === trim($model)) {
1759 + return [
1760 + 'success' => false,
1761 + 'message' => __('Enter the model id your endpoint should use, for example llama3.1 or gpt-4o.', 'thinkrank'),
1762 + ];
1763 + }
1764 +
1765 + $headers = ['Content-Type' => 'application/json'];
1766 + if ('' !== $api_key) {
1767 + $headers['Authorization'] = 'Bearer ' . $api_key;
1768 + $headers['api-key'] = $api_key;
1769 + }
1770 +
1771 + // A "Reply with OK" answer is tiny; anything approaching this is a
1772 + // server misbehaving, and the guard caps it before it is buffered.
1773 + $max_bytes = 131072;
1774 +
1775 + $started = microtime(true);
1776 +
1777 + $response = \ThinkRank\AI\Endpoint_URL_Validator::guarded_request(\ThinkRank\AI\Endpoint_URL_Validator::route($validated, 'chat/completions'), [
1778 + 'method' => 'POST',
1779 + // Long enough for a cold local model to load its weights, short
1780 + // enough that a wrong URL does not hang the settings screen.
1781 + 'timeout' => 30,
1782 + 'headers' => $headers,
1783 + 'limit_response_size' => $max_bytes,
1784 + 'body' => wp_json_encode([
1785 + 'model' => trim($model),
1786 + 'messages' => [['role' => 'user', 'content' => 'Reply with OK']],
1787 + // Not 16: a local reasoning model (deepseek-r1, a qwen3
1788 + // thinking build) spends its first tokens on hidden reasoning
1789 + // and returns empty content if the budget runs out there, which
1790 + // would report a working endpoint as broken.
1791 + 'max_tokens' => 128,
1792 + ]),
1793 + ]);
1794 +
1795 + $latency_ms = (int) round((microtime(true) - $started) * 1000);
1796 +
1797 + if (is_wp_error($response)) {
1798 + return [
1799 + 'success' => false,
1800 + /* translators: %s: transport error, e.g. "cURL error 7: Connection refused". */
1801 + 'message' => sprintf(__('Could not reach the endpoint: %s', 'thinkrank'), $response->get_error_message()),
1802 + ];
1803 + }
1804 +
1805 + $status = (int) wp_remote_retrieve_response_code($response);
1806 + $raw_body = wp_remote_retrieve_body($response);
1807 +
1808 + // Redirects are never followed (the key would go wherever the endpoint
1809 + // points). Say so, rather than letting the empty 3xx body read as a
1810 + // wrong model id: an http-to-https upgrade is the usual cause.
1811 + if ($status >= 300 && $status < 400) {
1812 + $location = (string) wp_remote_retrieve_header($response, 'location');
1813 +
1814 + return [
1815 + 'success' => false,
1816 + 'status' => $status,
1817 + 'message' => '' !== $location
1818 + ? sprintf(
1819 + /* translators: 1: HTTP status code, 2: the URL the endpoint redirected to. */
1820 + __('The endpoint redirected (%1$d) to %2$s. Redirects are refused so your API key cannot follow them. Enter the final URL instead, for example https:// in place of http://.', 'thinkrank'),
1821 + $status,
1822 + esc_url_raw($location)
1823 + )
1824 + : sprintf(
1825 + /* translators: %d: HTTP status code. */
1826 + __('The endpoint redirected (%d). Redirects are refused so your API key cannot follow them. Enter the final URL instead, for example https:// in place of http://.', 'thinkrank'),
1827 + $status
1828 + ),
1829 + ];
1830 + }
1831 +
1832 + // A body that reached the cap was cut mid-JSON. Report that, not a
1833 + // missing completion: the model id was never the problem.
1834 + if (strlen($raw_body) >= $max_bytes) {
1835 + return [
1836 + 'success' => false,
1837 + 'status' => $status,
1838 + 'message' => __('The endpoint sent more than ThinkRank will read for a connection test (128 KB). It is misconfigured or is not answering with a chat completion.', 'thinkrank'),
1839 + ];
1840 + }
1841 +
1842 + $body = json_decode($raw_body, true);
1843 +
1844 + if ($status >= 400) {
1845 + $error = '';
1846 + if (is_array($body)) {
1847 + $error = (string) ($body['error']['message'] ?? ($body['error'] ?? ($body['message'] ?? '')));
1848 + }
1849 + if ('' === $error) {
1850 + $error = wp_remote_retrieve_response_message($response);
1851 + }
1852 +
1853 + return [
1854 + 'success' => false,
1855 + 'status' => $status,
1856 + /* translators: 1: HTTP status code, 2: error message from the server. */
1857 + 'message' => sprintf(__('The endpoint answered %1$d: %2$s', 'thinkrank'), $status, $error),
1858 + ];
1859 + }
1860 +
1861 + $reply = '';
1862 + $reasoning_only = false;
1863 + if (is_array($body)) {
1864 + $message = is_array($body['choices'][0]['message'] ?? null) ? $body['choices'][0]['message'] : [];
1865 + $reply = trim((string) ($message['content'] ?? ''));
1866 +
1867 + // Ollama and vLLM expose a thinking model's hidden reasoning
1868 + // separately. Reasoning with no content still proves the endpoint
1869 + // and the model work — it means the model thinks before answering,
1870 + // which is worth saying out loud because it makes every generation
1871 + // slower.
1872 + if ('' === $reply) {
1873 + $reasoning = trim((string) ($message['reasoning'] ?? ($message['reasoning_content'] ?? '')));
1874 + if ('' !== $reasoning) {
1875 + $reply = $reasoning;
1876 + $reasoning_only = true;
1877 + }
1878 + }
1879 + }
1880 +
1881 + if ('' === $reply) {
1882 + return [
1883 + 'success' => false,
1884 + 'status' => $status,
1885 + 'message' => __('The endpoint replied, but with no completion text. Check that the model id is one this server serves.', 'thinkrank'),
1886 + ];
1887 + }
1888 +
1889 + $result = [
1890 + 'success' => true,
1891 + 'model' => trim($model),
1892 + 'model_available' => true,
1893 + 'latency_ms' => $latency_ms,
1894 + 'reply' => mb_substr($reply, 0, 200),
1895 + 'reasoning_only' => $reasoning_only,
1896 + 'message' => $reasoning_only
1897 + ? sprintf(
1898 + /* translators: 1: model id, 2: latency in milliseconds. */
1899 + __('Connected: "%1$s" answered in %2$d ms. It is a reasoning model: it thinks before replying, so generation will be slower and may need a higher timeout.', 'thinkrank'),
1900 + trim($model),
1901 + $latency_ms
1902 + )
1903 + : sprintf(
1904 + /* translators: 1: model id, 2: latency in milliseconds, 3: the model's reply. */
1905 + __('Connected: "%1$s" replied in %2$d ms: %3$s', 'thinkrank'),
1906 + trim($model),
1907 + $latency_ms,
1908 + mb_substr($reply, 0, 80)
1909 + ),
1910 + ];
1911 +
1912 + return $json_mode
1913 + ? $this->probe_openai_compatible_json_mode($validated, $headers, trim($model), $result)
1914 + : $result;
1915 + }
1916 +
1917 + /**
1918 + * Check that an endpoint takes response_format json_object.
1919 + *
1920 + * Runs only after the plain completion worked, so a failure here can mean
1921 + * one thing: the endpoint works, but not with "Force valid JSON answers"
1922 + * on. Generation still works then, because OpenAI_Client falls back to a
1923 + * plain request, but every JSON call pays a failed round trip first. The
1924 + * connection stays a success; the result carries json_mode_supported so
1925 + * the screen can warn instead of reporting a broken endpoint.
1926 + *
1927 + * @since 2.8.0
1928 + *
1929 + * @param string $base_url Validated base URL.
1930 + * @param array $headers Request headers, key included.
1931 + * @param string $model Model id.
1932 + * @param array $result Successful connection result to extend.
1933 + * @return array The result, with json_mode_supported and, when false, a warning message.
1934 + */
1935 + private function probe_openai_compatible_json_mode(string $base_url, array $headers, string $model, array $result): array {
1936 + $response = \ThinkRank\AI\Endpoint_URL_Validator::guarded_request(\ThinkRank\AI\Endpoint_URL_Validator::route($base_url, 'chat/completions'), [
1937 + 'method' => 'POST',
1938 + 'timeout' => 30,
1939 + 'headers' => $headers,
1940 + 'limit_response_size' => 131072,
1941 + 'body' => wp_json_encode([
1942 + 'model' => $model,
1943 + // OpenAI refuses json_object unless the messages mention JSON.
1944 + 'messages' => [['role' => 'user', 'content' => 'Reply with the JSON object {"ok": true}']],
1945 + 'max_tokens' => 128,
1946 + 'response_format' => ['type' => 'json_object'],
1947 + ]),
1948 + ]);
1949 +
1950 + // A timeout or dropped connection says nothing about JSON mode. Leave
1951 + // the result alone rather than warn about a field that was never judged.
1952 + if (is_wp_error($response)) {
1953 + return $result;
1954 + }
1955 +
1956 + $status = (int) wp_remote_retrieve_response_code($response);
1957 + if ($status < 400) {
1958 + $result['json_mode_supported'] = true;
1959 + return $result;
1960 + }
1961 +
1962 + $body = json_decode((string) wp_remote_retrieve_body($response), true);
1963 + $error = '';
1964 + if (is_array($body)) {
1965 + // OpenAI and vLLM nest the text under error.message; Ollama sends a bare error string.
1966 + $error = is_string($body['error'] ?? null)
1967 + ? $body['error']
1968 + : (string) ($body['error']['message'] ?? ($body['message'] ?? ''));
1969 + }
1970 + if ('' === $error) {
1971 + $error = (string) wp_remote_retrieve_response_message($response);
1972 + }
1973 +
1974 + $result['json_mode_supported'] = false;
1975 + $result['message'] = sprintf(
1976 + /* translators: 1: model id, 2: HTTP status code, 3: error message from the server. */
1977 + __('Connected to "%1$s", but the endpoint rejected JSON mode (%2$d: %3$s). Turn off "Force valid JSON answers": generation still works, but each request is sent twice.', 'thinkrank'),
1978 + $model,
1979 + $status,
1980 + $error
1981 + );
1982 +
1983 + return $result;
1984 + }
1985 +
1986 + /**
1987 + * List the models an OpenAI-compatible endpoint serves.
1988 + *
1989 + * @since 2.8.0
1990 + *
1991 + * @param \WP_REST_Request $request Request object.
1992 + * @return \WP_REST_Response Response object.
1993 + */
1994 + public function list_endpoint_models(\WP_REST_Request $request): \WP_REST_Response {
1995 + $settings = \ThinkRank\Core\Settings::instance();
1996 +
1997 + $base_url = trim((string) $request->get_param('base_url'));
1998 + if ('' === $base_url) {
1999 + $base_url = (string) $settings->get('openai_compatible_base_url', '');
2000 + }
2001 +
2002 + $validated = \ThinkRank\AI\Endpoint_URL_Validator::validate($base_url);
2003 + if (is_wp_error($validated)) {
2004 + return new \WP_REST_Response([
2005 + 'success' => false,
2006 + 'message' => $validated->get_error_message(),
2007 + ], 400);
2008 + }
2009 +
2010 + $api_key = trim((string) $request->get_param('api_key'));
2011 + if ('' === $api_key || false !== strpos($api_key, '••••••••')) {
2012 + $api_key = (string) $settings->get('openai_compatible_api_key', '');
2013 + }
2014 +
2015 + $headers = ['Content-Type' => 'application/json'];
2016 + if ('' !== $api_key) {
2017 + $headers['Authorization'] = 'Bearer ' . $api_key;
2018 + $headers['api-key'] = $api_key;
2019 + }
2020 +
2021 + $response = \ThinkRank\AI\Endpoint_URL_Validator::guarded_request(\ThinkRank\AI\Endpoint_URL_Validator::route($validated, 'models'), [
2022 + 'method' => 'GET',
2023 + 'timeout' => 15,
2024 + 'headers' => $headers,
2025 + // A hostile or misconfigured endpoint can answer with an unbounded
2026 + // body; buffering it whole would spend the worker's memory on a
2027 + // list we cap at MAX_ENDPOINT_MODELS anyway.
2028 + 'limit_response_size' => self::MAX_MODELS_RESPONSE_BYTES,
2029 + ]);
2030 +
2031 + if (is_wp_error($response)) {
2032 + return new \WP_REST_Response([
2033 + 'success' => false,
2034 + /* translators: %s: transport error. */
2035 + 'message' => sprintf(__('Could not reach the endpoint: %s', 'thinkrank'), $response->get_error_message()),
2036 + ], 400);
2037 + }
2038 +
2039 + $status = (int) wp_remote_retrieve_response_code($response);
2040 + $body = json_decode(wp_remote_retrieve_body($response), true);
2041 +
2042 + if ($status >= 400 || !is_array($body)) {
2043 + return new \WP_REST_Response([
2044 + 'success' => false,
2045 + /* translators: %d: HTTP status code. */
2046 + 'message' => sprintf(__('This endpoint does not list its models (HTTP %d). Type the model id by hand instead.', 'thinkrank'), $status),
2047 + ], 400);
2048 + }
2049 +
2050 + // OpenAI's shape is {data: [{id: …}]}; some gateways answer a bare list.
2051 + $entries = isset($body['data']) && is_array($body['data']) ? $body['data'] : $body;
2052 + $models = [];
2053 + $truncated = false;
2054 + foreach ($entries as $entry) {
2055 + if (count($models) >= self::MAX_ENDPOINT_MODELS) {
2056 + // A gateway fronting a public catalogue can list thousands of
2057 + // models. Sanitising and sorting all of them is work nobody
2058 + // asked for — the field is a suggestion list, not a registry.
2059 + $truncated = true;
2060 + break;
2061 + }
2062 +
2063 + if (is_array($entry) && !empty($entry['id'])) {
2064 + $models[] = sanitize_text_field((string) $entry['id']);
2065 + } elseif (is_string($entry) && '' !== $entry) {
2066 + $models[] = sanitize_text_field($entry);
2067 + }
2068 + }
2069 +
2070 + $models = array_values(array_unique($models));
2071 + sort($models);
2072 +
2073 + if (empty($models)) {
2074 + return new \WP_REST_Response([
2075 + 'success' => false,
2076 + 'message' => __('The endpoint answered, but listed no models. Type the model id by hand instead.', 'thinkrank'),
2077 + ], 400);
2078 + }
2079 +
2080 + return new \WP_REST_Response([
2081 + 'success' => true,
2082 + 'models' => $models,
2083 + 'truncated' => $truncated,
2084 + ]);
2085 + }
2086 +
2087 + /**
1469 2088 * Test OpenAI API connection
1470 2089 *
2090 + * The models endpoint doubles as the model check: it answers with every id
2091 + * this key may call, so an unknown or unentitled model is caught here
2092 + * instead of at the first real generation.
2093 + *
1471 2094 * @param string $api_key API key to test
2095 + * @param string $model Model id to verify, or '' to use the saved one
1472 2096 * @return array Test result
1473 2097 */
1474 - private function test_openai_connection(string $api_key): array {
2098 + private function test_openai_connection(string $api_key, string $model = ''): array {
2099 + $model = $model !== ''
2100 + ? $model
2101 + : (string) \ThinkRank\Core\Settings::instance()->get('openai_model', \ThinkRank\Core\Settings::DEFAULT_OPENAI_MODEL);
2102 +
1475 2103 $url = 'https://api.openai.com/v1/models';
1476 2104
1477 2105 $response = wp_remote_get($url, [
1478 2106 'headers' => [
@@ -1494,11 +2122,28 @@
1494 2122
1495 2123 if ($status_code === 200) {
1496 2124 $data = json_decode($body, true);
1497 2125 if (isset($data['data']) && is_array($data['data'])) {
2126 + $ids = array_column($data['data'], 'id');
2127 +
2128 + if ($model !== '' && !in_array($model, $ids, true)) {
2129 + return [
2130 + 'success' => false,
2131 + 'model' => $model,
2132 + 'model_available' => false,
2133 + /* translators: %s: the model id that was tested. */
2134 + 'message' => sprintf(__('API key works, but the model "%s" is not available to this account.', 'thinkrank'), $model),
2135 + ];
2136 + }
2137 +
1498 2138 return [
1499 2139 'success' => true,
1500 - 'message' => __('OpenAI API connection successful!', 'thinkrank'),
2140 + 'model' => $model,
2141 + 'model_available' => $model !== '',
2142 + 'message' => $model !== ''
2143 + /* translators: %s: the model id that was tested. */
2144 + ? sprintf(__('OpenAI API connection successful — model "%s" is available.', 'thinkrank'), $model)
2145 + : __('OpenAI API connection successful!', 'thinkrank'),
1501 2146 'models_count' => count($data['data']),
1502 2147 ];
1503 2148 }
1504 2149 }
@@ -1516,11 +2161,16 @@
1516 2161 /**
1517 2162 * Test OpenRouter API connection
1518 2163 *
1519 2164 * @param string $api_key API key to test
2165 + * @param string $model Model id to verify, or '' to use the saved one
1520 2166 * @return array Test result
1521 2167 */
1522 - private function test_openrouter_connection(string $api_key): array {
2168 + private function test_openrouter_connection(string $api_key, string $model = ''): array {
2169 + $model = $model !== ''
2170 + ? $model
2171 + : (string) \ThinkRank\Core\Settings::instance()->get('openrouter_model', \ThinkRank\Core\Settings::DEFAULT_OPENROUTER_MODEL);
2172 +
1523 2173 // Validate the key format first (OpenRouter keys start with "sk-or-").
1524 2174 if (!str_starts_with($api_key, 'sk-or-')) {
1525 2175 return [
1526 2176 'success' => false,
@@ -1553,11 +2203,25 @@
1553 2203
1554 2204 if ($status_code === 200) {
1555 2205 $data = json_decode($body, true);
1556 2206 if (isset($data['data']) && is_array($data['data'])) {
2207 + // The key is good; the catalogue is a separate document, so
2208 + // the model needs its own lookup.
2209 + if ($model !== '') {
2210 + $model_check = $this->check_openrouter_model($api_key, $model);
2211 + if ($model_check !== null) {
2212 + return $model_check;
2213 + }
2214 + }
2215 +
1557 2216 return [
1558 2217 'success' => true,
1559 - 'message' => __('OpenRouter API connection successful!', 'thinkrank'),
2218 + 'model' => $model,
2219 + 'model_available' => $model !== '',
2220 + 'message' => $model !== ''
2221 + /* translators: %s: the model id that was tested. */
2222 + ? sprintf(__('OpenRouter API connection successful — model "%s" is available.', 'thinkrank'), $model)
2223 + : __('OpenRouter API connection successful!', 'thinkrank'),
1560 2224 ];
1561 2225 }
1562 2226 }
1563 2227
@@ -1571,14 +2235,58 @@
1571 2235 ];
1572 2236 }
1573 2237
1574 2238 /**
2239 + * Verify a model id against OpenRouter's public catalogue.
2240 + *
2241 + * @param string $api_key API key to authenticate the lookup
2242 + * @param string $model Model id to look for
2243 + * @return array|null Failure payload when the model is unknown, null when it
2244 + * is available or when the catalogue could not be read —
2245 + * a listing hiccup must not fail an otherwise good key.
2246 + */
2247 + private function check_openrouter_model(string $api_key, string $model): ?array {
2248 + $response = wp_remote_get('https://openrouter.ai/api/v1/models', [
2249 + 'headers' => [
2250 + 'Authorization' => 'Bearer ' . $api_key,
2251 + 'Content-Type' => 'application/json',
2252 + 'HTTP-Referer' => home_url('/'),
2253 + 'X-Title' => 'ThinkRank',
2254 + ],
2255 + 'timeout' => 10,
2256 + ]);
2257 +
2258 + if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
2259 + return null;
2260 + }
2261 +
2262 + $data = json_decode(wp_remote_retrieve_body($response), true);
2263 + if (!isset($data['data']) || !is_array($data['data'])) {
2264 + return null;
2265 + }
2266 +
2267 + $ids = array_column($data['data'], 'id');
2268 + if (in_array($model, $ids, true)) {
2269 + return null;
2270 + }
2271 +
2272 + return [
2273 + 'success' => false,
2274 + 'model' => $model,
2275 + 'model_available' => false,
2276 + /* translators: %s: the model id that was tested. */
2277 + 'message' => sprintf(__('API key works, but "%s" is not a model OpenRouter offers.', 'thinkrank'), $model),
2278 + ];
2279 + }
2280 +
2281 + /**
1575 2282 * Test Claude API connection
1576 2283 *
1577 2284 * @param string $api_key API key to test
2285 + * @param string $model Model id to verify, or '' to use the saved one
1578 2286 * @return array Test result
1579 2287 */
1580 - private function test_claude_connection(string $api_key): array {
2288 + private function test_claude_connection(string $api_key, string $model = ''): array {
1581 2289 // First validate the key format
1582 2290 if (!str_starts_with($api_key, 'sk-ant-')) {
1583 2291 return [
1584 2292 'success' => false,
@@ -1588,12 +2296,18 @@
1588 2296
1589 2297 // Test with a simple API call
1590 2298 $url = 'https://api.anthropic.com/v1/messages';
1591 2299
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);
2300 + // A model sent with the request is tested verbatim: normalizing it would
2301 + // quietly swap a typo for a working id and report success for a model
2302 + // the user never asked for. Only the saved fallback is self-healed, as
2303 + // that is the path where a retired id from an older release shows up.
2304 + if ($model !== '') {
2305 + $claude_model = $model;
2306 + } else {
2307 + $claude_model = \ThinkRank\Core\Settings::instance()->get('claude_model', \ThinkRank\Core\Settings::DEFAULT_CLAUDE_MODEL);
2308 + $claude_model = \ThinkRank\AI\Claude_Client::normalize_model($claude_model);
2309 + }
1596 2310
1597 2311 $body = [
1598 2312 'model' => $claude_model,
1599 2313 'max_tokens' => 10,
@@ -1627,16 +2341,32 @@
1627 2341
1628 2342 if ($status_code === 200) {
1629 2343 return [
1630 2344 'success' => true,
1631 - 'message' => __('Claude API connection successful!', 'thinkrank'),
2345 + 'model' => $claude_model,
2346 + 'model_available' => true,
2347 + /* translators: %s: the model id that was tested. */
2348 + 'message' => sprintf(__('Claude API connection successful — model "%s" is available.', 'thinkrank'), $claude_model),
1632 2349 ];
1633 2350 } else {
1634 2351 $error_data = json_decode($response_body, true);
1635 2352 $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
1636 2353
2354 + // 404 on /v1/messages means the key authenticated but the model id
2355 + // does not exist — say so, instead of blaming the key.
2356 + if ($status_code === 404) {
2357 + return [
2358 + 'success' => false,
2359 + 'model' => $claude_model,
2360 + 'model_available' => false,
2361 + /* translators: %s: the model id that was tested. */
2362 + 'message' => sprintf(__('API key works, but the model "%s" was not found.', 'thinkrank'), $claude_model),
2363 + ];
2364 + }
2365 +
1637 2366 return [
1638 2367 'success' => false,
2368 + 'model' => $claude_model,
1639 2369 /* translators: %1$d: HTTP status code, %2$s: error message from Claude API */
1640 2370 'message' => sprintf(__('Claude API error (%1$d): %2$s', 'thinkrank'), $status_code, $error_message),
1641 2371 ];
1642 2372 }
@@ -1645,15 +2375,26 @@
1645 2375 /**
1646 2376 * Test Gemini API connection
1647 2377 *
1648 2378 * @param string $api_key API key to test
2379 + * @param string $model Model id to verify, or '' to use the saved one
1649 2380 * @return array Test result
1650 2381 */
1651 - private function test_gemini_connection(string $api_key): array {
2382 + private function test_gemini_connection(string $api_key, string $model = ''): array {
1652 2383 // 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}";
2384 + $gemini_model = $model !== ''
2385 + ? $model
2386 + : (string) \ThinkRank\Core\Settings::instance()->get('gemini_model', \ThinkRank\Core\Settings::DEFAULT_GEMINI_MODEL);
1655 2387
2388 + // The model is a path segment, and ids may arrive with the "models/"
2389 + // prefix Google's own docs use.
2390 + $gemini_model = ltrim($gemini_model, '/');
2391 + $gemini_model = preg_replace('#^models/#', '', $gemini_model);
2392 +
2393 + $url = 'https://generativelanguage.googleapis.com/v1beta/models/'
2394 + . rawurlencode($gemini_model)
2395 + . ':generateContent?key=' . rawurlencode($api_key);
2396 +
1656 2397 $body = [
1657 2398 'contents' => [
1658 2399 [
1659 2400 'parts' => [
@@ -1687,16 +2428,32 @@
1687 2428
1688 2429 if ($status_code === 200) {
1689 2430 return [
1690 2431 'success' => true,
1691 - 'message' => __('Gemini API connection successful!', 'thinkrank'),
2432 + 'model' => $gemini_model,
2433 + 'model_available' => true,
2434 + /* translators: %s: the model id that was tested. */
2435 + 'message' => sprintf(__('Gemini API connection successful — model "%s" is available.', 'thinkrank'), $gemini_model),
1692 2436 ];
1693 2437 } else {
1694 2438 $error_data = json_decode($response_body, true);
1695 2439 $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
1696 2440
2441 + // Gemini answers 404 for a model id it does not serve; the key
2442 + // itself authenticated fine, so name the real problem.
2443 + if ($status_code === 404) {
2444 + return [
2445 + 'success' => false,
2446 + 'model' => $gemini_model,
2447 + 'model_available' => false,
2448 + /* translators: %s: the model id that was tested. */
2449 + 'message' => sprintf(__('API key works, but the model "%s" was not found.', 'thinkrank'), $gemini_model),
2450 + ];
2451 + }
2452 +
1697 2453 return [
1698 2454 'success' => false,
2455 + 'model' => $gemini_model,
1699 2456 /* translators: %1$d: HTTP status code, %2$s: error message from Gemini API */
1700 2457 'message' => sprintf(__('Gemini API error (%1$d): %2$s', 'thinkrank'), $status_code, $error_message),
1701 2458 ];
1702 2459 }
@@ -1724,8 +2481,14 @@
1724 2481 public function get_ai_status(\WP_REST_Request $request): \WP_REST_Response {
1725 2482 $ai_manager = new \ThinkRank\AI\Manager();
1726 2483 $status = $ai_manager->get_provider_status();
1727 2484
2485 + // The spend ceiling and kill switch ride on the status the AI screen
2486 + // already polls, rather than a route of their own: a counter the user
2487 + // has to refresh separately to trust is a counter they will not trust
2488 + // (#448).
2489 + $status['budget'] = \ThinkRank\AI\Spend_Guard::status();
2490 +
1728 2491 return new \WP_REST_Response($status);
1729 2492 }
1730 2493
1731 2494 /**
@@ -1743,9 +2506,9 @@
1743 2506 // Rate limiting: per user/IP per route
1744 2507 $user_id = get_current_user_id();
1745 2508 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1746 2509 $bucket_id = 'ai_analyze|' . ($user_id ?: $ip);
1747 - $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10);
2510 + $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 0);
1748 2511 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1749 2512 if (is_wp_error($allowed)) {
1750 2513 return new \WP_REST_Response([
1751 2514 'success' => false,
@@ -1826,15 +2589,8 @@
1826 2589 // Failed to register AI Insights endpoint
1827 2590 }
1828 2591
1829 2592 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 2593 $performance_endpoint = new Performance_Endpoint();
1838 2594 $performance_endpoint->register_routes();
1839 2595 } catch (\Exception $e) {
1840 2596 // Failed to register Performance endpoint
@@ -1903,12 +2659,35 @@
1903 2659 // Failed to register Global SEO endpoint
1904 2660 }
1905 2661
1906 2662 try {
2663 + $content_type_matrix_endpoint = new \ThinkRank\API\Content_Type_Matrix_Endpoint();
2664 + $content_type_matrix_endpoint->register_routes();
2665 + } catch (\Exception $e) {
2666 + // Failed to register Content Type Matrix endpoint
2667 + }
2668 +
2669 + try {
2670 + // Bulk Snippets (#727): lives under global-seo/, so the Role
2671 + // Manager's Bulk SEO Optimization capability covers it.
2672 + $snippets_endpoint = new \ThinkRank\API\Snippets_Endpoint();
2673 + $snippets_endpoint->register_routes();
2674 + } catch (\Exception $e) {
2675 + // Failed to register Bulk Snippets endpoint
2676 + }
2677 +
2678 + try {
1907 2679 $image_seo_endpoint = new Image_SEO_Endpoint();
1908 2680 $image_seo_endpoint->register_routes();
1909 2681 } catch (\Exception $e) {
1910 2682 // Failed to register Image SEO endpoint
2683 + }
2684 +
2685 + try {
2686 + $external_links_endpoint = new External_Links_Endpoint();
2687 + $external_links_endpoint->register_routes();
2688 + } catch (\Exception $e) {
2689 + // Failed to register External Links endpoint
1911 2690 }
1912 2691
1913 2692 // Import_Controller is deliberately NOT gated on enable_migration_tools.
1914 2693 // /import/detect backs the setup wizard's migration step and the record