PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.0
2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 All 50 releases
← All changes | includes/api/class-manager.php +1785 -92 1.0.22.9.0 View file →
@@ -1,5 +1,6 @@
1 1 <?php
2 +
2 3 /**
3 4 * API Manager Class
4 5 *
5 6 * Handles REST API endpoints registration and management
@@ -14,9 +15,8 @@
14 15
15 16 // Import endpoint classes
16 17 use ThinkRank\API\Site_Identity_Endpoint;
17 18 use ThinkRank\API\Performance_Endpoint;
18 -
19 19 use ThinkRank\API\Schema_Endpoint;
20 20 use ThinkRank\API\Settings_Management_Endpoint;
21 21 use ThinkRank\API\Content_Brief_Endpoint;
22 22 use ThinkRank\API\Social_Media_Endpoint;
@@ -25,9 +25,21 @@
25 25 use ThinkRank\API\Usage_Analytics_Endpoint;
26 26 use ThinkRank\API\Integrations_Endpoint;
27 27 use ThinkRank\API\Social_Platforms_Endpoint;
28 28 use ThinkRank\API\LLMs_Txt_Endpoint;
29 +use ThinkRank\API\Global_SEO_Endpoint;
30 +use ThinkRank\API\Image_SEO_Endpoint;
31 +use ThinkRank\API\External_Links_Endpoint;
32 +use ThinkRank\API\Instant_Indexing_Endpoint;
33 +use ThinkRank\API\Pillar_Content_Endpoint;
34 +use ThinkRank\API\Global_Robot_Meta_Endpoint;
35 +use ThinkRank\API\Author_Archives_Endpoint;
36 +use ThinkRank\API\Email_Report_Endpoint;
37 +use ThinkRank\Admin\Importers\Import_Controller;
38 +use ThinkRank\Admin\Importers\Export_Controller;
39 +use ThinkRank\API\Setup_Wizard_Endpoint;
29 40
41 +
30 42 // Prevent direct access
31 43 if (!defined('ABSPATH')) {
32 44 exit;
33 45 }
@@ -47,9 +59,42 @@
47 59 * @var string
48 60 */
49 61 private const NAMESPACE = 'thinkrank/v1';
50 62
51 - /**
63 + /**
64 + * Maximum accepted length (characters) for AI `content` payloads. Enforced
65 + * at the REST boundary so oversized input can't drive expensive prompt
66 + * building, cache hashing, and AI requests/retries. Mirrors the frontend's
67 + * 5000-character trim.
68 + */
69 + private const AI_CONTENT_MAX_LENGTH = 5000;
70 +
71 + /**
72 + * Ceilings for the OpenAI-compatible endpoint's model listing (#721).
73 + *
74 + * The endpoint is a host the site owner named, not one we trust: a
75 + * misconfigured or hostile server can answer `GET /models` with an
76 + * unbounded body or a catalogue of thousands. Both are bounded here — the
77 + * field this feeds is a suggestion list, and the UI shows the first few.
78 + */
79 + private const MAX_MODELS_RESPONSE_BYTES = 262144; // 256 KB.
80 + private const MAX_ENDPOINT_MODELS = 200;
81 +
82 + /**
83 + * Sanitize and hard-cap an AI `content` request parameter.
84 + *
85 + * Used as the `sanitize_callback` for every AI endpoint's `content` arg so
86 + * the server enforces its own maximum regardless of what a direct REST
87 + * caller sends.
88 + *
89 + * @param mixed $value Raw request value.
90 + * @return string Sanitized content, truncated to AI_CONTENT_MAX_LENGTH.
91 + */
92 + public function sanitize_ai_content($value): string {
93 + return mb_substr(sanitize_textarea_field((string) $value), 0, self::AI_CONTENT_MAX_LENGTH);
94 + }
95 +
96 + /**
52 97 * Initialize API manager
53 98 *
54 99 * @return void
55 100 */
@@ -55,8 +100,21 @@
55 100 */
56 101 public function init(): void {
57 102 add_action('rest_api_init', [$this, 'register_routes']);
58 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);
59 117 }
60 118
61 119 /**
62 120 * Register REST API routes
@@ -82,25 +140,39 @@
82 140 'callback' => [$this, 'get_system_status'],
83 141 'permission_callback' => [$this, 'check_basic_permissions'],
84 142 ]);
85 143
144 + // Integration health check for MCP/Abilities clients (see #188). This
145 + // route is intentionally gated only by the admin capability, NOT by the
146 + // `enable_mcp` toggle, so it stays reachable as a diagnostic even when
147 + // the MCP server is off or abilities failed to register.
148 + register_rest_route(self::NAMESPACE, '/connection-status', [
149 + 'methods' => 'GET',
150 + 'callback' => [$this, 'get_connection_status'],
151 + 'permission_callback' => [$this, 'check_admin_permissions'],
152 + ]);
86 153
87 -
88 154 // Settings endpoints
89 155 register_rest_route(self::NAMESPACE, '/settings', [
90 156 'methods' => 'GET',
91 157 'callback' => [$this, 'get_settings'],
92 - 'permission_callback' => [$this, 'check_admin_permissions'],
158 + 'permission_callback' => [$this, 'check_settings_permissions'],
93 159 ]);
94 160
95 161 register_rest_route(self::NAMESPACE, '/settings', [
96 162 'methods' => 'POST',
97 163 'callback' => [$this, 'save_settings'],
98 - 'permission_callback' => [$this, 'check_admin_permissions'],
164 + 'permission_callback' => [$this, 'check_settings_permissions'],
99 165 'args' => [
100 166 'ai_provider' => [
101 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(),
102 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',
103 175 ],
104 176 'openai_api_key' => [
105 177 'type' => 'string',
106 178 'sanitize_callback' => 'sanitize_text_field',
@@ -124,12 +196,104 @@
124 196 'gemini_model' => [
125 197 'type' => 'string',
126 198 'sanitize_callback' => 'sanitize_text_field',
127 199 ],
200 + 'openrouter_api_key' => [
201 + 'type' => 'string',
202 + 'sanitize_callback' => 'sanitize_text_field',
203 + ],
204 + 'openrouter_model' => [
205 + 'type' => 'string',
206 + 'sanitize_callback' => 'sanitize_text_field',
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 + ],
128 260 'keep_data_on_uninstall' => [
129 261 'type' => 'boolean',
130 262 'sanitize_callback' => 'rest_sanitize_boolean',
131 263 ],
264 + 'enable_mcp' => [
265 + 'type' => 'boolean',
266 + 'sanitize_callback' => 'rest_sanitize_boolean',
267 + ],
268 + 'enable_migration_tools' => [
269 + 'type' => 'boolean',
270 + 'sanitize_callback' => 'rest_sanitize_boolean',
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 + ],
132 296 ],
133 297 ]);
134 298
135 299
@@ -155,8 +319,86 @@
155 319 'args' => [
156 320 'content' => [
157 321 'type' => 'string',
158 322 'required' => true,
323 + 'sanitize_callback' => [$this, 'sanitize_ai_content'],
324 + ],
325 + 'target_keyword' => [
326 + 'type' => 'string',
327 + 'sanitize_callback' => 'sanitize_text_field',
328 + ],
329 + 'content_type' => [
330 + 'type' => 'string',
331 + 'default' => 'blog_post',
332 + 'sanitize_callback' => 'sanitize_text_field',
333 + ],
334 + 'tone' => [
335 + 'type' => 'string',
336 + 'default' => 'professional',
337 + 'sanitize_callback' => 'sanitize_text_field',
338 + ],
339 + 'post_id' => [
340 + 'type' => 'integer',
341 + 'required' => false,
342 + 'default' => 0,
343 + 'sanitize_callback' => 'absint',
344 + ],
345 + ],
346 + ]);
347 +
348 + register_rest_route(self::NAMESPACE, '/ai/improve-title', [
349 + 'methods' => 'POST',
350 + 'callback' => [$this, 'improve_ai_title'],
351 + 'permission_callback' => [$this, 'check_basic_permissions'],
352 + 'args' => [
353 + 'content' => [
354 + 'type' => 'string',
355 + 'required' => true,
356 + 'sanitize_callback' => [$this, 'sanitize_ai_content'],
357 + ],
358 + 'current_title' => [
359 + 'type' => 'string',
360 + 'sanitize_callback' => 'sanitize_text_field',
361 + ],
362 + 'target_keyword' => [
363 + 'type' => 'string',
364 + 'sanitize_callback' => 'sanitize_text_field',
365 + ],
366 + 'content_type' => [
367 + 'type' => 'string',
368 + 'default' => 'blog_post',
369 + 'sanitize_callback' => 'sanitize_text_field',
370 + ],
371 + 'tone' => [
372 + 'type' => 'string',
373 + 'default' => 'professional',
374 + 'sanitize_callback' => 'sanitize_text_field',
375 + ],
376 + 'suggestion' => [
377 + 'type' => 'string',
378 + 'sanitize_callback' => 'sanitize_text_field',
379 + ],
380 + 'post_id' => [
381 + 'type' => 'integer',
382 + 'required' => false,
383 + 'default' => 0,
384 + 'sanitize_callback' => 'absint',
385 + ],
386 + ],
387 + ]);
388 +
389 + register_rest_route(self::NAMESPACE, '/ai/improve-meta-description', [
390 + 'methods' => 'POST',
391 + 'callback' => [$this, 'improve_ai_meta_description'],
392 + 'permission_callback' => [$this, 'check_basic_permissions'],
393 + 'args' => [
394 + 'content' => [
395 + 'type' => 'string',
396 + 'required' => true,
397 + 'sanitize_callback' => [$this, 'sanitize_ai_content'],
398 + ],
399 + 'current_description' => [
400 + 'type' => 'string',
159 401 'sanitize_callback' => 'sanitize_textarea_field',
160 402 ],
161 403 'target_keyword' => [
162 404 'type' => 'string',
@@ -171,11 +413,125 @@
171 413 'type' => 'string',
172 414 'default' => 'professional',
173 415 'sanitize_callback' => 'sanitize_text_field',
174 416 ],
417 + 'suggestion' => [
418 + 'type' => 'string',
419 + 'sanitize_callback' => 'sanitize_text_field',
420 + ],
421 + 'post_id' => [
422 + 'type' => 'integer',
423 + 'required' => false,
424 + 'default' => 0,
425 + 'sanitize_callback' => 'absint',
426 + ],
175 427 ],
176 428 ]);
177 429
430 + register_rest_route(self::NAMESPACE, '/ai/explain-suggestion', [
431 + 'methods' => 'POST',
432 + 'callback' => [$this, 'explain_ai_suggestion'],
433 + 'permission_callback' => [$this, 'check_basic_permissions'],
434 + 'args' => [
435 + 'content' => [
436 + 'type' => 'string',
437 + 'required' => true,
438 + 'sanitize_callback' => [$this, 'sanitize_ai_content'],
439 + ],
440 + 'suggestion' => [
441 + 'type' => 'string',
442 + 'required' => true,
443 + 'sanitize_callback' => 'sanitize_text_field',
444 + ],
445 + 'title' => [
446 + 'type' => 'string',
447 + 'sanitize_callback' => 'sanitize_text_field',
448 + ],
449 + 'target_keyword' => [
450 + 'type' => 'string',
451 + 'sanitize_callback' => 'sanitize_text_field',
452 + ],
453 + 'content_type' => [
454 + 'type' => 'string',
455 + 'default' => 'blog_post',
456 + 'sanitize_callback' => 'sanitize_text_field',
457 + ],
458 + ],
459 + ]);
460 +
461 + register_rest_route(self::NAMESPACE, '/ai/add-dofollow-link', [
462 + 'methods' => 'POST',
463 + 'callback' => [$this, 'add_ai_dofollow_link'],
464 + 'permission_callback' => [$this, 'check_basic_permissions'],
465 + 'args' => [
466 + 'content' => [
467 + 'type' => 'string',
468 + 'required' => true,
469 + 'sanitize_callback' => [$this, 'sanitize_ai_content'],
470 + ],
471 + 'target_keyword' => [
472 + 'type' => 'string',
473 + 'sanitize_callback' => 'sanitize_text_field',
474 + ],
475 + 'content_type' => [
476 + 'type' => 'string',
477 + 'default' => 'blog_post',
478 + 'sanitize_callback' => 'sanitize_text_field',
479 + ],
480 + ],
481 + ]);
482 +
483 + register_rest_route(self::NAMESPACE, '/ai/add-keyword-paragraph', [
484 + 'methods' => 'POST',
485 + 'callback' => [$this, 'add_ai_keyword_paragraph'],
486 + 'permission_callback' => [$this, 'check_basic_permissions'],
487 + 'args' => [
488 + 'content' => [
489 + 'type' => 'string',
490 + 'required' => true,
491 + 'sanitize_callback' => [$this, 'sanitize_ai_content'],
492 + ],
493 + 'target_keyword' => [
494 + 'type' => 'string',
495 + 'required' => true,
496 + 'sanitize_callback' => 'sanitize_text_field',
497 + ],
498 + 'content_type' => [
499 + 'type' => 'string',
500 + 'default' => 'blog_post',
501 + 'sanitize_callback' => 'sanitize_text_field',
502 + ],
503 + 'tone' => [
504 + 'type' => 'string',
505 + 'default' => 'professional',
506 + 'sanitize_callback' => 'sanitize_text_field',
507 + ],
508 + 'word_count' => [
509 + 'type' => 'integer',
510 + 'default' => 0,
511 + 'sanitize_callback' => 'absint',
512 + ],
513 + 'keyword_count' => [
514 + 'type' => 'integer',
515 + 'default' => 0,
516 + 'sanitize_callback' => 'absint',
517 + ],
518 + ],
519 + ]);
520 +
521 + register_rest_route(self::NAMESPACE, '/schema/enable-for-post', [
522 + 'methods' => 'POST',
523 + 'callback' => [$this, 'enable_schema_for_post'],
524 + 'permission_callback' => [$this, 'check_admin_permissions'],
525 + 'args' => [
526 + 'post_id' => [
527 + 'type' => 'integer',
528 + 'required' => true,
529 + 'sanitize_callback' => 'absint',
530 + ],
531 + ],
532 + ]);
533 +
178 534 register_rest_route(self::NAMESPACE, '/ai/test-connection', [
179 535 'methods' => 'POST',
180 536 'callback' => [$this, 'test_ai_connection'],
181 537 'permission_callback' => [$this, 'check_admin_permissions'],
@@ -190,11 +546,50 @@
190 546 'required' => false,
191 547 'default' => 'openai',
192 548 'sanitize_callback' => 'sanitize_key',
193 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 + ],
194 568 ],
195 569 ]);
196 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 +
197 592 register_rest_route(self::NAMESPACE, '/ai/providers', [
198 593 'methods' => 'GET',
199 594 'callback' => [$this, 'get_ai_providers'],
200 595 'permission_callback' => [$this, 'check_basic_permissions'],
@@ -221,8 +616,16 @@
221 616 } catch (\Exception $e) {
222 617 // Usage Analytics endpoint registration failed
223 618 }
224 619
620 + // Register Site SEO Analyzer endpoint
621 + try {
622 + $seo_analyzer_endpoint = new \ThinkRank\API\SEO_Analyzer_Endpoint();
623 + $seo_analyzer_endpoint->register_routes();
624 + } catch (\Exception $e) {
625 + // Site SEO Analyzer endpoint registration failed
626 + }
627 +
225 628 // Register SEO Analytics endpoints
226 629 try {
227 630 $seo_analytics_endpoint = new \ThinkRank\API\SEO_Analytics_Endpoint();
228 631 $seo_analytics_endpoint->register_routes();
@@ -229,19 +632,64 @@
229 632 } catch (\Exception $e) {
230 633 // Failed to register SEO Analytics endpoint
231 634 }
232 635
636 + // Register Instant Indexing endpoints
637 + try {
638 + $instant_indexing_endpoint = new \ThinkRank\API\Instant_Indexing_Endpoint();
639 + $instant_indexing_endpoint->register_routes();
640 + } catch (\Exception $e) {
641 + // Failed to register Instant Indexing endpoint
642 + }
233 643
644 + // Register Pillar Content endpoints
645 + try {
646 + $pillar_content_endpoint = new \ThinkRank\API\Pillar_Content_Endpoint();
647 + $pillar_content_endpoint->register_routes();
648 + } catch (\Exception $e) {
649 + // Failed to register Pillar Content endpoint
650 + }
234 651
235 - // Add a simple test endpoint to verify API is working
236 - register_rest_route(self::NAMESPACE, '/seo-score/test', [
237 - 'methods' => 'GET',
238 - 'callback' => function() {
239 - return ['message' => 'SEO Score API is working!', 'timestamp' => current_time('mysql')];
240 - },
241 - 'permission_callback' => [$this, 'check_basic_permissions'],
242 - ]);
652 + // Register Focus Keyword Usage endpoint ("already used" status).
653 + try {
654 + $focus_keyword_usage_endpoint = new \ThinkRank\API\Focus_Keyword_Usage_Endpoint();
655 + $focus_keyword_usage_endpoint->register_routes();
656 + } catch (\Exception $e) {
657 + // Failed to register Focus Keyword Usage endpoint
658 + }
659 + // Register Global Robot Meta endpoints
660 + try {
661 + $global_robot_meta_endpoint = new \ThinkRank\API\Global_Robot_Meta_Endpoint();
662 + $global_robot_meta_endpoint->register_routes();
663 + } catch (\Exception $e) {
664 + // Failed to register Global Robot Meta endpoint
665 + }
243 666
667 + // Register Author Archives endpoints
668 + try {
669 + $author_archives_endpoint = new \ThinkRank\API\Author_Archives_Endpoint();
670 + $author_archives_endpoint->register_routes();
671 + } catch (\Exception $e) {
672 + // Failed to register Author Archives endpoint
673 + }
674 +
675 + // Register Role Manager endpoint
676 + try {
677 + $role_manager_endpoint = new \ThinkRank\API\Role_Manager_Endpoint();
678 + $role_manager_endpoint->register_routes();
679 + } catch (\Exception $e) {
680 + // Failed to register Role Manager endpoint
681 + }
682 +
683 + // Register Email Report endpoints
684 + try {
685 + $email_report_endpoint = new \ThinkRank\API\Email_Report_Endpoint();
686 + $email_report_endpoint->register_routes();
687 + } catch (\Exception $e) {
688 + // Failed to register Email Report endpoint
689 + }
690 +
691 +
244 692 register_rest_route(self::NAMESPACE, '/ai/status', [
245 693 'methods' => 'GET',
246 694 'callback' => [$this, 'get_ai_status'],
247 695 'permission_callback' => [$this, 'check_basic_permissions'],
@@ -254,9 +702,9 @@
254 702 'args' => [
255 703 'content' => [
256 704 'type' => 'string',
257 705 'required' => true,
258 - 'sanitize_callback' => 'sanitize_textarea_field',
706 + 'sanitize_callback' => [$this, 'sanitize_ai_content'],
259 707 ],
260 708 'metadata' => [
261 709 'type' => 'object',
262 710 'required' => false,
@@ -298,38 +746,47 @@
298 746 return true;
299 747 }
300 748
301 749
302 - /**
303 - * Simple transient-based rate limiter
304 - *
305 - * @param string $bucket_id Unique bucket per user/IP and route
306 - * @param int $limit Max requests per minute
307 - * @return bool|\WP_Error True if allowed, or WP_Error when rate limited
308 - */
309 - private function enforce_rate_limit(string $bucket_id, int $limit) {
310 - $settings = new \ThinkRank\Core\Settings();
311 - $enabled = (bool) $settings->get('enable_rate_limiting', true);
312 - if (!$enabled) {
313 - return true;
314 - }
315 - $now = time();
316 - $window = 60;
317 - $key = 'thinkrank_rl_' . md5($bucket_id);
318 - $bucket = get_transient($key);
319 - if (!is_array($bucket)) {
320 - $bucket = ['start' => $now, 'count' => 0];
321 - }
322 - if ($now - ($bucket['start'] ?? 0) >= $window) {
323 - $bucket = ['start' => $now, 'count' => 0];
324 - }
325 - if (($bucket['count'] ?? 0) >= max(1, $limit)) {
326 - return new \WP_Error('rate_limited', __('Rate limit exceeded. Please wait a moment and try again.', 'thinkrank'), ['status' => 429]);
327 - }
328 - $bucket['count']++;
329 - set_transient($key, $bucket, $window);
330 - return true;
331 - }
750 + /**
751 + * Simple transient-based rate limiter
752 + *
753 + * @param string $bucket_id Unique bucket per user/IP and route
754 + * @param int $limit Max requests per minute
755 + * @return bool|\WP_Error True if allowed, or WP_Error when rate limited
756 + */
757 + private function enforce_rate_limit(string $bucket_id, int $limit) {
758 + $settings = \ThinkRank\Core\Settings::instance();
759 + $enabled = (bool) $settings->get('enable_rate_limiting', true);
760 + if (!$enabled) {
761 + return true;
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 + }
772 + $now = time();
773 + $window = 60;
774 + $key = 'thinkrank_rl_' . md5($bucket_id);
775 + $bucket = get_transient($key);
776 + if (!is_array($bucket)) {
777 + $bucket = ['start' => $now, 'count' => 0];
778 + }
779 + if ($now - ($bucket['start'] ?? 0) >= $window) {
780 + $bucket = ['start' => $now, 'count' => 0];
781 + }
782 + if (($bucket['count'] ?? 0) >= $limit) {
783 + return new \WP_Error('rate_limited', __('Rate limit exceeded. Please wait a moment and try again.', 'thinkrank'), ['status' => 429]);
784 + }
785 + $bucket['count']++;
786 + set_transient($key, $bucket, $window);
787 + return true;
788 + }
332 789
333 790 /**
334 791 * Check admin permissions (for settings)
335 792 *
@@ -357,8 +814,38 @@
357 814 return true;
358 815 }
359 816
360 817 /**
818 + * Check Settings section permissions.
819 + *
820 + * Delegable via Role Manager: passes for administrators (bypass) and for
821 + * any role granted the `thinkrank_settings` capability. Used by the core
822 + * /settings routes so the "Settings & API Keys" area can be delegated.
823 + *
824 + * @param \WP_REST_Request $request Request object
825 + * @return bool|\WP_Error Permission status
826 + */
827 + public function check_settings_permissions(\WP_REST_Request $request) {
828 + if (!is_user_logged_in()) {
829 + return new \WP_Error(
830 + 'rest_forbidden',
831 + __('You must be logged in to access this endpoint.', 'thinkrank'),
832 + ['status' => 401]
833 + );
834 + }
835 +
836 + if (!\ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_settings')) {
837 + return new \WP_Error(
838 + 'rest_forbidden',
839 + __('You do not have permission to manage ThinkRank settings.', 'thinkrank'),
840 + ['status' => 403]
841 + );
842 + }
843 +
844 + return true;
845 + }
846 +
847 + /**
361 848 * Get user capabilities
362 849 *
363 850 * @param \WP_REST_Request $request Request object
364 851 * @return \WP_REST_Response Response object
@@ -400,10 +887,23 @@
400 887 'wp_version' => get_bloginfo('version'),
401 888 ]);
402 889 }
403 890
891 + /**
892 + * Get ThinkRank integration health for MCP/Abilities clients (see #188).
893 + *
894 + * Admin-gated diagnostic; never returns secret material. Delegates to the
895 + * shared reporter so the ability and this route stay in lock-step.
896 + *
897 + * @param \WP_REST_Request $request Request object
898 + * @return \WP_REST_Response Response object
899 + */
900 + public function get_connection_status(\WP_REST_Request $request): \WP_REST_Response {
901 + return new \WP_REST_Response(\ThinkRank\Diagnostics\Connection_Status::report());
902 + }
404 903
405 904
905 +
406 906 /**
407 907 * Get settings
408 908 *
409 909 * @param \WP_REST_Request $request Request object
@@ -410,41 +910,83 @@
410 910 * @return \WP_REST_Response Response object
411 911 */
412 912 public function get_settings(\WP_REST_Request $request): \WP_REST_Response {
413 913 // Use Settings class for consistent access (handles decryption automatically)
414 - $settings_instance = new \ThinkRank\Core\Settings();
914 + $settings_instance = \ThinkRank\Core\Settings::instance();
415 915
416 916 $settings = [
417 - 'ai_provider' => $settings_instance->get('ai_provider', 'openai'),
917 + 'ai_provider' => $settings_instance->get('ai_provider', \ThinkRank\Core\Settings::AI_PROVIDER_NONE),
418 918 'openai_api_key' => $settings_instance->get('openai_api_key', ''),
419 - 'openai_model' => $settings_instance->get('openai_model', 'gpt-5-nano'),
919 + 'openai_model' => $settings_instance->get('openai_model', \ThinkRank\Core\Settings::DEFAULT_OPENAI_MODEL),
420 920 'claude_api_key' => $settings_instance->get('claude_api_key', ''),
421 - 'claude_model' => $settings_instance->get('claude_model', 'claude-3-7-sonnet-latest'),
921 + 'claude_model' => $settings_instance->get('claude_model', \ThinkRank\Core\Settings::DEFAULT_CLAUDE_MODEL),
422 922 'gemini_api_key' => $settings_instance->get('gemini_api_key', ''),
423 - '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),
924 + 'openrouter_api_key' => $settings_instance->get('openrouter_api_key', ''),
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),
424 933 'max_tokens' => $settings_instance->get('max_tokens', 1000),
425 934 'temperature' => $settings_instance->get('temperature', 0.7),
426 935 'cache_duration' => $settings_instance->get('cache_duration', 3600),
427 - 'keep_data_on_uninstall' => $settings_instance->get('keep_data_on_uninstall', true),
936 + 'keep_data_on_uninstall' => (bool) $settings_instance->get('keep_data_on_uninstall', true),
937 + 'enable_migration_tools' => (bool) $settings_instance->get('enable_migration_tools', false),
938 + 'enable_import_export' => (bool) $settings_instance->get('enable_import_export', false),
939 + 'google_account_connected' => (bool) $settings_instance->get('google_account_connected', false),
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),
428 944 ];
429 945
430 946
431 947
432 - // Don't send full API keys to frontend for security - mask them
948 + // Don't send full API keys to frontend for security - mask them,
949 + // revealing the first 5 and last 3 chars so the saved key is recognizable.
433 950 if (!empty($settings['openai_api_key'])) {
434 - $settings['openai_api_key'] = '••••••••' . substr($settings['openai_api_key'], -4);
951 + $settings['openai_api_key'] = $this->mask_ai_api_key($settings['openai_api_key']);
435 952 }
436 953 if (!empty($settings['claude_api_key'])) {
437 - $settings['claude_api_key'] = '••••••••' . substr($settings['claude_api_key'], -4);
954 + $settings['claude_api_key'] = $this->mask_ai_api_key($settings['claude_api_key']);
438 955 }
439 956 if (!empty($settings['gemini_api_key'])) {
440 - $settings['gemini_api_key'] = '••••••••' . substr($settings['gemini_api_key'], -4);
957 + $settings['gemini_api_key'] = $this->mask_ai_api_key($settings['gemini_api_key']);
441 958 }
959 + if (!empty($settings['openrouter_api_key'])) {
960 + $settings['openrouter_api_key'] = $this->mask_ai_api_key($settings['openrouter_api_key']);
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 + }
442 965
443 966 return new \WP_REST_Response($settings);
444 967 }
445 968
446 969 /**
970 + * Mask an AI provider API key for display.
971 + *
972 + * Reveals the first 5 and last 3 characters with a bullet run in between
973 + * (e.g. "sk-pr••••••••abc"). Keys of 8 chars or fewer are fully masked so
974 + * head + tail can't reconstruct the whole value. The "••••••••" sentinel is
975 + * what save_settings() looks for to skip re-saving a resubmitted mask.
976 + *
977 + * @param string $key Raw API key.
978 + * @return string Masked key safe to send to the frontend.
979 + */
980 + private function mask_ai_api_key(string $key): string {
981 + if (strlen($key) <= 8) {
982 + return '••••••••';
983 + }
984 +
985 + return substr($key, 0, 5) . '••••••••' . substr($key, -3);
986 + }
987 +
988 + /**
447 989 * Save settings
448 990 *
449 991 * @param \WP_REST_Request $request Request object
450 992 * @return \WP_REST_Response Response object
@@ -452,10 +994,111 @@
452 994 public function save_settings(\WP_REST_Request $request): \WP_REST_Response {
453 995 $params = $request->get_params();
454 996
455 997 // Get Settings instance for proper encryption handling
456 - $settings = new \ThinkRank\Core\Settings();
998 + $settings = \ThinkRank\Core\Settings::instance();
457 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 +
458 1101 // Map frontend parameter names to setting keys
459 1102 $settings_map = [
460 1103 'ai_provider' => 'ai_provider',
461 1104 'openai_api_key' => 'openai_api_key',
@@ -463,12 +1106,27 @@
463 1106 'claude_api_key' => 'claude_api_key',
464 1107 'claude_model' => 'claude_model',
465 1108 'gemini_api_key' => 'gemini_api_key',
466 1109 'gemini_model' => 'gemini_model',
1110 + 'openrouter_api_key' => 'openrouter_api_key',
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',
467 1119 'max_tokens' => 'max_tokens',
468 1120 'temperature' => 'temperature',
469 1121 'cache_duration' => 'cache_duration',
470 1122 'keep_data_on_uninstall' => 'keep_data_on_uninstall',
1123 + 'enable_mcp' => 'enable_mcp',
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',
471 1129 ];
472 1130
473 1131 // Processing settings save request
474 1132
@@ -476,27 +1134,48 @@
476 1134 if (isset($params[$param_key])) {
477 1135 $value = $params[$param_key];
478 1136
479 1137 // Handle API keys specially - check for masked values
480 - if (in_array($param_key, ['openai_api_key', 'claude_api_key', 'gemini_api_key'])) {
481 - // Don't update if masked (but allow empty to clear)
482 - if (strpos($value, '••••••••') === 0) {
1138 + if (in_array($param_key, ['openai_api_key', 'claude_api_key', 'gemini_api_key', 'openrouter_api_key', 'openai_compatible_api_key'], true)) {
1139 + // Don't update if the value carries the mask sentinel (the
1140 + // preview now keeps real head/tail chars around it, so match
1141 + // anywhere rather than only at the start). Empty still clears.
1142 + if (strpos($value, '••••••••') !== false) {
483 1143 continue;
484 1144 }
485 1145 }
486 1146
487 1147 // Use Settings class for all operations (handles encryption automatically)
488 - if (!$settings->set($setting_key, $value)) {
489 - // Settings save failed, continue with other settings
490 - }
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);
491 1151 }
492 1152 }
493 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 +
494 1173 // Auto-dismiss welcome notice if API key was saved
495 1174 $this->maybe_dismiss_welcome_notice($params);
496 1175
497 1176 // Force AI Manager to re-initialize client with new settings
498 - if (isset($params['ai_provider']) || isset($params['openai_api_key']) || isset($params['claude_api_key']) || isset($params['gemini_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'])) {
499 1178 // Clear any cached AI Manager instances to force re-initialization
500 1179 wp_cache_delete('thinkrank_ai_manager', 'thinkrank');
501 1180
502 1181 // If we have an AI Manager instance, force it to re-initialize
@@ -545,17 +1224,40 @@
545 1224 * @param \WP_REST_Request $request Request object
546 1225 * @return \WP_REST_Response Response object
547 1226 */
548 1227 public function get_metadata(\WP_REST_Request $request): \WP_REST_Response {
549 - $post_id = $request->get_param('post_id');
1228 + $post_id = (int) $request->get_param('post_id');
550 1229
551 - // Get existing metadata
1230 + // Object-level guard: only expose a post's stored SEO meta to a user who
1231 + // can edit that specific post (the section capability gate handles the
1232 + // AI Tools toggle; this adds per-post ownership).
1233 + if (!current_user_can('edit_post', $post_id)) {
1234 + return new \WP_REST_Response(['message' => 'You are not allowed to view this metadata.'], 403);
1235 + }
1236 +
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)
552 1250 $metadata = [
553 - 'title' => get_post_meta($post_id, '_thinkrank_title', true),
554 - 'description' => get_post_meta($post_id, '_thinkrank_description', true),
555 - '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),
556 1254 'seo_score' => get_post_meta($post_id, '_thinkrank_seo_score', true) ?: 0,
557 - '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,
558 1260 ];
559 1261
560 1262 return new \WP_REST_Response($metadata);
561 1263 }
@@ -572,15 +1274,18 @@
572 1274 $options = [
573 1275 'target_keyword' => $request->get_param('target_keyword'),
574 1276 'content_type' => $request->get_param('content_type'),
575 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')),
576 1281 ];
577 1282
578 1283 // Rate limiting: per user/IP per route
579 1284 $user_id = get_current_user_id();
580 - $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
1285 + $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
581 1286 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
582 - $limit = (int) (new \ThinkRank\Core\Settings())->get('max_requests_per_minute', 10);
1287 + $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 0);
583 1288 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
584 1289 if (is_wp_error($allowed)) {
585 1290 return new \WP_REST_Response([
586 1291 'success' => false,
@@ -592,9 +1297,12 @@
592 1297 // Get AI manager instance
593 1298 $ai_manager = new \ThinkRank\AI\Manager();
594 1299 $ai_manager->initialize_client();
595 1300
596 - $metadata = $ai_manager->generate_seo_metadata($content, $options);
1301 + // Run through the generator so title/description are capped to the
1302 + // configured limits and the character counts are returned.
1303 + $generator = new \ThinkRank\AI\Metadata_Generator($ai_manager);
1304 + $metadata = $generator->generate_for_content($content, $options);
597 1305
598 1306 return new \WP_REST_Response([
599 1307 'success' => true,
600 1308 'data' => $metadata,
@@ -599,9 +1307,58 @@
599 1307 'success' => true,
600 1308 'data' => $metadata,
601 1309 'message' => __('SEO metadata generated successfully', 'thinkrank'),
602 1310 ]);
1311 + } catch (\Exception $e) {
1312 + return new \WP_REST_Response([
1313 + 'success' => false,
1314 + 'message' => $e->getMessage(),
1315 + ], 400);
1316 + }
1317 + }
603 1318
1319 + /**
1320 + * Generate and return an improved SEO title for an "Apply" suggestion action.
1321 + *
1322 + * @param \WP_REST_Request $request Request object.
1323 + * @return \WP_REST_Response Response with the improved title under data.title.
1324 + * @throws \Exception When title improvement fails or the AI client is unavailable.
1325 + */
1326 + public function improve_ai_title(\WP_REST_Request $request): \WP_REST_Response {
1327 + $content = $request->get_param('content');
1328 + $options = [
1329 + 'current_title' => $request->get_param('current_title'),
1330 + 'target_keyword' => $request->get_param('target_keyword'),
1331 + 'content_type' => $request->get_param('content_type'),
1332 + 'tone' => $request->get_param('tone'),
1333 + 'suggestion' => $request->get_param('suggestion'),
1334 + 'language' => \ThinkRank\AI\Language_Resolver::resolve((int) $request->get_param('post_id')),
1335 + ];
1336 +
1337 + // Rate limiting: shares the AI generation bucket.
1338 + $user_id = get_current_user_id();
1339 + $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1340 + $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1341 + $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 0);
1342 + $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1343 + if (is_wp_error($allowed)) {
1344 + return new \WP_REST_Response([
1345 + 'success' => false,
1346 + 'message' => $allowed->get_error_message(),
1347 + ], $allowed->get_error_data()['status'] ?? 429);
1348 + }
1349 +
1350 + try {
1351 + $ai_manager = new \ThinkRank\AI\Manager();
1352 + $ai_manager->initialize_client();
1353 +
1354 + $result = $ai_manager->improve_seo_title($content, $options);
1355 +
1356 + return new \WP_REST_Response([
1357 + 'success' => true,
1358 + 'data' => $result,
1359 + 'message' => __('SEO title improved successfully', 'thinkrank'),
1360 + ]);
604 1361 } catch (\Exception $e) {
605 1362 return new \WP_REST_Response([
606 1363 'success' => false,
607 1364 'message' => $e->getMessage(),
@@ -609,8 +1366,264 @@
609 1366 }
610 1367 }
611 1368
612 1369 /**
1370 + * Generate and return an improved meta description for an "Apply" action.
1371 + *
1372 + * @param \WP_REST_Request $request Request object.
1373 + * @return \WP_REST_Response Response with the description under data.description.
1374 + * @throws \Exception When generation fails or the AI client is unavailable.
1375 + */
1376 + public function improve_ai_meta_description(\WP_REST_Request $request): \WP_REST_Response {
1377 + $content = $request->get_param('content');
1378 + $options = [
1379 + 'current_description' => $request->get_param('current_description'),
1380 + 'target_keyword' => $request->get_param('target_keyword'),
1381 + 'content_type' => $request->get_param('content_type'),
1382 + 'tone' => $request->get_param('tone'),
1383 + 'suggestion' => $request->get_param('suggestion'),
1384 + 'language' => \ThinkRank\AI\Language_Resolver::resolve((int) $request->get_param('post_id')),
1385 + ];
1386 +
1387 + // Rate limiting: shares the AI generation bucket.
1388 + $user_id = get_current_user_id();
1389 + $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1390 + $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1391 + $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 0);
1392 + $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1393 + if (is_wp_error($allowed)) {
1394 + return new \WP_REST_Response([
1395 + 'success' => false,
1396 + 'message' => $allowed->get_error_message(),
1397 + ], $allowed->get_error_data()['status'] ?? 429);
1398 + }
1399 +
1400 + try {
1401 + $ai_manager = new \ThinkRank\AI\Manager();
1402 + $ai_manager->initialize_client();
1403 +
1404 + $result = $ai_manager->improve_meta_description($content, $options);
1405 +
1406 + return new \WP_REST_Response([
1407 + 'success' => true,
1408 + 'data' => $result,
1409 + 'message' => __('Meta description generated successfully', 'thinkrank'),
1410 + ]);
1411 + } catch (\Exception $e) {
1412 + return new \WP_REST_Response([
1413 + 'success' => false,
1414 + 'message' => $e->getMessage(),
1415 + ], 400);
1416 + }
1417 + }
1418 +
1419 + /**
1420 + * Explain a single SEO suggestion in plain, post-specific language.
1421 + *
1422 + * Read-only copilot action: returns a short AI explanation of why the
1423 + * suggestion matters for this post and how to resolve it. Does not modify
1424 + * any content.
1425 + *
1426 + * @param \WP_REST_Request $request Request object.
1427 + * @return \WP_REST_Response Response with the explanation under data.explanation.
1428 + * @throws \Exception When generation fails or the AI client is unavailable.
1429 + */
1430 + public function explain_ai_suggestion(\WP_REST_Request $request): \WP_REST_Response {
1431 + $content = $request->get_param('content');
1432 + $options = [
1433 + 'suggestion' => $request->get_param('suggestion'),
1434 + 'title' => $request->get_param('title'),
1435 + 'target_keyword' => $request->get_param('target_keyword'),
1436 + 'content_type' => $request->get_param('content_type'),
1437 + ];
1438 +
1439 + // Rate limiting: shares the AI generation bucket.
1440 + $user_id = get_current_user_id();
1441 + $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1442 + $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1443 + $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 0);
1444 + $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1445 + if (is_wp_error($allowed)) {
1446 + return new \WP_REST_Response([
1447 + 'success' => false,
1448 + 'message' => $allowed->get_error_message(),
1449 + ], $allowed->get_error_data()['status'] ?? 429);
1450 + }
1451 +
1452 + try {
1453 + $ai_manager = new \ThinkRank\AI\Manager();
1454 + $ai_manager->initialize_client();
1455 +
1456 + $result = $ai_manager->explain_seo_suggestion($content, $options);
1457 +
1458 + return new \WP_REST_Response([
1459 + 'success' => true,
1460 + 'data' => $result,
1461 + 'message' => __('Explanation generated successfully', 'thinkrank'),
1462 + ]);
1463 + } catch (\Exception $e) {
1464 + return new \WP_REST_Response([
1465 + 'success' => false,
1466 + 'message' => $e->getMessage(),
1467 + ], 400);
1468 + }
1469 + }
1470 +
1471 + /**
1472 + * Generate a content fragment with one authoritative external dofollow link.
1473 + *
1474 + * @param \WP_REST_Request $request Request object.
1475 + * @return \WP_REST_Response Response with the HTML fragment under data.html.
1476 + * @throws \Exception When generation fails or the AI client is unavailable.
1477 + */
1478 + public function add_ai_dofollow_link(\WP_REST_Request $request): \WP_REST_Response {
1479 + $content = $request->get_param('content');
1480 + $options = [
1481 + 'target_keyword' => $request->get_param('target_keyword'),
1482 + 'content_type' => $request->get_param('content_type'),
1483 + ];
1484 +
1485 + // Rate limiting: shares the AI generation bucket.
1486 + $user_id = get_current_user_id();
1487 + $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1488 + $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1489 + $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 0);
1490 + $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1491 + if (is_wp_error($allowed)) {
1492 + return new \WP_REST_Response([
1493 + 'success' => false,
1494 + 'message' => $allowed->get_error_message(),
1495 + ], $allowed->get_error_data()['status'] ?? 429);
1496 + }
1497 +
1498 + try {
1499 + $ai_manager = new \ThinkRank\AI\Manager();
1500 + $ai_manager->initialize_client();
1501 +
1502 + $result = $ai_manager->generate_dofollow_link($content, $options);
1503 +
1504 + return new \WP_REST_Response([
1505 + 'success' => true,
1506 + 'data' => $result,
1507 + 'message' => __('Added an authoritative source link', 'thinkrank'),
1508 + ]);
1509 + } catch (\Exception $e) {
1510 + return new \WP_REST_Response([
1511 + 'success' => false,
1512 + 'message' => $e->getMessage(),
1513 + ], 400);
1514 + }
1515 + }
1516 +
1517 + /**
1518 + * Generate a keyword-rich paragraph to lift keyword density into band.
1519 + *
1520 + * @param \WP_REST_Request $request Request object.
1521 + * @return \WP_REST_Response Response with the HTML fragment under data.html.
1522 + * @throws \Exception When generation fails or the AI client is unavailable.
1523 + */
1524 + public function add_ai_keyword_paragraph(\WP_REST_Request $request): \WP_REST_Response {
1525 + $content = $request->get_param('content');
1526 + $options = [
1527 + 'target_keyword' => $request->get_param('target_keyword'),
1528 + 'content_type' => $request->get_param('content_type'),
1529 + 'tone' => $request->get_param('tone'),
1530 + 'word_count' => $request->get_param('word_count'),
1531 + 'keyword_count' => $request->get_param('keyword_count'),
1532 + ];
1533 +
1534 + // Rate limiting: shares the AI generation bucket.
1535 + $user_id = get_current_user_id();
1536 + $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1537 + $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1538 + $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 0);
1539 + $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1540 + if (is_wp_error($allowed)) {
1541 + return new \WP_REST_Response([
1542 + 'success' => false,
1543 + 'message' => $allowed->get_error_message(),
1544 + ], $allowed->get_error_data()['status'] ?? 429);
1545 + }
1546 +
1547 + try {
1548 + $ai_manager = new \ThinkRank\AI\Manager();
1549 + $ai_manager->initialize_client();
1550 +
1551 + $result = $ai_manager->generate_keyword_paragraph($content, $options);
1552 +
1553 + return new \WP_REST_Response([
1554 + 'success' => true,
1555 + 'data' => $result,
1556 + 'message' => __('Added a keyword-focused paragraph', 'thinkrank'),
1557 + ]);
1558 + } catch (\Exception $e) {
1559 + return new \WP_REST_Response([
1560 + 'success' => false,
1561 + 'message' => $e->getMessage(),
1562 + ], 400);
1563 + }
1564 + }
1565 +
1566 + /**
1567 + * Enable ThinkRank's Global SEO schema output for a post's post type.
1568 + *
1569 + * Sets a sensible default schema type (Article for posts, WebPage for pages)
1570 + * when none is configured yet, so ThinkRank emits JSON-LD for the post. This
1571 + * resolves the "add structured data" suggestion, which the scorer now credits
1572 + * when ThinkRank schema is active.
1573 + *
1574 + * @param \WP_REST_Request $request Request object.
1575 + * @return \WP_REST_Response Response describing the enabled schema type.
1576 + */
1577 + public function enable_schema_for_post(\WP_REST_Request $request): \WP_REST_Response {
1578 + $post_id = (int) $request->get_param('post_id');
1579 + $post = get_post($post_id);
1580 + if (!$post) {
1581 + return new \WP_REST_Response([
1582 + 'success' => false,
1583 + 'message' => __('Post not found.', 'thinkrank'),
1584 + ], 404);
1585 + }
1586 +
1587 + $post_type = $post->post_type;
1588 + $settings = get_option('thinkrank_global_seo_settings', []);
1589 + if (!is_array($settings)) {
1590 + $settings = [];
1591 + }
1592 + if (!isset($settings[$post_type]) || !is_array($settings[$post_type])) {
1593 + $settings[$post_type] = [];
1594 + }
1595 +
1596 + $already_enabled = !empty($settings[$post_type]['schema_type']);
1597 + if (!$already_enabled) {
1598 + if ($post_type === 'page') {
1599 + $settings[$post_type]['schema_type'] = 'WebPage';
1600 + } else {
1601 + $settings[$post_type]['schema_type'] = 'Article';
1602 + if (empty($settings[$post_type]['article_type'])) {
1603 + $settings[$post_type]['article_type'] = 'BlogPosting';
1604 + }
1605 + }
1606 + update_option('thinkrank_global_seo_settings', $settings);
1607 + }
1608 +
1609 + $schema_type = $settings[$post_type]['schema_type'];
1610 +
1611 + return new \WP_REST_Response([
1612 + 'success' => true,
1613 + 'data' => [
1614 + 'schema_type' => $schema_type,
1615 + 'post_type' => $post_type,
1616 + 'already_enabled' => $already_enabled,
1617 + ],
1618 + 'message' => $already_enabled
1619 + ? __('Schema was already enabled for this post type.', 'thinkrank')
1620 + /* translators: %s: schema type. */
1621 + : sprintf(__('Enabled %s schema for this post type.', 'thinkrank'), $schema_type),
1622 + ]);
1623 + }
1624 +
1625 + /**
613 1626 * Test AI connection for specified provider
614 1627 *
615 1628 * @param \WP_REST_Request $request Request object containing api_key and provider parameters
616 1629 * @return \WP_REST_Response Response object with connection test results
@@ -618,11 +1631,11 @@
618 1631 */
619 1632 public function test_ai_connection(\WP_REST_Request $request): \WP_REST_Response {
620 1633 // Rate limiting: per user/IP per route
621 1634 $user_id = get_current_user_id();
622 - $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
1635 + $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
623 1636 $bucket_id = 'ai_test|' . ($user_id ?: $ip);
624 - $limit = (int) (new \ThinkRank\Core\Settings())->get('max_requests_per_minute', 10);
1637 + $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 0);
625 1638 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
626 1639 if (is_wp_error($allowed)) {
627 1640 return new \WP_REST_Response([
628 1641 'success' => false,
@@ -632,16 +1645,59 @@
632 1645
633 1646 try {
634 1647 $api_key = $request->get_param('api_key');
635 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'));
636 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 +
637 1691 // If no API key provided in request, try to get from saved settings
638 1692 if (empty($api_key)) {
639 - $settings = new \ThinkRank\Core\Settings();
1693 + $settings = \ThinkRank\Core\Settings::instance();
640 1694 if ($provider === 'openai') {
641 1695 $api_key = (string) $settings->get('openai_api_key', '');
642 1696 } elseif ($provider === 'claude') {
643 1697 $api_key = (string) $settings->get('claude_api_key', '');
1698 + } elseif ($provider === 'openrouter') {
1699 + $api_key = (string) $settings->get('openrouter_api_key', '');
644 1700 } else {
645 1701 $api_key = (string) $settings->get('gemini_api_key', '');
646 1702 }
647 1703
@@ -654,17 +1710,18 @@
654 1710 }
655 1711
656 1712 // Test the connection with a simple API call
657 1713 if ($provider === 'openai') {
658 - $result = $this->test_openai_connection($api_key);
1714 + $result = $this->test_openai_connection($api_key, $model);
659 1715 } elseif ($provider === 'claude') {
660 - $result = $this->test_claude_connection($api_key);
1716 + $result = $this->test_claude_connection($api_key, $model);
1717 + } elseif ($provider === 'openrouter') {
1718 + $result = $this->test_openrouter_connection($api_key, $model);
661 1719 } else {
662 - $result = $this->test_gemini_connection($api_key);
1720 + $result = $this->test_gemini_connection($api_key, $model);
663 1721 }
664 1722
665 1723 return new \WP_REST_Response($result, $result['success'] ? 200 : 400);
666 -
667 1724 } catch (\Exception $e) {
668 1725 return new \WP_REST_Response([
669 1726 'success' => false,
670 1727 'message' => $e->getMessage(),
@@ -672,14 +1729,378 @@
672 1729 }
673 1730 }
674 1731
675 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 + /**
676 2088 * Test OpenAI API connection
677 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 + *
678 2094 * @param string $api_key API key to test
2095 + * @param string $model Model id to verify, or '' to use the saved one
679 2096 * @return array Test result
680 2097 */
681 - 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 +
682 2103 $url = 'https://api.openai.com/v1/models';
683 2104
684 2105 $response = wp_remote_get($url, [
685 2106 'headers' => [
@@ -701,11 +2122,28 @@
701 2122
702 2123 if ($status_code === 200) {
703 2124 $data = json_decode($body, true);
704 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 +
705 2138 return [
706 2139 'success' => true,
707 - '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'),
708 2146 'models_count' => count($data['data']),
709 2147 ];
710 2148 }
711 2149 }
@@ -720,14 +2158,135 @@
720 2158 ];
721 2159 }
722 2160
723 2161 /**
2162 + * Test OpenRouter API connection
2163 + *
2164 + * @param string $api_key API key to test
2165 + * @param string $model Model id to verify, or '' to use the saved one
2166 + * @return array Test result
2167 + */
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 +
2173 + // Validate the key format first (OpenRouter keys start with "sk-or-").
2174 + if (!str_starts_with($api_key, 'sk-or-')) {
2175 + return [
2176 + 'success' => false,
2177 + 'message' => __('Invalid OpenRouter API key format. Should start with "sk-or-"', 'thinkrank'),
2178 + ];
2179 + }
2180 +
2181 + // The key endpoint validates the credential and returns its metadata.
2182 + $url = 'https://openrouter.ai/api/v1/key';
2183 +
2184 + $response = wp_remote_get($url, [
2185 + 'headers' => [
2186 + 'Authorization' => 'Bearer ' . $api_key,
2187 + 'Content-Type' => 'application/json',
2188 + 'HTTP-Referer' => home_url('/'),
2189 + 'X-Title' => 'ThinkRank',
2190 + ],
2191 + 'timeout' => 10,
2192 + ]);
2193 +
2194 + if (is_wp_error($response)) {
2195 + return [
2196 + 'success' => false,
2197 + 'message' => __('Failed to connect to OpenRouter API: ', 'thinkrank') . $response->get_error_message(),
2198 + ];
2199 + }
2200 +
2201 + $status_code = wp_remote_retrieve_response_code($response);
2202 + $body = wp_remote_retrieve_body($response);
2203 +
2204 + if ($status_code === 200) {
2205 + $data = json_decode($body, true);
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 +
2216 + return [
2217 + 'success' => true,
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'),
2224 + ];
2225 + }
2226 + }
2227 +
2228 + // Handle error response
2229 + $error_data = json_decode($body, true);
2230 + $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
2231 +
2232 + return [
2233 + 'success' => false,
2234 + 'message' => __('OpenRouter API Error: ', 'thinkrank') . $error_message,
2235 + ];
2236 + }
2237 +
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 + /**
724 2282 * Test Claude API connection
725 2283 *
726 2284 * @param string $api_key API key to test
2285 + * @param string $model Model id to verify, or '' to use the saved one
727 2286 * @return array Test result
728 2287 */
729 - private function test_claude_connection(string $api_key): array {
2288 + private function test_claude_connection(string $api_key, string $model = ''): array {
730 2289 // First validate the key format
731 2290 if (!str_starts_with($api_key, 'sk-ant-')) {
732 2291 return [
733 2292 'success' => false,
@@ -737,10 +2296,18 @@
737 2296
738 2297 // Test with a simple API call
739 2298 $url = 'https://api.anthropic.com/v1/messages';
740 2299
741 - // Get the configured Claude model, with fallback to a current model
742 - $claude_model = (new \ThinkRank\Core\Settings())->get('claude_model', 'claude-3-7-sonnet-latest');
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 + }
743 2310
744 2311 $body = [
745 2312 'model' => $claude_model,
746 2313 'max_tokens' => 10,
@@ -774,16 +2341,32 @@
774 2341
775 2342 if ($status_code === 200) {
776 2343 return [
777 2344 'success' => true,
778 - '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),
779 2349 ];
780 2350 } else {
781 2351 $error_data = json_decode($response_body, true);
782 2352 $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
783 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 +
784 2366 return [
785 2367 'success' => false,
2368 + 'model' => $claude_model,
786 2369 /* translators: %1$d: HTTP status code, %2$s: error message from Claude API */
787 2370 'message' => sprintf(__('Claude API error (%1$d): %2$s', 'thinkrank'), $status_code, $error_message),
788 2371 ];
789 2372 }
@@ -792,15 +2375,26 @@
792 2375 /**
793 2376 * Test Gemini API connection
794 2377 *
795 2378 * @param string $api_key API key to test
2379 + * @param string $model Model id to verify, or '' to use the saved one
796 2380 * @return array Test result
797 2381 */
798 - private function test_gemini_connection(string $api_key): array {
2382 + private function test_gemini_connection(string $api_key, string $model = ''): array {
799 2383 // Test with a simple API call
800 - $gemini_model = (new \ThinkRank\Core\Settings())->get('gemini_model', 'gemini-2.5-flash');
801 - $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);
802 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 +
803 2397 $body = [
804 2398 'contents' => [
805 2399 [
806 2400 'parts' => [
@@ -834,16 +2428,32 @@
834 2428
835 2429 if ($status_code === 200) {
836 2430 return [
837 2431 'success' => true,
838 - '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),
839 2436 ];
840 2437 } else {
841 2438 $error_data = json_decode($response_body, true);
842 2439 $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
843 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 +
844 2453 return [
845 2454 'success' => false,
2455 + 'model' => $gemini_model,
846 2456 /* translators: %1$d: HTTP status code, %2$s: error message from Gemini API */
847 2457 'message' => sprintf(__('Gemini API error (%1$d): %2$s', 'thinkrank'), $status_code, $error_message),
848 2458 ];
849 2459 }
@@ -871,8 +2481,14 @@
871 2481 public function get_ai_status(\WP_REST_Request $request): \WP_REST_Response {
872 2482 $ai_manager = new \ThinkRank\AI\Manager();
873 2483 $status = $ai_manager->get_provider_status();
874 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 +
875 2491 return new \WP_REST_Response($status);
876 2492 }
877 2493
878 2494 /**
@@ -888,11 +2504,11 @@
888 2504 $post_id = $request->get_param('post_id');
889 2505
890 2506 // Rate limiting: per user/IP per route
891 2507 $user_id = get_current_user_id();
892 - $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
2508 + $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
893 2509 $bucket_id = 'ai_analyze|' . ($user_id ?: $ip);
894 - $limit = (int) (new \ThinkRank\Core\Settings())->get('max_requests_per_minute', 10);
2510 + $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 0);
895 2511 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
896 2512 if (is_wp_error($allowed)) {
897 2513 return new \WP_REST_Response([
898 2514 'success' => false,
@@ -912,9 +2528,8 @@
912 2528 'success' => true,
913 2529 'data' => $analysis,
914 2530 'message' => __('Content analyzed successfully', 'thinkrank'),
915 2531 ]);
916 -
917 2532 } catch (\Exception $e) {
918 2533 return new \WP_REST_Response([
919 2534 'success' => false,
920 2535 'message' => $e->getMessage(),
@@ -967,8 +2582,15 @@
967 2582 // Failed to register Site Identity endpoint
968 2583 }
969 2584
970 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 {
971 2593 $performance_endpoint = new Performance_Endpoint();
972 2594 $performance_endpoint->register_routes();
973 2595 } catch (\Exception $e) {
974 2596 // Failed to register Performance endpoint
@@ -1028,7 +2650,78 @@
1028 2650 $llms_txt_endpoint->register_routes();
1029 2651 } catch (\Exception $e) {
1030 2652 // Failed to register LLMs.txt endpoint
1031 2653 }
2654 +
2655 + try {
2656 + $global_seo_endpoint = new Global_SEO_Endpoint();
2657 + $global_seo_endpoint->register_routes();
2658 + } catch (\Exception $e) {
2659 + // Failed to register Global SEO endpoint
2660 + }
2661 +
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 + $image_seo_endpoint = new Image_SEO_Endpoint();
2680 + $image_seo_endpoint->register_routes();
2681 + } catch (\Exception $e) {
2682 + // Failed to register Image SEO endpoint
2683 + }
2684 +
2685 + try {
2686 + $external_links_endpoint = new External_Links_Endpoint();
2687 + $external_links_endpoint->register_routes();
2688 + } catch (\Exception $e) {
2689 + // Failed to register External Links endpoint
2690 + }
2691 +
2692 + // Import_Controller is deliberately NOT gated on enable_migration_tools.
2693 + // /import/detect backs the setup wizard's migration step and the record
2694 + // count on Settings > Import / Export, and /import/snapshot + /migrate
2695 + // run the wizard's actual import — all on a fresh install, where the
2696 + // setting is off. Gating them would break onboarding, which is a worse
2697 + // bug than the one #583 reports.
2698 + try {
2699 + $import_controller = new Import_Controller();
2700 + $import_controller->register_routes();
2701 + } catch (\Exception $e) {
2702 + // Failed to register Import endpoint
2703 + }
2704 +
2705 + // Export/restore is gated on the setting that gates its admin screen,
2706 + // so turning Import / Export off removes its REST surface along with
2707 + // its menu item (#583). Nothing in the setup wizard calls these:
2708 + // MigrationPluginRow takes startExport/startMigration/cancel from
2709 + // useImportWorkflow and never uploadFile. rest_api_init runs per
2710 + // request, so a toggle takes effect on the next one — no flush.
2711 + if ((bool) \ThinkRank\Core\Settings::instance()->get('enable_import_export', false)) {
2712 + try {
2713 + $export_controller = new Export_Controller();
2714 + $export_controller->register_routes();
2715 + } catch (\Exception $e) {
2716 + // Failed to register Export endpoint
2717 + }
2718 + }
2719 +
2720 + try {
2721 + $setup_wizard_endpoint = new Setup_Wizard_Endpoint();
2722 + $setup_wizard_endpoint->register_routes();
2723 + } catch (\Exception $e) {
2724 + // Failed to register Setup Wizard endpoint
2725 + }
1032 2726 }
1033 -
1034 2727 }