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 +820 -36 2.2.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
@@ -89,8 +101,13 @@
89 101 public function init(): void {
90 102 add_action('rest_api_init', [$this, 'register_routes']);
91 103 add_action('rest_api_init', [$this, 'register_endpoint_classes']);
92 104
105 + // Analytics cache invalidation must listen on every request, not only
106 + // REST ones — AI usage is logged from cron and WP-CLI too, and a
107 + // listener bound on rest_api_init never hears those.
108 + Usage_Analytics_Endpoint::boot_cache_invalidation();
109 +
93 110 // Make declared schema constraints mean something. Applied once over
94 111 // the whole namespace rather than at 70-odd call sites, because that is
95 112 // exactly how the enum on /setup-wizard/migrated-plugins and the one on
96 113 // /seo-analytics/dashboard came to be inert while the route next door
@@ -187,8 +204,41 @@
187 204 'openrouter_model' => [
188 205 'type' => 'string',
189 206 'sanitize_callback' => 'sanitize_text_field',
190 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 + ],
191 241 'max_tokens' => [
192 242 'type' => 'integer',
193 243 'minimum' => 1,
194 244 'maximum' => 32000,
@@ -222,8 +272,28 @@
222 272 'enable_import_export' => [
223 273 'type' => 'boolean',
224 274 'sanitize_callback' => 'rest_sanitize_boolean',
225 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 + ],
226 296 ],
227 297 ]);
228 298
229 299
@@ -476,11 +546,50 @@
476 546 'required' => false,
477 547 'default' => 'openai',
478 548 'sanitize_callback' => 'sanitize_key',
479 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 + ],
480 568 ],
481 569 ]);
482 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 +
483 592 register_rest_route(self::NAMESPACE, '/ai/providers', [
484 593 'methods' => 'GET',
485 594 'callback' => [$this, 'get_ai_providers'],
486 595 'permission_callback' => [$this, 'check_basic_permissions'],
@@ -650,8 +759,17 @@
650 759 $enabled = (bool) $settings->get('enable_rate_limiting', true);
651 760 if (!$enabled) {
652 761 return true;
653 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 + }
654 772 $now = time();
655 773 $window = 60;
656 774 $key = 'thinkrank_rl_' . md5($bucket_id);
657 775 $bucket = get_transient($key);
@@ -660,9 +778,9 @@
660 778 }
661 779 if ($now - ($bucket['start'] ?? 0) >= $window) {
662 780 $bucket = ['start' => $now, 'count' => 0];
663 781 }
664 - if (($bucket['count'] ?? 0) >= max(1, $limit)) {
782 + if (($bucket['count'] ?? 0) >= $limit) {
665 783 return new \WP_Error('rate_limited', __('Rate limit exceeded. Please wait a moment and try again.', 'thinkrank'), ['status' => 429]);
666 784 }
667 785 $bucket['count']++;
668 786 set_transient($key, $bucket, $window);
@@ -804,8 +922,15 @@
804 922 'gemini_api_key' => $settings_instance->get('gemini_api_key', ''),
805 923 'gemini_model' => $settings_instance->get('gemini_model', \ThinkRank\Core\Settings::DEFAULT_GEMINI_MODEL),
806 924 'openrouter_api_key' => $settings_instance->get('openrouter_api_key', ''),
807 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),
808 933 'max_tokens' => $settings_instance->get('max_tokens', 1000),
809 934 'temperature' => $settings_instance->get('temperature', 0.7),
810 935 'cache_duration' => $settings_instance->get('cache_duration', 3600),
811 936 'keep_data_on_uninstall' => (bool) $settings_instance->get('keep_data_on_uninstall', true),
@@ -812,8 +937,11 @@
812 937 'enable_migration_tools' => (bool) $settings_instance->get('enable_migration_tools', false),
813 938 'enable_import_export' => (bool) $settings_instance->get('enable_import_export', false),
814 939 'google_account_connected' => (bool) $settings_instance->get('google_account_connected', false),
815 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),
816 944 ];
817 945
818 946
819 947
@@ -830,8 +958,11 @@
830 958 }
831 959 if (!empty($settings['openrouter_api_key'])) {
832 960 $settings['openrouter_api_key'] = $this->mask_ai_api_key($settings['openrouter_api_key']);
833 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 + }
834 965
835 966 return new \WP_REST_Response($settings);
836 967 }
837 968
@@ -869,8 +1000,105 @@
869 1000 // Capture the pre-save MCP state so we can detect an on/off transition
870 1001 // below and mint/revoke the connection token to match (see #244).
871 1002 $mcp_was_enabled = (bool) $settings->get('enable_mcp', false);
872 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 +
873 1101 // Map frontend parameter names to setting keys
874 1102 $settings_map = [
875 1103 'ai_provider' => 'ai_provider',
876 1104 'openai_api_key' => 'openai_api_key',
@@ -880,8 +1108,15 @@
880 1108 'gemini_api_key' => 'gemini_api_key',
881 1109 'gemini_model' => 'gemini_model',
882 1110 'openrouter_api_key' => 'openrouter_api_key',
883 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',
884 1119 'max_tokens' => 'max_tokens',
885 1120 'temperature' => 'temperature',
886 1121 'cache_duration' => 'cache_duration',
887 1122 'keep_data_on_uninstall' => 'keep_data_on_uninstall',
@@ -887,8 +1122,11 @@
887 1122 'keep_data_on_uninstall' => 'keep_data_on_uninstall',
888 1123 'enable_mcp' => 'enable_mcp',
889 1124 'enable_migration_tools' => 'enable_migration_tools',
890 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',
891 1129 ];
892 1130
893 1131 // Processing settings save request
894 1132
@@ -896,9 +1134,9 @@
896 1134 if (isset($params[$param_key])) {
897 1135 $value = $params[$param_key];
898 1136
899 1137 // Handle API keys specially - check for masked values
900 - 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)) {
901 1139 // Don't update if the value carries the mask sentinel (the
902 1140 // preview now keeps real head/tail chars around it, so match
903 1141 // anywhere rather than only at the start). Empty still clears.
904 1142 if (strpos($value, '••••••••') !== false) {
@@ -935,9 +1173,9 @@
935 1173 // Auto-dismiss welcome notice if API key was saved
936 1174 $this->maybe_dismiss_welcome_notice($params);
937 1175
938 1176 // Force AI Manager to re-initialize client with new settings
939 - 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'])) {
940 1178 // Clear any cached AI Manager instances to force re-initialization
941 1179 wp_cache_delete('thinkrank_ai_manager', 'thinkrank');
942 1180
943 1181 // If we have an AI Manager instance, force it to re-initialize
@@ -1045,9 +1283,9 @@
1045 1283 // Rate limiting: per user/IP per route
1046 1284 $user_id = get_current_user_id();
1047 1285 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1048 1286 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1049 - $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);
1050 1288 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1051 1289 if (is_wp_error($allowed)) {
1052 1290 return new \WP_REST_Response([
1053 1291 'success' => false,
@@ -1099,9 +1337,9 @@
1099 1337 // Rate limiting: shares the AI generation bucket.
1100 1338 $user_id = get_current_user_id();
1101 1339 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1102 1340 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1103 - $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);
1104 1342 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1105 1343 if (is_wp_error($allowed)) {
1106 1344 return new \WP_REST_Response([
1107 1345 'success' => false,
@@ -1149,9 +1387,9 @@
1149 1387 // Rate limiting: shares the AI generation bucket.
1150 1388 $user_id = get_current_user_id();
1151 1389 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1152 1390 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1153 - $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);
1154 1392 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1155 1393 if (is_wp_error($allowed)) {
1156 1394 return new \WP_REST_Response([
1157 1395 'success' => false,
@@ -1201,9 +1439,9 @@
1201 1439 // Rate limiting: shares the AI generation bucket.
1202 1440 $user_id = get_current_user_id();
1203 1441 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1204 1442 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1205 - $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);
1206 1444 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1207 1445 if (is_wp_error($allowed)) {
1208 1446 return new \WP_REST_Response([
1209 1447 'success' => false,
@@ -1247,9 +1485,9 @@
1247 1485 // Rate limiting: shares the AI generation bucket.
1248 1486 $user_id = get_current_user_id();
1249 1487 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1250 1488 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1251 - $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);
1252 1490 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1253 1491 if (is_wp_error($allowed)) {
1254 1492 return new \WP_REST_Response([
1255 1493 'success' => false,
@@ -1296,9 +1534,9 @@
1296 1534 // Rate limiting: shares the AI generation bucket.
1297 1535 $user_id = get_current_user_id();
1298 1536 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1299 1537 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1300 - $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);
1301 1539 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1302 1540 if (is_wp_error($allowed)) {
1303 1541 return new \WP_REST_Response([
1304 1542 'success' => false,
@@ -1395,9 +1633,9 @@
1395 1633 // Rate limiting: per user/IP per route
1396 1634 $user_id = get_current_user_id();
1397 1635 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1398 1636 $bucket_id = 'ai_test|' . ($user_id ?: $ip);
1399 - $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);
1400 1638 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1401 1639 if (is_wp_error($allowed)) {
1402 1640 return new \WP_REST_Response([
1403 1641 'success' => false,
@@ -1407,8 +1645,13 @@
1407 1645
1408 1646 try {
1409 1647 $api_key = $request->get_param('api_key');
1410 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'));
1411 1654
1412 1655 // An unrecognised provider used to fall through to the Gemini arm
1413 1656 // below, so a typo silently tested the wrong provider's key.
1414 1657 if (!in_array($provider, \ThinkRank\Core\Settings::SUPPORTED_AI_PROVIDERS, true)) {
@@ -1418,8 +1661,34 @@
1418 1661 'message' => sprintf(__('Unknown AI provider: %s', 'thinkrank'), $provider),
1419 1662 ], 400);
1420 1663 }
1421 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 +
1422 1691 // If no API key provided in request, try to get from saved settings
1423 1692 if (empty($api_key)) {
1424 1693 $settings = \ThinkRank\Core\Settings::instance();
1425 1694 if ($provider === 'openai') {
@@ -1441,15 +1710,15 @@
1441 1710 }
1442 1711
1443 1712 // Test the connection with a simple API call
1444 1713 if ($provider === 'openai') {
1445 - $result = $this->test_openai_connection($api_key);
1714 + $result = $this->test_openai_connection($api_key, $model);
1446 1715 } elseif ($provider === 'claude') {
1447 - $result = $this->test_claude_connection($api_key);
1716 + $result = $this->test_claude_connection($api_key, $model);
1448 1717 } elseif ($provider === 'openrouter') {
1449 - $result = $this->test_openrouter_connection($api_key);
1718 + $result = $this->test_openrouter_connection($api_key, $model);
1450 1719 } else {
1451 - $result = $this->test_gemini_connection($api_key);
1720 + $result = $this->test_gemini_connection($api_key, $model);
1452 1721 }
1453 1722
1454 1723 return new \WP_REST_Response($result, $result['success'] ? 200 : 400);
1455 1724 } catch (\Exception $e) {
@@ -1460,14 +1729,378 @@
1460 1729 }
1461 1730 }
1462 1731
1463 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 + /**
1464 2088 * Test OpenAI API connection
1465 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 + *
1466 2094 * @param string $api_key API key to test
2095 + * @param string $model Model id to verify, or '' to use the saved one
1467 2096 * @return array Test result
1468 2097 */
1469 - 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 +
1470 2103 $url = 'https://api.openai.com/v1/models';
1471 2104
1472 2105 $response = wp_remote_get($url, [
1473 2106 'headers' => [
@@ -1489,11 +2122,28 @@
1489 2122
1490 2123 if ($status_code === 200) {
1491 2124 $data = json_decode($body, true);
1492 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 +
1493 2138 return [
1494 2139 'success' => true,
1495 - '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'),
1496 2146 'models_count' => count($data['data']),
1497 2147 ];
1498 2148 }
1499 2149 }
@@ -1511,11 +2161,16 @@
1511 2161 /**
1512 2162 * Test OpenRouter API connection
1513 2163 *
1514 2164 * @param string $api_key API key to test
2165 + * @param string $model Model id to verify, or '' to use the saved one
1515 2166 * @return array Test result
1516 2167 */
1517 - 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 +
1518 2173 // Validate the key format first (OpenRouter keys start with "sk-or-").
1519 2174 if (!str_starts_with($api_key, 'sk-or-')) {
1520 2175 return [
1521 2176 'success' => false,
@@ -1548,11 +2203,25 @@
1548 2203
1549 2204 if ($status_code === 200) {
1550 2205 $data = json_decode($body, true);
1551 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 +
1552 2216 return [
1553 2217 'success' => true,
1554 - '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'),
1555 2224 ];
1556 2225 }
1557 2226 }
1558 2227
@@ -1566,14 +2235,58 @@
1566 2235 ];
1567 2236 }
1568 2237
1569 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 + /**
1570 2282 * Test Claude API connection
1571 2283 *
1572 2284 * @param string $api_key API key to test
2285 + * @param string $model Model id to verify, or '' to use the saved one
1573 2286 * @return array Test result
1574 2287 */
1575 - private function test_claude_connection(string $api_key): array {
2288 + private function test_claude_connection(string $api_key, string $model = ''): array {
1576 2289 // First validate the key format
1577 2290 if (!str_starts_with($api_key, 'sk-ant-')) {
1578 2291 return [
1579 2292 'success' => false,
@@ -1583,12 +2296,18 @@
1583 2296
1584 2297 // Test with a simple API call
1585 2298 $url = 'https://api.anthropic.com/v1/messages';
1586 2299
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);
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 + }
1591 2310
1592 2311 $body = [
1593 2312 'model' => $claude_model,
1594 2313 'max_tokens' => 10,
@@ -1622,16 +2341,32 @@
1622 2341
1623 2342 if ($status_code === 200) {
1624 2343 return [
1625 2344 'success' => true,
1626 - '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),
1627 2349 ];
1628 2350 } else {
1629 2351 $error_data = json_decode($response_body, true);
1630 2352 $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
1631 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 +
1632 2366 return [
1633 2367 'success' => false,
2368 + 'model' => $claude_model,
1634 2369 /* translators: %1$d: HTTP status code, %2$s: error message from Claude API */
1635 2370 'message' => sprintf(__('Claude API error (%1$d): %2$s', 'thinkrank'), $status_code, $error_message),
1636 2371 ];
1637 2372 }
@@ -1640,15 +2375,26 @@
1640 2375 /**
1641 2376 * Test Gemini API connection
1642 2377 *
1643 2378 * @param string $api_key API key to test
2379 + * @param string $model Model id to verify, or '' to use the saved one
1644 2380 * @return array Test result
1645 2381 */
1646 - private function test_gemini_connection(string $api_key): array {
2382 + private function test_gemini_connection(string $api_key, string $model = ''): array {
1647 2383 // 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}";
2384 + $gemini_model = $model !== ''
2385 + ? $model
2386 + : (string) \ThinkRank\Core\Settings::instance()->get('gemini_model', \ThinkRank\Core\Settings::DEFAULT_GEMINI_MODEL);
1650 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 +
1651 2397 $body = [
1652 2398 'contents' => [
1653 2399 [
1654 2400 'parts' => [
@@ -1682,16 +2428,32 @@
1682 2428
1683 2429 if ($status_code === 200) {
1684 2430 return [
1685 2431 'success' => true,
1686 - '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),
1687 2436 ];
1688 2437 } else {
1689 2438 $error_data = json_decode($response_body, true);
1690 2439 $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
1691 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 +
1692 2453 return [
1693 2454 'success' => false,
2455 + 'model' => $gemini_model,
1694 2456 /* translators: %1$d: HTTP status code, %2$s: error message from Gemini API */
1695 2457 'message' => sprintf(__('Gemini API error (%1$d): %2$s', 'thinkrank'), $status_code, $error_message),
1696 2458 ];
1697 2459 }
@@ -1719,8 +2481,14 @@
1719 2481 public function get_ai_status(\WP_REST_Request $request): \WP_REST_Response {
1720 2482 $ai_manager = new \ThinkRank\AI\Manager();
1721 2483 $status = $ai_manager->get_provider_status();
1722 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 +
1723 2491 return new \WP_REST_Response($status);
1724 2492 }
1725 2493
1726 2494 /**
@@ -1738,9 +2506,9 @@
1738 2506 // Rate limiting: per user/IP per route
1739 2507 $user_id = get_current_user_id();
1740 2508 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1741 2509 $bucket_id = 'ai_analyze|' . ($user_id ?: $ip);
1742 - $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);
1743 2511 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1744 2512 if (is_wp_error($allowed)) {
1745 2513 return new \WP_REST_Response([
1746 2514 'success' => false,
@@ -1821,15 +2589,8 @@
1821 2589 // Failed to register AI Insights endpoint
1822 2590 }
1823 2591
1824 2592 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 2593 $performance_endpoint = new Performance_Endpoint();
1833 2594 $performance_endpoint->register_routes();
1834 2595 } catch (\Exception $e) {
1835 2596 // Failed to register Performance endpoint
@@ -1898,12 +2659,35 @@
1898 2659 // Failed to register Global SEO endpoint
1899 2660 }
1900 2661
1901 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 {
1902 2679 $image_seo_endpoint = new Image_SEO_Endpoint();
1903 2680 $image_seo_endpoint->register_routes();
1904 2681 } catch (\Exception $e) {
1905 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
1906 2690 }
1907 2691
1908 2692 // Import_Controller is deliberately NOT gated on enable_migration_tools.
1909 2693 // /import/detect backs the setup wizard's migration step and the record