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 +884 -37 2.1.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;
@@ -33,8 +34,9 @@
33 34 use ThinkRank\API\Global_Robot_Meta_Endpoint;
34 35 use ThinkRank\API\Author_Archives_Endpoint;
35 36 use ThinkRank\API\Email_Report_Endpoint;
36 37 use ThinkRank\Admin\Importers\Import_Controller;
38 +use ThinkRank\Admin\Importers\Export_Controller;
37 39 use ThinkRank\API\Setup_Wizard_Endpoint;
38 40
39 41
40 42 // Prevent direct access
@@ -66,8 +68,19 @@
66 68 */
67 69 private const AI_CONTENT_MAX_LENGTH = 5000;
68 70
69 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 + /**
70 83 * Sanitize and hard-cap an AI `content` request parameter.
71 84 *
72 85 * Used as the `sanitize_callback` for every AI endpoint's `content` arg so
73 86 * the server enforces its own maximum regardless of what a direct REST
@@ -88,8 +101,13 @@
88 101 public function init(): void {
89 102 add_action('rest_api_init', [$this, 'register_routes']);
90 103 add_action('rest_api_init', [$this, 'register_endpoint_classes']);
91 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 +
92 110 // Make declared schema constraints mean something. Applied once over
93 111 // the whole namespace rather than at 70-odd call sites, because that is
94 112 // exactly how the enum on /setup-wizard/migrated-plugins and the one on
95 113 // /seo-analytics/dashboard came to be inert while the route next door
@@ -146,9 +164,15 @@
146 164 'permission_callback' => [$this, 'check_settings_permissions'],
147 165 'args' => [
148 166 'ai_provider' => [
149 167 'type' => 'string',
168 + // Includes '' (Settings::AI_PROVIDER_NONE) so a client can
169 + // clear the selection, not just switch between providers.
170 + 'enum' => \ThinkRank\Core\Settings::selectable_ai_providers(),
150 171 'sanitize_callback' => 'sanitize_key',
172 + // The enum is inert without this: has_valid_params() skips
173 + // an arg entirely unless a validate_callback is set (#394).
174 + 'validate_callback' => 'rest_validate_request_arg',
151 175 ],
152 176 'openai_api_key' => [
153 177 'type' => 'string',
154 178 'sanitize_callback' => 'sanitize_text_field',
@@ -180,8 +204,60 @@
180 204 'openrouter_model' => [
181 205 'type' => 'string',
182 206 'sanitize_callback' => 'sanitize_text_field',
183 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 + ],
241 + 'max_tokens' => [
242 + 'type' => 'integer',
243 + 'minimum' => 1,
244 + 'maximum' => 32000,
245 + 'sanitize_callback' => 'absint',
246 + 'validate_callback' => 'rest_validate_request_arg',
247 + ],
248 + 'temperature' => [
249 + 'type' => 'number',
250 + 'minimum' => 0,
251 + 'maximum' => 2,
252 + 'validate_callback' => 'rest_validate_request_arg',
253 + ],
254 + 'cache_duration' => [
255 + 'type' => 'integer',
256 + 'minimum' => 0,
257 + 'sanitize_callback' => 'absint',
258 + 'validate_callback' => 'rest_validate_request_arg',
259 + ],
184 260 'keep_data_on_uninstall' => [
185 261 'type' => 'boolean',
186 262 'sanitize_callback' => 'rest_sanitize_boolean',
187 263 ],
@@ -192,8 +268,32 @@
192 268 'enable_migration_tools' => [
193 269 'type' => 'boolean',
194 270 'sanitize_callback' => 'rest_sanitize_boolean',
195 271 ],
272 + 'enable_import_export' => [
273 + 'type' => 'boolean',
274 + 'sanitize_callback' => 'rest_sanitize_boolean',
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 + ],
196 296 ],
197 297 ]);
198 298
199 299
@@ -446,11 +546,50 @@
446 546 'required' => false,
447 547 'default' => 'openai',
448 548 'sanitize_callback' => 'sanitize_key',
449 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 + ],
450 568 ],
451 569 ]);
452 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 +
453 592 register_rest_route(self::NAMESPACE, '/ai/providers', [
454 593 'methods' => 'GET',
455 594 'callback' => [$this, 'get_ai_providers'],
456 595 'permission_callback' => [$this, 'check_basic_permissions'],
@@ -620,8 +759,17 @@
620 759 $enabled = (bool) $settings->get('enable_rate_limiting', true);
621 760 if (!$enabled) {
622 761 return true;
623 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 + }
624 772 $now = time();
625 773 $window = 60;
626 774 $key = 'thinkrank_rl_' . md5($bucket_id);
627 775 $bucket = get_transient($key);
@@ -630,9 +778,9 @@
630 778 }
631 779 if ($now - ($bucket['start'] ?? 0) >= $window) {
632 780 $bucket = ['start' => $now, 'count' => 0];
633 781 }
634 - if (($bucket['count'] ?? 0) >= max(1, $limit)) {
782 + if (($bucket['count'] ?? 0) >= $limit) {
635 783 return new \WP_Error('rate_limited', __('Rate limit exceeded. Please wait a moment and try again.', 'thinkrank'), ['status' => 429]);
636 784 }
637 785 $bucket['count']++;
638 786 set_transient($key, $bucket, $window);
@@ -765,9 +913,9 @@
765 913 // Use Settings class for consistent access (handles decryption automatically)
766 914 $settings_instance = \ThinkRank\Core\Settings::instance();
767 915
768 916 $settings = [
769 - 'ai_provider' => $settings_instance->get('ai_provider', 'openai'),
917 + 'ai_provider' => $settings_instance->get('ai_provider', \ThinkRank\Core\Settings::AI_PROVIDER_NONE),
770 918 'openai_api_key' => $settings_instance->get('openai_api_key', ''),
771 919 'openai_model' => $settings_instance->get('openai_model', \ThinkRank\Core\Settings::DEFAULT_OPENAI_MODEL),
772 920 'claude_api_key' => $settings_instance->get('claude_api_key', ''),
773 921 'claude_model' => $settings_instance->get('claude_model', \ThinkRank\Core\Settings::DEFAULT_CLAUDE_MODEL),
@@ -774,15 +922,26 @@
774 922 'gemini_api_key' => $settings_instance->get('gemini_api_key', ''),
775 923 'gemini_model' => $settings_instance->get('gemini_model', \ThinkRank\Core\Settings::DEFAULT_GEMINI_MODEL),
776 924 'openrouter_api_key' => $settings_instance->get('openrouter_api_key', ''),
777 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),
778 933 'max_tokens' => $settings_instance->get('max_tokens', 1000),
779 934 'temperature' => $settings_instance->get('temperature', 0.7),
780 935 'cache_duration' => $settings_instance->get('cache_duration', 3600),
781 936 'keep_data_on_uninstall' => (bool) $settings_instance->get('keep_data_on_uninstall', true),
782 937 'enable_migration_tools' => (bool) $settings_instance->get('enable_migration_tools', false),
938 + 'enable_import_export' => (bool) $settings_instance->get('enable_import_export', false),
783 939 'google_account_connected' => (bool) $settings_instance->get('google_account_connected', false),
784 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),
785 944 ];
786 945
787 946
788 947
@@ -799,8 +958,11 @@
799 958 }
800 959 if (!empty($settings['openrouter_api_key'])) {
801 960 $settings['openrouter_api_key'] = $this->mask_ai_api_key($settings['openrouter_api_key']);
802 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 + }
803 965
804 966 return new \WP_REST_Response($settings);
805 967 }
806 968
@@ -838,8 +1000,105 @@
838 1000 // Capture the pre-save MCP state so we can detect an on/off transition
839 1001 // below and mint/revoke the connection token to match (see #244).
840 1002 $mcp_was_enabled = (bool) $settings->get('enable_mcp', false);
841 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 +
842 1101 // Map frontend parameter names to setting keys
843 1102 $settings_map = [
844 1103 'ai_provider' => 'ai_provider',
845 1104 'openai_api_key' => 'openai_api_key',
@@ -849,8 +1108,15 @@
849 1108 'gemini_api_key' => 'gemini_api_key',
850 1109 'gemini_model' => 'gemini_model',
851 1110 'openrouter_api_key' => 'openrouter_api_key',
852 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',
853 1119 'max_tokens' => 'max_tokens',
854 1120 'temperature' => 'temperature',
855 1121 'cache_duration' => 'cache_duration',
856 1122 'keep_data_on_uninstall' => 'keep_data_on_uninstall',
@@ -855,8 +1121,12 @@
855 1121 'cache_duration' => 'cache_duration',
856 1122 'keep_data_on_uninstall' => 'keep_data_on_uninstall',
857 1123 'enable_mcp' => 'enable_mcp',
858 1124 'enable_migration_tools' => 'enable_migration_tools',
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',
859 1129 ];
860 1130
861 1131 // Processing settings save request
862 1132
@@ -864,9 +1134,9 @@
864 1134 if (isset($params[$param_key])) {
865 1135 $value = $params[$param_key];
866 1136
867 1137 // Handle API keys specially - check for masked values
868 - 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)) {
869 1139 // Don't update if the value carries the mask sentinel (the
870 1140 // preview now keeps real head/tail chars around it, so match
871 1141 // anywhere rather than only at the start). Empty still clears.
872 1142 if (strpos($value, '••••••••') !== false) {
@@ -903,9 +1173,9 @@
903 1173 // Auto-dismiss welcome notice if API key was saved
904 1174 $this->maybe_dismiss_welcome_notice($params);
905 1175
906 1176 // Force AI Manager to re-initialize client with new settings
907 - 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'])) {
908 1178 // Clear any cached AI Manager instances to force re-initialization
909 1179 wp_cache_delete('thinkrank_ai_manager', 'thinkrank');
910 1180
911 1181 // If we have an AI Manager instance, force it to re-initialize
@@ -1013,9 +1283,9 @@
1013 1283 // Rate limiting: per user/IP per route
1014 1284 $user_id = get_current_user_id();
1015 1285 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1016 1286 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1017 - $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);
1018 1288 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1019 1289 if (is_wp_error($allowed)) {
1020 1290 return new \WP_REST_Response([
1021 1291 'success' => false,
@@ -1067,9 +1337,9 @@
1067 1337 // Rate limiting: shares the AI generation bucket.
1068 1338 $user_id = get_current_user_id();
1069 1339 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1070 1340 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1071 - $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);
1072 1342 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1073 1343 if (is_wp_error($allowed)) {
1074 1344 return new \WP_REST_Response([
1075 1345 'success' => false,
@@ -1117,9 +1387,9 @@
1117 1387 // Rate limiting: shares the AI generation bucket.
1118 1388 $user_id = get_current_user_id();
1119 1389 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1120 1390 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1121 - $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);
1122 1392 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1123 1393 if (is_wp_error($allowed)) {
1124 1394 return new \WP_REST_Response([
1125 1395 'success' => false,
@@ -1169,9 +1439,9 @@
1169 1439 // Rate limiting: shares the AI generation bucket.
1170 1440 $user_id = get_current_user_id();
1171 1441 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1172 1442 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1173 - $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);
1174 1444 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1175 1445 if (is_wp_error($allowed)) {
1176 1446 return new \WP_REST_Response([
1177 1447 'success' => false,
@@ -1215,9 +1485,9 @@
1215 1485 // Rate limiting: shares the AI generation bucket.
1216 1486 $user_id = get_current_user_id();
1217 1487 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1218 1488 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1219 - $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);
1220 1490 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1221 1491 if (is_wp_error($allowed)) {
1222 1492 return new \WP_REST_Response([
1223 1493 'success' => false,
@@ -1264,9 +1534,9 @@
1264 1534 // Rate limiting: shares the AI generation bucket.
1265 1535 $user_id = get_current_user_id();
1266 1536 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1267 1537 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1268 - $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);
1269 1539 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1270 1540 if (is_wp_error($allowed)) {
1271 1541 return new \WP_REST_Response([
1272 1542 'success' => false,
@@ -1363,9 +1633,9 @@
1363 1633 // Rate limiting: per user/IP per route
1364 1634 $user_id = get_current_user_id();
1365 1635 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1366 1636 $bucket_id = 'ai_test|' . ($user_id ?: $ip);
1367 - $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);
1368 1638 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1369 1639 if (is_wp_error($allowed)) {
1370 1640 return new \WP_REST_Response([
1371 1641 'success' => false,
@@ -1375,9 +1645,50 @@
1375 1645
1376 1646 try {
1377 1647 $api_key = $request->get_param('api_key');
1378 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'));
1379 1654
1655 + // An unrecognised provider used to fall through to the Gemini arm
1656 + // below, so a typo silently tested the wrong provider's key.
1657 + if (!in_array($provider, \ThinkRank\Core\Settings::SUPPORTED_AI_PROVIDERS, true)) {
1658 + return new \WP_REST_Response([
1659 + 'success' => false,
1660 + /* translators: %s: the unrecognised provider value. */
1661 + 'message' => sprintf(__('Unknown AI provider: %s', 'thinkrank'), $provider),
1662 + ], 400);
1663 + }
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 +
1380 1691 // If no API key provided in request, try to get from saved settings
1381 1692 if (empty($api_key)) {
1382 1693 $settings = \ThinkRank\Core\Settings::instance();
1383 1694 if ($provider === 'openai') {
@@ -1399,15 +1710,15 @@
1399 1710 }
1400 1711
1401 1712 // Test the connection with a simple API call
1402 1713 if ($provider === 'openai') {
1403 - $result = $this->test_openai_connection($api_key);
1714 + $result = $this->test_openai_connection($api_key, $model);
1404 1715 } elseif ($provider === 'claude') {
1405 - $result = $this->test_claude_connection($api_key);
1716 + $result = $this->test_claude_connection($api_key, $model);
1406 1717 } elseif ($provider === 'openrouter') {
1407 - $result = $this->test_openrouter_connection($api_key);
1718 + $result = $this->test_openrouter_connection($api_key, $model);
1408 1719 } else {
1409 - $result = $this->test_gemini_connection($api_key);
1720 + $result = $this->test_gemini_connection($api_key, $model);
1410 1721 }
1411 1722
1412 1723 return new \WP_REST_Response($result, $result['success'] ? 200 : 400);
1413 1724 } catch (\Exception $e) {
@@ -1418,14 +1729,378 @@
1418 1729 }
1419 1730 }
1420 1731
1421 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 + /**
1422 2088 * Test OpenAI API connection
1423 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 + *
1424 2094 * @param string $api_key API key to test
2095 + * @param string $model Model id to verify, or '' to use the saved one
1425 2096 * @return array Test result
1426 2097 */
1427 - 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 +
1428 2103 $url = 'https://api.openai.com/v1/models';
1429 2104
1430 2105 $response = wp_remote_get($url, [
1431 2106 'headers' => [
@@ -1447,11 +2122,28 @@
1447 2122
1448 2123 if ($status_code === 200) {
1449 2124 $data = json_decode($body, true);
1450 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 +
1451 2138 return [
1452 2139 'success' => true,
1453 - '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'),
1454 2146 'models_count' => count($data['data']),
1455 2147 ];
1456 2148 }
1457 2149 }
@@ -1469,11 +2161,16 @@
1469 2161 /**
1470 2162 * Test OpenRouter API connection
1471 2163 *
1472 2164 * @param string $api_key API key to test
2165 + * @param string $model Model id to verify, or '' to use the saved one
1473 2166 * @return array Test result
1474 2167 */
1475 - 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 +
1476 2173 // Validate the key format first (OpenRouter keys start with "sk-or-").
1477 2174 if (!str_starts_with($api_key, 'sk-or-')) {
1478 2175 return [
1479 2176 'success' => false,
@@ -1506,11 +2203,25 @@
1506 2203
1507 2204 if ($status_code === 200) {
1508 2205 $data = json_decode($body, true);
1509 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 +
1510 2216 return [
1511 2217 'success' => true,
1512 - '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'),
1513 2224 ];
1514 2225 }
1515 2226 }
1516 2227
@@ -1524,14 +2235,58 @@
1524 2235 ];
1525 2236 }
1526 2237
1527 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 + /**
1528 2282 * Test Claude API connection
1529 2283 *
1530 2284 * @param string $api_key API key to test
2285 + * @param string $model Model id to verify, or '' to use the saved one
1531 2286 * @return array Test result
1532 2287 */
1533 - private function test_claude_connection(string $api_key): array {
2288 + private function test_claude_connection(string $api_key, string $model = ''): array {
1534 2289 // First validate the key format
1535 2290 if (!str_starts_with($api_key, 'sk-ant-')) {
1536 2291 return [
1537 2292 'success' => false,
@@ -1541,12 +2296,18 @@
1541 2296
1542 2297 // Test with a simple API call
1543 2298 $url = 'https://api.anthropic.com/v1/messages';
1544 2299
1545 - // Get the configured Claude model, with fallback to a current model.
1546 - // Self-heal retired/unavailable IDs saved by earlier versions.
1547 - $claude_model = \ThinkRank\Core\Settings::instance()->get('claude_model', \ThinkRank\Core\Settings::DEFAULT_CLAUDE_MODEL);
1548 - $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 + }
1549 2310
1550 2311 $body = [
1551 2312 'model' => $claude_model,
1552 2313 'max_tokens' => 10,
@@ -1580,16 +2341,32 @@
1580 2341
1581 2342 if ($status_code === 200) {
1582 2343 return [
1583 2344 'success' => true,
1584 - '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),
1585 2349 ];
1586 2350 } else {
1587 2351 $error_data = json_decode($response_body, true);
1588 2352 $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
1589 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 +
1590 2366 return [
1591 2367 'success' => false,
2368 + 'model' => $claude_model,
1592 2369 /* translators: %1$d: HTTP status code, %2$s: error message from Claude API */
1593 2370 'message' => sprintf(__('Claude API error (%1$d): %2$s', 'thinkrank'), $status_code, $error_message),
1594 2371 ];
1595 2372 }
@@ -1598,15 +2375,26 @@
1598 2375 /**
1599 2376 * Test Gemini API connection
1600 2377 *
1601 2378 * @param string $api_key API key to test
2379 + * @param string $model Model id to verify, or '' to use the saved one
1602 2380 * @return array Test result
1603 2381 */
1604 - private function test_gemini_connection(string $api_key): array {
2382 + private function test_gemini_connection(string $api_key, string $model = ''): array {
1605 2383 // Test with a simple API call
1606 - $gemini_model = \ThinkRank\Core\Settings::instance()->get('gemini_model', \ThinkRank\Core\Settings::DEFAULT_GEMINI_MODEL);
1607 - $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);
1608 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 +
1609 2397 $body = [
1610 2398 'contents' => [
1611 2399 [
1612 2400 'parts' => [
@@ -1640,16 +2428,32 @@
1640 2428
1641 2429 if ($status_code === 200) {
1642 2430 return [
1643 2431 'success' => true,
1644 - '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),
1645 2436 ];
1646 2437 } else {
1647 2438 $error_data = json_decode($response_body, true);
1648 2439 $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
1649 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 +
1650 2453 return [
1651 2454 'success' => false,
2455 + 'model' => $gemini_model,
1652 2456 /* translators: %1$d: HTTP status code, %2$s: error message from Gemini API */
1653 2457 'message' => sprintf(__('Gemini API error (%1$d): %2$s', 'thinkrank'), $status_code, $error_message),
1654 2458 ];
1655 2459 }
@@ -1677,8 +2481,14 @@
1677 2481 public function get_ai_status(\WP_REST_Request $request): \WP_REST_Response {
1678 2482 $ai_manager = new \ThinkRank\AI\Manager();
1679 2483 $status = $ai_manager->get_provider_status();
1680 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 +
1681 2491 return new \WP_REST_Response($status);
1682 2492 }
1683 2493
1684 2494 /**
@@ -1696,9 +2506,9 @@
1696 2506 // Rate limiting: per user/IP per route
1697 2507 $user_id = get_current_user_id();
1698 2508 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1699 2509 $bucket_id = 'ai_analyze|' . ($user_id ?: $ip);
1700 - $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);
1701 2511 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1702 2512 if (is_wp_error($allowed)) {
1703 2513 return new \WP_REST_Response([
1704 2514 'success' => false,
@@ -1779,15 +2589,8 @@
1779 2589 // Failed to register AI Insights endpoint
1780 2590 }
1781 2591
1782 2592 try {
1783 - $brand_visibility_endpoint = new Brand_Visibility_Endpoint();
1784 - $brand_visibility_endpoint->register_routes();
1785 - } catch (\Exception $e) {
1786 - // Failed to register Brand Visibility endpoint
1787 - }
1788 -
1789 - try {
1790 2593 $performance_endpoint = new Performance_Endpoint();
1791 2594 $performance_endpoint->register_routes();
1792 2595 } catch (\Exception $e) {
1793 2596 // Failed to register Performance endpoint
@@ -1856,8 +2659,24 @@
1856 2659 // Failed to register Global SEO endpoint
1857 2660 }
1858 2661
1859 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 {
1860 2679 $image_seo_endpoint = new Image_SEO_Endpoint();
1861 2680 $image_seo_endpoint->register_routes();
1862 2681 } catch (\Exception $e) {
1863 2682 // Failed to register Image SEO endpoint
@@ -1863,12 +2682,40 @@
1863 2682 // Failed to register Image SEO endpoint
1864 2683 }
1865 2684
1866 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
2690 + }
2691 +
2692 + // Import_Controller is deliberately NOT gated on enable_migration_tools.
2693 + // /import/detect backs the setup wizard's migration step and the record
2694 + // count on Settings > Import / Export, and /import/snapshot + /migrate
2695 + // run the wizard's actual import — all on a fresh install, where the
2696 + // setting is off. Gating them would break onboarding, which is a worse
2697 + // bug than the one #583 reports.
2698 + try {
1867 2699 $import_controller = new Import_Controller();
1868 2700 $import_controller->register_routes();
1869 2701 } catch (\Exception $e) {
1870 2702 // Failed to register Import endpoint
2703 + }
2704 +
2705 + // Export/restore is gated on the setting that gates its admin screen,
2706 + // so turning Import / Export off removes its REST surface along with
2707 + // its menu item (#583). Nothing in the setup wizard calls these:
2708 + // MigrationPluginRow takes startExport/startMigration/cancel from
2709 + // useImportWorkflow and never uploadFile. rest_api_init runs per
2710 + // request, so a toggle takes effect on the next one — no flush.
2711 + if ((bool) \ThinkRank\Core\Settings::instance()->get('enable_import_export', false)) {
2712 + try {
2713 + $export_controller = new Export_Controller();
2714 + $export_controller->register_routes();
2715 + } catch (\Exception $e) {
2716 + // Failed to register Export endpoint
2717 + }
1871 2718 }
1872 2719
1873 2720 try {
1874 2721 $setup_wizard_endpoint = new Setup_Wizard_Endpoint();