PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.0
2.14.0 2.13.0 2.12.0 2.11.0 2.10.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 All 55 releases
← All changes | includes/api/class-manager.php +964 -39 2.0.2 → 2.14.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
@@ -420,9 +520,9 @@
420 520
421 521 register_rest_route(self::NAMESPACE, '/schema/enable-for-post', [
422 522 'methods' => 'POST',
423 523 'callback' => [$this, 'enable_schema_for_post'],
424 - 'permission_callback' => [$this, 'check_admin_permissions'],
524 + 'permission_callback' => [$this, 'check_schema_permissions'],
425 525 'args' => [
426 526 'post_id' => [
427 527 'type' => 'integer',
428 528 'required' => true,
@@ -433,9 +533,9 @@
433 533
434 534 register_rest_route(self::NAMESPACE, '/ai/test-connection', [
435 535 'methods' => 'POST',
436 536 'callback' => [$this, 'test_ai_connection'],
437 - 'permission_callback' => [$this, 'check_admin_permissions'],
537 + 'permission_callback' => [$this, 'check_ai_tools_permissions'],
438 538 'args' => [
439 539 'api_key' => [
440 540 'type' => 'string',
441 541 'required' => false,
@@ -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_ai_tools_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);
@@ -696,8 +844,70 @@
696 844 return true;
697 845 }
698 846
699 847 /**
848 + * Check Schema Manager section permissions.
849 + *
850 + * Delegable via Role Manager: route_map() maps the `schema` prefix to
851 + * thinkrank_schema. /schema/enable-for-post is what the editor's "enable
852 + * structured data" suggestion posts to, so gating it on manage_options made
853 + * that button fail for exactly the roles the Schema grant was meant to
854 + * serve (#844).
855 + *
856 + * @param \WP_REST_Request $request Request object
857 + * @return bool|\WP_Error Permission status
858 + */
859 + public function check_schema_permissions(\WP_REST_Request $request) {
860 + return $this->check_mapped_capability('thinkrank_schema');
861 + }
862 +
863 + /**
864 + * Check AI Tools section permissions.
865 + *
866 + * Delegable via Role Manager: route_map() maps the `ai` prefix to
867 + * thinkrank_content_tools. Testing a provider and listing its models are
868 + * configuration reads that report whether the stored key works; neither
869 + * returns the key.
870 + *
871 + * @param \WP_REST_Request $request Request object
872 + * @return bool|\WP_Error Permission status
873 + */
874 + public function check_ai_tools_permissions(\WP_REST_Request $request) {
875 + return $this->check_mapped_capability('thinkrank_content_tools');
876 + }
877 +
878 + /**
879 + * Logged in, and holding a ThinkRank capability from the map.
880 + *
881 + * One body for the per-section callbacks above so they cannot drift apart
882 + * the way the hardcoded manage_options checks drifted from the map.
883 + * Administrators are unaffected: Role_Manager grants every ThinkRank
884 + * capability to manage_options holders through `user_has_cap`.
885 + *
886 + * @param string $capability ThinkRank capability slug.
887 + * @return bool|\WP_Error Permission status
888 + */
889 + private function check_mapped_capability(string $capability) {
890 + if (!is_user_logged_in()) {
891 + return new \WP_Error(
892 + 'rest_forbidden',
893 + __('You must be logged in to access this endpoint.', 'thinkrank'),
894 + ['status' => 401]
895 + );
896 + }
897 +
898 + if (!\ThinkRank\Core\Capability_Manager::current_user_can($capability)) {
899 + return new \WP_Error(
900 + 'rest_forbidden',
901 + __('You do not have permission to access this ThinkRank feature.', 'thinkrank'),
902 + ['status' => 403]
903 + );
904 + }
905 +
906 + return true;
907 + }
908 +
909 + /**
700 910 * Get user capabilities
701 911 *
702 912 * @param \WP_REST_Request $request Request object
703 913 * @return \WP_REST_Response Response object
@@ -765,9 +975,9 @@
765 975 // Use Settings class for consistent access (handles decryption automatically)
766 976 $settings_instance = \ThinkRank\Core\Settings::instance();
767 977
768 978 $settings = [
769 - 'ai_provider' => $settings_instance->get('ai_provider', 'openai'),
979 + 'ai_provider' => $settings_instance->get('ai_provider', \ThinkRank\Core\Settings::AI_PROVIDER_NONE),
770 980 'openai_api_key' => $settings_instance->get('openai_api_key', ''),
771 981 'openai_model' => $settings_instance->get('openai_model', \ThinkRank\Core\Settings::DEFAULT_OPENAI_MODEL),
772 982 'claude_api_key' => $settings_instance->get('claude_api_key', ''),
773 983 'claude_model' => $settings_instance->get('claude_model', \ThinkRank\Core\Settings::DEFAULT_CLAUDE_MODEL),
@@ -774,15 +984,26 @@
774 984 'gemini_api_key' => $settings_instance->get('gemini_api_key', ''),
775 985 'gemini_model' => $settings_instance->get('gemini_model', \ThinkRank\Core\Settings::DEFAULT_GEMINI_MODEL),
776 986 'openrouter_api_key' => $settings_instance->get('openrouter_api_key', ''),
777 987 'openrouter_model' => $settings_instance->get('openrouter_model', \ThinkRank\Core\Settings::DEFAULT_OPENROUTER_MODEL),
988 + 'openai_compatible_base_url' => $settings_instance->get('openai_compatible_base_url', ''),
989 + 'openai_compatible_api_key' => $settings_instance->get('openai_compatible_api_key', ''),
990 + 'openai_compatible_model' => $settings_instance->get('openai_compatible_model', ''),
991 + 'openai_compatible_timeout' => (int) $settings_instance->get('openai_compatible_timeout', \ThinkRank\Core\Settings::DEFAULT_OPENAI_COMPATIBLE_TIMEOUT),
992 + 'openai_compatible_supports_images' => (bool) $settings_instance->get('openai_compatible_supports_images', false),
993 + 'openai_compatible_json_mode' => (bool) $settings_instance->get('openai_compatible_json_mode', false),
994 + 'openai_compatible_price_per_million' => (float) $settings_instance->get('openai_compatible_price_per_million', 0),
778 995 'max_tokens' => $settings_instance->get('max_tokens', 1000),
779 996 'temperature' => $settings_instance->get('temperature', 0.7),
780 997 'cache_duration' => $settings_instance->get('cache_duration', 3600),
781 998 'keep_data_on_uninstall' => (bool) $settings_instance->get('keep_data_on_uninstall', true),
782 999 'enable_migration_tools' => (bool) $settings_instance->get('enable_migration_tools', false),
1000 + 'enable_import_export' => (bool) $settings_instance->get('enable_import_export', false),
783 1001 'google_account_connected' => (bool) $settings_instance->get('google_account_connected', false),
784 1002 'enable_mcp' => (bool) $settings_instance->get('enable_mcp', false),
1003 + 'max_requests_per_minute' => (int) $settings_instance->get('max_requests_per_minute', 0),
1004 + 'ai_daily_request_limit' => (int) $settings_instance->get('ai_daily_request_limit', 0),
1005 + 'ai_paused' => (bool) $settings_instance->get('ai_paused', false),
785 1006 ];
786 1007
787 1008
788 1009
@@ -799,8 +1020,11 @@
799 1020 }
800 1021 if (!empty($settings['openrouter_api_key'])) {
801 1022 $settings['openrouter_api_key'] = $this->mask_ai_api_key($settings['openrouter_api_key']);
802 1023 }
1024 + if (!empty($settings['openai_compatible_api_key'])) {
1025 + $settings['openai_compatible_api_key'] = $this->mask_ai_api_key($settings['openai_compatible_api_key']);
1026 + }
803 1027
804 1028 return new \WP_REST_Response($settings);
805 1029 }
806 1030
@@ -838,8 +1062,105 @@
838 1062 // Capture the pre-save MCP state so we can detect an on/off transition
839 1063 // below and mint/revoke the connection token to match (see #244).
840 1064 $mcp_was_enabled = (bool) $settings->get('enable_mcp', false);
841 1065
1066 + // Pointing the site's AI at an arbitrary host — including loopback and
1067 + // LAN addresses, which this provider deliberately allows — is an
1068 + // administrator's decision, not a delegated one. The settings route
1069 + // itself is delegable through the Role Manager's `thinkrank_settings`
1070 + // capability, so an editor granted "manage ThinkRank settings" could
1071 + // otherwise aim server-side requests (with an Authorization header of
1072 + // their choosing) at internal services. Every other field on this route
1073 + // stays delegable; only these are held back (#721).
1074 + $endpoint_fields = [
1075 + 'openai_compatible_base_url',
1076 + 'openai_compatible_api_key',
1077 + 'openai_compatible_model',
1078 + 'openai_compatible_timeout',
1079 + 'openai_compatible_supports_images',
1080 + 'openai_compatible_json_mode',
1081 + 'openai_compatible_price_per_million',
1082 + ];
1083 +
1084 + foreach ($endpoint_fields as $endpoint_field) {
1085 + if (!isset($params[$endpoint_field])) {
1086 + continue;
1087 + }
1088 +
1089 + // Only an actual change needs the capability: a client that echoes
1090 + // the whole settings payload back unchanged is not reconfiguring
1091 + // anything, and failing that save would break the Settings screen
1092 + // for delegated users editing an unrelated field.
1093 + //
1094 + // The key needs the mask rule the persistence loop below already
1095 + // uses. GET /settings returns it masked ("sk-pr••••••••abc"), so
1096 + // comparing that against the stored plaintext always differs, and
1097 + // every echoed payload would read as "an administrator changed the
1098 + // key" — locking delegated users out of saving anything at all.
1099 + $submitted = $params[$endpoint_field];
1100 + if (is_string($submitted) && false !== strpos($submitted, '••••••••')) {
1101 + continue;
1102 + }
1103 +
1104 + $stored = $settings->get($endpoint_field);
1105 +
1106 + // Booleans and numbers arrive typed from the REST layer but are
1107 + // stored as '1'/'' and '120'; compare them as the values they are.
1108 + if (is_bool($submitted) || is_bool($stored)) {
1109 + if ((bool) $stored === (bool) $submitted) {
1110 + continue;
1111 + }
1112 + } elseif (is_numeric($submitted) && is_numeric($stored)) {
1113 + if ((float) $stored === (float) $submitted) {
1114 + continue;
1115 + }
1116 + } elseif ((string) $stored === (string) $submitted) {
1117 + continue;
1118 + }
1119 +
1120 + if (!current_user_can('manage_options')) {
1121 + return new \WP_REST_Response([
1122 + 'success' => false,
1123 + 'message' => __('Only an administrator can configure a custom AI endpoint.', 'thinkrank'),
1124 + 'field' => $endpoint_field,
1125 + ], 403);
1126 + }
1127 +
1128 + break;
1129 + }
1130 +
1131 + // Selecting the provider is the same decision by another name.
1132 + if (isset($params['ai_provider'])
1133 + && 'openai_compatible' === $params['ai_provider']
1134 + && 'openai_compatible' !== (string) $settings->get('ai_provider', \ThinkRank\Core\Settings::AI_PROVIDER_NONE)
1135 + && !current_user_can('manage_options')
1136 + ) {
1137 + return new \WP_REST_Response([
1138 + 'success' => false,
1139 + 'message' => __('Only an administrator can configure a custom AI endpoint.', 'thinkrank'),
1140 + 'field' => 'ai_provider',
1141 + ], 403);
1142 + }
1143 +
1144 + // A refused endpoint URL has to say why. Settings::sanitize_setting()
1145 + // stores '' for one that fails validation — right, since an unvalidated
1146 + // URL must never become a URL we fetch — but silent, so the user would
1147 + // see "Settings saved" and an endpoint that vanished. Validate here,
1148 + // where the reason can be returned, and reject the whole save: a
1149 + // half-applied AI provider is worse than none (#721).
1150 + if (!empty($params['openai_compatible_base_url'])) {
1151 + $validated_base_url = \ThinkRank\AI\Endpoint_URL_Validator::validate((string) $params['openai_compatible_base_url']);
1152 + if (is_wp_error($validated_base_url)) {
1153 + return new \WP_REST_Response([
1154 + 'success' => false,
1155 + 'message' => $validated_base_url->get_error_message(),
1156 + 'field' => 'openai_compatible_base_url',
1157 + ], 400);
1158 + }
1159 +
1160 + $params['openai_compatible_base_url'] = $validated_base_url;
1161 + }
1162 +
842 1163 // Map frontend parameter names to setting keys
843 1164 $settings_map = [
844 1165 'ai_provider' => 'ai_provider',
845 1166 'openai_api_key' => 'openai_api_key',
@@ -849,8 +1170,15 @@
849 1170 'gemini_api_key' => 'gemini_api_key',
850 1171 'gemini_model' => 'gemini_model',
851 1172 'openrouter_api_key' => 'openrouter_api_key',
852 1173 'openrouter_model' => 'openrouter_model',
1174 + 'openai_compatible_base_url' => 'openai_compatible_base_url',
1175 + 'openai_compatible_api_key' => 'openai_compatible_api_key',
1176 + 'openai_compatible_model' => 'openai_compatible_model',
1177 + 'openai_compatible_timeout' => 'openai_compatible_timeout',
1178 + 'openai_compatible_supports_images' => 'openai_compatible_supports_images',
1179 + 'openai_compatible_json_mode' => 'openai_compatible_json_mode',
1180 + 'openai_compatible_price_per_million' => 'openai_compatible_price_per_million',
853 1181 'max_tokens' => 'max_tokens',
854 1182 'temperature' => 'temperature',
855 1183 'cache_duration' => 'cache_duration',
856 1184 'keep_data_on_uninstall' => 'keep_data_on_uninstall',
@@ -855,8 +1183,12 @@
855 1183 'cache_duration' => 'cache_duration',
856 1184 'keep_data_on_uninstall' => 'keep_data_on_uninstall',
857 1185 'enable_mcp' => 'enable_mcp',
858 1186 'enable_migration_tools' => 'enable_migration_tools',
1187 + 'enable_import_export' => 'enable_import_export',
1188 + 'max_requests_per_minute' => 'max_requests_per_minute',
1189 + 'ai_daily_request_limit' => 'ai_daily_request_limit',
1190 + 'ai_paused' => 'ai_paused',
859 1191 ];
860 1192
861 1193 // Processing settings save request
862 1194
@@ -864,9 +1196,9 @@
864 1196 if (isset($params[$param_key])) {
865 1197 $value = $params[$param_key];
866 1198
867 1199 // 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)) {
1200 + if (in_array($param_key, ['openai_api_key', 'claude_api_key', 'gemini_api_key', 'openrouter_api_key', 'openai_compatible_api_key'], true)) {
869 1201 // Don't update if the value carries the mask sentinel (the
870 1202 // preview now keeps real head/tail chars around it, so match
871 1203 // anywhere rather than only at the start). Empty still clears.
872 1204 if (strpos($value, '••••••••') !== false) {
@@ -903,9 +1235,9 @@
903 1235 // Auto-dismiss welcome notice if API key was saved
904 1236 $this->maybe_dismiss_welcome_notice($params);
905 1237
906 1238 // 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'])) {
1239 + 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 1240 // Clear any cached AI Manager instances to force re-initialization
909 1241 wp_cache_delete('thinkrank_ai_manager', 'thinkrank');
910 1242
911 1243 // If we have an AI Manager instance, force it to re-initialize
@@ -1013,9 +1345,9 @@
1013 1345 // Rate limiting: per user/IP per route
1014 1346 $user_id = get_current_user_id();
1015 1347 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1016 1348 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1017 - $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10);
1349 + $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 0);
1018 1350 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1019 1351 if (is_wp_error($allowed)) {
1020 1352 return new \WP_REST_Response([
1021 1353 'success' => false,
@@ -1067,9 +1399,9 @@
1067 1399 // Rate limiting: shares the AI generation bucket.
1068 1400 $user_id = get_current_user_id();
1069 1401 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1070 1402 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1071 - $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10);
1403 + $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 0);
1072 1404 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1073 1405 if (is_wp_error($allowed)) {
1074 1406 return new \WP_REST_Response([
1075 1407 'success' => false,
@@ -1117,9 +1449,9 @@
1117 1449 // Rate limiting: shares the AI generation bucket.
1118 1450 $user_id = get_current_user_id();
1119 1451 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1120 1452 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1121 - $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10);
1453 + $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 0);
1122 1454 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1123 1455 if (is_wp_error($allowed)) {
1124 1456 return new \WP_REST_Response([
1125 1457 'success' => false,
@@ -1169,9 +1501,9 @@
1169 1501 // Rate limiting: shares the AI generation bucket.
1170 1502 $user_id = get_current_user_id();
1171 1503 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1172 1504 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1173 - $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10);
1505 + $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 0);
1174 1506 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1175 1507 if (is_wp_error($allowed)) {
1176 1508 return new \WP_REST_Response([
1177 1509 'success' => false,
@@ -1215,9 +1547,9 @@
1215 1547 // Rate limiting: shares the AI generation bucket.
1216 1548 $user_id = get_current_user_id();
1217 1549 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1218 1550 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1219 - $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10);
1551 + $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 0);
1220 1552 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1221 1553 if (is_wp_error($allowed)) {
1222 1554 return new \WP_REST_Response([
1223 1555 'success' => false,
@@ -1264,9 +1596,9 @@
1264 1596 // Rate limiting: shares the AI generation bucket.
1265 1597 $user_id = get_current_user_id();
1266 1598 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1267 1599 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1268 - $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10);
1600 + $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 0);
1269 1601 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1270 1602 if (is_wp_error($allowed)) {
1271 1603 return new \WP_REST_Response([
1272 1604 'success' => false,
@@ -1363,9 +1695,9 @@
1363 1695 // Rate limiting: per user/IP per route
1364 1696 $user_id = get_current_user_id();
1365 1697 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1366 1698 $bucket_id = 'ai_test|' . ($user_id ?: $ip);
1367 - $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10);
1699 + $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 0);
1368 1700 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1369 1701 if (is_wp_error($allowed)) {
1370 1702 return new \WP_REST_Response([
1371 1703 'success' => false,
@@ -1375,9 +1707,50 @@
1375 1707
1376 1708 try {
1377 1709 $api_key = $request->get_param('api_key');
1378 1710 $provider = $request->get_param('provider') ?: 'openai';
1711 + // The model the caller is asking about. Empty means "whatever is
1712 + // saved" — the settings screen sends the model currently on screen
1713 + // so an unsaved pick or a hand-typed id is what actually gets
1714 + // tested, rather than the last saved one.
1715 + $model = trim((string) $request->get_param('model'));
1379 1716
1717 + // An unrecognised provider used to fall through to the Gemini arm
1718 + // below, so a typo silently tested the wrong provider's key.
1719 + if (!in_array($provider, \ThinkRank\Core\Settings::SUPPORTED_AI_PROVIDERS, true)) {
1720 + return new \WP_REST_Response([
1721 + 'success' => false,
1722 + /* translators: %s: the unrecognised provider value. */
1723 + 'message' => sprintf(__('Unknown AI provider: %s', 'thinkrank'), $provider),
1724 + ], 400);
1725 + }
1726 +
1727 + // The OpenAI-compatible endpoint is tested by URL, not by key: a
1728 + // local Ollama or LM Studio server wants no key, so the key checks
1729 + // below would refuse to test a perfectly good endpoint (#721).
1730 + if ('openai_compatible' === $provider) {
1731 + $base_url = trim((string) $request->get_param('base_url'));
1732 + if ('' === $base_url) {
1733 + $base_url = (string) \ThinkRank\Core\Settings::instance()->get('openai_compatible_base_url', '');
1734 + }
1735 +
1736 + if (empty($api_key)) {
1737 + $api_key = (string) \ThinkRank\Core\Settings::instance()->get('openai_compatible_api_key', '');
1738 + }
1739 +
1740 + if ('' === $model) {
1741 + $model = trim((string) \ThinkRank\Core\Settings::instance()->get('openai_compatible_model', ''));
1742 + }
1743 +
1744 + $json_mode = $request->has_param('json_mode')
1745 + ? (bool) $request->get_param('json_mode')
1746 + : (bool) \ThinkRank\Core\Settings::instance()->get('openai_compatible_json_mode', false);
1747 +
1748 + $result = $this->test_openai_compatible_connection($base_url, $api_key, $model, $json_mode);
1749 +
1750 + return new \WP_REST_Response($result, $result['success'] ? 200 : 400);
1751 + }
1752 +
1380 1753 // If no API key provided in request, try to get from saved settings
1381 1754 if (empty($api_key)) {
1382 1755 $settings = \ThinkRank\Core\Settings::instance();
1383 1756 if ($provider === 'openai') {
@@ -1399,15 +1772,15 @@
1399 1772 }
1400 1773
1401 1774 // Test the connection with a simple API call
1402 1775 if ($provider === 'openai') {
1403 - $result = $this->test_openai_connection($api_key);
1776 + $result = $this->test_openai_connection($api_key, $model);
1404 1777 } elseif ($provider === 'claude') {
1405 - $result = $this->test_claude_connection($api_key);
1778 + $result = $this->test_claude_connection($api_key, $model);
1406 1779 } elseif ($provider === 'openrouter') {
1407 - $result = $this->test_openrouter_connection($api_key);
1780 + $result = $this->test_openrouter_connection($api_key, $model);
1408 1781 } else {
1409 - $result = $this->test_gemini_connection($api_key);
1782 + $result = $this->test_gemini_connection($api_key, $model);
1410 1783 }
1411 1784
1412 1785 return new \WP_REST_Response($result, $result['success'] ? 200 : 400);
1413 1786 } catch (\Exception $e) {
@@ -1418,14 +1791,378 @@
1418 1791 }
1419 1792 }
1420 1793
1421 1794 /**
1795 + * Test an OpenAI-compatible endpoint with a real (tiny) completion.
1796 + *
1797 + * Deliberately not a GET /models probe: a server can list models and still
1798 + * fail to complete (wrong model id, model not pulled, gateway that only
1799 + * proxies /models). The one-token chat completion answers the question the
1800 + * user is actually asking — "can ThinkRank generate with this?" — and its
1801 + * reply plus latency is what the settings screen shows (#721).
1802 + *
1803 + * @since 2.8.0
1804 + *
1805 + * @param string $base_url Base URL as typed (validated here).
1806 + * @param string $api_key Optional API key.
1807 + * @param string $model Model id to complete with.
1808 + * @param bool $json_mode Also check the server accepts response_format json_object.
1809 + * @return array Test result.
1810 + */
1811 + private function test_openai_compatible_connection(string $base_url, string $api_key, string $model, bool $json_mode = false): array {
1812 + $validated = \ThinkRank\AI\Endpoint_URL_Validator::validate($base_url);
1813 + if (is_wp_error($validated)) {
1814 + return [
1815 + 'success' => false,
1816 + 'message' => $validated->get_error_message(),
1817 + ];
1818 + }
1819 +
1820 + if ('' === trim($model)) {
1821 + return [
1822 + 'success' => false,
1823 + 'message' => __('Enter the model id your endpoint should use, for example llama3.1 or gpt-4o.', 'thinkrank'),
1824 + ];
1825 + }
1826 +
1827 + $headers = ['Content-Type' => 'application/json'];
1828 + if ('' !== $api_key) {
1829 + $headers['Authorization'] = 'Bearer ' . $api_key;
1830 + $headers['api-key'] = $api_key;
1831 + }
1832 +
1833 + // A "Reply with OK" answer is tiny; anything approaching this is a
1834 + // server misbehaving, and the guard caps it before it is buffered.
1835 + $max_bytes = 131072;
1836 +
1837 + $started = microtime(true);
1838 +
1839 + $response = \ThinkRank\AI\Endpoint_URL_Validator::guarded_request(\ThinkRank\AI\Endpoint_URL_Validator::route($validated, 'chat/completions'), [
1840 + 'method' => 'POST',
1841 + // Long enough for a cold local model to load its weights, short
1842 + // enough that a wrong URL does not hang the settings screen.
1843 + 'timeout' => 30,
1844 + 'headers' => $headers,
1845 + 'limit_response_size' => $max_bytes,
1846 + 'body' => wp_json_encode([
1847 + 'model' => trim($model),
1848 + 'messages' => [['role' => 'user', 'content' => 'Reply with OK']],
1849 + // Not 16: a local reasoning model (deepseek-r1, a qwen3
1850 + // thinking build) spends its first tokens on hidden reasoning
1851 + // and returns empty content if the budget runs out there, which
1852 + // would report a working endpoint as broken.
1853 + 'max_tokens' => 128,
1854 + ]),
1855 + ]);
1856 +
1857 + $latency_ms = (int) round((microtime(true) - $started) * 1000);
1858 +
1859 + if (is_wp_error($response)) {
1860 + return [
1861 + 'success' => false,
1862 + /* translators: %s: transport error, e.g. "cURL error 7: Connection refused". */
1863 + 'message' => sprintf(__('Could not reach the endpoint: %s', 'thinkrank'), $response->get_error_message()),
1864 + ];
1865 + }
1866 +
1867 + $status = (int) wp_remote_retrieve_response_code($response);
1868 + $raw_body = wp_remote_retrieve_body($response);
1869 +
1870 + // Redirects are never followed (the key would go wherever the endpoint
1871 + // points). Say so, rather than letting the empty 3xx body read as a
1872 + // wrong model id: an http-to-https upgrade is the usual cause.
1873 + if ($status >= 300 && $status < 400) {
1874 + $location = (string) wp_remote_retrieve_header($response, 'location');
1875 +
1876 + return [
1877 + 'success' => false,
1878 + 'status' => $status,
1879 + 'message' => '' !== $location
1880 + ? sprintf(
1881 + /* translators: 1: HTTP status code, 2: the URL the endpoint redirected to. */
1882 + __('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'),
1883 + $status,
1884 + esc_url_raw($location)
1885 + )
1886 + : sprintf(
1887 + /* translators: %d: HTTP status code. */
1888 + __('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'),
1889 + $status
1890 + ),
1891 + ];
1892 + }
1893 +
1894 + // A body that reached the cap was cut mid-JSON. Report that, not a
1895 + // missing completion: the model id was never the problem.
1896 + if (strlen($raw_body) >= $max_bytes) {
1897 + return [
1898 + 'success' => false,
1899 + 'status' => $status,
1900 + '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'),
1901 + ];
1902 + }
1903 +
1904 + $body = json_decode($raw_body, true);
1905 +
1906 + if ($status >= 400) {
1907 + $error = '';
1908 + if (is_array($body)) {
1909 + $error = (string) ($body['error']['message'] ?? ($body['error'] ?? ($body['message'] ?? '')));
1910 + }
1911 + if ('' === $error) {
1912 + $error = wp_remote_retrieve_response_message($response);
1913 + }
1914 +
1915 + return [
1916 + 'success' => false,
1917 + 'status' => $status,
1918 + /* translators: 1: HTTP status code, 2: error message from the server. */
1919 + 'message' => sprintf(__('The endpoint answered %1$d: %2$s', 'thinkrank'), $status, $error),
1920 + ];
1921 + }
1922 +
1923 + $reply = '';
1924 + $reasoning_only = false;
1925 + if (is_array($body)) {
1926 + $message = is_array($body['choices'][0]['message'] ?? null) ? $body['choices'][0]['message'] : [];
1927 + $reply = trim((string) ($message['content'] ?? ''));
1928 +
1929 + // Ollama and vLLM expose a thinking model's hidden reasoning
1930 + // separately. Reasoning with no content still proves the endpoint
1931 + // and the model work — it means the model thinks before answering,
1932 + // which is worth saying out loud because it makes every generation
1933 + // slower.
1934 + if ('' === $reply) {
1935 + $reasoning = trim((string) ($message['reasoning'] ?? ($message['reasoning_content'] ?? '')));
1936 + if ('' !== $reasoning) {
1937 + $reply = $reasoning;
1938 + $reasoning_only = true;
1939 + }
1940 + }
1941 + }
1942 +
1943 + if ('' === $reply) {
1944 + return [
1945 + 'success' => false,
1946 + 'status' => $status,
1947 + 'message' => __('The endpoint replied, but with no completion text. Check that the model id is one this server serves.', 'thinkrank'),
1948 + ];
1949 + }
1950 +
1951 + $result = [
1952 + 'success' => true,
1953 + 'model' => trim($model),
1954 + 'model_available' => true,
1955 + 'latency_ms' => $latency_ms,
1956 + 'reply' => mb_substr($reply, 0, 200),
1957 + 'reasoning_only' => $reasoning_only,
1958 + 'message' => $reasoning_only
1959 + ? sprintf(
1960 + /* translators: 1: model id, 2: latency in milliseconds. */
1961 + __('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'),
1962 + trim($model),
1963 + $latency_ms
1964 + )
1965 + : sprintf(
1966 + /* translators: 1: model id, 2: latency in milliseconds, 3: the model's reply. */
1967 + __('Connected: "%1$s" replied in %2$d ms: %3$s', 'thinkrank'),
1968 + trim($model),
1969 + $latency_ms,
1970 + mb_substr($reply, 0, 80)
1971 + ),
1972 + ];
1973 +
1974 + return $json_mode
1975 + ? $this->probe_openai_compatible_json_mode($validated, $headers, trim($model), $result)
1976 + : $result;
1977 + }
1978 +
1979 + /**
1980 + * Check that an endpoint takes response_format json_object.
1981 + *
1982 + * Runs only after the plain completion worked, so a failure here can mean
1983 + * one thing: the endpoint works, but not with "Force valid JSON answers"
1984 + * on. Generation still works then, because OpenAI_Client falls back to a
1985 + * plain request, but every JSON call pays a failed round trip first. The
1986 + * connection stays a success; the result carries json_mode_supported so
1987 + * the screen can warn instead of reporting a broken endpoint.
1988 + *
1989 + * @since 2.8.0
1990 + *
1991 + * @param string $base_url Validated base URL.
1992 + * @param array $headers Request headers, key included.
1993 + * @param string $model Model id.
1994 + * @param array $result Successful connection result to extend.
1995 + * @return array The result, with json_mode_supported and, when false, a warning message.
1996 + */
1997 + private function probe_openai_compatible_json_mode(string $base_url, array $headers, string $model, array $result): array {
1998 + $response = \ThinkRank\AI\Endpoint_URL_Validator::guarded_request(\ThinkRank\AI\Endpoint_URL_Validator::route($base_url, 'chat/completions'), [
1999 + 'method' => 'POST',
2000 + 'timeout' => 30,
2001 + 'headers' => $headers,
2002 + 'limit_response_size' => 131072,
2003 + 'body' => wp_json_encode([
2004 + 'model' => $model,
2005 + // OpenAI refuses json_object unless the messages mention JSON.
2006 + 'messages' => [['role' => 'user', 'content' => 'Reply with the JSON object {"ok": true}']],
2007 + 'max_tokens' => 128,
2008 + 'response_format' => ['type' => 'json_object'],
2009 + ]),
2010 + ]);
2011 +
2012 + // A timeout or dropped connection says nothing about JSON mode. Leave
2013 + // the result alone rather than warn about a field that was never judged.
2014 + if (is_wp_error($response)) {
2015 + return $result;
2016 + }
2017 +
2018 + $status = (int) wp_remote_retrieve_response_code($response);
2019 + if ($status < 400) {
2020 + $result['json_mode_supported'] = true;
2021 + return $result;
2022 + }
2023 +
2024 + $body = json_decode((string) wp_remote_retrieve_body($response), true);
2025 + $error = '';
2026 + if (is_array($body)) {
2027 + // OpenAI and vLLM nest the text under error.message; Ollama sends a bare error string.
2028 + $error = is_string($body['error'] ?? null)
2029 + ? $body['error']
2030 + : (string) ($body['error']['message'] ?? ($body['message'] ?? ''));
2031 + }
2032 + if ('' === $error) {
2033 + $error = (string) wp_remote_retrieve_response_message($response);
2034 + }
2035 +
2036 + $result['json_mode_supported'] = false;
2037 + $result['message'] = sprintf(
2038 + /* translators: 1: model id, 2: HTTP status code, 3: error message from the server. */
2039 + __('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'),
2040 + $model,
2041 + $status,
2042 + $error
2043 + );
2044 +
2045 + return $result;
2046 + }
2047 +
2048 + /**
2049 + * List the models an OpenAI-compatible endpoint serves.
2050 + *
2051 + * @since 2.8.0
2052 + *
2053 + * @param \WP_REST_Request $request Request object.
2054 + * @return \WP_REST_Response Response object.
2055 + */
2056 + public function list_endpoint_models(\WP_REST_Request $request): \WP_REST_Response {
2057 + $settings = \ThinkRank\Core\Settings::instance();
2058 +
2059 + $base_url = trim((string) $request->get_param('base_url'));
2060 + if ('' === $base_url) {
2061 + $base_url = (string) $settings->get('openai_compatible_base_url', '');
2062 + }
2063 +
2064 + $validated = \ThinkRank\AI\Endpoint_URL_Validator::validate($base_url);
2065 + if (is_wp_error($validated)) {
2066 + return new \WP_REST_Response([
2067 + 'success' => false,
2068 + 'message' => $validated->get_error_message(),
2069 + ], 400);
2070 + }
2071 +
2072 + $api_key = trim((string) $request->get_param('api_key'));
2073 + if ('' === $api_key || false !== strpos($api_key, '••••••••')) {
2074 + $api_key = (string) $settings->get('openai_compatible_api_key', '');
2075 + }
2076 +
2077 + $headers = ['Content-Type' => 'application/json'];
2078 + if ('' !== $api_key) {
2079 + $headers['Authorization'] = 'Bearer ' . $api_key;
2080 + $headers['api-key'] = $api_key;
2081 + }
2082 +
2083 + $response = \ThinkRank\AI\Endpoint_URL_Validator::guarded_request(\ThinkRank\AI\Endpoint_URL_Validator::route($validated, 'models'), [
2084 + 'method' => 'GET',
2085 + 'timeout' => 15,
2086 + 'headers' => $headers,
2087 + // A hostile or misconfigured endpoint can answer with an unbounded
2088 + // body; buffering it whole would spend the worker's memory on a
2089 + // list we cap at MAX_ENDPOINT_MODELS anyway.
2090 + 'limit_response_size' => self::MAX_MODELS_RESPONSE_BYTES,
2091 + ]);
2092 +
2093 + if (is_wp_error($response)) {
2094 + return new \WP_REST_Response([
2095 + 'success' => false,
2096 + /* translators: %s: transport error. */
2097 + 'message' => sprintf(__('Could not reach the endpoint: %s', 'thinkrank'), $response->get_error_message()),
2098 + ], 400);
2099 + }
2100 +
2101 + $status = (int) wp_remote_retrieve_response_code($response);
2102 + $body = json_decode(wp_remote_retrieve_body($response), true);
2103 +
2104 + if ($status >= 400 || !is_array($body)) {
2105 + return new \WP_REST_Response([
2106 + 'success' => false,
2107 + /* translators: %d: HTTP status code. */
2108 + 'message' => sprintf(__('This endpoint does not list its models (HTTP %d). Type the model id by hand instead.', 'thinkrank'), $status),
2109 + ], 400);
2110 + }
2111 +
2112 + // OpenAI's shape is {data: [{id: …}]}; some gateways answer a bare list.
2113 + $entries = isset($body['data']) && is_array($body['data']) ? $body['data'] : $body;
2114 + $models = [];
2115 + $truncated = false;
2116 + foreach ($entries as $entry) {
2117 + if (count($models) >= self::MAX_ENDPOINT_MODELS) {
2118 + // A gateway fronting a public catalogue can list thousands of
2119 + // models. Sanitising and sorting all of them is work nobody
2120 + // asked for — the field is a suggestion list, not a registry.
2121 + $truncated = true;
2122 + break;
2123 + }
2124 +
2125 + if (is_array($entry) && !empty($entry['id'])) {
2126 + $models[] = sanitize_text_field((string) $entry['id']);
2127 + } elseif (is_string($entry) && '' !== $entry) {
2128 + $models[] = sanitize_text_field($entry);
2129 + }
2130 + }
2131 +
2132 + $models = array_values(array_unique($models));
2133 + sort($models);
2134 +
2135 + if (empty($models)) {
2136 + return new \WP_REST_Response([
2137 + 'success' => false,
2138 + 'message' => __('The endpoint answered, but listed no models. Type the model id by hand instead.', 'thinkrank'),
2139 + ], 400);
2140 + }
2141 +
2142 + return new \WP_REST_Response([
2143 + 'success' => true,
2144 + 'models' => $models,
2145 + 'truncated' => $truncated,
2146 + ]);
2147 + }
2148 +
2149 + /**
1422 2150 * Test OpenAI API connection
1423 2151 *
2152 + * The models endpoint doubles as the model check: it answers with every id
2153 + * this key may call, so an unknown or unentitled model is caught here
2154 + * instead of at the first real generation.
2155 + *
1424 2156 * @param string $api_key API key to test
2157 + * @param string $model Model id to verify, or '' to use the saved one
1425 2158 * @return array Test result
1426 2159 */
1427 - private function test_openai_connection(string $api_key): array {
2160 + private function test_openai_connection(string $api_key, string $model = ''): array {
2161 + $model = $model !== ''
2162 + ? $model
2163 + : (string) \ThinkRank\Core\Settings::instance()->get('openai_model', \ThinkRank\Core\Settings::DEFAULT_OPENAI_MODEL);
2164 +
1428 2165 $url = 'https://api.openai.com/v1/models';
1429 2166
1430 2167 $response = wp_remote_get($url, [
1431 2168 'headers' => [
@@ -1447,11 +2184,28 @@
1447 2184
1448 2185 if ($status_code === 200) {
1449 2186 $data = json_decode($body, true);
1450 2187 if (isset($data['data']) && is_array($data['data'])) {
2188 + $ids = array_column($data['data'], 'id');
2189 +
2190 + if ($model !== '' && !in_array($model, $ids, true)) {
2191 + return [
2192 + 'success' => false,
2193 + 'model' => $model,
2194 + 'model_available' => false,
2195 + /* translators: %s: the model id that was tested. */
2196 + 'message' => sprintf(__('API key works, but the model "%s" is not available to this account.', 'thinkrank'), $model),
2197 + ];
2198 + }
2199 +
1451 2200 return [
1452 2201 'success' => true,
1453 - 'message' => __('OpenAI API connection successful!', 'thinkrank'),
2202 + 'model' => $model,
2203 + 'model_available' => $model !== '',
2204 + 'message' => $model !== ''
2205 + /* translators: %s: the model id that was tested. */
2206 + ? sprintf(__('OpenAI API connection successful. Model "%s" is available.', 'thinkrank'), $model)
2207 + : __('OpenAI API connection successful!', 'thinkrank'),
1454 2208 'models_count' => count($data['data']),
1455 2209 ];
1456 2210 }
1457 2211 }
@@ -1469,11 +2223,16 @@
1469 2223 /**
1470 2224 * Test OpenRouter API connection
1471 2225 *
1472 2226 * @param string $api_key API key to test
2227 + * @param string $model Model id to verify, or '' to use the saved one
1473 2228 * @return array Test result
1474 2229 */
1475 - private function test_openrouter_connection(string $api_key): array {
2230 + private function test_openrouter_connection(string $api_key, string $model = ''): array {
2231 + $model = $model !== ''
2232 + ? $model
2233 + : (string) \ThinkRank\Core\Settings::instance()->get('openrouter_model', \ThinkRank\Core\Settings::DEFAULT_OPENROUTER_MODEL);
2234 +
1476 2235 // Validate the key format first (OpenRouter keys start with "sk-or-").
1477 2236 if (!str_starts_with($api_key, 'sk-or-')) {
1478 2237 return [
1479 2238 'success' => false,
@@ -1506,11 +2265,25 @@
1506 2265
1507 2266 if ($status_code === 200) {
1508 2267 $data = json_decode($body, true);
1509 2268 if (isset($data['data']) && is_array($data['data'])) {
2269 + // The key is good; the catalogue is a separate document, so
2270 + // the model needs its own lookup.
2271 + if ($model !== '') {
2272 + $model_check = $this->check_openrouter_model($api_key, $model);
2273 + if ($model_check !== null) {
2274 + return $model_check;
2275 + }
2276 + }
2277 +
1510 2278 return [
1511 2279 'success' => true,
1512 - 'message' => __('OpenRouter API connection successful!', 'thinkrank'),
2280 + 'model' => $model,
2281 + 'model_available' => $model !== '',
2282 + 'message' => $model !== ''
2283 + /* translators: %s: the model id that was tested. */
2284 + ? sprintf(__('OpenRouter API connection successful. Model "%s" is available.', 'thinkrank'), $model)
2285 + : __('OpenRouter API connection successful!', 'thinkrank'),
1513 2286 ];
1514 2287 }
1515 2288 }
1516 2289
@@ -1524,14 +2297,58 @@
1524 2297 ];
1525 2298 }
1526 2299
1527 2300 /**
2301 + * Verify a model id against OpenRouter's public catalogue.
2302 + *
2303 + * @param string $api_key API key to authenticate the lookup
2304 + * @param string $model Model id to look for
2305 + * @return array|null Failure payload when the model is unknown, null when it
2306 + * is available or when the catalogue could not be read —
2307 + * a listing hiccup must not fail an otherwise good key.
2308 + */
2309 + private function check_openrouter_model(string $api_key, string $model): ?array {
2310 + $response = wp_remote_get('https://openrouter.ai/api/v1/models', [
2311 + 'headers' => [
2312 + 'Authorization' => 'Bearer ' . $api_key,
2313 + 'Content-Type' => 'application/json',
2314 + 'HTTP-Referer' => home_url('/'),
2315 + 'X-Title' => 'ThinkRank',
2316 + ],
2317 + 'timeout' => 10,
2318 + ]);
2319 +
2320 + if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
2321 + return null;
2322 + }
2323 +
2324 + $data = json_decode(wp_remote_retrieve_body($response), true);
2325 + if (!isset($data['data']) || !is_array($data['data'])) {
2326 + return null;
2327 + }
2328 +
2329 + $ids = array_column($data['data'], 'id');
2330 + if (in_array($model, $ids, true)) {
2331 + return null;
2332 + }
2333 +
2334 + return [
2335 + 'success' => false,
2336 + 'model' => $model,
2337 + 'model_available' => false,
2338 + /* translators: %s: the model id that was tested. */
2339 + 'message' => sprintf(__('API key works, but "%s" is not a model OpenRouter offers.', 'thinkrank'), $model),
2340 + ];
2341 + }
2342 +
2343 + /**
1528 2344 * Test Claude API connection
1529 2345 *
1530 2346 * @param string $api_key API key to test
2347 + * @param string $model Model id to verify, or '' to use the saved one
1531 2348 * @return array Test result
1532 2349 */
1533 - private function test_claude_connection(string $api_key): array {
2350 + private function test_claude_connection(string $api_key, string $model = ''): array {
1534 2351 // First validate the key format
1535 2352 if (!str_starts_with($api_key, 'sk-ant-')) {
1536 2353 return [
1537 2354 'success' => false,
@@ -1541,12 +2358,18 @@
1541 2358
1542 2359 // Test with a simple API call
1543 2360 $url = 'https://api.anthropic.com/v1/messages';
1544 2361
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);
2362 + // A model sent with the request is tested verbatim: normalizing it would
2363 + // quietly swap a typo for a working id and report success for a model
2364 + // the user never asked for. Only the saved fallback is self-healed, as
2365 + // that is the path where a retired id from an older release shows up.
2366 + if ($model !== '') {
2367 + $claude_model = $model;
2368 + } else {
2369 + $claude_model = \ThinkRank\Core\Settings::instance()->get('claude_model', \ThinkRank\Core\Settings::DEFAULT_CLAUDE_MODEL);
2370 + $claude_model = \ThinkRank\AI\Claude_Client::normalize_model($claude_model);
2371 + }
1549 2372
1550 2373 $body = [
1551 2374 'model' => $claude_model,
1552 2375 'max_tokens' => 10,
@@ -1580,16 +2403,32 @@
1580 2403
1581 2404 if ($status_code === 200) {
1582 2405 return [
1583 2406 'success' => true,
1584 - 'message' => __('Claude API connection successful!', 'thinkrank'),
2407 + 'model' => $claude_model,
2408 + 'model_available' => true,
2409 + /* translators: %s: the model id that was tested. */
2410 + 'message' => sprintf(__('Claude API connection successful. Model "%s" is available.', 'thinkrank'), $claude_model),
1585 2411 ];
1586 2412 } else {
1587 2413 $error_data = json_decode($response_body, true);
1588 2414 $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
1589 2415
2416 + // 404 on /v1/messages means the key authenticated but the model id
2417 + // does not exist — say so, instead of blaming the key.
2418 + if ($status_code === 404) {
2419 + return [
2420 + 'success' => false,
2421 + 'model' => $claude_model,
2422 + 'model_available' => false,
2423 + /* translators: %s: the model id that was tested. */
2424 + 'message' => sprintf(__('API key works, but the model "%s" was not found.', 'thinkrank'), $claude_model),
2425 + ];
2426 + }
2427 +
1590 2428 return [
1591 2429 'success' => false,
2430 + 'model' => $claude_model,
1592 2431 /* translators: %1$d: HTTP status code, %2$s: error message from Claude API */
1593 2432 'message' => sprintf(__('Claude API error (%1$d): %2$s', 'thinkrank'), $status_code, $error_message),
1594 2433 ];
1595 2434 }
@@ -1598,15 +2437,26 @@
1598 2437 /**
1599 2438 * Test Gemini API connection
1600 2439 *
1601 2440 * @param string $api_key API key to test
2441 + * @param string $model Model id to verify, or '' to use the saved one
1602 2442 * @return array Test result
1603 2443 */
1604 - private function test_gemini_connection(string $api_key): array {
2444 + private function test_gemini_connection(string $api_key, string $model = ''): array {
1605 2445 // 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}";
2446 + $gemini_model = $model !== ''
2447 + ? $model
2448 + : (string) \ThinkRank\Core\Settings::instance()->get('gemini_model', \ThinkRank\Core\Settings::DEFAULT_GEMINI_MODEL);
1608 2449
2450 + // The model is a path segment, and ids may arrive with the "models/"
2451 + // prefix Google's own docs use.
2452 + $gemini_model = ltrim($gemini_model, '/');
2453 + $gemini_model = preg_replace('#^models/#', '', $gemini_model);
2454 +
2455 + $url = 'https://generativelanguage.googleapis.com/v1beta/models/'
2456 + . rawurlencode($gemini_model)
2457 + . ':generateContent?key=' . rawurlencode($api_key);
2458 +
1609 2459 $body = [
1610 2460 'contents' => [
1611 2461 [
1612 2462 'parts' => [
@@ -1640,16 +2490,32 @@
1640 2490
1641 2491 if ($status_code === 200) {
1642 2492 return [
1643 2493 'success' => true,
1644 - 'message' => __('Gemini API connection successful!', 'thinkrank'),
2494 + 'model' => $gemini_model,
2495 + 'model_available' => true,
2496 + /* translators: %s: the model id that was tested. */
2497 + 'message' => sprintf(__('Gemini API connection successful. Model "%s" is available.', 'thinkrank'), $gemini_model),
1645 2498 ];
1646 2499 } else {
1647 2500 $error_data = json_decode($response_body, true);
1648 2501 $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
1649 2502
2503 + // Gemini answers 404 for a model id it does not serve; the key
2504 + // itself authenticated fine, so name the real problem.
2505 + if ($status_code === 404) {
2506 + return [
2507 + 'success' => false,
2508 + 'model' => $gemini_model,
2509 + 'model_available' => false,
2510 + /* translators: %s: the model id that was tested. */
2511 + 'message' => sprintf(__('API key works, but the model "%s" was not found.', 'thinkrank'), $gemini_model),
2512 + ];
2513 + }
2514 +
1650 2515 return [
1651 2516 'success' => false,
2517 + 'model' => $gemini_model,
1652 2518 /* translators: %1$d: HTTP status code, %2$s: error message from Gemini API */
1653 2519 'message' => sprintf(__('Gemini API error (%1$d): %2$s', 'thinkrank'), $status_code, $error_message),
1654 2520 ];
1655 2521 }
@@ -1677,8 +2543,14 @@
1677 2543 public function get_ai_status(\WP_REST_Request $request): \WP_REST_Response {
1678 2544 $ai_manager = new \ThinkRank\AI\Manager();
1679 2545 $status = $ai_manager->get_provider_status();
1680 2546
2547 + // The spend ceiling and kill switch ride on the status the AI screen
2548 + // already polls, rather than a route of their own: a counter the user
2549 + // has to refresh separately to trust is a counter they will not trust
2550 + // (#448).
2551 + $status['budget'] = \ThinkRank\AI\Spend_Guard::status();
2552 +
1681 2553 return new \WP_REST_Response($status);
1682 2554 }
1683 2555
1684 2556 /**
@@ -1696,9 +2568,9 @@
1696 2568 // Rate limiting: per user/IP per route
1697 2569 $user_id = get_current_user_id();
1698 2570 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1699 2571 $bucket_id = 'ai_analyze|' . ($user_id ?: $ip);
1700 - $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10);
2572 + $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 0);
1701 2573 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1702 2574 if (is_wp_error($allowed)) {
1703 2575 return new \WP_REST_Response([
1704 2576 'success' => false,
@@ -1779,15 +2651,8 @@
1779 2651 // Failed to register AI Insights endpoint
1780 2652 }
1781 2653
1782 2654 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 2655 $performance_endpoint = new Performance_Endpoint();
1791 2656 $performance_endpoint->register_routes();
1792 2657 } catch (\Exception $e) {
1793 2658 // Failed to register Performance endpoint
@@ -1856,8 +2721,33 @@
1856 2721 // Failed to register Global SEO endpoint
1857 2722 }
1858 2723
1859 2724 try {
2725 + $content_type_matrix_endpoint = new \ThinkRank\API\Content_Type_Matrix_Endpoint();
2726 + $content_type_matrix_endpoint->register_routes();
2727 + } catch (\Exception $e) {
2728 + // Failed to register Content Type Matrix endpoint
2729 + }
2730 +
2731 + try {
2732 + // Bulk Snippets (#727): lives under global-seo/, so the Role
2733 + // Manager's Bulk SEO Optimization capability covers it.
2734 + $snippets_endpoint = new \ThinkRank\API\Snippets_Endpoint();
2735 + $snippets_endpoint->register_routes();
2736 + } catch (\Exception $e) {
2737 + // Failed to register Bulk Snippets endpoint
2738 + }
2739 +
2740 + try {
2741 + // Thin content report (#565): also under global-seo/, so the same
2742 + // Bulk SEO Optimization capability covers it.
2743 + $thin_content_endpoint = new \ThinkRank\API\Thin_Content_Endpoint();
2744 + $thin_content_endpoint->register_routes();
2745 + } catch (\Exception $e) {
2746 + // Failed to register Thin Content endpoint
2747 + }
2748 +
2749 + try {
1860 2750 $image_seo_endpoint = new Image_SEO_Endpoint();
1861 2751 $image_seo_endpoint->register_routes();
1862 2752 } catch (\Exception $e) {
1863 2753 // Failed to register Image SEO endpoint
@@ -1863,8 +2753,21 @@
1863 2753 // Failed to register Image SEO endpoint
1864 2754 }
1865 2755
1866 2756 try {
2757 + $external_links_endpoint = new External_Links_Endpoint();
2758 + $external_links_endpoint->register_routes();
2759 + } catch (\Exception $e) {
2760 + // Failed to register External Links endpoint
2761 + }
2762 +
2763 + // Import_Controller is deliberately NOT gated on enable_migration_tools.
2764 + // /import/detect backs the setup wizard's migration step and the record
2765 + // count on Settings > Import / Export, and /import/snapshot + /migrate
2766 + // run the wizard's actual import — all on a fresh install, where the
2767 + // setting is off. Gating them would break onboarding, which is a worse
2768 + // bug than the one #583 reports.
2769 + try {
1867 2770 $import_controller = new Import_Controller();
1868 2771 $import_controller->register_routes();
1869 2772 } catch (\Exception $e) {
1870 2773 // Failed to register Import endpoint
@@ -1869,12 +2772,34 @@
1869 2772 } catch (\Exception $e) {
1870 2773 // Failed to register Import endpoint
1871 2774 }
1872 2775
2776 + // Export/restore is gated on the setting that gates its admin screen,
2777 + // so turning Import / Export off removes its REST surface along with
2778 + // its menu item (#583). Nothing in the setup wizard calls these:
2779 + // MigrationPluginRow takes startExport/startMigration/cancel from
2780 + // useImportWorkflow and never uploadFile. rest_api_init runs per
2781 + // request, so a toggle takes effect on the next one — no flush.
2782 + if ((bool) \ThinkRank\Core\Settings::instance()->get('enable_import_export', false)) {
2783 + try {
2784 + $export_controller = new Export_Controller();
2785 + $export_controller->register_routes();
2786 + } catch (\Exception $e) {
2787 + // Failed to register Export endpoint
2788 + }
2789 + }
2790 +
1873 2791 try {
1874 2792 $setup_wizard_endpoint = new Setup_Wizard_Endpoint();
1875 2793 $setup_wizard_endpoint->register_routes();
1876 2794 } catch (\Exception $e) {
1877 2795 // Failed to register Setup Wizard endpoint
2796 + }
2797 +
2798 + // Simple / Advanced navigation, per user (#730)
2799 + try {
2800 + (new UI_Mode_Endpoint())->register_routes();
2801 + } catch (\Exception $e) {
2802 + // Failed to register UI mode endpoint
1878 2803 }
1879 2804 }
1880 2805 }