PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.0
2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 All 51 releases
← All changes | includes/ai/class-manager.php +293 -69 1.25.0 → 2.10.0 View file →
@@ -83,12 +83,21 @@
83 83 /**
84 84 * Initialize AI client
85 85 *
86 86 * @return void
87 + *
88 + * @throws \Exception On failure.
87 89 */
88 90 public function initialize_client(): void {
89 - $provider = $this->settings->get('ai_provider', 'openai');
91 + $provider = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
90 92
93 + // No provider chosen yet (a fresh install, or the user cleared it). That
94 + // is a normal unconfigured state, not a failure — leave $this->client
95 + // null and let get_client_unavailable_message() explain it (#572).
96 + if (Settings::AI_PROVIDER_NONE === $provider) {
97 + return;
98 + }
99 +
91 100 try {
92 101 switch ($provider) {
93 102 case 'openai':
94 103 $api_key = $this->settings->get('openai_api_key');
@@ -94,11 +103,11 @@
94 103 $api_key = $this->settings->get('openai_api_key');
95 104 if ($api_key) {
96 105 // Allow any model id (incl. user-entered custom models);
97 106 // only fall back to the default when none is set.
98 - $model = $this->settings->get('openai_model', 'gpt-5-nano');
107 + $model = $this->settings->get('openai_model', Settings::DEFAULT_OPENAI_MODEL);
99 108 if (empty($model)) {
100 - $model = 'gpt-5-nano';
109 + $model = Settings::DEFAULT_OPENAI_MODEL;
101 110 }
102 111 // OpenAI's reasoning models (GPT-5/o-series) spend a long
103 112 // time on reasoning tokens before emitting content, so
104 113 // large completions (content briefs) regularly outlive the
@@ -114,11 +123,11 @@
114 123 $api_key = $this->settings->get('claude_api_key');
115 124 if ($api_key) {
116 125 // Allow any model id (incl. user-entered custom models);
117 126 // only fall back to the default when none is set.
118 - $model = $this->settings->get('claude_model', 'claude-sonnet-5');
127 + $model = $this->settings->get('claude_model', Settings::DEFAULT_CLAUDE_MODEL);
119 128 if (empty($model)) {
120 - $model = 'claude-sonnet-5';
129 + $model = Settings::DEFAULT_CLAUDE_MODEL;
121 130 }
122 131 // Use 120-second timeout for complex AI operations
123 132 $timeout = 120;
124 133 $this->client = new Claude_Client($api_key, $model, $timeout);
@@ -131,11 +140,11 @@
131 140 $api_key = $this->settings->get('gemini_api_key');
132 141 if ($api_key) {
133 142 // Allow any model id (incl. user-entered custom models);
134 143 // only fall back to the default when none is set.
135 - $model = $this->settings->get('gemini_model', 'gemini-2.5-flash');
144 + $model = $this->settings->get('gemini_model', Settings::DEFAULT_GEMINI_MODEL);
136 145 if (empty($model)) {
137 - $model = 'gemini-2.5-flash';
146 + $model = Settings::DEFAULT_GEMINI_MODEL;
138 147 }
139 148 // Use 120-second timeout for complex AI operations
140 149 $timeout = 120;
141 150 $this->client = new Gemini_Client($api_key, $model, $timeout);
@@ -146,11 +155,11 @@
146 155 $api_key = $this->settings->get('openrouter_api_key');
147 156 if ($api_key) {
148 157 // Allow any model id (incl. user-entered custom models);
149 158 // only fall back to the default when none is set.
150 - $model = $this->settings->get('openrouter_model', 'openai/gpt-4o-mini');
159 + $model = $this->settings->get('openrouter_model', Settings::DEFAULT_OPENROUTER_MODEL);
151 160 if (empty($model)) {
152 - $model = 'openai/gpt-4o-mini';
161 + $model = Settings::DEFAULT_OPENROUTER_MODEL;
153 162 }
154 163 // Use 120-second timeout for complex AI operations
155 164 $timeout = 120;
156 165 $this->client = new OpenRouter_Client($api_key, $model, $timeout);
@@ -156,17 +165,63 @@
156 165 $this->client = new OpenRouter_Client($api_key, $model, $timeout);
157 166 }
158 167 break;
159 168
169 + case 'openai_compatible':
170 + // Any server speaking the OpenAI Chat Completions API:
171 + // Ollama, LM Studio, vLLM, Azure OpenAI, Groq, a company
172 + // gateway (#721). The key is optional — a local server
173 + // usually wants none — so the URL and the model id are what
174 + // decide whether this provider is configured.
175 + $base_url = (string) $this->settings->get('openai_compatible_base_url', '');
176 + $model = trim((string) $this->settings->get('openai_compatible_model', ''));
177 + if ('' !== $base_url && '' !== $model) {
178 + $compatible_client = new OpenAI_Client(
179 + (string) $this->settings->get('openai_compatible_api_key', ''),
180 + $model,
181 + (int) $this->settings->get('openai_compatible_timeout', Settings::DEFAULT_OPENAI_COMPATIBLE_TIMEOUT),
182 + $base_url
183 + );
184 + $compatible_client->set_json_mode((bool) $this->settings->get('openai_compatible_json_mode', false));
185 + $this->client = $compatible_client;
186 + }
187 + break;
188 +
160 189 default:
161 190 throw new \Exception("Unsupported AI provider: {$provider}");
162 191 }
163 192 } catch (\Exception $e) {
164 - // AI client initialization failed, will be handled later
193 + // Leave a trace. Swallowing this meant a misconfigured provider
194 + // produced a NULL client and every AI feature became a silent
195 + // no-op with nothing to diagnose from.
196 + if (defined('WP_DEBUG') && WP_DEBUG) {
197 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- diagnostic, WP_DEBUG only.
198 + error_log('ThinkRank [ai]: client initialization failed — ' . $e->getMessage());
199 + }
165 200 }
166 201 }
167 202
168 203 /**
204 + * Has the user configured enough for the selected provider to run?
205 + *
206 + * Every generator re-checks this before spending a request, because an
207 + * initialised client is not proof of configuration — the client is built
208 + * from whatever was stored. It used to be an inline OR over the four API
209 + * key settings, repeated at nine call sites; the OpenAI-compatible
210 + * provider broke that shape, since a local Ollama or LM Studio server
211 + * legitimately has no key and is configured by URL + model instead (#721).
212 + * Settings::has_ai_provider_configured() is now the single answer, shared
213 + * with the admin menu notice and the metabox.
214 + *
215 + * @since 2.8.0
216 + *
217 + * @return bool True when the selected provider has what it needs.
218 + */
219 + private function has_provider_credentials(): bool {
220 + return $this->settings->has_ai_provider_configured();
221 + }
222 +
223 + /**
169 224 * Get the display name of the currently selected AI provider
170 225 *
171 226 * @return string Provider display name (e.g. "OpenAI")
172 227 */
@@ -172,14 +227,17 @@
172 227 */
173 228 private function get_provider_label(): string {
174 229 $labels = [
175 230 'openai' => 'OpenAI',
176 - 'claude' => 'Claude',
231 + // The vendor, not the model family — matches the settings UI (#572).
232 + 'claude' => 'Anthropic',
177 233 'gemini' => 'Gemini',
178 234 'openrouter' => 'OpenRouter',
235 + // Named by what it is, not by OpenAI — the host is the customer's.
236 + 'openai_compatible' => 'OpenAI-compatible endpoint',
179 237 ];
180 238
181 - $provider = (string) $this->settings->get('ai_provider', 'openai');
239 + $provider = (string) $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
182 240
183 241 return $labels[$provider] ?? ucfirst($provider);
184 242 }
185 243
@@ -191,9 +249,9 @@
191 249 *
192 250 * @return string Actionable error message for end users
193 251 */
194 252 private function get_client_unavailable_message(): string {
195 - $provider = (string) $this->settings->get('ai_provider', 'openai');
253 + $provider = (string) $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
196 254
197 255 // The React admin renders this anchor as a real link via linkifyMessage().
198 256 $settings_link = sprintf(
199 257 '<a href="%s" target="_blank" rel="noopener noreferrer">%s</a>',
@@ -200,12 +258,50 @@
200 258 esc_url(admin_url('admin.php?page=thinkrank-settings')),
201 259 __('ThinkRank → Settings', 'thinkrank')
202 260 );
203 261
262 + // No provider chosen at all — asking for a key would put the cart before
263 + // the horse, so name the actual first step (#572).
264 + if (Settings::AI_PROVIDER_NONE === $provider) {
265 + return sprintf(
266 + /* translators: %s: link to the ThinkRank settings page. */
267 + __('AI features are not set up yet. Choose an AI provider and add its API key under %s.', 'thinkrank'),
268 + $settings_link
269 + );
270 + }
271 +
272 + // The OpenAI-compatible provider has no key requirement — a local
273 + // server usually wants none — so the generic "add your API key" copy
274 + // below would send the user looking for the wrong field (#721).
275 + if ('openai_compatible' === $provider) {
276 + if (empty($this->settings->get('openai_compatible_base_url'))) {
277 + return sprintf(
278 + /* translators: %s: link to the ThinkRank settings page. */
279 + __('AI features are not set up yet. Add the base URL of your OpenAI-compatible endpoint under %s.', 'thinkrank'),
280 + $settings_link
281 + );
282 + }
283 +
284 + if (empty(trim((string) $this->settings->get('openai_compatible_model', '')))) {
285 + return sprintf(
286 + /* translators: %s: link to the ThinkRank settings page. */
287 + __('AI features are not set up yet. Enter the model id your endpoint should use under %s.', 'thinkrank'),
288 + $settings_link
289 + );
290 + }
291 +
292 + return sprintf(
293 + /* translators: %s: link to the ThinkRank settings page. */
294 + __('ThinkRank could not reach your OpenAI-compatible endpoint. Check the base URL, model id and that the server is running under %s, then try again.', 'thinkrank'),
295 + $settings_link
296 + );
297 + }
298 +
204 299 if (empty($this->settings->get("{$provider}_api_key"))) {
205 300 return sprintf(
206 - /* translators: %s: link to the ThinkRank settings page. */
207 - __('AI features are not set up yet. To enable them, add your API key under %s.', 'thinkrank'),
301 + /* translators: 1: AI provider name (e.g. OpenAI), 2: link to the ThinkRank settings page. */
302 + __('AI features are not set up yet. To enable them, add your %1$s API key under %2$s.', 'thinkrank'),
303 + $this->get_provider_label(),
208 304 $settings_link
209 305 );
210 306 }
211 307
@@ -240,9 +336,9 @@
240 336 }
241 337
242 338 // If still not available, throw error
243 339 if (!$this->client) {
244 - throw new \Exception($this->get_client_unavailable_message());
340 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
245 341 }
246 342
247 343 return $this->client;
248 344 }
@@ -261,9 +357,9 @@
261 357 $this->initialize_client();
262 358
263 359 // If still not available, throw error
264 360 if (!$this->client) {
265 - throw new \Exception($this->get_client_unavailable_message());
361 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
266 362 }
267 363 }
268 364
269 365 // Check rate limits
@@ -287,12 +383,12 @@
287 383 // Generate metadata using AI
288 384 $metadata = $this->client->generate_seo_metadata($content, $options);
289 385
290 386 // Ensure user has configured their API key
291 - $user_has_api_key = !empty($this->settings->get('openai_api_key')) || !empty($this->settings->get('claude_api_key')) || !empty($this->settings->get('gemini_api_key')) || !empty($this->settings->get('openrouter_api_key'));
387 + $user_has_api_key = $this->has_provider_credentials();
292 388
293 389 if (!$user_has_api_key) {
294 - throw new \Exception($this->get_client_unavailable_message());
390 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
295 391 }
296 392
297 393 // Cache the result
298 394 $this->cache->set($cache_key, $metadata);
@@ -336,16 +432,16 @@
336 432 public function improve_seo_title(string $content, array $options = []): array {
337 433 if (!$this->client) {
338 434 $this->initialize_client();
339 435 if (!$this->client) {
340 - throw new \Exception($this->get_client_unavailable_message());
436 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
341 437 }
342 438 }
343 439
344 440 // Ensure user has configured their API key.
345 - $user_has_api_key = !empty($this->settings->get('openai_api_key')) || !empty($this->settings->get('claude_api_key')) || !empty($this->settings->get('gemini_api_key')) || !empty($this->settings->get('openrouter_api_key'));
441 + $user_has_api_key = $this->has_provider_credentials();
346 442 if (!$user_has_api_key) {
347 - throw new \Exception($this->get_client_unavailable_message());
443 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
348 444 }
349 445
350 446 // Check rate limits.
351 447 if (!$this->check_rate_limit()) {
@@ -356,8 +452,9 @@
356 452 $target_keyword = (string) ($options['target_keyword'] ?? '');
357 453 $content_type = (string) ($options['content_type'] ?? 'blog_post');
358 454 $tone = (string) ($options['tone'] ?? 'professional');
359 455 $suggestion = (string) ($options['suggestion'] ?? '');
456 + $language = (string) ($options['language'] ?? '');
360 457
361 458 // Cache identical requests (same content + inputs) to avoid duplicate calls.
362 459 // Cap content server-side (mirror the frontend 5000-char trim) so a
363 460 // direct REST caller can't force oversized prompt/cache/AI work.
@@ -389,9 +486,10 @@
389 486 $tone,
390 487 $suggestion,
391 488 $provider,
392 489 $sentiment_words,
393 - $power_words
490 + $power_words,
491 + $language
394 492 );
395 493
396 494 $generated = $this->request_title($prompt);
397 495 $title = $generated['title'];
@@ -396,8 +494,9 @@
396 494 $generated = $this->request_title($prompt);
397 495 $title = $generated['title'];
398 496 $total_tokens = $generated['tokens'];
399 497 $ai_text = $generated['ai_text'];
498 + $finish_reason = $generated['finish_reason'];
400 499
401 500 // A reasoning model can still return an empty/truncated title on the
402 501 // first pass; retry once before giving up so the "Apply" action reliably
403 502 // produces a title.
@@ -406,8 +505,9 @@
406 505 $total_tokens += $retry['tokens'];
407 506 if ($retry['ai_text'] !== '') {
408 507 $ai_text = $retry['ai_text'];
409 508 }
509 + $finish_reason = $retry['finish_reason'];
410 510 if ($retry['title'] !== '') {
411 511 $title = $retry['title'];
412 512 }
413 513 }
@@ -421,8 +521,9 @@
421 521 $total_tokens += $retry['tokens'];
422 522 if ($retry['ai_text'] !== '') {
423 523 $ai_text = $retry['ai_text'];
424 524 }
525 + $finish_reason = $retry['finish_reason'];
425 526 if ($retry['title'] !== '' && $this->title_contains_word($retry['title'], $sentiment_words)) {
426 527 $title = $retry['title'];
427 528 }
428 529 }
@@ -427,8 +528,20 @@
427 528 }
428 529 }
429 530
430 531 if ($title === '') {
532 + // Nothing about a raw JSON-parse failure is visible to support
533 + // otherwise — log_ai_usage() below only runs on success, so a
534 + // failed attempt left no trace of what the model actually sent
535 + // back or why generation stopped.
536 + if (defined('WP_DEBUG') && WP_DEBUG) {
537 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Debug logging only when WP_DEBUG is enabled.
538 + error_log(sprintf(
539 + '[ThinkRank] Title improvement failed to extract a title. finish_reason=%s ai_text=%s',
540 + $finish_reason !== '' ? $finish_reason : '(none)',
541 + mb_substr($ai_text, 0, 500)
542 + ));
543 + }
431 544 throw new \Exception('The AI did not return a usable title. Please try again.');
432 545 }
433 546
434 547 // Log usage.
@@ -462,8 +575,9 @@
462 575 return [
463 576 'title' => $title,
464 577 'ai_text' => $completion['ai_text'],
465 578 'tokens' => $completion['tokens'],
579 + 'finish_reason' => $completion['finish_reason'],
466 580 ];
467 581 }
468 582
469 583 /**
@@ -489,15 +603,15 @@
489 603 public function improve_meta_description(string $content, array $options = []): array {
490 604 if (!$this->client) {
491 605 $this->initialize_client();
492 606 if (!$this->client) {
493 - throw new \Exception($this->get_client_unavailable_message());
607 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
494 608 }
495 609 }
496 610
497 - $user_has_api_key = !empty($this->settings->get('openai_api_key')) || !empty($this->settings->get('claude_api_key')) || !empty($this->settings->get('gemini_api_key')) || !empty($this->settings->get('openrouter_api_key'));
611 + $user_has_api_key = $this->has_provider_credentials();
498 612 if (!$user_has_api_key) {
499 - throw new \Exception($this->get_client_unavailable_message());
613 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
500 614 }
501 615
502 616 if (!$this->check_rate_limit()) {
503 617 throw new \Exception('Rate limit exceeded. Please try again later.');
@@ -507,8 +621,9 @@
507 621 $target_keyword = (string) ($options['target_keyword'] ?? '');
508 622 $content_type = (string) ($options['content_type'] ?? 'blog_post');
509 623 $tone = (string) ($options['tone'] ?? 'professional');
510 624 $suggestion = (string) ($options['suggestion'] ?? '');
625 + $language = (string) ($options['language'] ?? '');
511 626
512 627 // Cap content server-side (mirror the frontend 5000-char trim) so a
513 628 // direct REST caller can't force oversized prompt/cache/AI work.
514 629 $content = mb_substr($content, 0, 5000);
@@ -532,9 +647,10 @@
532 647 $target_keyword,
533 648 $content_type,
534 649 $tone,
535 650 $suggestion,
536 - $provider
651 + $provider,
652 + $language
537 653 );
538 654
539 655 $valid = function (string $desc) use ($needs_keyword, $target_keyword): bool {
540 656 $len = mb_strlen($desc);
@@ -880,14 +996,14 @@
880 996 private function ensure_ready_for_ai(): void {
881 997 if (!$this->client) {
882 998 $this->initialize_client();
883 999 if (!$this->client) {
884 - throw new \Exception($this->get_client_unavailable_message());
1000 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
885 1001 }
886 1002 }
887 - $user_has_api_key = !empty($this->settings->get('openai_api_key')) || !empty($this->settings->get('claude_api_key')) || !empty($this->settings->get('gemini_api_key')) || !empty($this->settings->get('openrouter_api_key'));
1003 + $user_has_api_key = $this->has_provider_credentials();
888 1004 if (!$user_has_api_key) {
889 - throw new \Exception($this->get_client_unavailable_message());
1005 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
890 1006 }
891 1007 if (!$this->check_rate_limit()) {
892 1008 throw new \Exception('Rate limit exceeded. Please try again later.');
893 1009 }
@@ -916,18 +1032,75 @@
916 1032 *
917 1033 * @param string $prompt The prompt to send.
918 1034 * @return array{ai_text:string,tokens:int}
919 1035 */
920 - private function request_completion(string $prompt, int $max_tokens = 2048): array {
1036 + /**
1037 + * Detect a provider-side refusal or content-policy block and fail with
1038 + * the real reason. Each provider signals these differently, and none of
1039 + * the signals set the content field the extraction chain looks for — left
1040 + * unchecked they read as an empty/unusable result with no explanation of
1041 + * why, and every caller here retries an empty result once, which just
1042 + * repeats the same refusal at the cost of more tokens.
1043 + *
1044 + * @param array $response Raw response from the AI client.
1045 + * @throws \Exception If the response is a refusal or policy block.
1046 + */
1047 + private function guard_against_refusal(array $response): void {
1048 + // --- OpenAI (Chat Completions) ---
1049 + // A structured refusal is HTTP 200 with message.content=null and the
1050 + // stated reason carried in message.refusal.
1051 + if (isset($response['choices'][0]['message'])) {
1052 + $message = $response['choices'][0]['message'];
1053 + $finish = (string) ($response['choices'][0]['finish_reason'] ?? '');
1054 +
1055 + if (!empty($message['refusal'])) {
1056 + throw new \Exception(esc_html('The AI declined this request: ' . (string) $message['refusal']));
1057 + }
1058 + if ('content_filter' === $finish) {
1059 + throw new \Exception('The AI blocked this request under its content policy. Try different wording.');
1060 + }
1061 + }
1062 +
1063 + // --- Claude (Messages) ---
1064 + if (isset($response['stop_reason']) && 'refusal' === (string) $response['stop_reason']) {
1065 + throw new \Exception('The AI declined this request. Try different wording.');
1066 + }
1067 +
1068 + // --- Gemini ---
1069 + // A prompt rejected outright returns no candidate at all, only
1070 + // promptFeedback.blockReason; a candidate can also finish on SAFETY or
1071 + // PROHIBITED_CONTENT.
1072 + $block_reason = (string) ($response['promptFeedback']['blockReason'] ?? '');
1073 + if ('' !== $block_reason) {
1074 + throw new \Exception(esc_html(sprintf('The AI blocked this request under its content policy (%s). Try different wording.', $block_reason)));
1075 + }
1076 + $gemini_finish = (string) ($response['candidates'][0]['finishReason'] ?? '');
1077 + if (in_array($gemini_finish, ['SAFETY', 'PROHIBITED_CONTENT'], true)) {
1078 + throw new \Exception('The AI blocked this request under its content policy. Try different wording.');
1079 + }
1080 + }
1081 +
1082 + private function request_completion(string $prompt, int $max_tokens = 2048, array $options = []): array {
921 1083 // "Thinking" providers (e.g. Gemini 2.5) spend output tokens on reasoning
922 1084 // before emitting text, so the cap must cover both the reasoning and the
923 1085 // visible JSON. Longer outputs (paragraphs) need a bigger budget. It's
924 1086 // only a ceiling — short replies cost no more.
925 - $response = $this->client->generate_completion($prompt, [
1087 + // Extra options (e.g. reasoning_effort) pass through; every client
1088 + // cherry-picks the keys it understands and ignores the rest.
1089 + $response = $this->client->generate_completion($prompt, array_merge($options, [
926 1090 'max_tokens' => $max_tokens,
927 1091 'temperature' => 0.4,
928 - ]);
1092 + ]));
929 1093
1094 + // Fail fast on a genuine refusal/policy block instead of retrying the
1095 + // same prompt (every caller retries on an empty result) and burning
1096 + // more tokens on a request the model has already declined. Truncation
1097 + // (finish_reason length/max_tokens) is deliberately NOT treated as a
1098 + // hard failure here — callers' existing empty-result retries already
1099 + // recover from that, and a retry can succeed where the first attempt
1100 + // spent its budget on hidden reasoning.
1101 + $this->guard_against_refusal($response);
1102 +
930 1103 $ai_text = '';
931 1104 if (isset($response['choices'][0]['message']['content'])) {
932 1105 $ai_text = is_array($response['choices'][0]['message']['content'])
933 1106 ? implode(' ', array_map(static fn($part) => is_array($part) ? ($part['text'] ?? '') : (string) $part, $response['choices'][0]['message']['content']))
@@ -943,9 +1116,25 @@
943 1116 $tokens = $response['usage']['total_tokens']
944 1117 ?? $response['usage']['output_tokens']
945 1118 ?? ($response['usageMetadata']['totalTokenCount'] ?? 0);
946 1119
947 - return ['ai_text' => $ai_text, 'tokens' => (int) $tokens];
1120 + // Diagnostics for callers that must explain an empty answer: why
1121 + // generation stopped, and how much of the
1122 + // completion budget hidden reasoning consumed (OpenAI reasoning models).
1123 + // All three provider shapes are read — Gemini reports the stop reason
1124 + // per candidate, so without that arm the diagnostic was always blank
1125 + // for exactly the provider whose truncation it exists to explain.
1126 + $finish_reason = (string) ($response['choices'][0]['finish_reason']
1127 + ?? ($response['stop_reason']
1128 + ?? ($response['candidates'][0]['finishReason'] ?? '')));
1129 + $reasoning_tokens = (int) ($response['usage']['completion_tokens_details']['reasoning_tokens'] ?? 0);
1130 +
1131 + return [
1132 + 'ai_text' => $ai_text,
1133 + 'tokens' => (int) $tokens,
1134 + 'finish_reason' => $finish_reason,
1135 + 'reasoning_tokens' => $reasoning_tokens,
1136 + ];
948 1137 }
949 1138
950 1139 /**
951 1140 * Trim a meta description to at most 160 characters at a word boundary,
@@ -1027,18 +1216,18 @@
1027 1216 * @throws \Exception If analysis fails
1028 1217 */
1029 1218 public function analyze_content(string $content, array $metadata = []): array {
1030 1219 if (!$this->client) {
1031 - throw new \Exception($this->get_client_unavailable_message());
1220 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1032 1221 }
1033 1222
1034 1223 $user_id = get_current_user_id();
1035 1224
1036 1225 // Ensure user has configured their API key
1037 - $user_has_api_key = !empty($this->settings->get('openai_api_key')) || !empty($this->settings->get('claude_api_key')) || !empty($this->settings->get('gemini_api_key')) || !empty($this->settings->get('openrouter_api_key'));
1226 + $user_has_api_key = $this->has_provider_credentials();
1038 1227
1039 1228 if (!$user_has_api_key) {
1040 - throw new \Exception($this->get_client_unavailable_message());
1229 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1041 1230 }
1042 1231
1043 1232 // Check rate limits
1044 1233 if (!$this->check_rate_limit($user_id, 'content_analysis')) {
@@ -1045,9 +1234,9 @@
1045 1234 throw new \Exception('Rate limit exceeded. Please try again later.');
1046 1235 }
1047 1236
1048 1237 // Check cache first
1049 - $cache_key = 'content_analysis_' . md5($content . serialize($metadata));
1238 + $cache_key = 'content_analysis_' . md5($content . wp_json_encode($metadata));
1050 1239 $cached_result = $this->cache->get($cache_key);
1051 1240 if ($cached_result) {
1052 1241 return $cached_result['data'] ?? $cached_result;
1053 1242 }
@@ -1123,16 +1312,16 @@
1123 1312
1124 1313 $user_id = get_current_user_id();
1125 1314
1126 1315 // Ensure user has configured their API key
1127 - $user_has_api_key = !empty($this->settings->get('openai_api_key')) || !empty($this->settings->get('claude_api_key')) || !empty($this->settings->get('gemini_api_key')) || !empty($this->settings->get('openrouter_api_key'));
1316 + $user_has_api_key = $this->has_provider_credentials();
1128 1317
1129 1318 if (!$user_has_api_key) {
1130 - throw new \Exception($this->get_client_unavailable_message());
1319 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1131 1320 }
1132 1321
1133 1322 // Generate cache key using existing pattern
1134 - $cache_key = 'site_identity_' . md5(serialize($site_data) . serialize($options)) . '_' . $user_id;
1323 + $cache_key = 'site_identity_' . md5(wp_json_encode($site_data) . wp_json_encode($options)) . '_' . $user_id;
1135 1324
1136 1325 // Check existing cache infrastructure
1137 1326 // Cache_Manager::set() wraps entries as ['data' => …], so unwrap
1138 1327 // before inspecting — checking optimized_data on the wrapped array
@@ -1151,9 +1340,9 @@
1151 1340 // Get AI client
1152 1341 $client = $this->get_client();
1153 1342
1154 1343 if (!$client) {
1155 - throw new \Exception($this->get_client_unavailable_message());
1344 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1156 1345 }
1157 1346
1158 1347 // Perform AI optimization
1159 1348 $optimization_results = $client->optimize_site_identity($site_data, $options);
@@ -1164,9 +1353,9 @@
1164 1353 }
1165 1354
1166 1355 // Add metadata
1167 1356 $optimization_results['ai_model'] = $client->get_model();
1168 - $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1357 + $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1169 1358 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1170 1359 $optimization_results['user_id'] = $user_id;
1171 1360
1172 1361 // Cache the results (24 hours)
@@ -1201,16 +1390,16 @@
1201 1390
1202 1391 $user_id = get_current_user_id();
1203 1392
1204 1393 // Ensure user has configured their API key
1205 - $user_has_api_key = !empty($this->settings->get('openai_api_key')) || !empty($this->settings->get('claude_api_key')) || !empty($this->settings->get('gemini_api_key')) || !empty($this->settings->get('openrouter_api_key'));
1394 + $user_has_api_key = $this->has_provider_credentials();
1206 1395
1207 1396 if (!$user_has_api_key) {
1208 - throw new \Exception($this->get_client_unavailable_message());
1397 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1209 1398 }
1210 1399
1211 1400 // Generate cache key
1212 - $cache_key = 'llms_txt_' . md5(serialize($website_data) . serialize($options)) . '_' . $user_id;
1401 + $cache_key = 'llms_txt_' . md5(wp_json_encode($website_data) . wp_json_encode($options)) . '_' . $user_id;
1213 1402
1214 1403 // Check cache first
1215 1404 // Cache_Manager::set() wraps entries as ['data' => …], so unwrap
1216 1405 // before inspecting — checking optimized_data on the wrapped array
@@ -1229,9 +1418,9 @@
1229 1418 // Get AI client
1230 1419 $client = $this->get_client();
1231 1420
1232 1421 if (!$client) {
1233 - throw new \Exception($this->get_client_unavailable_message());
1422 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1234 1423 }
1235 1424
1236 1425 // Perform AI optimization
1237 1426 $optimization_results = $client->optimize_llms_txt($website_data, $options);
@@ -1242,9 +1431,9 @@
1242 1431 }
1243 1432
1244 1433 // Add metadata
1245 1434 $optimization_results['ai_model'] = $client->get_model();
1246 - $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1435 + $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1247 1436 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1248 1437 $optimization_results['user_id'] = $user_id;
1249 1438
1250 1439 // Cache the results (24 hours)
@@ -1274,25 +1463,42 @@
1274 1463 'models' => ['gpt-5-nano', 'gpt-5-mini', 'gpt-5', 'gpt-4o'],
1275 1464 'requires_key' => true,
1276 1465 ],
1277 1466 'claude' => [
1278 - 'name' => 'Claude (Anthropic)',
1279 - 'description' => 'Claude Opus 4.8, Sonnet 5, and Haiku 4.5',
1280 - 'models' => ['claude-opus-4-8', 'claude-sonnet-5', 'claude-haiku-4-5'],
1467 + // The vendor, not the model family: the other three entries name
1468 + // vendors, and a family name goes stale on every rename (#572).
1469 + 'name' => 'Anthropic',
1470 + 'description' => 'Claude Opus 5, Opus 4.8, Sonnet 5, and Haiku 4.5',
1471 + 'models' => ['claude-opus-5', 'claude-opus-4-8', 'claude-sonnet-5', 'claude-haiku-4-5'],
1281 1472 'requires_key' => true,
1282 1473 ],
1283 1474 'gemini' => [
1284 1475 'name' => 'Google Gemini',
1285 - 'description' => 'Gemini 3.x and 2.x models',
1286 - 'models' => ['gemini-3.1-pro', 'gemini-3.5-flash', 'gemini-3.1-flash-lite', 'gemini-2.5-flash', 'gemini-2.5-flash-lite', 'gemini-2.5-pro', 'gemini-2.0-flash', 'gemini-1.5-flash'],
1476 + 'description' => 'Gemini 3.x models',
1477 + // 2.5 Pro / 2.5 Flash-Lite retire in Oct 2026 and 3.1 Pro only
1478 + // ships under its -preview id, so none of the three belong in a
1479 + // list users pick from (#572).
1480 + 'models' => ['gemini-3.5-flash', 'gemini-3.1-flash-lite', 'gemini-3.1-pro-preview'],
1287 1481 'requires_key' => true,
1288 1482 ],
1289 1483 'openrouter' => [
1290 1484 'name' => 'OpenRouter',
1291 1485 'description' => 'Unified access to many models via one key',
1292 - 'models' => ['openai/gpt-4o-mini', 'anthropic/claude-3.5-sonnet', 'google/gemini-2.0-flash-001', 'meta-llama/llama-3.3-70b-instruct', 'deepseek/deepseek-chat'],
1486 + // claude-3.5-sonnet is retired (Claude_Client::normalize_model
1487 + // already self-heals it on the direct path) and
1488 + // gemini-2.0-flash-001 was shut down on 1 Jun 2026 (#572).
1489 + 'models' => ['openai/gpt-4o-mini', 'anthropic/claude-sonnet-5', 'google/gemini-3.5-flash', 'meta-llama/llama-3.3-70b-instruct', 'deepseek/deepseek-chat'],
1293 1490 'requires_key' => true,
1294 1491 ],
1492 + 'openai_compatible' => [
1493 + 'name' => 'OpenAI-compatible endpoint',
1494 + 'description' => 'Any server speaking the OpenAI Chat Completions API: Ollama, LM Studio, vLLM, Azure OpenAI, Groq, Together, DeepSeek or your own gateway',
1495 + // Deliberately empty: the model list belongs to the server the
1496 + // user names, and is read from GET {base}/models at runtime.
1497 + 'models' => [],
1498 + 'requires_key' => false,
1499 + 'requires_base_url' => true,
1500 + ],
1295 1501 ];
1296 1502 }
1297 1503
1298 1504 /**
@@ -1300,14 +1506,20 @@
1300 1506 *
1301 1507 * @return array Provider status
1302 1508 */
1303 1509 public function get_provider_status(): array {
1304 - $provider = $this->settings->get('ai_provider', 'openai');
1305 - $api_key = $this->settings->get($provider . '_api_key');
1510 + $provider = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1306 1511
1512 + // "Configured" is per-provider: a key for the hosted three, a base URL
1513 + // plus a model id for an OpenAI-compatible endpoint, whose key is
1514 + // optional (#721).
1515 + $configured = Settings::AI_PROVIDER_NONE === $provider
1516 + ? false
1517 + : $this->has_provider_credentials();
1518 +
1307 1519 return [
1308 1520 'provider' => $provider,
1309 - 'configured' => !empty($api_key),
1521 + 'configured' => $configured,
1310 1522 'connected' => $this->client !== null,
1311 1523 ];
1312 1524 }
1313 1525
@@ -1390,9 +1602,9 @@
1390 1602 * @return bool True if within limits.
1391 1603 */
1392 1604 private function check_rate_limit(?int $user_id = null, string $context = 'ai'): bool {
1393 1605 $user_id = $user_id ?? get_current_user_id();
1394 - $max_requests = (int) $this->settings->get('max_requests_per_minute', 10);
1606 + $max_requests = (int) $this->settings->get('max_requests_per_minute', 0);
1395 1607
1396 1608 // A non-positive limit means "unlimited".
1397 1609 if ($max_requests <= 0) {
1398 1610 return true;
@@ -1444,14 +1656,26 @@
1444 1656 [
1445 1657 'user_id' => $user_id,
1446 1658 'action' => $action,
1447 1659 'tokens_used' => $tokens_used,
1448 - 'provider' => $this->settings->get('ai_provider', 'openai'),
1660 + 'provider' => $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE),
1449 1661 'metadata' => !empty($metadata) ? wp_json_encode($metadata) : null,
1450 1662 'created_at' => current_time('mysql'),
1451 1663 ],
1452 1664 ['%d', '%s', '%d', '%s', '%s', '%s']
1453 1665 );
1666 +
1667 + /**
1668 + * Fires after an AI usage row is recorded.
1669 + *
1670 + * Analytics listens to drop its cached overview, so the Usages page
1671 + * reflects this action immediately instead of after the 600s TTL.
1672 + *
1673 + * @since 2.2.1
1674 + *
1675 + * @param int $user_id User the usage was recorded against.
1676 + */
1677 + do_action('thinkrank_ai_usage_logged', $user_id);
1454 1678 }
1455 1679
1456 1680 /**
1457 1681 * Cleanup expired cache entries
@@ -1480,16 +1704,16 @@
1480 1704
1481 1705 $user_id = get_current_user_id();
1482 1706
1483 1707 // Ensure user has configured their API key (copying Site Identity pattern)
1484 - $user_has_api_key = !empty($this->settings->get('openai_api_key')) || !empty($this->settings->get('claude_api_key')) || !empty($this->settings->get('gemini_api_key')) || !empty($this->settings->get('openrouter_api_key'));
1708 + $user_has_api_key = $this->has_provider_credentials();
1485 1709
1486 1710 if (!$user_has_api_key) {
1487 - throw new \Exception($this->get_client_unavailable_message());
1711 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1488 1712 }
1489 1713
1490 1714 // Generate cache key using existing pattern
1491 - $cache_key = 'homepage_meta_' . md5(serialize($content_data) . serialize($options)) . '_' . $user_id;
1715 + $cache_key = 'homepage_meta_' . md5(wp_json_encode($content_data) . wp_json_encode($options)) . '_' . $user_id;
1492 1716
1493 1717 // Check existing cache infrastructure
1494 1718 // Cache_Manager::set() wraps entries as ['data' => …], so unwrap
1495 1719 // before inspecting — checking optimized_data on the wrapped array
@@ -1508,9 +1732,9 @@
1508 1732 // Get AI client
1509 1733 $client = $this->get_client();
1510 1734
1511 1735 if (!$client) {
1512 - throw new \Exception($this->get_client_unavailable_message());
1736 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1513 1737 }
1514 1738
1515 1739 // Perform AI optimization
1516 1740 $optimization_results = $client->optimize_homepage_meta($content_data, $options);
@@ -1521,9 +1745,9 @@
1521 1745 }
1522 1746
1523 1747 // Add metadata
1524 1748 $optimization_results['ai_model'] = $client->get_model();
1525 - $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1749 + $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1526 1750 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1527 1751 $optimization_results['user_id'] = $user_id;
1528 1752
1529 1753 // Cache the results (24 hours)
@@ -1558,16 +1782,16 @@
1558 1782
1559 1783 $user_id = get_current_user_id();
1560 1784
1561 1785 // Ensure user has configured their API key (copying Site Identity pattern)
1562 - $user_has_api_key = !empty($this->settings->get('openai_api_key')) || !empty($this->settings->get('claude_api_key')) || !empty($this->settings->get('gemini_api_key')) || !empty($this->settings->get('openrouter_api_key'));
1786 + $user_has_api_key = $this->has_provider_credentials();
1563 1787
1564 1788 if (!$user_has_api_key) {
1565 - throw new \Exception($this->get_client_unavailable_message());
1789 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1566 1790 }
1567 1791
1568 1792 // Generate cache key using existing pattern
1569 - $cache_key = 'homepage_hero_' . md5(serialize($hero_data) . serialize($options)) . '_' . $user_id;
1793 + $cache_key = 'homepage_hero_' . md5(wp_json_encode($hero_data) . wp_json_encode($options)) . '_' . $user_id;
1570 1794
1571 1795 // Check existing cache infrastructure
1572 1796 // Cache_Manager::set() wraps entries as ['data' => …], so unwrap
1573 1797 // before inspecting — checking optimized_data on the wrapped array
@@ -1586,9 +1810,9 @@
1586 1810 // Get AI client
1587 1811 $client = $this->get_client();
1588 1812
1589 1813 if (!$client) {
1590 - throw new \Exception($this->get_client_unavailable_message());
1814 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1591 1815 }
1592 1816
1593 1817 // Perform AI optimization
1594 1818 $optimization_results = $client->optimize_homepage_hero($hero_data, $options);
@@ -1599,9 +1823,9 @@
1599 1823 }
1600 1824
1601 1825 // Add metadata
1602 1826 $optimization_results['ai_model'] = $client->get_model();
1603 - $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1827 + $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1604 1828 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1605 1829 $optimization_results['user_id'] = $user_id;
1606 1830
1607 1831 // Cache the results (24 hours)