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 +718 -17 2.7.0 → 2.14.0 View file →
@@ -68,8 +68,19 @@
68 68 */
69 69 private const AI_CONTENT_MAX_LENGTH = 5000;
70 70
71 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 + /**
72 83 * Sanitize and hard-cap an AI `content` request parameter.
73 84 *
74 85 * Used as the `sanitize_callback` for every AI endpoint's `content` arg so
75 86 * the server enforces its own maximum regardless of what a direct REST
@@ -193,8 +204,41 @@
193 204 'openrouter_model' => [
194 205 'type' => 'string',
195 206 'sanitize_callback' => 'sanitize_text_field',
196 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 + ],
197 241 'max_tokens' => [
198 242 'type' => 'integer',
199 243 'minimum' => 1,
200 244 'maximum' => 32000,
@@ -228,8 +272,28 @@
228 272 'enable_import_export' => [
229 273 'type' => 'boolean',
230 274 'sanitize_callback' => 'rest_sanitize_boolean',
231 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 + ],
232 296 ],
233 297 ]);
234 298
235 299
@@ -456,9 +520,9 @@
456 520
457 521 register_rest_route(self::NAMESPACE, '/schema/enable-for-post', [
458 522 'methods' => 'POST',
459 523 'callback' => [$this, 'enable_schema_for_post'],
460 - 'permission_callback' => [$this, 'check_admin_permissions'],
524 + 'permission_callback' => [$this, 'check_schema_permissions'],
461 525 'args' => [
462 526 'post_id' => [
463 527 'type' => 'integer',
464 528 'required' => true,
@@ -469,9 +533,9 @@
469 533
470 534 register_rest_route(self::NAMESPACE, '/ai/test-connection', [
471 535 'methods' => 'POST',
472 536 'callback' => [$this, 'test_ai_connection'],
473 - 'permission_callback' => [$this, 'check_admin_permissions'],
537 + 'permission_callback' => [$this, 'check_ai_tools_permissions'],
474 538 'args' => [
475 539 'api_key' => [
476 540 'type' => 'string',
477 541 'required' => false,
@@ -487,11 +551,45 @@
487 551 'type' => 'string',
488 552 'required' => false,
489 553 'sanitize_callback' => 'sanitize_text_field',
490 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 + ],
491 568 ],
492 569 ]);
493 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 +
494 592 register_rest_route(self::NAMESPACE, '/ai/providers', [
495 593 'methods' => 'GET',
496 594 'callback' => [$this, 'get_ai_providers'],
497 595 'permission_callback' => [$this, 'check_basic_permissions'],
@@ -661,8 +759,17 @@
661 759 $enabled = (bool) $settings->get('enable_rate_limiting', true);
662 760 if (!$enabled) {
663 761 return true;
664 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 + }
665 772 $now = time();
666 773 $window = 60;
667 774 $key = 'thinkrank_rl_' . md5($bucket_id);
668 775 $bucket = get_transient($key);
@@ -671,9 +778,9 @@
671 778 }
672 779 if ($now - ($bucket['start'] ?? 0) >= $window) {
673 780 $bucket = ['start' => $now, 'count' => 0];
674 781 }
675 - if (($bucket['count'] ?? 0) >= max(1, $limit)) {
782 + if (($bucket['count'] ?? 0) >= $limit) {
676 783 return new \WP_Error('rate_limited', __('Rate limit exceeded. Please wait a moment and try again.', 'thinkrank'), ['status' => 429]);
677 784 }
678 785 $bucket['count']++;
679 786 set_transient($key, $bucket, $window);
@@ -737,8 +844,70 @@
737 844 return true;
738 845 }
739 846
740 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 + /**
741 910 * Get user capabilities
742 911 *
743 912 * @param \WP_REST_Request $request Request object
744 913 * @return \WP_REST_Response Response object
@@ -815,8 +984,15 @@
815 984 'gemini_api_key' => $settings_instance->get('gemini_api_key', ''),
816 985 'gemini_model' => $settings_instance->get('gemini_model', \ThinkRank\Core\Settings::DEFAULT_GEMINI_MODEL),
817 986 'openrouter_api_key' => $settings_instance->get('openrouter_api_key', ''),
818 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),
819 995 'max_tokens' => $settings_instance->get('max_tokens', 1000),
820 996 'temperature' => $settings_instance->get('temperature', 0.7),
821 997 'cache_duration' => $settings_instance->get('cache_duration', 3600),
822 998 'keep_data_on_uninstall' => (bool) $settings_instance->get('keep_data_on_uninstall', true),
@@ -823,8 +999,11 @@
823 999 'enable_migration_tools' => (bool) $settings_instance->get('enable_migration_tools', false),
824 1000 'enable_import_export' => (bool) $settings_instance->get('enable_import_export', false),
825 1001 'google_account_connected' => (bool) $settings_instance->get('google_account_connected', false),
826 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),
827 1006 ];
828 1007
829 1008
830 1009
@@ -841,8 +1020,11 @@
841 1020 }
842 1021 if (!empty($settings['openrouter_api_key'])) {
843 1022 $settings['openrouter_api_key'] = $this->mask_ai_api_key($settings['openrouter_api_key']);
844 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 + }
845 1027
846 1028 return new \WP_REST_Response($settings);
847 1029 }
848 1030
@@ -880,8 +1062,105 @@
880 1062 // Capture the pre-save MCP state so we can detect an on/off transition
881 1063 // below and mint/revoke the connection token to match (see #244).
882 1064 $mcp_was_enabled = (bool) $settings->get('enable_mcp', false);
883 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 +
884 1163 // Map frontend parameter names to setting keys
885 1164 $settings_map = [
886 1165 'ai_provider' => 'ai_provider',
887 1166 'openai_api_key' => 'openai_api_key',
@@ -891,8 +1170,15 @@
891 1170 'gemini_api_key' => 'gemini_api_key',
892 1171 'gemini_model' => 'gemini_model',
893 1172 'openrouter_api_key' => 'openrouter_api_key',
894 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',
895 1181 'max_tokens' => 'max_tokens',
896 1182 'temperature' => 'temperature',
897 1183 'cache_duration' => 'cache_duration',
898 1184 'keep_data_on_uninstall' => 'keep_data_on_uninstall',
@@ -898,8 +1184,11 @@
898 1184 'keep_data_on_uninstall' => 'keep_data_on_uninstall',
899 1185 'enable_mcp' => 'enable_mcp',
900 1186 'enable_migration_tools' => 'enable_migration_tools',
901 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',
902 1191 ];
903 1192
904 1193 // Processing settings save request
905 1194
@@ -907,9 +1196,9 @@
907 1196 if (isset($params[$param_key])) {
908 1197 $value = $params[$param_key];
909 1198
910 1199 // Handle API keys specially - check for masked values
911 - 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)) {
912 1201 // Don't update if the value carries the mask sentinel (the
913 1202 // preview now keeps real head/tail chars around it, so match
914 1203 // anywhere rather than only at the start). Empty still clears.
915 1204 if (strpos($value, '••••••••') !== false) {
@@ -946,9 +1235,9 @@
946 1235 // Auto-dismiss welcome notice if API key was saved
947 1236 $this->maybe_dismiss_welcome_notice($params);
948 1237
949 1238 // Force AI Manager to re-initialize client with new settings
950 - 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'])) {
951 1240 // Clear any cached AI Manager instances to force re-initialization
952 1241 wp_cache_delete('thinkrank_ai_manager', 'thinkrank');
953 1242
954 1243 // If we have an AI Manager instance, force it to re-initialize
@@ -1056,9 +1345,9 @@
1056 1345 // Rate limiting: per user/IP per route
1057 1346 $user_id = get_current_user_id();
1058 1347 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1059 1348 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1060 - $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);
1061 1350 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1062 1351 if (is_wp_error($allowed)) {
1063 1352 return new \WP_REST_Response([
1064 1353 'success' => false,
@@ -1110,9 +1399,9 @@
1110 1399 // Rate limiting: shares the AI generation bucket.
1111 1400 $user_id = get_current_user_id();
1112 1401 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1113 1402 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1114 - $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);
1115 1404 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1116 1405 if (is_wp_error($allowed)) {
1117 1406 return new \WP_REST_Response([
1118 1407 'success' => false,
@@ -1160,9 +1449,9 @@
1160 1449 // Rate limiting: shares the AI generation bucket.
1161 1450 $user_id = get_current_user_id();
1162 1451 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1163 1452 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1164 - $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);
1165 1454 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1166 1455 if (is_wp_error($allowed)) {
1167 1456 return new \WP_REST_Response([
1168 1457 'success' => false,
@@ -1212,9 +1501,9 @@
1212 1501 // Rate limiting: shares the AI generation bucket.
1213 1502 $user_id = get_current_user_id();
1214 1503 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1215 1504 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1216 - $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);
1217 1506 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1218 1507 if (is_wp_error($allowed)) {
1219 1508 return new \WP_REST_Response([
1220 1509 'success' => false,
@@ -1258,9 +1547,9 @@
1258 1547 // Rate limiting: shares the AI generation bucket.
1259 1548 $user_id = get_current_user_id();
1260 1549 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1261 1550 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1262 - $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);
1263 1552 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1264 1553 if (is_wp_error($allowed)) {
1265 1554 return new \WP_REST_Response([
1266 1555 'success' => false,
@@ -1307,9 +1596,9 @@
1307 1596 // Rate limiting: shares the AI generation bucket.
1308 1597 $user_id = get_current_user_id();
1309 1598 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1310 1599 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1311 - $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);
1312 1601 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1313 1602 if (is_wp_error($allowed)) {
1314 1603 return new \WP_REST_Response([
1315 1604 'success' => false,
@@ -1406,9 +1695,9 @@
1406 1695 // Rate limiting: per user/IP per route
1407 1696 $user_id = get_current_user_id();
1408 1697 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1409 1698 $bucket_id = 'ai_test|' . ($user_id ?: $ip);
1410 - $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);
1411 1700 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1412 1701 if (is_wp_error($allowed)) {
1413 1702 return new \WP_REST_Response([
1414 1703 'success' => false,
@@ -1434,8 +1723,34 @@
1434 1723 'message' => sprintf(__('Unknown AI provider: %s', 'thinkrank'), $provider),
1435 1724 ], 400);
1436 1725 }
1437 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 +
1438 1753 // If no API key provided in request, try to get from saved settings
1439 1754 if (empty($api_key)) {
1440 1755 $settings = \ThinkRank\Core\Settings::instance();
1441 1756 if ($provider === 'openai') {
@@ -1476,8 +1791,363 @@
1476 1791 }
1477 1792 }
1478 1793
1479 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 + /**
1480 2150 * Test OpenAI API connection
1481 2151 *
1482 2152 * The models endpoint doubles as the model check: it answers with every id
1483 2153 * this key may call, so an unknown or unentitled model is caught here
@@ -1532,9 +2202,9 @@
1532 2202 'model' => $model,
1533 2203 'model_available' => $model !== '',
1534 2204 'message' => $model !== ''
1535 2205 /* translators: %s: the model id that was tested. */
1536 - ? sprintf(__('OpenAI API connection successful — model "%s" is available.', 'thinkrank'), $model)
2206 + ? sprintf(__('OpenAI API connection successful. Model "%s" is available.', 'thinkrank'), $model)
1537 2207 : __('OpenAI API connection successful!', 'thinkrank'),
1538 2208 'models_count' => count($data['data']),
1539 2209 ];
1540 2210 }
@@ -1610,9 +2280,9 @@
1610 2280 'model' => $model,
1611 2281 'model_available' => $model !== '',
1612 2282 'message' => $model !== ''
1613 2283 /* translators: %s: the model id that was tested. */
1614 - ? sprintf(__('OpenRouter API connection successful — model "%s" is available.', 'thinkrank'), $model)
2284 + ? sprintf(__('OpenRouter API connection successful. Model "%s" is available.', 'thinkrank'), $model)
1615 2285 : __('OpenRouter API connection successful!', 'thinkrank'),
1616 2286 ];
1617 2287 }
1618 2288 }
@@ -1736,9 +2406,9 @@
1736 2406 'success' => true,
1737 2407 'model' => $claude_model,
1738 2408 'model_available' => true,
1739 2409 /* translators: %s: the model id that was tested. */
1740 - 'message' => sprintf(__('Claude API connection successful — model "%s" is available.', 'thinkrank'), $claude_model),
2410 + 'message' => sprintf(__('Claude API connection successful. Model "%s" is available.', 'thinkrank'), $claude_model),
1741 2411 ];
1742 2412 } else {
1743 2413 $error_data = json_decode($response_body, true);
1744 2414 $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
@@ -1823,9 +2493,9 @@
1823 2493 'success' => true,
1824 2494 'model' => $gemini_model,
1825 2495 'model_available' => true,
1826 2496 /* translators: %s: the model id that was tested. */
1827 - 'message' => sprintf(__('Gemini API connection successful — model "%s" is available.', 'thinkrank'), $gemini_model),
2497 + 'message' => sprintf(__('Gemini API connection successful. Model "%s" is available.', 'thinkrank'), $gemini_model),
1828 2498 ];
1829 2499 } else {
1830 2500 $error_data = json_decode($response_body, true);
1831 2501 $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
@@ -1873,8 +2543,14 @@
1873 2543 public function get_ai_status(\WP_REST_Request $request): \WP_REST_Response {
1874 2544 $ai_manager = new \ThinkRank\AI\Manager();
1875 2545 $status = $ai_manager->get_provider_status();
1876 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 +
1877 2553 return new \WP_REST_Response($status);
1878 2554 }
1879 2555
1880 2556 /**
@@ -1892,9 +2568,9 @@
1892 2568 // Rate limiting: per user/IP per route
1893 2569 $user_id = get_current_user_id();
1894 2570 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1895 2571 $bucket_id = 'ai_analyze|' . ($user_id ?: $ip);
1896 - $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);
1897 2573 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1898 2574 if (is_wp_error($allowed)) {
1899 2575 return new \WP_REST_Response([
1900 2576 'success' => false,
@@ -2052,8 +2728,26 @@
2052 2728 // Failed to register Content Type Matrix endpoint
2053 2729 }
2054 2730
2055 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 {
2056 2750 $image_seo_endpoint = new Image_SEO_Endpoint();
2057 2751 $image_seo_endpoint->register_routes();
2058 2752 } catch (\Exception $e) {
2059 2753 // Failed to register Image SEO endpoint
@@ -2098,7 +2792,14 @@
2098 2792 $setup_wizard_endpoint = new Setup_Wizard_Endpoint();
2099 2793 $setup_wizard_endpoint->register_routes();
2100 2794 } catch (\Exception $e) {
2101 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
2102 2803 }
2103 2804 }
2104 2805 }