PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.0
2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 All 51 releases
thinkrank / includes / api / class-manager.php

class-manager.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.10.0, at includes/api/class-manager.php

2,737 lines 112.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * API Manager Class
5 *
6 * Handles REST API endpoints registration and management
7 *
8 * @package ThinkRank\API
9 * @since 1.0.0
10 */
11
12 declare(strict_types=1);
13
14 namespace ThinkRank\API;
15
16 // Import endpoint classes
17 use ThinkRank\API\Site_Identity_Endpoint;
18 use ThinkRank\API\Performance_Endpoint;
19 use ThinkRank\API\Schema_Endpoint;
20 use ThinkRank\API\Settings_Management_Endpoint;
21 use ThinkRank\API\Content_Brief_Endpoint;
22 use ThinkRank\API\Social_Media_Endpoint;
23 use ThinkRank\API\Sitemap_Endpoint;
24 use ThinkRank\API\SEO_Analytics_Endpoint;
25 use ThinkRank\API\Usage_Analytics_Endpoint;
26 use ThinkRank\API\Integrations_Endpoint;
27 use ThinkRank\API\Social_Platforms_Endpoint;
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;
40
41
42 // Prevent direct access
43 if (!defined('ABSPATH')) {
44 exit;
45 }
46
47 /**
48 * API Manager Class
49 *
50 * Single Responsibility: Manage REST API endpoints
51 *
52 * @since 1.0.0
53 */
54 class Manager {
55
56 /**
57 * API namespace
58 *
59 * @var string
60 */
61 private const NAMESPACE = 'thinkrank/v1';
62
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 /**
97 * Initialize API manager
98 *
99 * @return void
100 */
101 public function init(): void {
102 add_action('rest_api_init', [$this, 'register_routes']);
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);
117 }
118
119 /**
120 * Register REST API routes
121 *
122 * @return void
123 */
124 public function register_routes(): void {
125 // Core endpoints
126 register_rest_route(self::NAMESPACE, '/capabilities', [
127 'methods' => 'GET',
128 'callback' => [$this, 'get_capabilities'],
129 'permission_callback' => [$this, 'check_basic_permissions'],
130 ]);
131
132 register_rest_route(self::NAMESPACE, '/plugin-info', [
133 'methods' => 'GET',
134 'callback' => [$this, 'get_plugin_info'],
135 'permission_callback' => [$this, 'check_basic_permissions'],
136 ]);
137
138 register_rest_route(self::NAMESPACE, '/system-status', [
139 'methods' => 'GET',
140 'callback' => [$this, 'get_system_status'],
141 'permission_callback' => [$this, 'check_basic_permissions'],
142 ]);
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 ]);
153
154 // Settings endpoints
155 register_rest_route(self::NAMESPACE, '/settings', [
156 'methods' => 'GET',
157 'callback' => [$this, 'get_settings'],
158 'permission_callback' => [$this, 'check_settings_permissions'],
159 ]);
160
161 register_rest_route(self::NAMESPACE, '/settings', [
162 'methods' => 'POST',
163 'callback' => [$this, 'save_settings'],
164 'permission_callback' => [$this, 'check_settings_permissions'],
165 'args' => [
166 'ai_provider' => [
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(),
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',
175 ],
176 'openai_api_key' => [
177 'type' => 'string',
178 'sanitize_callback' => 'sanitize_text_field',
179 ],
180 'openai_model' => [
181 'type' => 'string',
182 'sanitize_callback' => 'sanitize_text_field',
183 ],
184 'claude_api_key' => [
185 'type' => 'string',
186 'sanitize_callback' => 'sanitize_text_field',
187 ],
188 'claude_model' => [
189 'type' => 'string',
190 'sanitize_callback' => 'sanitize_text_field',
191 ],
192 'gemini_api_key' => [
193 'type' => 'string',
194 'sanitize_callback' => 'sanitize_text_field',
195 ],
196 'gemini_model' => [
197 'type' => 'string',
198 'sanitize_callback' => 'sanitize_text_field',
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 ],
260 'keep_data_on_uninstall' => [
261 'type' => 'boolean',
262 'sanitize_callback' => 'rest_sanitize_boolean',
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 ],
296 ],
297 ]);
298
299
300
301 // Metadata endpoints
302 register_rest_route(self::NAMESPACE, '/metadata/(?P<post_id>\d+)', [
303 'methods' => 'GET',
304 'callback' => [$this, 'get_metadata'],
305 'permission_callback' => [$this, 'check_basic_permissions'],
306 'args' => [
307 'post_id' => [
308 'type' => 'integer',
309 'required' => true,
310 ],
311 ],
312 ]);
313
314 // AI endpoints
315 register_rest_route(self::NAMESPACE, '/ai/generate-metadata', [
316 'methods' => 'POST',
317 'callback' => [$this, 'generate_ai_metadata'],
318 'permission_callback' => [$this, 'check_basic_permissions'],
319 'args' => [
320 'content' => [
321 'type' => 'string',
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',
401 'sanitize_callback' => 'sanitize_textarea_field',
402 ],
403 'target_keyword' => [
404 'type' => 'string',
405 'sanitize_callback' => 'sanitize_text_field',
406 ],
407 'content_type' => [
408 'type' => 'string',
409 'default' => 'blog_post',
410 'sanitize_callback' => 'sanitize_text_field',
411 ],
412 'tone' => [
413 'type' => 'string',
414 'default' => 'professional',
415 'sanitize_callback' => 'sanitize_text_field',
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 ],
427 ],
428 ]);
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
534 register_rest_route(self::NAMESPACE, '/ai/test-connection', [
535 'methods' => 'POST',
536 'callback' => [$this, 'test_ai_connection'],
537 'permission_callback' => [$this, 'check_admin_permissions'],
538 'args' => [
539 'api_key' => [
540 'type' => 'string',
541 'required' => false,
542 'sanitize_callback' => 'sanitize_text_field',
543 ],
544 'provider' => [
545 'type' => 'string',
546 'required' => false,
547 'default' => 'openai',
548 'sanitize_callback' => 'sanitize_key',
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 ],
568 ],
569 ]);
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
592 register_rest_route(self::NAMESPACE, '/ai/providers', [
593 'methods' => 'GET',
594 'callback' => [$this, 'get_ai_providers'],
595 'permission_callback' => [$this, 'check_basic_permissions'],
596 ]);
597
598 // Register content brief endpoints
599 $content_brief_endpoint = new \ThinkRank\API\Content_Brief_Endpoint();
600 $content_brief_endpoint->register_routes();
601
602 // Register SEO score endpoints
603 try {
604 $database = new \ThinkRank\Core\Database();
605 $seo_calculator = new \ThinkRank\AI\SEOScoreCalculator($database);
606 $seo_score_endpoint = new \ThinkRank\API\SEOScoreEndpoint($seo_calculator);
607 $seo_score_endpoint->register_routes();
608 } catch (\Exception $e) {
609 // SEO Score endpoint registration failed
610 }
611
612 // Register Usage Analytics endpoints
613 try {
614 $usage_analytics_endpoint = new \ThinkRank\API\Usage_Analytics_Endpoint();
615 $usage_analytics_endpoint->register_routes();
616 } catch (\Exception $e) {
617 // Usage Analytics endpoint registration failed
618 }
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
628 // Register SEO Analytics endpoints
629 try {
630 $seo_analytics_endpoint = new \ThinkRank\API\SEO_Analytics_Endpoint();
631 $seo_analytics_endpoint->register_routes();
632 } catch (\Exception $e) {
633 // Failed to register SEO Analytics endpoint
634 }
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 }
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 }
651
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 }
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
692 register_rest_route(self::NAMESPACE, '/ai/status', [
693 'methods' => 'GET',
694 'callback' => [$this, 'get_ai_status'],
695 'permission_callback' => [$this, 'check_basic_permissions'],
696 ]);
697
698 register_rest_route(self::NAMESPACE, '/ai/analyze-content', [
699 'methods' => 'POST',
700 'callback' => [$this, 'analyze_content'],
701 'permission_callback' => [$this, 'check_basic_permissions'],
702 'args' => [
703 'content' => [
704 'type' => 'string',
705 'required' => true,
706 'sanitize_callback' => [$this, 'sanitize_ai_content'],
707 ],
708 'metadata' => [
709 'type' => 'object',
710 'required' => false,
711 'sanitize_callback' => [$this, 'sanitize_metadata_object'],
712 ],
713 'post_id' => [
714 'type' => 'integer',
715 'required' => false,
716 'sanitize_callback' => 'absint',
717 ],
718 ],
719 ]);
720 }
721
722 /**
723 * Check basic permissions (for logged-in users)
724 *
725 * @param \WP_REST_Request $request Request object
726 * @return bool|WP_Error Permission status
727 */
728 public function check_basic_permissions(\WP_REST_Request $request) {
729 // Allow access for logged-in users who can edit posts
730 if (!is_user_logged_in()) {
731 return new \WP_Error(
732 'rest_forbidden',
733 __('You must be logged in to access this endpoint.', 'thinkrank'),
734 ['status' => 401]
735 );
736 }
737
738 if (!current_user_can('edit_posts')) {
739 return new \WP_Error(
740 'rest_forbidden',
741 __('You do not have permission to access this endpoint.', 'thinkrank'),
742 ['status' => 403]
743 );
744 }
745
746 return true;
747 }
748
749
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 }
789
790 /**
791 * Check admin permissions (for settings)
792 *
793 * @param \WP_REST_Request $request Request object
794 * @return bool|WP_Error Permission status
795 */
796 public function check_admin_permissions(\WP_REST_Request $request) {
797 // Allow access for administrators only
798 if (!is_user_logged_in()) {
799 return new \WP_Error(
800 'rest_forbidden',
801 __('You must be logged in to access this endpoint.', 'thinkrank'),
802 ['status' => 401]
803 );
804 }
805
806 if (!current_user_can('manage_options')) {
807 return new \WP_Error(
808 'rest_forbidden',
809 __('You do not have permission to manage settings.', 'thinkrank'),
810 ['status' => 403]
811 );
812 }
813
814 return true;
815 }
816
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 /**
848 * Get user capabilities
849 *
850 * @param \WP_REST_Request $request Request object
851 * @return \WP_REST_Response Response object
852 */
853 public function get_capabilities(\WP_REST_Request $request): \WP_REST_Response {
854 return new \WP_REST_Response([
855 'manage_settings' => current_user_can('manage_options'),
856 'view_analytics' => current_user_can('edit_posts'),
857
858 'use_ai_features' => current_user_can('edit_posts'),
859 ]);
860 }
861
862 /**
863 * Get plugin information
864 *
865 * @param \WP_REST_Request $request Request object
866 * @return \WP_REST_Response Response object
867 */
868 public function get_plugin_info(\WP_REST_Request $request): \WP_REST_Response {
869 return new \WP_REST_Response([
870 'version' => THINKRANK_VERSION,
871 'name' => 'ThinkRank',
872 'description' => 'AI-native SEO plugin for WordPress',
873 ]);
874 }
875
876 /**
877 * Get system status
878 *
879 * @param \WP_REST_Request $request Request object
880 * @return \WP_REST_Response Response object
881 */
882 public function get_system_status(\WP_REST_Request $request): \WP_REST_Response {
883 return new \WP_REST_Response([
884 'status' => 'healthy',
885 'issues' => [],
886 'php_version' => PHP_VERSION,
887 'wp_version' => get_bloginfo('version'),
888 ]);
889 }
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 }
903
904
905
906 /**
907 * Get settings
908 *
909 * @param \WP_REST_Request $request Request object
910 * @return \WP_REST_Response Response object
911 */
912 public function get_settings(\WP_REST_Request $request): \WP_REST_Response {
913 // Use Settings class for consistent access (handles decryption automatically)
914 $settings_instance = \ThinkRank\Core\Settings::instance();
915
916 $settings = [
917 'ai_provider' => $settings_instance->get('ai_provider', \ThinkRank\Core\Settings::AI_PROVIDER_NONE),
918 'openai_api_key' => $settings_instance->get('openai_api_key', ''),
919 'openai_model' => $settings_instance->get('openai_model', \ThinkRank\Core\Settings::DEFAULT_OPENAI_MODEL),
920 'claude_api_key' => $settings_instance->get('claude_api_key', ''),
921 'claude_model' => $settings_instance->get('claude_model', \ThinkRank\Core\Settings::DEFAULT_CLAUDE_MODEL),
922 'gemini_api_key' => $settings_instance->get('gemini_api_key', ''),
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),
933 'max_tokens' => $settings_instance->get('max_tokens', 1000),
934 'temperature' => $settings_instance->get('temperature', 0.7),
935 'cache_duration' => $settings_instance->get('cache_duration', 3600),
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),
944 ];
945
946
947
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.
950 if (!empty($settings['openai_api_key'])) {
951 $settings['openai_api_key'] = $this->mask_ai_api_key($settings['openai_api_key']);
952 }
953 if (!empty($settings['claude_api_key'])) {
954 $settings['claude_api_key'] = $this->mask_ai_api_key($settings['claude_api_key']);
955 }
956 if (!empty($settings['gemini_api_key'])) {
957 $settings['gemini_api_key'] = $this->mask_ai_api_key($settings['gemini_api_key']);
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 }
965
966 return new \WP_REST_Response($settings);
967 }
968
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 /**
989 * Save settings
990 *
991 * @param \WP_REST_Request $request Request object
992 * @return \WP_REST_Response Response object
993 */
994 public function save_settings(\WP_REST_Request $request): \WP_REST_Response {
995 $params = $request->get_params();
996
997 // Get Settings instance for proper encryption handling
998 $settings = \ThinkRank\Core\Settings::instance();
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
1101 // Map frontend parameter names to setting keys
1102 $settings_map = [
1103 'ai_provider' => 'ai_provider',
1104 'openai_api_key' => 'openai_api_key',
1105 'openai_model' => 'openai_model',
1106 'claude_api_key' => 'claude_api_key',
1107 'claude_model' => 'claude_model',
1108 'gemini_api_key' => 'gemini_api_key',
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',
1119 'max_tokens' => 'max_tokens',
1120 'temperature' => 'temperature',
1121 'cache_duration' => 'cache_duration',
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',
1129 ];
1130
1131 // Processing settings save request
1132
1133 foreach ($settings_map as $param_key => $setting_key) {
1134 if (isset($params[$param_key])) {
1135 $value = $params[$param_key];
1136
1137 // Handle API keys specially - check for masked values
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) {
1143 continue;
1144 }
1145 }
1146
1147 // Use Settings class for all operations (handles encryption automatically)
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);
1151 }
1152 }
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
1173 // Auto-dismiss welcome notice if API key was saved
1174 $this->maybe_dismiss_welcome_notice($params);
1175
1176 // Force AI Manager to re-initialize client with new settings
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'])) {
1178 // Clear any cached AI Manager instances to force re-initialization
1179 wp_cache_delete('thinkrank_ai_manager', 'thinkrank');
1180
1181 // If we have an AI Manager instance, force it to re-initialize
1182 try {
1183 $ai_manager = new \ThinkRank\AI\Manager($settings);
1184 $ai_manager->reinitialize_client();
1185 } catch (\Exception $e) {
1186 // Ignore initialization errors at this point
1187 }
1188 }
1189
1190 return new \WP_REST_Response([
1191 'success' => true,
1192 'message' => __('Settings saved successfully', 'thinkrank'),
1193 'settings' => $this->get_settings($request)->get_data(),
1194 ]);
1195 }
1196
1197 /**
1198 * Maybe dismiss welcome notice if API key was saved
1199 *
1200 * @param array $params Request parameters
1201 * @return void
1202 */
1203 private function maybe_dismiss_welcome_notice(array $params): void {
1204 // Check if an API key was saved (not cleared)
1205 $api_key_saved = false;
1206
1207 if (!empty($params['openai_api_key']) && $params['openai_api_key'] !== '') {
1208 $api_key_saved = true;
1209 }
1210
1211 if (!empty($params['claude_api_key']) && $params['claude_api_key'] !== '') {
1212 $api_key_saved = true;
1213 }
1214
1215 // Auto-dismiss welcome notice if API key was configured
1216 if ($api_key_saved && get_option('thinkrank_show_welcome')) {
1217 delete_option('thinkrank_show_welcome');
1218 }
1219 }
1220
1221 /**
1222 * Get metadata for post
1223 *
1224 * @param \WP_REST_Request $request Request object
1225 * @return \WP_REST_Response Response object
1226 */
1227 public function get_metadata(\WP_REST_Request $request): \WP_REST_Response {
1228 $post_id = (int) $request->get_param('post_id');
1229
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)
1250 $metadata = [
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),
1254 'seo_score' => get_post_meta($post_id, '_thinkrank_seo_score', true) ?: 0,
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,
1260 ];
1261
1262 return new \WP_REST_Response($metadata);
1263 }
1264
1265 /**
1266 * Generate AI-powered SEO metadata
1267 *
1268 * @param \WP_REST_Request $request Request object containing content and generation options
1269 * @return \WP_REST_Response Response object with generated metadata or error message
1270 * @throws \Exception When AI metadata generation fails or AI client is unavailable
1271 */
1272 public function generate_ai_metadata(\WP_REST_Request $request): \WP_REST_Response {
1273 $content = $request->get_param('content');
1274 $options = [
1275 'target_keyword' => $request->get_param('target_keyword'),
1276 'content_type' => $request->get_param('content_type'),
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')),
1281 ];
1282
1283 // Rate limiting: per user/IP per route
1284 $user_id = get_current_user_id();
1285 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1286 $bucket_id = 'ai_generate|' . ($user_id ?: $ip);
1287 $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 0);
1288 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1289 if (is_wp_error($allowed)) {
1290 return new \WP_REST_Response([
1291 'success' => false,
1292 'message' => $allowed->get_error_message(),
1293 ], $allowed->get_error_data()['status'] ?? 429);
1294 }
1295
1296 try {
1297 // Get AI manager instance
1298 $ai_manager = new \ThinkRank\AI\Manager();
1299 $ai_manager->initialize_client();
1300
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);
1305
1306 return new \WP_REST_Response([
1307 'success' => true,
1308 'data' => $metadata,
1309 'message' => __('SEO metadata generated successfully', 'thinkrank'),
1310 ]);
1311 } catch (\Exception $e) {
1312 return new \WP_REST_Response([
1313 'success' => false,
1314 'message' => $e->getMessage(),
1315 ], 400);
1316 }
1317 }
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 ]);
1361 } catch (\Exception $e) {
1362 return new \WP_REST_Response([
1363 'success' => false,
1364 'message' => $e->getMessage(),
1365 ], 400);
1366 }
1367 }
1368
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 /**
1626 * Test AI connection for specified provider
1627 *
1628 * @param \WP_REST_Request $request Request object containing api_key and provider parameters
1629 * @return \WP_REST_Response Response object with connection test results
1630 * @throws \Exception When API connection test encounters unexpected errors
1631 */
1632 public function test_ai_connection(\WP_REST_Request $request): \WP_REST_Response {
1633 // Rate limiting: per user/IP per route
1634 $user_id = get_current_user_id();
1635 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1636 $bucket_id = 'ai_test|' . ($user_id ?: $ip);
1637 $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 0);
1638 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
1639 if (is_wp_error($allowed)) {
1640 return new \WP_REST_Response([
1641 'success' => false,
1642 'message' => $allowed->get_error_message(),
1643 ], $allowed->get_error_data()['status'] ?? 429);
1644 }
1645
1646 try {
1647 $api_key = $request->get_param('api_key');
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'));
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
1691 // If no API key provided in request, try to get from saved settings
1692 if (empty($api_key)) {
1693 $settings = \ThinkRank\Core\Settings::instance();
1694 if ($provider === 'openai') {
1695 $api_key = (string) $settings->get('openai_api_key', '');
1696 } elseif ($provider === 'claude') {
1697 $api_key = (string) $settings->get('claude_api_key', '');
1698 } elseif ($provider === 'openrouter') {
1699 $api_key = (string) $settings->get('openrouter_api_key', '');
1700 } else {
1701 $api_key = (string) $settings->get('gemini_api_key', '');
1702 }
1703
1704 if (empty($api_key)) {
1705 return new \WP_REST_Response([
1706 'success' => false,
1707 'message' => __('No API key provided or saved for the selected provider.', 'thinkrank'),
1708 ], 400);
1709 }
1710 }
1711
1712 // Test the connection with a simple API call
1713 if ($provider === 'openai') {
1714 $result = $this->test_openai_connection($api_key, $model);
1715 } elseif ($provider === 'claude') {
1716 $result = $this->test_claude_connection($api_key, $model);
1717 } elseif ($provider === 'openrouter') {
1718 $result = $this->test_openrouter_connection($api_key, $model);
1719 } else {
1720 $result = $this->test_gemini_connection($api_key, $model);
1721 }
1722
1723 return new \WP_REST_Response($result, $result['success'] ? 200 : 400);
1724 } catch (\Exception $e) {
1725 return new \WP_REST_Response([
1726 'success' => false,
1727 'message' => $e->getMessage(),
1728 ], 500);
1729 }
1730 }
1731
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 /**
2088 * Test OpenAI API connection
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 *
2094 * @param string $api_key API key to test
2095 * @param string $model Model id to verify, or '' to use the saved one
2096 * @return array Test result
2097 */
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
2103 $url = 'https://api.openai.com/v1/models';
2104
2105 $response = wp_remote_get($url, [
2106 'headers' => [
2107 'Authorization' => 'Bearer ' . $api_key,
2108 'Content-Type' => 'application/json',
2109 ],
2110 'timeout' => 10,
2111 ]);
2112
2113 if (is_wp_error($response)) {
2114 return [
2115 'success' => false,
2116 'message' => __('Failed to connect to OpenAI API: ', 'thinkrank') . $response->get_error_message(),
2117 ];
2118 }
2119
2120 $status_code = wp_remote_retrieve_response_code($response);
2121 $body = wp_remote_retrieve_body($response);
2122
2123 if ($status_code === 200) {
2124 $data = json_decode($body, true);
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
2138 return [
2139 'success' => true,
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'),
2146 'models_count' => count($data['data']),
2147 ];
2148 }
2149 }
2150
2151 // Handle error response
2152 $error_data = json_decode($body, true);
2153 $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
2154
2155 return [
2156 'success' => false,
2157 'message' => __('OpenAI API Error: ', 'thinkrank') . $error_message,
2158 ];
2159 }
2160
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 /**
2282 * Test Claude API connection
2283 *
2284 * @param string $api_key API key to test
2285 * @param string $model Model id to verify, or '' to use the saved one
2286 * @return array Test result
2287 */
2288 private function test_claude_connection(string $api_key, string $model = ''): array {
2289 // First validate the key format
2290 if (!str_starts_with($api_key, 'sk-ant-')) {
2291 return [
2292 'success' => false,
2293 'message' => __('Invalid Claude API key format. Should start with "sk-ant-"', 'thinkrank'),
2294 ];
2295 }
2296
2297 // Test with a simple API call
2298 $url = 'https://api.anthropic.com/v1/messages';
2299
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 }
2310
2311 $body = [
2312 'model' => $claude_model,
2313 'max_tokens' => 10,
2314 'messages' => [
2315 [
2316 'role' => 'user',
2317 'content' => 'Hello'
2318 ]
2319 ]
2320 ];
2321
2322 $response = wp_remote_post($url, [
2323 'headers' => [
2324 'x-api-key' => $api_key,
2325 'Content-Type' => 'application/json',
2326 'anthropic-version' => '2023-06-01',
2327 ],
2328 'body' => wp_json_encode($body),
2329 'timeout' => 10,
2330 ]);
2331
2332 if (is_wp_error($response)) {
2333 return [
2334 'success' => false,
2335 'message' => __('Failed to connect to Claude API: ', 'thinkrank') . $response->get_error_message(),
2336 ];
2337 }
2338
2339 $status_code = wp_remote_retrieve_response_code($response);
2340 $response_body = wp_remote_retrieve_body($response);
2341
2342 if ($status_code === 200) {
2343 return [
2344 'success' => true,
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),
2349 ];
2350 } else {
2351 $error_data = json_decode($response_body, true);
2352 $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
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
2366 return [
2367 'success' => false,
2368 'model' => $claude_model,
2369 /* translators: %1$d: HTTP status code, %2$s: error message from Claude API */
2370 'message' => sprintf(__('Claude API error (%1$d): %2$s', 'thinkrank'), $status_code, $error_message),
2371 ];
2372 }
2373 }
2374
2375 /**
2376 * Test Gemini API connection
2377 *
2378 * @param string $api_key API key to test
2379 * @param string $model Model id to verify, or '' to use the saved one
2380 * @return array Test result
2381 */
2382 private function test_gemini_connection(string $api_key, string $model = ''): array {
2383 // Test with a simple API call
2384 $gemini_model = $model !== ''
2385 ? $model
2386 : (string) \ThinkRank\Core\Settings::instance()->get('gemini_model', \ThinkRank\Core\Settings::DEFAULT_GEMINI_MODEL);
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
2397 $body = [
2398 'contents' => [
2399 [
2400 'parts' => [
2401 ['text' => 'Hello']
2402 ]
2403 ]
2404 ],
2405 'generationConfig' => [
2406 'maxOutputTokens' => 10,
2407 'temperature' => 0.1,
2408 ]
2409 ];
2410
2411 $response = wp_remote_post($url, [
2412 'headers' => [
2413 'Content-Type' => 'application/json',
2414 ],
2415 'body' => wp_json_encode($body),
2416 'timeout' => 10,
2417 ]);
2418
2419 if (is_wp_error($response)) {
2420 return [
2421 'success' => false,
2422 'message' => __('Failed to connect to Gemini API: ', 'thinkrank') . $response->get_error_message(),
2423 ];
2424 }
2425
2426 $status_code = wp_remote_retrieve_response_code($response);
2427 $response_body = wp_remote_retrieve_body($response);
2428
2429 if ($status_code === 200) {
2430 return [
2431 'success' => true,
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),
2436 ];
2437 } else {
2438 $error_data = json_decode($response_body, true);
2439 $error_message = $error_data['error']['message'] ?? __('Unknown API error', 'thinkrank');
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
2453 return [
2454 'success' => false,
2455 'model' => $gemini_model,
2456 /* translators: %1$d: HTTP status code, %2$s: error message from Gemini API */
2457 'message' => sprintf(__('Gemini API error (%1$d): %2$s', 'thinkrank'), $status_code, $error_message),
2458 ];
2459 }
2460 }
2461
2462 /**
2463 * Get AI providers
2464 *
2465 * @param \WP_REST_Request $request Request object
2466 * @return \WP_REST_Response Response object
2467 */
2468 public function get_ai_providers(\WP_REST_Request $request): \WP_REST_Response {
2469 $ai_manager = new \ThinkRank\AI\Manager();
2470 $providers = $ai_manager->get_available_providers();
2471
2472 return new \WP_REST_Response($providers);
2473 }
2474
2475 /**
2476 * Get AI status
2477 *
2478 * @param \WP_REST_Request $request Request object
2479 * @return \WP_REST_Response Response object
2480 */
2481 public function get_ai_status(\WP_REST_Request $request): \WP_REST_Response {
2482 $ai_manager = new \ThinkRank\AI\Manager();
2483 $status = $ai_manager->get_provider_status();
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
2491 return new \WP_REST_Response($status);
2492 }
2493
2494 /**
2495 * Analyze content for SEO optimization
2496 *
2497 * @param \WP_REST_Request $request Request object containing content, metadata, and optional post_id
2498 * @return \WP_REST_Response Response object with analysis results or error message
2499 * @throws \Exception When AI analysis fails or AI client initialization fails
2500 */
2501 public function analyze_content(\WP_REST_Request $request): \WP_REST_Response {
2502 $content = $request->get_param('content');
2503 $metadata = $request->get_param('metadata') ?: [];
2504 $post_id = $request->get_param('post_id');
2505
2506 // Rate limiting: per user/IP per route
2507 $user_id = get_current_user_id();
2508 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
2509 $bucket_id = 'ai_analyze|' . ($user_id ?: $ip);
2510 $limit = (int) \ThinkRank\Core\Settings::instance()->get('max_requests_per_minute', 0);
2511 $allowed = $this->enforce_rate_limit($bucket_id, $limit);
2512 if (is_wp_error($allowed)) {
2513 return new \WP_REST_Response([
2514 'success' => false,
2515 'message' => $allowed->get_error_message(),
2516 ], $allowed->get_error_data()['status'] ?? 429);
2517 }
2518
2519 try {
2520 // Get AI manager instance
2521 $ai_manager = new \ThinkRank\AI\Manager();
2522 $ai_manager->initialize_client();
2523
2524 // Perform content analysis
2525 $analysis = $ai_manager->analyze_content($content, $metadata);
2526
2527 return new \WP_REST_Response([
2528 'success' => true,
2529 'data' => $analysis,
2530 'message' => __('Content analyzed successfully', 'thinkrank'),
2531 ]);
2532 } catch (\Exception $e) {
2533 return new \WP_REST_Response([
2534 'success' => false,
2535 'message' => $e->getMessage(),
2536 ], 400);
2537 }
2538 }
2539
2540 /**
2541 * Sanitize metadata object for API endpoints
2542 *
2543 * @param mixed $metadata Metadata to sanitize
2544 * @return array Sanitized metadata array
2545 */
2546 public function sanitize_metadata_object($metadata): array {
2547 if (!is_array($metadata)) {
2548 return [];
2549 }
2550
2551 $sanitized = [];
2552 foreach ($metadata as $key => $value) {
2553 $sanitized_key = sanitize_key($key);
2554
2555 if (is_string($value)) {
2556 $sanitized[$sanitized_key] = sanitize_text_field($value);
2557 } elseif (is_array($value)) {
2558 // Recursively sanitize nested arrays
2559 $sanitized[$sanitized_key] = array_map('sanitize_text_field', $value);
2560 } elseif (is_numeric($value)) {
2561 $sanitized[$sanitized_key] = (float) $value;
2562 } elseif (is_bool($value)) {
2563 $sanitized[$sanitized_key] = (bool) $value;
2564 }
2565 // Skip other data types for security
2566 }
2567
2568 return $sanitized;
2569 }
2570
2571 /**
2572 * Register endpoint classes
2573 *
2574 * @return void
2575 */
2576 public function register_endpoint_classes(): void {
2577 // Register endpoint classes that exist
2578 try {
2579 $site_identity_endpoint = new Site_Identity_Endpoint();
2580 $site_identity_endpoint->register_routes();
2581 } catch (\Exception $e) {
2582 // Failed to register Site Identity endpoint
2583 }
2584
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 {
2593 $performance_endpoint = new Performance_Endpoint();
2594 $performance_endpoint->register_routes();
2595 } catch (\Exception $e) {
2596 // Failed to register Performance endpoint
2597 }
2598
2599 try {
2600 $schema_endpoint = new Schema_Endpoint();
2601 $schema_endpoint->register_routes();
2602 } catch (\Exception $e) {
2603 // Failed to register Schema endpoint
2604 }
2605
2606 try {
2607 $settings_endpoint = new Settings_Management_Endpoint();
2608 $settings_endpoint->register_routes();
2609 } catch (\Exception $e) {
2610 // Failed to register Settings Management endpoint
2611 }
2612
2613 try {
2614 $integrations_endpoint = new Integrations_Endpoint();
2615 $integrations_endpoint->register_routes();
2616 } catch (\Exception $e) {
2617 // Failed to register Integrations endpoint
2618 }
2619
2620 try {
2621 $social_platforms_endpoint = new Social_Platforms_Endpoint();
2622 $social_platforms_endpoint->register_routes();
2623 } catch (\Exception $e) {
2624 // Failed to register Social Platforms endpoint
2625 }
2626
2627 try {
2628 $content_brief_endpoint = new Content_Brief_Endpoint();
2629 $content_brief_endpoint->register_routes();
2630 } catch (\Exception $e) {
2631 // Failed to register Content Brief endpoint
2632 }
2633
2634 try {
2635 $social_media_endpoint = new Social_Media_Endpoint();
2636 $social_media_endpoint->register_routes();
2637 } catch (\Exception $e) {
2638 // Failed to register Social Media endpoint
2639 }
2640
2641 try {
2642 $sitemap_endpoint = new Sitemap_Endpoint();
2643 $sitemap_endpoint->register_routes();
2644 } catch (\Exception $e) {
2645 // Failed to register Sitemap endpoint
2646 }
2647
2648 try {
2649 $llms_txt_endpoint = new LLMs_Txt_Endpoint();
2650 $llms_txt_endpoint->register_routes();
2651 } catch (\Exception $e) {
2652 // Failed to register LLMs.txt endpoint
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 // Thin content report (#565): also under global-seo/, so the same
2680 // Bulk SEO Optimization capability covers it.
2681 $thin_content_endpoint = new \ThinkRank\API\Thin_Content_Endpoint();
2682 $thin_content_endpoint->register_routes();
2683 } catch (\Exception $e) {
2684 // Failed to register Thin Content endpoint
2685 }
2686
2687 try {
2688 $image_seo_endpoint = new Image_SEO_Endpoint();
2689 $image_seo_endpoint->register_routes();
2690 } catch (\Exception $e) {
2691 // Failed to register Image SEO endpoint
2692 }
2693
2694 try {
2695 $external_links_endpoint = new External_Links_Endpoint();
2696 $external_links_endpoint->register_routes();
2697 } catch (\Exception $e) {
2698 // Failed to register External Links endpoint
2699 }
2700
2701 // Import_Controller is deliberately NOT gated on enable_migration_tools.
2702 // /import/detect backs the setup wizard's migration step and the record
2703 // count on Settings > Import / Export, and /import/snapshot + /migrate
2704 // run the wizard's actual import — all on a fresh install, where the
2705 // setting is off. Gating them would break onboarding, which is a worse
2706 // bug than the one #583 reports.
2707 try {
2708 $import_controller = new Import_Controller();
2709 $import_controller->register_routes();
2710 } catch (\Exception $e) {
2711 // Failed to register Import endpoint
2712 }
2713
2714 // Export/restore is gated on the setting that gates its admin screen,
2715 // so turning Import / Export off removes its REST surface along with
2716 // its menu item (#583). Nothing in the setup wizard calls these:
2717 // MigrationPluginRow takes startExport/startMigration/cancel from
2718 // useImportWorkflow and never uploadFile. rest_api_init runs per
2719 // request, so a toggle takes effect on the next one — no flush.
2720 if ((bool) \ThinkRank\Core\Settings::instance()->get('enable_import_export', false)) {
2721 try {
2722 $export_controller = new Export_Controller();
2723 $export_controller->register_routes();
2724 } catch (\Exception $e) {
2725 // Failed to register Export endpoint
2726 }
2727 }
2728
2729 try {
2730 $setup_wizard_endpoint = new Setup_Wizard_Endpoint();
2731 $setup_wizard_endpoint->register_routes();
2732 } catch (\Exception $e) {
2733 // Failed to register Setup Wizard endpoint
2734 }
2735 }
2736 }
2737