PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.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 1.25.0 trunk 1.0.0 1.0.1 All 51 releases
← All changes | includes/api/class-manager.php +982 -42 1.25.0 → 2.10.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
@@ -87,8 +100,21 @@
87 100 */
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']);
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 +
110 + // Make declared schema constraints mean something. Applied once over
111 + // the whole namespace rather than at 70-odd call sites, because that is
112 + // exactly how the enum on /setup-wizard/migrated-plugins and the one on
113 + // /seo-analytics/dashboard came to be inert while the route next door
114 + // was fine (#394). Late priority so it sees every route, including any
115 + // an add-on registered.
116 + add_filter('rest_endpoints', [Rest_Args::class, 'enforce_namespace'], 99);
91 117 }
92 118
93 119 /**
94 120 * Register REST API routes
@@ -138,9 +164,15 @@
138 164 'permission_callback' => [$this, 'check_settings_permissions'],
139 165 'args' => [
140 166 'ai_provider' => [
141 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(),
142 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',
143 175 ],
144 176 'openai_api_key' => [
145 177 'type' => 'string',
146 178 'sanitize_callback' => 'sanitize_text_field',
@@ -172,8 +204,60 @@
172 204 'openrouter_model' => [
173 205 'type' => 'string',
174 206 'sanitize_callback' => 'sanitize_text_field',
175 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 + ],
176 260 'keep_data_on_uninstall' => [
177 261 'type' => 'boolean',
178 262 'sanitize_callback' => 'rest_sanitize_boolean',
179 263 ],
@@ -184,8 +268,32 @@
184 268 'enable_migration_tools' => [
185 269 'type' => 'boolean',
186 270 'sanitize_callback' => 'rest_sanitize_boolean',
187 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 + ],
188 296 ],
189 297 ]);
190 298
191 299
@@ -227,8 +335,14 @@
227 335 'type' => 'string',
228 336 'default' => 'professional',
229 337 'sanitize_callback' => 'sanitize_text_field',
230 338 ],
339 + 'post_id' => [
340 + 'type' => 'integer',
341 + 'required' => false,
342 + 'default' => 0,
343 + 'sanitize_callback' => 'absint',
344 + ],
231 345 ],
232 346 ]);
233 347
234 348 register_rest_route(self::NAMESPACE, '/ai/improve-title', [
@@ -262,8 +376,14 @@
262 376 'suggestion' => [
263 377 'type' => 'string',
264 378 'sanitize_callback' => 'sanitize_text_field',
265 379 ],
380 + 'post_id' => [
381 + 'type' => 'integer',
382 + 'required' => false,
383 + 'default' => 0,
384 + 'sanitize_callback' => 'absint',
385 + ],
266 386 ],
267 387 ]);
268 388
269 389 register_rest_route(self::NAMESPACE, '/ai/improve-meta-description', [
@@ -297,8 +417,14 @@
297 417 'suggestion' => [
298 418 'type' => 'string',
299 419 'sanitize_callback' => 'sanitize_text_field',
300 420 ],
421 + 'post_id' => [
422 + 'type' => 'integer',
423 + 'required' => false,
424 + 'default' => 0,
425 + 'sanitize_callback' => 'absint',
426 + ],
301 427 ],
302 428 ]);
303 429
304 430 register_rest_route(self::NAMESPACE, '/ai/explain-suggestion', [
@@ -420,11 +546,50 @@
420 546 'required' => false,
421 547 'default' => 'openai',
422 548 'sanitize_callback' => 'sanitize_key',
423 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 + ],
424 568 ],
425 569 ]);
426 570
571 + // Ask an OpenAI-compatible endpoint what models it serves. Ollama, LM
572 + // Studio and vLLM all answer GET {base}/models; a gateway that does not
573 + // simply leaves the user typing the id by hand (#721).
574 + register_rest_route(self::NAMESPACE, '/ai/models', [
575 + 'methods' => 'POST',
576 + 'callback' => [$this, 'list_endpoint_models'],
577 + 'permission_callback' => [$this, 'check_admin_permissions'],
578 + 'args' => [
579 + 'base_url' => [
580 + 'type' => 'string',
581 + 'required' => false,
582 + 'sanitize_callback' => 'sanitize_text_field',
583 + ],
584 + 'api_key' => [
585 + 'type' => 'string',
586 + 'required' => false,
587 + 'sanitize_callback' => 'sanitize_text_field',
588 + ],
589 + ],
590 + ]);
591 +
427 592 register_rest_route(self::NAMESPACE, '/ai/providers', [
428 593 'methods' => 'GET',
429 594 'callback' => [$this, 'get_ai_providers'],
430 595 'permission_callback' => [$this, 'check_basic_permissions'],
@@ -594,8 +759,17 @@
594 759 $enabled = (bool) $settings->get('enable_rate_limiting', true);
595 760 if (!$enabled) {
596 761 return true;
597 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 + }
598 772 $now = time();
599 773 $window = 60;
600 774 $key = 'thinkrank_rl_' . md5($bucket_id);
601 775 $bucket = get_transient($key);
@@ -604,9 +778,9 @@
604 778 }
605 779 if ($now - ($bucket['start'] ?? 0) >= $window) {
606 780 $bucket = ['start' => $now, 'count' => 0];
607 781 }
608 - if (($bucket['count'] ?? 0) >= max(1, $limit)) {
782 + if (($bucket['count'] ?? 0) >= $limit) {
609 783 return new \WP_Error('rate_limited', __('Rate limit exceeded. Please wait a moment and try again.', 'thinkrank'), ['status' => 429]);
610 784 }
611 785 $bucket['count']++;
612 786 set_transient($key, $bucket, $window);
@@ -739,24 +913,35 @@
739 913 // Use Settings class for consistent access (handles decryption automatically)
740 914 $settings_instance = \ThinkRank\Core\Settings::instance();
741 915
742 916 $settings = [
743 - 'ai_provider' => $settings_instance->get('ai_provider', 'openai'),
917 + 'ai_provider' => $settings_instance->get('ai_provider', \ThinkRank\Core\Settings::AI_PROVIDER_NONE),
744 918 'openai_api_key' => $settings_instance->get('openai_api_key', ''),
745 - 'openai_model' => $settings_instance->get('openai_model', 'gpt-5-nano'),
919 + 'openai_model' => $settings_instance->get('openai_model', \ThinkRank\Core\Settings::DEFAULT_OPENAI_MODEL),
746 920 'claude_api_key' => $settings_instance->get('claude_api_key', ''),
747 - 'claude_model' => $settings_instance->get('claude_model', 'claude-sonnet-5'),
921 + 'claude_model' => $settings_instance->get('claude_model', \ThinkRank\Core\Settings::DEFAULT_CLAUDE_MODEL),
748 922 'gemini_api_key' => $settings_instance->get('gemini_api_key', ''),
749 - 'gemini_model' => $settings_instance->get('gemini_model', 'gemini-2.5-flash'),
923 + 'gemini_model' => $settings_instance->get('gemini_model', \ThinkRank\Core\Settings::DEFAULT_GEMINI_MODEL),
750 924 'openrouter_api_key' => $settings_instance->get('openrouter_api_key', ''),
751 - 'openrouter_model' => $settings_instance->get('openrouter_model', 'openai/gpt-4o-mini'),
925 + 'openrouter_model' => $settings_instance->get('openrouter_model', \ThinkRank\Core\Settings::DEFAULT_OPENROUTER_MODEL),
926 + 'openai_compatible_base_url' => $settings_instance->get('openai_compatible_base_url', ''),
927 + 'openai_compatible_api_key' => $settings_instance->get('openai_compatible_api_key', ''),
928 + 'openai_compatible_model' => $settings_instance->get('openai_compatible_model', ''),
929 + 'openai_compatible_timeout' => (int) $settings_instance->get('openai_compatible_timeout', \ThinkRank\Core\Settings::DEFAULT_OPENAI_COMPATIBLE_TIMEOUT),
930 + 'openai_compatible_supports_images' => (bool) $settings_instance->get('openai_compatible_supports_images', false),
931 + 'openai_compatible_json_mode' => (bool) $settings_instance->get('openai_compatible_json_mode', false),
932 + 'openai_compatible_price_per_million' => (float) $settings_instance->get('openai_compatible_price_per_million', 0),
752 933 'max_tokens' => $settings_instance->get('max_tokens', 1000),
753 934 'temperature' => $settings_instance->get('temperature', 0.7),
754 935 'cache_duration' => $settings_instance->get('cache_duration', 3600),
755 936 'keep_data_on_uninstall' => (bool) $settings_instance->get('keep_data_on_uninstall', true),
756 937 'enable_migration_tools' => (bool) $settings_instance->get('enable_migration_tools', false),
938 + 'enable_import_export' => (bool) $settings_instance->get('enable_import_export', false),
757 939 'google_account_connected' => (bool) $settings_instance->get('google_account_connected', false),
758 940 'enable_mcp' => (bool) $settings_instance->get('enable_mcp', false),
941 + 'max_requests_per_minute' => (int) $settings_instance->get('max_requests_per_minute', 0),
942 + 'ai_daily_request_limit' => (int) $settings_instance->get('ai_daily_request_limit', 0),
943 + 'ai_paused' => (bool) $settings_instance->get('ai_paused', false),
759 944 ];
760 945
761 946
762 947
@@ -773,8 +958,11 @@
773 958 }
774 959 if (!empty($settings['openrouter_api_key'])) {
775 960 $settings['openrouter_api_key'] = $this->mask_ai_api_key($settings['openrouter_api_key']);
776 961 }
962 + if (!empty($settings['openai_compatible_api_key'])) {
963 + $settings['openai_compatible_api_key'] = $this->mask_ai_api_key($settings['openai_compatible_api_key']);
964 + }
777 965
778 966 return new \WP_REST_Response($settings);
779 967 }
780 968
@@ -808,8 +996,109 @@
808 996
809 997 // Get Settings instance for proper encryption handling
810 998 $settings = \ThinkRank\Core\Settings::instance();
811 999
1000 + // Capture the pre-save MCP state so we can detect an on/off transition
1001 + // below and mint/revoke the connection token to match (see #244).
1002 + $mcp_was_enabled = (bool) $settings->get('enable_mcp', false);
1003 +
1004 + // Pointing the site's AI at an arbitrary host — including loopback and
1005 + // LAN addresses, which this provider deliberately allows — is an
1006 + // administrator's decision, not a delegated one. The settings route
1007 + // itself is delegable through the Role Manager's `thinkrank_settings`
1008 + // capability, so an editor granted "manage ThinkRank settings" could
1009 + // otherwise aim server-side requests (with an Authorization header of
1010 + // their choosing) at internal services. Every other field on this route
1011 + // stays delegable; only these are held back (#721).
1012 + $endpoint_fields = [
1013 + 'openai_compatible_base_url',
1014 + 'openai_compatible_api_key',
1015 + 'openai_compatible_model',
1016 + 'openai_compatible_timeout',
1017 + 'openai_compatible_supports_images',
1018 + 'openai_compatible_json_mode',
1019 + 'openai_compatible_price_per_million',
1020 + ];
1021 +
1022 + foreach ($endpoint_fields as $endpoint_field) {
1023 + if (!isset($params[$endpoint_field])) {
1024 + continue;
1025 + }
1026 +
1027 + // Only an actual change needs the capability: a client that echoes
1028 + // the whole settings payload back unchanged is not reconfiguring
1029 + // anything, and failing that save would break the Settings screen
1030 + // for delegated users editing an unrelated field.
1031 + //
1032 + // The key needs the mask rule the persistence loop below already
1033 + // uses. GET /settings returns it masked ("sk-pr••••••••abc"), so
1034 + // comparing that against the stored plaintext always differs, and
1035 + // every echoed payload would read as "an administrator changed the
1036 + // key" — locking delegated users out of saving anything at all.
1037 + $submitted = $params[$endpoint_field];
1038 + if (is_string($submitted) && false !== strpos($submitted, '••••••••')) {
1039 + continue;
1040 + }
1041 +
1042 + $stored = $settings->get($endpoint_field);
1043 +
1044 + // Booleans and numbers arrive typed from the REST layer but are
1045 + // stored as '1'/'' and '120'; compare them as the values they are.
1046 + if (is_bool($submitted) || is_bool($stored)) {
1047 + if ((bool) $stored === (bool) $submitted) {
1048 + continue;
1049 + }
1050 + } elseif (is_numeric($submitted) && is_numeric($stored)) {
1051 + if ((float) $stored === (float) $submitted) {
1052 + continue;
1053 + }
1054 + } elseif ((string) $stored === (string) $submitted) {
1055 + continue;
1056 + }
1057 +
1058 + if (!current_user_can('manage_options')) {
1059 + return new \WP_REST_Response([
1060 + 'success' => false,
1061 + 'message' => __('Only an administrator can configure a custom AI endpoint.', 'thinkrank'),
1062 + 'field' => $endpoint_field,
1063 + ], 403);
1064 + }
1065 +
1066 + break;
1067 + }
1068 +
1069 + // Selecting the provider is the same decision by another name.
1070 + if (isset($params['ai_provider'])
1071 + && 'openai_compatible' === $params['ai_provider']
1072 + && 'openai_compatible' !== (string) $settings->get('ai_provider', \ThinkRank\Core\Settings::AI_PROVIDER_NONE)
1073 + && !current_user_can('manage_options')
1074 + ) {
1075 + return new \WP_REST_Response([
1076 + 'success' => false,
1077 + 'message' => __('Only an administrator can configure a custom AI endpoint.', 'thinkrank'),
1078 + 'field' => 'ai_provider',
1079 + ], 403);
1080 + }
1081 +
1082 + // A refused endpoint URL has to say why. Settings::sanitize_setting()
1083 + // stores '' for one that fails validation — right, since an unvalidated
1084 + // URL must never become a URL we fetch — but silent, so the user would
1085 + // see "Settings saved" and an endpoint that vanished. Validate here,
1086 + // where the reason can be returned, and reject the whole save: a
1087 + // half-applied AI provider is worse than none (#721).
1088 + if (!empty($params['openai_compatible_base_url'])) {
1089 + $validated_base_url = \ThinkRank\AI\Endpoint_URL_Validator::validate((string) $params['openai_compatible_base_url']);
1090 + if (is_wp_error($validated_base_url)) {
1091 + return new \WP_REST_Response([
1092 + 'success' => false,
1093 + 'message' => $validated_base_url->get_error_message(),
1094 + 'field' => 'openai_compatible_base_url',
1095 + ], 400);
1096 + }
1097 +
1098 + $params['openai_compatible_base_url'] = $validated_base_url;
1099 + }
1100 +
812 1101 // Map frontend parameter names to setting keys
813 1102 $settings_map = [
814 1103 'ai_provider' => 'ai_provider',
815 1104 'openai_api_key' => 'openai_api_key',
@@ -819,8 +1108,15 @@
819 1108 'gemini_api_key' => 'gemini_api_key',
820 1109 'gemini_model' => 'gemini_model',
821 1110 'openrouter_api_key' => 'openrouter_api_key',
822 1111 'openrouter_model' => 'openrouter_model',
1112 + 'openai_compatible_base_url' => 'openai_compatible_base_url',
1113 + 'openai_compatible_api_key' => 'openai_compatible_api_key',
1114 + 'openai_compatible_model' => 'openai_compatible_model',
1115 + 'openai_compatible_timeout' => 'openai_compatible_timeout',
1116 + 'openai_compatible_supports_images' => 'openai_compatible_supports_images',
1117 + 'openai_compatible_json_mode' => 'openai_compatible_json_mode',
1118 + 'openai_compatible_price_per_million' => 'openai_compatible_price_per_million',
823 1119 'max_tokens' => 'max_tokens',
824 1120 'temperature' => 'temperature',
825 1121 'cache_duration' => 'cache_duration',
826 1122 'keep_data_on_uninstall' => 'keep_data_on_uninstall',
@@ -825,8 +1121,12 @@
825 1121 'cache_duration' => 'cache_duration',
826 1122 'keep_data_on_uninstall' => 'keep_data_on_uninstall',
827 1123 'enable_mcp' => 'enable_mcp',
828 1124 'enable_migration_tools' => 'enable_migration_tools',
1125 + 'enable_import_export' => 'enable_import_export',
1126 + 'max_requests_per_minute' => 'max_requests_per_minute',
1127 + 'ai_daily_request_limit' => 'ai_daily_request_limit',
1128 + 'ai_paused' => 'ai_paused',
829 1129 ];
830 1130
831 1131 // Processing settings save request
832 1132
@@ -834,9 +1134,9 @@
834 1134 if (isset($params[$param_key])) {
835 1135 $value = $params[$param_key];
836 1136
837 1137 // Handle API keys specially - check for masked values
838 - if (in_array($param_key, ['openai_api_key', 'claude_api_key', 'gemini_api_key', 'openrouter_api_key'])) {
1138 + if (in_array($param_key, ['openai_api_key', 'claude_api_key', 'gemini_api_key', 'openrouter_api_key', 'openai_compatible_api_key'], true)) {
839 1139 // Don't update if the value carries the mask sentinel (the
840 1140 // preview now keeps real head/tail chars around it, so match
841 1141 // anywhere rather than only at the start). Empty still clears.
842 1142 if (strpos($value, '••••••••') !== false) {
@@ -844,19 +1144,38 @@
844 1144 }
845 1145 }
846 1146
847 1147 // Use Settings class for all operations (handles encryption automatically)
848 - if (!$settings->set($setting_key, $value)) {
849 - // Settings save failed, continue with other settings
850 - }
1148 + // A failed write is skipped rather than aborting the batch, so
1149 + // one bad setting cannot block the rest of the save.
1150 + $settings->set($setting_key, $value);
851 1151 }
852 1152 }
853 1153
1154 + // MCP is a single master switch (see #244): enabling it auto-mints a
1155 + // read/write connection token so the connect recipes are ready without
1156 + // a separate "Generate token" step.
1157 + //
1158 + // Disabling is a PAUSE, not a wipe: the switch alone already denies all
1159 + // access (Mcp_Server 403s and the OAuth/discovery endpoints refuse while
1160 + // off), so stored tokens and OAuth grants are inert. We keep them so
1161 + // re-enabling restores every previously connected app with no
1162 + // re-approval. Explicit revocation stays available per-app (the
1163 + // Connected AI apps trash button) and for the shared token (Reset
1164 + // token / rotate). Only act on an actual on->off->on transition so
1165 + // saving unrelated settings never touches the connection.
1166 + if (isset($params['enable_mcp'])) {
1167 + $mcp_now_enabled = (bool) $settings->get('enable_mcp', false);
1168 + if ($mcp_now_enabled && !$mcp_was_enabled) {
1169 + \ThinkRank\Mcp\Mcp_Pairing::connect();
1170 + }
1171 + }
1172 +
854 1173 // Auto-dismiss welcome notice if API key was saved
855 1174 $this->maybe_dismiss_welcome_notice($params);
856 1175
857 1176 // Force AI Manager to re-initialize client with new settings
858 - if (isset($params['ai_provider']) || isset($params['openai_api_key']) || isset($params['claude_api_key']) || isset($params['gemini_api_key']) || isset($params['openrouter_api_key'])) {
1177 + if (isset($params['ai_provider']) || isset($params['openai_api_key']) || isset($params['claude_api_key']) || isset($params['gemini_api_key']) || isset($params['openrouter_api_key']) || isset($params['openai_compatible_base_url']) || isset($params['openai_compatible_api_key']) || isset($params['openai_compatible_model'])) {
859 1178 // Clear any cached AI Manager instances to force re-initialization
860 1179 wp_cache_delete('thinkrank_ai_manager', 'thinkrank');
861 1180
862 1181 // If we have an AI Manager instance, force it to re-initialize
@@ -914,15 +1233,31 @@
914 1233 if (!current_user_can('edit_post', $post_id)) {
915 1234 return new \WP_REST_Response(['message' => 'You are not allowed to view this metadata.'], 403);
916 1235 }
917 1236
918 - // Get existing metadata
1237 + // Read the pending flag BEFORE the meta below, never after. A writer
1238 + // that finishes mid-request writes the meta and *then* clears the
1239 + // flag; reading the flag last could therefore observe "no value" and
1240 + // "not pending" for the same run and stop the editor panel polling one
1241 + // tick before the value it was waiting for lands (#329).
1242 + $pending = \ThinkRank\SEO\Metadata_Pending::is_pending($post_id);
1243 +
1244 + // Get existing metadata. These must read the same canonical meta keys
1245 + // the rest of the plugin writes/reads (frontend, metabox, scoring),
1246 + // otherwise the response is always empty:
1247 + // title/description → _thinkrank_seo_title / _thinkrank_meta_description
1248 + // keywords → Focus_Keywords (stored as _thinkrank_focus_keywords)
1249 + // last_generated → _thinkrank_generated_at (written by Metadata_Generator)
919 1250 $metadata = [
920 - 'title' => get_post_meta($post_id, '_thinkrank_title', true),
921 - 'description' => get_post_meta($post_id, '_thinkrank_description', true),
922 - 'keywords' => get_post_meta($post_id, '_thinkrank_keywords', true),
1251 + 'title' => get_post_meta($post_id, '_thinkrank_seo_title', true),
1252 + 'description' => get_post_meta($post_id, '_thinkrank_meta_description', true),
1253 + 'keywords' => \ThinkRank\SEO\Focus_Keywords::get($post_id),
923 1254 'seo_score' => get_post_meta($post_id, '_thinkrank_seo_score', true) ?: 0,
924 - 'last_generated' => get_post_meta($post_id, '_thinkrank_last_generated', true),
1255 + 'last_generated' => get_post_meta($post_id, '_thinkrank_generated_at', true),
1256 + // Whether a background writer (Auto AI on publish, bulk
1257 + // optimization, imports) is about to fill these fields. The editor
1258 + // panel polls only while this is true.
1259 + 'pending' => $pending,
925 1260 ];
926 1261
927 1262 return new \WP_REST_Response($metadata);
928 1263 }
@@ -939,8 +1274,11 @@
939 1274 $options = [
940 1275 'target_keyword' => $request->get_param('target_keyword'),
941 1276 'content_type' => $request->get_param('content_type'),
942 1277 'tone' => $request->get_param('tone'),
1278 + // Instruct the model to write in the post/site language instead of
1279 + // defaulting to English on non-English sites (issue #234).
1280 + 'language' => \ThinkRank\AI\Language_Resolver::resolve((int) $request->get_param('post_id')),
943 1281 ];
944 1282
945 1283 // Rate limiting: per user/IP per route
946 1284 $user_id = get_current_user_id();
@@ -945,9 +1283,9 @@
945 1283 // Rate limiting: per user/IP per route
946 1284 $user_id = get_current_user_id();
947 1285 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
948 1286 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
949 - $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10);
1287 + $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 0);
950 1288 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
951 1289 if (is_wp_error($allowed)) {
952 1290 return new \WP_REST_Response([
953 1291 'success' => false,
@@ -992,8 +1330,9 @@
992 1330 'target_keyword' => $request->get_param('target_keyword'),
993 1331 'content_type' => $request->get_param('content_type'),
994 1332 'tone' => $request->get_param('tone'),
995 1333 'suggestion' => $request->get_param('suggestion'),
1334 + 'language' => \ThinkRank\AI\Language_Resolver::resolve((int) $request->get_param('post_id')),
996 1335 ];
997 1336
998 1337 // Rate limiting: shares the AI generation bucket.
999 1338 $user_id = get_current_user_id();
@@ -998,9 +1337,9 @@
998 1337 // Rate limiting: shares the AI generation bucket.
999 1338 $user_id = get_current_user_id();
1000 1339 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1001 1340 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1002 - $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10);
1341 + $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 0);
1003 1342 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1004 1343 if (is_wp_error($allowed)) {
1005 1344 return new \WP_REST_Response([
1006 1345 'success' => false,
@@ -1041,8 +1380,9 @@
1041 1380 'target_keyword' => $request->get_param('target_keyword'),
1042 1381 'content_type' => $request->get_param('content_type'),
1043 1382 'tone' => $request->get_param('tone'),
1044 1383 'suggestion' => $request->get_param('suggestion'),
1384 + 'language' => \ThinkRank\AI\Language_Resolver::resolve((int) $request->get_param('post_id')),
1045 1385 ];
1046 1386
1047 1387 // Rate limiting: shares the AI generation bucket.
1048 1388 $user_id = get_current_user_id();
@@ -1047,9 +1387,9 @@
1047 1387 // Rate limiting: shares the AI generation bucket.
1048 1388 $user_id = get_current_user_id();
1049 1389 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1050 1390 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1051 - $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10);
1391 + $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 0);
1052 1392 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1053 1393 if (is_wp_error($allowed)) {
1054 1394 return new \WP_REST_Response([
1055 1395 'success' => false,
@@ -1099,9 +1439,9 @@
1099 1439 // Rate limiting: shares the AI generation bucket.
1100 1440 $user_id = get_current_user_id();
1101 1441 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1102 1442 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1103 - $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10);
1443 + $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 0);
1104 1444 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1105 1445 if (is_wp_error($allowed)) {
1106 1446 return new \WP_REST_Response([
1107 1447 'success' => false,
@@ -1145,9 +1485,9 @@
1145 1485 // Rate limiting: shares the AI generation bucket.
1146 1486 $user_id = get_current_user_id();
1147 1487 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1148 1488 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1149 - $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10);
1489 + $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 0);
1150 1490 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1151 1491 if (is_wp_error($allowed)) {
1152 1492 return new \WP_REST_Response([
1153 1493 'success' => false,
@@ -1194,9 +1534,9 @@
1194 1534 // Rate limiting: shares the AI generation bucket.
1195 1535 $user_id = get_current_user_id();
1196 1536 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1197 1537 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1198 - $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10);
1538 + $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 0);
1199 1539 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1200 1540 if (is_wp_error($allowed)) {
1201 1541 return new \WP_REST_Response([
1202 1542 'success' => false,
@@ -1293,9 +1633,9 @@
1293 1633 // Rate limiting: per user/IP per route
1294 1634 $user_id = get_current_user_id();
1295 1635 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1296 1636 $bucket_id = 'ai_test|' . ($user_id ?: $ip);
1297 - $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10);
1637 + $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 0);
1298 1638 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1299 1639 if (is_wp_error($allowed)) {
1300 1640 return new \WP_REST_Response([
1301 1641 'success' => false,
@@ -1305,9 +1645,50 @@
1305 1645
1306 1646 try {
1307 1647 $api_key = $request->get_param('api_key');
1308 1648 $provider = $request->get_param('provider') ?: 'openai';
1649 + // The model the caller is asking about. Empty means "whatever is
1650 + // saved" — the settings screen sends the model currently on screen
1651 + // so an unsaved pick or a hand-typed id is what actually gets
1652 + // tested, rather than the last saved one.
1653 + $model = trim((string) $request->get_param('model'));
1309 1654
1655 + // An unrecognised provider used to fall through to the Gemini arm
1656 + // below, so a typo silently tested the wrong provider's key.
1657 + if (!in_array($provider, \ThinkRank\Core\Settings::SUPPORTED_AI_PROVIDERS, true)) {
1658 + return new \WP_REST_Response([
1659 + 'success' => false,
1660 + /* translators: %s: the unrecognised provider value. */
1661 + 'message' => sprintf(__('Unknown AI provider: %s', 'thinkrank'), $provider),
1662 + ], 400);
1663 + }
1664 +
1665 + // The OpenAI-compatible endpoint is tested by URL, not by key: a
1666 + // local Ollama or LM Studio server wants no key, so the key checks
1667 + // below would refuse to test a perfectly good endpoint (#721).
1668 + if ('openai_compatible' === $provider) {
1669 + $base_url = trim((string) $request->get_param('base_url'));
1670 + if ('' === $base_url) {
1671 + $base_url = (string) \ThinkRank\Core\Settings::instance()->get('openai_compatible_base_url', '');
1672 + }
1673 +
1674 + if (empty($api_key)) {
1675 + $api_key = (string) \ThinkRank\Core\Settings::instance()->get('openai_compatible_api_key', '');
1676 + }
1677 +
1678 + if ('' === $model) {
1679 + $model = trim((string) \ThinkRank\Core\Settings::instance()->get('openai_compatible_model', ''));
1680 + }
1681 +
1682 + $json_mode = $request->has_param('json_mode')
1683 + ? (bool) $request->get_param('json_mode')
1684 + : (bool) \ThinkRank\Core\Settings::instance()->get('openai_compatible_json_mode', false);
1685 +
1686 + $result = $this->test_openai_compatible_connection($base_url, $api_key, $model, $json_mode);
1687 +
1688 + return new \WP_REST_Response($result, $result['success'] ? 200 : 400);
1689 + }
1690 +
1310 1691 // If no API key provided in request, try to get from saved settings
1311 1692 if (empty($api_key)) {
1312 1693 $settings = \ThinkRank\Core\Settings::instance();
1313 1694 if ($provider === 'openai') {
@@ -1329,15 +1710,15 @@
1329 1710 }
1330 1711
1331 1712 // Test the connection with a simple API call
1332 1713 if ($provider === 'openai') {
1333 - $result = $this->test_openai_connection($api_key);
1714 + $result = $this->test_openai_connection($api_key, $model);
1334 1715 } elseif ($provider === 'claude') {
1335 - $result = $this->test_claude_connection($api_key);
1716 + $result = $this->test_claude_connection($api_key, $model);
1336 1717 } elseif ($provider === 'openrouter') {
1337 - $result = $this->test_openrouter_connection($api_key);
1718 + $result = $this->test_openrouter_connection($api_key, $model);
1338 1719 } else {
1339 - $result = $this->test_gemini_connection($api_key);
1720 + $result = $this->test_gemini_connection($api_key, $model);
1340 1721 }
1341 1722
1342 1723 return new \WP_REST_Response($result, $result['success'] ? 200 : 400);
1343 1724 } catch (\Exception $e) {
@@ -1348,14 +1729,378 @@
1348 1729 }
1349 1730 }
1350 1731
1351 1732 /**
1733 + * Test an OpenAI-compatible endpoint with a real (tiny) completion.
1734 + *
1735 + * Deliberately not a GET /models probe: a server can list models and still
1736 + * fail to complete (wrong model id, model not pulled, gateway that only
1737 + * proxies /models). The one-token chat completion answers the question the
1738 + * user is actually asking — "can ThinkRank generate with this?" — and its
1739 + * reply plus latency is what the settings screen shows (#721).
1740 + *
1741 + * @since 2.8.0
1742 + *
1743 + * @param string $base_url Base URL as typed (validated here).
1744 + * @param string $api_key Optional API key.
1745 + * @param string $model Model id to complete with.
1746 + * @param bool $json_mode Also check the server accepts response_format json_object.
1747 + * @return array Test result.
1748 + */
1749 + private function test_openai_compatible_connection(string $base_url, string $api_key, string $model, bool $json_mode = false): array {
1750 + $validated = \ThinkRank\AI\Endpoint_URL_Validator::validate($base_url);
1751 + if (is_wp_error($validated)) {
1752 + return [
1753 + 'success' => false,
1754 + 'message' => $validated->get_error_message(),
1755 + ];
1756 + }
1757 +
1758 + if ('' === trim($model)) {
1759 + return [
1760 + 'success' => false,
1761 + 'message' => __('Enter the model id your endpoint should use, for example llama3.1 or gpt-4o.', 'thinkrank'),
1762 + ];
1763 + }
1764 +
1765 + $headers = ['Content-Type' => 'application/json'];
1766 + if ('' !== $api_key) {
1767 + $headers['Authorization'] = 'Bearer ' . $api_key;
1768 + $headers['api-key'] = $api_key;
1769 + }
1770 +
1771 + // A "Reply with OK" answer is tiny; anything approaching this is a
1772 + // server misbehaving, and the guard caps it before it is buffered.
1773 + $max_bytes = 131072;
1774 +
1775 + $started = microtime(true);
1776 +
1777 + $response = \ThinkRank\AI\Endpoint_URL_Validator::guarded_request(\ThinkRank\AI\Endpoint_URL_Validator::route($validated, 'chat/completions'), [
1778 + 'method' => 'POST',
1779 + // Long enough for a cold local model to load its weights, short
1780 + // enough that a wrong URL does not hang the settings screen.
1781 + 'timeout' => 30,
1782 + 'headers' => $headers,
1783 + 'limit_response_size' => $max_bytes,
1784 + 'body' => wp_json_encode([
1785 + 'model' => trim($model),
1786 + 'messages' => [['role' => 'user', 'content' => 'Reply with OK']],
1787 + // Not 16: a local reasoning model (deepseek-r1, a qwen3
1788 + // thinking build) spends its first tokens on hidden reasoning
1789 + // and returns empty content if the budget runs out there, which
1790 + // would report a working endpoint as broken.
1791 + 'max_tokens' => 128,
1792 + ]),
1793 + ]);
1794 +
1795 + $latency_ms = (int) round((microtime(true) - $started) * 1000);
1796 +
1797 + if (is_wp_error($response)) {
1798 + return [
1799 + 'success' => false,
1800 + /* translators: %s: transport error, e.g. "cURL error 7: Connection refused". */
1801 + 'message' => sprintf(__('Could not reach the endpoint: %s', 'thinkrank'), $response->get_error_message()),
1802 + ];
1803 + }
1804 +
1805 + $status = (int) wp_remote_retrieve_response_code($response);
1806 + $raw_body = wp_remote_retrieve_body($response);
1807 +
1808 + // Redirects are never followed (the key would go wherever the endpoint
1809 + // points). Say so, rather than letting the empty 3xx body read as a
1810 + // wrong model id: an http-to-https upgrade is the usual cause.
1811 + if ($status >= 300 && $status < 400) {
1812 + $location = (string) wp_remote_retrieve_header($response, 'location');
1813 +
1814 + return [
1815 + 'success' => false,
1816 + 'status' => $status,
1817 + 'message' => '' !== $location
1818 + ? sprintf(
1819 + /* translators: 1: HTTP status code, 2: the URL the endpoint redirected to. */
1820 + __('The endpoint redirected (%1$d) to %2$s. Redirects are refused so your API key cannot follow them. Enter the final URL instead, for example https:// in place of http://.', 'thinkrank'),
1821 + $status,
1822 + esc_url_raw($location)
1823 + )
1824 + : sprintf(
1825 + /* translators: %d: HTTP status code. */
1826 + __('The endpoint redirected (%d). Redirects are refused so your API key cannot follow them. Enter the final URL instead, for example https:// in place of http://.', 'thinkrank'),
1827 + $status
1828 + ),
1829 + ];
1830 + }
1831 +
1832 + // A body that reached the cap was cut mid-JSON. Report that, not a
1833 + // missing completion: the model id was never the problem.
1834 + if (strlen($raw_body) >= $max_bytes) {
1835 + return [
1836 + 'success' => false,
1837 + 'status' => $status,
1838 + 'message' => __('The endpoint sent more than ThinkRank will read for a connection test (128 KB). It is misconfigured or is not answering with a chat completion.', 'thinkrank'),
1839 + ];
1840 + }
1841 +
1842 + $body = json_decode($raw_body, true);
1843 +
1844 + if ($status >= 400) {
1845 + $error = '';
1846 + if (is_array($body)) {
1847 + $error = (string) ($body['error']['message'] ?? ($body['error'] ?? ($body['message'] ?? '')));
1848 + }
1849 + if ('' === $error) {
1850 + $error = wp_remote_retrieve_response_message($response);
1851 + }
1852 +
1853 + return [
1854 + 'success' => false,
1855 + 'status' => $status,
1856 + /* translators: 1: HTTP status code, 2: error message from the server. */
1857 + 'message' => sprintf(__('The endpoint answered %1$d: %2$s', 'thinkrank'), $status, $error),
1858 + ];
1859 + }
1860 +
1861 + $reply = '';
1862 + $reasoning_only = false;
1863 + if (is_array($body)) {
1864 + $message = is_array($body['choices'][0]['message'] ?? null) ? $body['choices'][0]['message'] : [];
1865 + $reply = trim((string) ($message['content'] ?? ''));
1866 +
1867 + // Ollama and vLLM expose a thinking model's hidden reasoning
1868 + // separately. Reasoning with no content still proves the endpoint
1869 + // and the model work — it means the model thinks before answering,
1870 + // which is worth saying out loud because it makes every generation
1871 + // slower.
1872 + if ('' === $reply) {
1873 + $reasoning = trim((string) ($message['reasoning'] ?? ($message['reasoning_content'] ?? '')));
1874 + if ('' !== $reasoning) {
1875 + $reply = $reasoning;
1876 + $reasoning_only = true;
1877 + }
1878 + }
1879 + }
1880 +
1881 + if ('' === $reply) {
1882 + return [
1883 + 'success' => false,
1884 + 'status' => $status,
1885 + 'message' => __('The endpoint replied, but with no completion text. Check that the model id is one this server serves.', 'thinkrank'),
1886 + ];
1887 + }
1888 +
1889 + $result = [
1890 + 'success' => true,
1891 + 'model' => trim($model),
1892 + 'model_available' => true,
1893 + 'latency_ms' => $latency_ms,
1894 + 'reply' => mb_substr($reply, 0, 200),
1895 + 'reasoning_only' => $reasoning_only,
1896 + 'message' => $reasoning_only
1897 + ? sprintf(
1898 + /* translators: 1: model id, 2: latency in milliseconds. */
1899 + __('Connected: "%1$s" answered in %2$d ms. It is a reasoning model: it thinks before replying, so generation will be slower and may need a higher timeout.', 'thinkrank'),
1900 + trim($model),
1901 + $latency_ms
1902 + )
1903 + : sprintf(
1904 + /* translators: 1: model id, 2: latency in milliseconds, 3: the model's reply. */
1905 + __('Connected: "%1$s" replied in %2$d ms: %3$s', 'thinkrank'),
1906 + trim($model),
1907 + $latency_ms,
1908 + mb_substr($reply, 0, 80)
1909 + ),
1910 + ];
1911 +
1912 + return $json_mode
1913 + ? $this->probe_openai_compatible_json_mode($validated, $headers, trim($model), $result)
1914 + : $result;
1915 + }
1916 +
1917 + /**
1918 + * Check that an endpoint takes response_format json_object.
1919 + *
1920 + * Runs only after the plain completion worked, so a failure here can mean
1921 + * one thing: the endpoint works, but not with "Force valid JSON answers"
1922 + * on. Generation still works then, because OpenAI_Client falls back to a
1923 + * plain request, but every JSON call pays a failed round trip first. The
1924 + * connection stays a success; the result carries json_mode_supported so
1925 + * the screen can warn instead of reporting a broken endpoint.
1926 + *
1927 + * @since 2.8.0
1928 + *
1929 + * @param string $base_url Validated base URL.
1930 + * @param array $headers Request headers, key included.
1931 + * @param string $model Model id.
1932 + * @param array $result Successful connection result to extend.
1933 + * @return array The result, with json_mode_supported and, when false, a warning message.
1934 + */
1935 + private function probe_openai_compatible_json_mode(string $base_url, array $headers, string $model, array $result): array {
1936 + $response = \ThinkRank\AI\Endpoint_URL_Validator::guarded_request(\ThinkRank\AI\Endpoint_URL_Validator::route($base_url, 'chat/completions'), [
1937 + 'method' => 'POST',
1938 + 'timeout' => 30,
1939 + 'headers' => $headers,
1940 + 'limit_response_size' => 131072,
1941 + 'body' => wp_json_encode([
1942 + 'model' => $model,
1943 + // OpenAI refuses json_object unless the messages mention JSON.
1944 + 'messages' => [['role' => 'user', 'content' => 'Reply with the JSON object {"ok": true}']],
1945 + 'max_tokens' => 128,
1946 + 'response_format' => ['type' => 'json_object'],
1947 + ]),
1948 + ]);
1949 +
1950 + // A timeout or dropped connection says nothing about JSON mode. Leave
1951 + // the result alone rather than warn about a field that was never judged.
1952 + if (is_wp_error($response)) {
1953 + return $result;
1954 + }
1955 +
1956 + $status = (int) wp_remote_retrieve_response_code($response);
1957 + if ($status < 400) {
1958 + $result['json_mode_supported'] = true;
1959 + return $result;
1960 + }
1961 +
1962 + $body = json_decode((string) wp_remote_retrieve_body($response), true);
1963 + $error = '';
1964 + if (is_array($body)) {
1965 + // OpenAI and vLLM nest the text under error.message; Ollama sends a bare error string.
1966 + $error = is_string($body['error'] ?? null)
1967 + ? $body['error']
1968 + : (string) ($body['error']['message'] ?? ($body['message'] ?? ''));
1969 + }
1970 + if ('' === $error) {
1971 + $error = (string) wp_remote_retrieve_response_message($response);
1972 + }
1973 +
1974 + $result['json_mode_supported'] = false;
1975 + $result['message'] = sprintf(
1976 + /* translators: 1: model id, 2: HTTP status code, 3: error message from the server. */
1977 + __('Connected to "%1$s", but the endpoint rejected JSON mode (%2$d: %3$s). Turn off "Force valid JSON answers": generation still works, but each request is sent twice.', 'thinkrank'),
1978 + $model,
1979 + $status,
1980 + $error
1981 + );
1982 +
1983 + return $result;
1984 + }
1985 +
1986 + /**
1987 + * List the models an OpenAI-compatible endpoint serves.
1988 + *
1989 + * @since 2.8.0
1990 + *
1991 + * @param \WP_REST_Request $request Request object.
1992 + * @return \WP_REST_Response Response object.
1993 + */
1994 + public function list_endpoint_models(\WP_REST_Request $request): \WP_REST_Response {
1995 + $settings = \ThinkRank\Core\Settings::instance();
1996 +
1997 + $base_url = trim((string) $request->get_param('base_url'));
1998 + if ('' === $base_url) {
1999 + $base_url = (string) $settings->get('openai_compatible_base_url', '');
2000 + }
2001 +
2002 + $validated = \ThinkRank\AI\Endpoint_URL_Validator::validate($base_url);
2003 + if (is_wp_error($validated)) {
2004 + return new \WP_REST_Response([
2005 + 'success' => false,
2006 + 'message' => $validated->get_error_message(),
2007 + ], 400);
2008 + }
2009 +
2010 + $api_key = trim((string) $request->get_param('api_key'));
2011 + if ('' === $api_key || false !== strpos($api_key, '••••••••')) {
2012 + $api_key = (string) $settings->get('openai_compatible_api_key', '');
2013 + }
2014 +
2015 + $headers = ['Content-Type' => 'application/json'];
2016 + if ('' !== $api_key) {
2017 + $headers['Authorization'] = 'Bearer ' . $api_key;
2018 + $headers['api-key'] = $api_key;
2019 + }
2020 +
2021 + $response = \ThinkRank\AI\Endpoint_URL_Validator::guarded_request(\ThinkRank\AI\Endpoint_URL_Validator::route($validated, 'models'), [
2022 + 'method' => 'GET',
2023 + 'timeout' => 15,
2024 + 'headers' => $headers,
2025 + // A hostile or misconfigured endpoint can answer with an unbounded
2026 + // body; buffering it whole would spend the worker's memory on a
2027 + // list we cap at MAX_ENDPOINT_MODELS anyway.
2028 + 'limit_response_size' => self::MAX_MODELS_RESPONSE_BYTES,
2029 + ]);
2030 +
2031 + if (is_wp_error($response)) {
2032 + return new \WP_REST_Response([
2033 + 'success' => false,
2034 + /* translators: %s: transport error. */
2035 + 'message' => sprintf(__('Could not reach the endpoint: %s', 'thinkrank'), $response->get_error_message()),
2036 + ], 400);
2037 + }
2038 +
2039 + $status = (int) wp_remote_retrieve_response_code($response);
2040 + $body = json_decode(wp_remote_retrieve_body($response), true);
2041 +
2042 + if ($status >= 400 || !is_array($body)) {
2043 + return new \WP_REST_Response([
2044 + 'success' => false,
2045 + /* translators: %d: HTTP status code. */
2046 + 'message' => sprintf(__('This endpoint does not list its models (HTTP %d). Type the model id by hand instead.', 'thinkrank'), $status),
2047 + ], 400);
2048 + }
2049 +
2050 + // OpenAI's shape is {data: [{id: …}]}; some gateways answer a bare list.
2051 + $entries = isset($body['data']) && is_array($body['data']) ? $body['data'] : $body;
2052 + $models = [];
2053 + $truncated = false;
2054 + foreach ($entries as $entry) {
2055 + if (count($models) >= self::MAX_ENDPOINT_MODELS) {
2056 + // A gateway fronting a public catalogue can list thousands of
2057 + // models. Sanitising and sorting all of them is work nobody
2058 + // asked for — the field is a suggestion list, not a registry.
2059 + $truncated = true;
2060 + break;
2061 + }
2062 +
2063 + if (is_array($entry) && !empty($entry['id'])) {
2064 + $models[] = sanitize_text_field((string) $entry['id']);
2065 + } elseif (is_string($entry) && '' !== $entry) {
2066 + $models[] = sanitize_text_field($entry);
2067 + }
2068 + }
2069 +
2070 + $models = array_values(array_unique($models));
2071 + sort($models);
2072 +
2073 + if (empty($models)) {
2074 + return new \WP_REST_Response([
2075 + 'success' => false,
2076 + 'message' => __('The endpoint answered, but listed no models. Type the model id by hand instead.', 'thinkrank'),
2077 + ], 400);
2078 + }
2079 +
2080 + return new \WP_REST_Response([
2081 + 'success' => true,
2082 + 'models' => $models,
2083 + 'truncated' => $truncated,
2084 + ]);
2085 + }
2086 +
2087 + /**
1352 2088 * Test OpenAI API connection
1353 2089 *
2090 + * The models endpoint doubles as the model check: it answers with every id
2091 + * this key may call, so an unknown or unentitled model is caught here
2092 + * instead of at the first real generation.
2093 + *
1354 2094 * @param string $api_key API key to test
2095 + * @param string $model Model id to verify, or '' to use the saved one
1355 2096 * @return array Test result
1356 2097 */
1357 - private function test_openai_connection(string $api_key): array {
2098 + private function test_openai_connection(string $api_key, string $model = ''): array {
2099 + $model = $model !== ''
2100 + ? $model
2101 + : (string) \ThinkRank\Core\Settings::instance()->get('openai_model', \ThinkRank\Core\Settings::DEFAULT_OPENAI_MODEL);
2102 +
1358 2103 $url = 'https://api.openai.com/v1/models';
1359 2104
1360 2105 $response = wp_remote_get($url, [
1361 2106 'headers' => [
@@ -1377,11 +2122,28 @@
1377 2122
1378 2123 if ($status_code === 200) {
1379 2124 $data = json_decode($body, true);
1380 2125 if (isset($data['data']) && is_array($data['data'])) {
2126 + $ids = array_column($data['data'], 'id');
2127 +
2128 + if ($model !== '' && !in_array($model, $ids, true)) {
2129 + return [
2130 + 'success' => false,
2131 + 'model' => $model,
2132 + 'model_available' => false,
2133 + /* translators: %s: the model id that was tested. */
2134 + 'message' => sprintf(__('API key works, but the model "%s" is not available to this account.', 'thinkrank'), $model),
2135 + ];
2136 + }
2137 +
1381 2138 return [
1382 2139 'success' => true,
1383 - 'message' => __('OpenAI API connection successful!', 'thinkrank'),
2140 + 'model' => $model,
2141 + 'model_available' => $model !== '',
2142 + 'message' => $model !== ''
2143 + /* translators: %s: the model id that was tested. */
2144 + ? sprintf(__('OpenAI API connection successful — model "%s" is available.', 'thinkrank'), $model)
2145 + : __('OpenAI API connection successful!', 'thinkrank'),
1384 2146 'models_count' => count($data['data']),
1385 2147 ];
1386 2148 }
1387 2149 }
@@ -1399,11 +2161,16 @@
1399 2161 /**
1400 2162 * Test OpenRouter API connection
1401 2163 *
1402 2164 * @param string $api_key API key to test
2165 + * @param string $model Model id to verify, or '' to use the saved one
1403 2166 * @return array Test result
1404 2167 */
1405 - private function test_openrouter_connection(string $api_key): array {
2168 + private function test_openrouter_connection(string $api_key, string $model = ''): array {
2169 + $model = $model !== ''
2170 + ? $model
2171 + : (string) \ThinkRank\Core\Settings::instance()->get('openrouter_model', \ThinkRank\Core\Settings::DEFAULT_OPENROUTER_MODEL);
2172 +
1406 2173 // Validate the key format first (OpenRouter keys start with "sk-or-").
1407 2174 if (!str_starts_with($api_key, 'sk-or-')) {
1408 2175 return [
1409 2176 'success' => false,
@@ -1436,11 +2203,25 @@
1436 2203
1437 2204 if ($status_code === 200) {
1438 2205 $data = json_decode($body, true);
1439 2206 if (isset($data['data']) && is_array($data['data'])) {
2207 + // The key is good; the catalogue is a separate document, so
2208 + // the model needs its own lookup.
2209 + if ($model !== '') {
2210 + $model_check = $this->check_openrouter_model($api_key, $model);
2211 + if ($model_check !== null) {
2212 + return $model_check;
2213 + }
2214 + }
2215 +
1440 2216 return [
1441 2217 'success' => true,
1442 - 'message' => __('OpenRouter API connection successful!', 'thinkrank'),
2218 + 'model' => $model,
2219 + 'model_available' => $model !== '',
2220 + 'message' => $model !== ''
2221 + /* translators: %s: the model id that was tested. */
2222 + ? sprintf(__('OpenRouter API connection successful — model "%s" is available.', 'thinkrank'), $model)
2223 + : __('OpenRouter API connection successful!', 'thinkrank'),
1443 2224 ];
1444 2225 }
1445 2226 }
1446 2227
@@ -1454,14 +2235,58 @@
1454 2235 ];
1455 2236 }
1456 2237
1457 2238 /**
2239 + * Verify a model id against OpenRouter's public catalogue.
2240 + *
2241 + * @param string $api_key API key to authenticate the lookup
2242 + * @param string $model Model id to look for
2243 + * @return array|null Failure payload when the model is unknown, null when it
2244 + * is available or when the catalogue could not be read —
2245 + * a listing hiccup must not fail an otherwise good key.
2246 + */
2247 + private function check_openrouter_model(string $api_key, string $model): ?array {
2248 + $response = wp_remote_get('https://openrouter.ai/api/v1/models', [
2249 + 'headers' => [
2250 + 'Authorization' => 'Bearer ' . $api_key,
2251 + 'Content-Type' => 'application/json',
2252 + 'HTTP-Referer' => home_url('/'),
2253 + 'X-Title' => 'ThinkRank',
2254 + ],
2255 + 'timeout' => 10,
2256 + ]);
2257 +
2258 + if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
2259 + return null;
2260 + }
2261 +
2262 + $data = json_decode(wp_remote_retrieve_body($response), true);
2263 + if (!isset($data['data']) || !is_array($data['data'])) {
2264 + return null;
2265 + }
2266 +
2267 + $ids = array_column($data['data'], 'id');
2268 + if (in_array($model, $ids, true)) {
2269 + return null;
2270 + }
2271 +
2272 + return [
2273 + 'success' => false,
2274 + 'model' => $model,
2275 + 'model_available' => false,
2276 + /* translators: %s: the model id that was tested. */
2277 + 'message' => sprintf(__('API key works, but "%s" is not a model OpenRouter offers.', 'thinkrank'), $model),
2278 + ];
2279 + }
2280 +
2281 + /**
1458 2282 * Test Claude API connection
1459 2283 *
1460 2284 * @param string $api_key API key to test
2285 + * @param string $model Model id to verify, or '' to use the saved one
1461 2286 * @return array Test result
1462 2287 */
1463 - private function test_claude_connection(string $api_key): array {
2288 + private function test_claude_connection(string $api_key, string $model = ''): array {
1464 2289 // First validate the key format
1465 2290 if (!str_starts_with($api_key, 'sk-ant-')) {
1466 2291 return [
1467 2292 'success' => false,
@@ -1471,12 +2296,18 @@
1471 2296
1472 2297 // Test with a simple API call
1473 2298 $url = 'https://api.anthropic.com/v1/messages';
1474 2299
1475 - // Get the configured Claude model, with fallback to a current model.
1476 - // Self-heal retired/unavailable IDs saved by earlier versions.
1477 - $claude_model = \ThinkRank\Core\Settings::instance()->get('claude_model', 'claude-sonnet-5');
1478 - $claude_model = \ThinkRank\AI\Claude_Client::normalize_model($claude_model);
2300 + // A model sent with the request is tested verbatim: normalizing it would
2301 + // quietly swap a typo for a working id and report success for a model
2302 + // the user never asked for. Only the saved fallback is self-healed, as
2303 + // that is the path where a retired id from an older release shows up.
2304 + if ($model !== '') {
2305 + $claude_model = $model;
2306 + } else {
2307 + $claude_model = \ThinkRank\Core\Settings::instance()->get('claude_model', \ThinkRank\Core\Settings::DEFAULT_CLAUDE_MODEL);
2308 + $claude_model = \ThinkRank\AI\Claude_Client::normalize_model($claude_model);
2309 + }
1479 2310
1480 2311 $body = [
1481 2312 'model' => $claude_model,
1482 2313 'max_tokens' => 10,
@@ -1510,16 +2341,32 @@
1510 2341
1511 2342 if ($status_code === 200) {
1512 2343 return [
1513 2344 'success' => true,
1514 - 'message' => __('Claude API connection successful!', 'thinkrank'),
2345 + 'model' => $claude_model,
2346 + 'model_available' => true,
2347 + /* translators: %s: the model id that was tested. */
2348 + 'message' => sprintf(__('Claude API connection successful — model "%s" is available.', 'thinkrank'), $claude_model),
1515 2349 ];
1516 2350 } else {
1517 2351 $error_data = json_decode($response_body, true);
1518 2352 $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
1519 2353
2354 + // 404 on /v1/messages means the key authenticated but the model id
2355 + // does not exist — say so, instead of blaming the key.
2356 + if ($status_code === 404) {
2357 + return [
2358 + 'success' => false,
2359 + 'model' => $claude_model,
2360 + 'model_available' => false,
2361 + /* translators: %s: the model id that was tested. */
2362 + 'message' => sprintf(__('API key works, but the model "%s" was not found.', 'thinkrank'), $claude_model),
2363 + ];
2364 + }
2365 +
1520 2366 return [
1521 2367 'success' => false,
2368 + 'model' => $claude_model,
1522 2369 /* translators: %1$d: HTTP status code, %2$s: error message from Claude API */
1523 2370 'message' => sprintf(__('Claude API error (%1$d): %2$s', 'thinkrank'), $status_code, $error_message),
1524 2371 ];
1525 2372 }
@@ -1528,15 +2375,26 @@
1528 2375 /**
1529 2376 * Test Gemini API connection
1530 2377 *
1531 2378 * @param string $api_key API key to test
2379 + * @param string $model Model id to verify, or '' to use the saved one
1532 2380 * @return array Test result
1533 2381 */
1534 - private function test_gemini_connection(string $api_key): array {
2382 + private function test_gemini_connection(string $api_key, string $model = ''): array {
1535 2383 // Test with a simple API call
1536 - $gemini_model = \ThinkRank\Core\Settings::instance()->get('gemini_model', 'gemini-2.5-flash');
1537 - $url = "https://generativelanguage.googleapis.com/v1beta/models/{$gemini_model}:generateContent?key={$api_key}";
2384 + $gemini_model = $model !== ''
2385 + ? $model
2386 + : (string) \ThinkRank\Core\Settings::instance()->get('gemini_model', \ThinkRank\Core\Settings::DEFAULT_GEMINI_MODEL);
1538 2387
2388 + // The model is a path segment, and ids may arrive with the "models/"
2389 + // prefix Google's own docs use.
2390 + $gemini_model = ltrim($gemini_model, '/');
2391 + $gemini_model = preg_replace('#^models/#', '', $gemini_model);
2392 +
2393 + $url = 'https://generativelanguage.googleapis.com/v1beta/models/'
2394 + . rawurlencode($gemini_model)
2395 + . ':generateContent?key=' . rawurlencode($api_key);
2396 +
1539 2397 $body = [
1540 2398 'contents' => [
1541 2399 [
1542 2400 'parts' => [
@@ -1570,16 +2428,32 @@
1570 2428
1571 2429 if ($status_code === 200) {
1572 2430 return [
1573 2431 'success' => true,
1574 - 'message' => __('Gemini API connection successful!', 'thinkrank'),
2432 + 'model' => $gemini_model,
2433 + 'model_available' => true,
2434 + /* translators: %s: the model id that was tested. */
2435 + 'message' => sprintf(__('Gemini API connection successful — model "%s" is available.', 'thinkrank'), $gemini_model),
1575 2436 ];
1576 2437 } else {
1577 2438 $error_data = json_decode($response_body, true);
1578 2439 $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
1579 2440
2441 + // Gemini answers 404 for a model id it does not serve; the key
2442 + // itself authenticated fine, so name the real problem.
2443 + if ($status_code === 404) {
2444 + return [
2445 + 'success' => false,
2446 + 'model' => $gemini_model,
2447 + 'model_available' => false,
2448 + /* translators: %s: the model id that was tested. */
2449 + 'message' => sprintf(__('API key works, but the model "%s" was not found.', 'thinkrank'), $gemini_model),
2450 + ];
2451 + }
2452 +
1580 2453 return [
1581 2454 'success' => false,
2455 + 'model' => $gemini_model,
1582 2456 /* translators: %1$d: HTTP status code, %2$s: error message from Gemini API */
1583 2457 'message' => sprintf(__('Gemini API error (%1$d): %2$s', 'thinkrank'), $status_code, $error_message),
1584 2458 ];
1585 2459 }
@@ -1607,8 +2481,14 @@
1607 2481 public function get_ai_status(\WP_REST_Request $request): \WP_REST_Response {
1608 2482 $ai_manager = new \ThinkRank\AI\Manager();
1609 2483 $status = $ai_manager->get_provider_status();
1610 2484
2485 + // The spend ceiling and kill switch ride on the status the AI screen
2486 + // already polls, rather than a route of their own: a counter the user
2487 + // has to refresh separately to trust is a counter they will not trust
2488 + // (#448).
2489 + $status['budget'] = \ThinkRank\AI\Spend_Guard::status();
2490 +
1611 2491 return new \WP_REST_Response($status);
1612 2492 }
1613 2493
1614 2494 /**
@@ -1626,9 +2506,9 @@
1626 2506 // Rate limiting: per user/IP per route
1627 2507 $user_id = get_current_user_id();
1628 2508 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1629 2509 $bucket_id = 'ai_analyze|' . ($user_id ?: $ip);
1630 - $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 10);
2510 + $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 0);
1631 2511 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1632 2512 if (is_wp_error($allowed)) {
1633 2513 return new \WP_REST_Response([
1634 2514 'success' => false,
@@ -1702,8 +2582,15 @@
1702 2582 // Failed to register Site Identity endpoint
1703 2583 }
1704 2584
1705 2585 try {
2586 + $ai_insights_endpoint = new Ai_Insights_Endpoint();
2587 + $ai_insights_endpoint->register_routes();
2588 + } catch (\Exception $e) {
2589 + // Failed to register AI Insights endpoint
2590 + }
2591 +
2592 + try {
1706 2593 $performance_endpoint = new Performance_Endpoint();
1707 2594 $performance_endpoint->register_routes();
1708 2595 } catch (\Exception $e) {
1709 2596 // Failed to register Performance endpoint
@@ -1772,8 +2659,33 @@
1772 2659 // Failed to register Global SEO endpoint
1773 2660 }
1774 2661
1775 2662 try {
2663 + $content_type_matrix_endpoint = new \ThinkRank\API\Content_Type_Matrix_Endpoint();
2664 + $content_type_matrix_endpoint->register_routes();
2665 + } catch (\Exception $e) {
2666 + // Failed to register Content Type Matrix endpoint
2667 + }
2668 +
2669 + try {
2670 + // Bulk Snippets (#727): lives under global-seo/, so the Role
2671 + // Manager's Bulk SEO Optimization capability covers it.
2672 + $snippets_endpoint = new \ThinkRank\API\Snippets_Endpoint();
2673 + $snippets_endpoint->register_routes();
2674 + } catch (\Exception $e) {
2675 + // Failed to register Bulk Snippets endpoint
2676 + }
2677 +
2678 + try {
2679 + // Thin content report (#565): also under global-seo/, so the same
2680 + // Bulk SEO Optimization capability covers it.
2681 + $thin_content_endpoint = new \ThinkRank\API\Thin_Content_Endpoint();
2682 + $thin_content_endpoint->register_routes();
2683 + } catch (\Exception $e) {
2684 + // Failed to register Thin Content endpoint
2685 + }
2686 +
2687 + try {
1776 2688 $image_seo_endpoint = new Image_SEO_Endpoint();
1777 2689 $image_seo_endpoint->register_routes();
1778 2690 } catch (\Exception $e) {
1779 2691 // Failed to register Image SEO endpoint
@@ -1779,12 +2691,40 @@
1779 2691 // Failed to register Image SEO endpoint
1780 2692 }
1781 2693
1782 2694 try {
2695 + $external_links_endpoint = new External_Links_Endpoint();
2696 + $external_links_endpoint->register_routes();
2697 + } catch (\Exception $e) {
2698 + // Failed to register External Links endpoint
2699 + }
2700 +
2701 + // Import_Controller is deliberately NOT gated on enable_migration_tools.
2702 + // /import/detect backs the setup wizard's migration step and the record
2703 + // count on Settings > Import / Export, and /import/snapshot + /migrate
2704 + // run the wizard's actual import — all on a fresh install, where the
2705 + // setting is off. Gating them would break onboarding, which is a worse
2706 + // bug than the one #583 reports.
2707 + try {
1783 2708 $import_controller = new Import_Controller();
1784 2709 $import_controller->register_routes();
1785 2710 } catch (\Exception $e) {
1786 2711 // Failed to register Import endpoint
2712 + }
2713 +
2714 + // Export/restore is gated on the setting that gates its admin screen,
2715 + // so turning Import / Export off removes its REST surface along with
2716 + // its menu item (#583). Nothing in the setup wizard calls these:
2717 + // MigrationPluginRow takes startExport/startMigration/cancel from
2718 + // useImportWorkflow and never uploadFile. rest_api_init runs per
2719 + // request, so a toggle takes effect on the next one — no flush.
2720 + if ((bool) \ThinkRank\Core\Settings::instance()->get('enable_import_export', false)) {
2721 + try {
2722 + $export_controller = new Export_Controller();
2723 + $export_controller->register_routes();
2724 + } catch (\Exception $e) {
2725 + // Failed to register Export endpoint
2726 + }
1787 2727 }
1788 2728
1789 2729 try {
1790 2730 $setup_wizard_endpoint = new Setup_Wizard_Endpoint();