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 +287 -67 1.27.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()) {
@@ -398,8 +494,9 @@
398 494 $generated = $this->request_title($prompt);
399 495 $title = $generated['title'];
400 496 $total_tokens = $generated['tokens'];
401 497 $ai_text = $generated['ai_text'];
498 + $finish_reason = $generated['finish_reason'];
402 499
403 500 // A reasoning model can still return an empty/truncated title on the
404 501 // first pass; retry once before giving up so the "Apply" action reliably
405 502 // produces a title.
@@ -408,8 +505,9 @@
408 505 $total_tokens += $retry['tokens'];
409 506 if ($retry['ai_text'] !== '') {
410 507 $ai_text = $retry['ai_text'];
411 508 }
509 + $finish_reason = $retry['finish_reason'];
412 510 if ($retry['title'] !== '') {
413 511 $title = $retry['title'];
414 512 }
415 513 }
@@ -423,8 +521,9 @@
423 521 $total_tokens += $retry['tokens'];
424 522 if ($retry['ai_text'] !== '') {
425 523 $ai_text = $retry['ai_text'];
426 524 }
525 + $finish_reason = $retry['finish_reason'];
427 526 if ($retry['title'] !== '' && $this->title_contains_word($retry['title'], $sentiment_words)) {
428 527 $title = $retry['title'];
429 528 }
430 529 }
@@ -429,8 +528,20 @@
429 528 }
430 529 }
431 530
432 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 + }
433 544 throw new \Exception('The AI did not return a usable title. Please try again.');
434 545 }
435 546
436 547 // Log usage.
@@ -464,8 +575,9 @@
464 575 return [
465 576 'title' => $title,
466 577 'ai_text' => $completion['ai_text'],
467 578 'tokens' => $completion['tokens'],
579 + 'finish_reason' => $completion['finish_reason'],
468 580 ];
469 581 }
470 582
471 583 /**
@@ -491,15 +603,15 @@
491 603 public function improve_meta_description(string $content, array $options = []): array {
492 604 if (!$this->client) {
493 605 $this->initialize_client();
494 606 if (!$this->client) {
495 - throw new \Exception($this->get_client_unavailable_message());
607 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
496 608 }
497 609 }
498 610
499 - $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();
500 612 if (!$user_has_api_key) {
501 - throw new \Exception($this->get_client_unavailable_message());
613 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
502 614 }
503 615
504 616 if (!$this->check_rate_limit()) {
505 617 throw new \Exception('Rate limit exceeded. Please try again later.');
@@ -884,14 +996,14 @@
884 996 private function ensure_ready_for_ai(): void {
885 997 if (!$this->client) {
886 998 $this->initialize_client();
887 999 if (!$this->client) {
888 - throw new \Exception($this->get_client_unavailable_message());
1000 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
889 1001 }
890 1002 }
891 - $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();
892 1004 if (!$user_has_api_key) {
893 - throw new \Exception($this->get_client_unavailable_message());
1005 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
894 1006 }
895 1007 if (!$this->check_rate_limit()) {
896 1008 throw new \Exception('Rate limit exceeded. Please try again later.');
897 1009 }
@@ -920,18 +1032,75 @@
920 1032 *
921 1033 * @param string $prompt The prompt to send.
922 1034 * @return array{ai_text:string,tokens:int}
923 1035 */
924 - 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 {
925 1083 // "Thinking" providers (e.g. Gemini 2.5) spend output tokens on reasoning
926 1084 // before emitting text, so the cap must cover both the reasoning and the
927 1085 // visible JSON. Longer outputs (paragraphs) need a bigger budget. It's
928 1086 // only a ceiling — short replies cost no more.
929 - $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, [
930 1090 'max_tokens' => $max_tokens,
931 1091 'temperature' => 0.4,
932 - ]);
1092 + ]));
933 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 +
934 1103 $ai_text = '';
935 1104 if (isset($response['choices'][0]['message']['content'])) {
936 1105 $ai_text = is_array($response['choices'][0]['message']['content'])
937 1106 ? implode(' ', array_map(static fn($part) => is_array($part) ? ($part['text'] ?? '') : (string) $part, $response['choices'][0]['message']['content']))
@@ -947,9 +1116,25 @@
947 1116 $tokens = $response['usage']['total_tokens']
948 1117 ?? $response['usage']['output_tokens']
949 1118 ?? ($response['usageMetadata']['totalTokenCount'] ?? 0);
950 1119
951 - 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 + ];
952 1137 }
953 1138
954 1139 /**
955 1140 * Trim a meta description to at most 160 characters at a word boundary,
@@ -1031,18 +1216,18 @@
1031 1216 * @throws \Exception If analysis fails
1032 1217 */
1033 1218 public function analyze_content(string $content, array $metadata = []): array {
1034 1219 if (!$this->client) {
1035 - throw new \Exception($this->get_client_unavailable_message());
1220 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1036 1221 }
1037 1222
1038 1223 $user_id = get_current_user_id();
1039 1224
1040 1225 // Ensure user has configured their API key
1041 - $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();
1042 1227
1043 1228 if (!$user_has_api_key) {
1044 - throw new \Exception($this->get_client_unavailable_message());
1229 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1045 1230 }
1046 1231
1047 1232 // Check rate limits
1048 1233 if (!$this->check_rate_limit($user_id, 'content_analysis')) {
@@ -1049,9 +1234,9 @@
1049 1234 throw new \Exception('Rate limit exceeded. Please try again later.');
1050 1235 }
1051 1236
1052 1237 // Check cache first
1053 - $cache_key = 'content_analysis_' . md5($content . serialize($metadata));
1238 + $cache_key = 'content_analysis_' . md5($content . wp_json_encode($metadata));
1054 1239 $cached_result = $this->cache->get($cache_key);
1055 1240 if ($cached_result) {
1056 1241 return $cached_result['data'] ?? $cached_result;
1057 1242 }
@@ -1127,16 +1312,16 @@
1127 1312
1128 1313 $user_id = get_current_user_id();
1129 1314
1130 1315 // Ensure user has configured their API key
1131 - $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();
1132 1317
1133 1318 if (!$user_has_api_key) {
1134 - throw new \Exception($this->get_client_unavailable_message());
1319 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1135 1320 }
1136 1321
1137 1322 // Generate cache key using existing pattern
1138 - $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;
1139 1324
1140 1325 // Check existing cache infrastructure
1141 1326 // Cache_Manager::set() wraps entries as ['data' => …], so unwrap
1142 1327 // before inspecting — checking optimized_data on the wrapped array
@@ -1155,9 +1340,9 @@
1155 1340 // Get AI client
1156 1341 $client = $this->get_client();
1157 1342
1158 1343 if (!$client) {
1159 - throw new \Exception($this->get_client_unavailable_message());
1344 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1160 1345 }
1161 1346
1162 1347 // Perform AI optimization
1163 1348 $optimization_results = $client->optimize_site_identity($site_data, $options);
@@ -1168,9 +1353,9 @@
1168 1353 }
1169 1354
1170 1355 // Add metadata
1171 1356 $optimization_results['ai_model'] = $client->get_model();
1172 - $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1357 + $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1173 1358 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1174 1359 $optimization_results['user_id'] = $user_id;
1175 1360
1176 1361 // Cache the results (24 hours)
@@ -1205,16 +1390,16 @@
1205 1390
1206 1391 $user_id = get_current_user_id();
1207 1392
1208 1393 // Ensure user has configured their API key
1209 - $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();
1210 1395
1211 1396 if (!$user_has_api_key) {
1212 - throw new \Exception($this->get_client_unavailable_message());
1397 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1213 1398 }
1214 1399
1215 1400 // Generate cache key
1216 - $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;
1217 1402
1218 1403 // Check cache first
1219 1404 // Cache_Manager::set() wraps entries as ['data' => …], so unwrap
1220 1405 // before inspecting — checking optimized_data on the wrapped array
@@ -1233,9 +1418,9 @@
1233 1418 // Get AI client
1234 1419 $client = $this->get_client();
1235 1420
1236 1421 if (!$client) {
1237 - throw new \Exception($this->get_client_unavailable_message());
1422 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1238 1423 }
1239 1424
1240 1425 // Perform AI optimization
1241 1426 $optimization_results = $client->optimize_llms_txt($website_data, $options);
@@ -1246,9 +1431,9 @@
1246 1431 }
1247 1432
1248 1433 // Add metadata
1249 1434 $optimization_results['ai_model'] = $client->get_model();
1250 - $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1435 + $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1251 1436 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1252 1437 $optimization_results['user_id'] = $user_id;
1253 1438
1254 1439 // Cache the results (24 hours)
@@ -1278,25 +1463,42 @@
1278 1463 'models' => ['gpt-5-nano', 'gpt-5-mini', 'gpt-5', 'gpt-4o'],
1279 1464 'requires_key' => true,
1280 1465 ],
1281 1466 'claude' => [
1282 - 'name' => 'Claude (Anthropic)',
1283 - 'description' => 'Claude Opus 4.8, Sonnet 5, and Haiku 4.5',
1284 - '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'],
1285 1472 'requires_key' => true,
1286 1473 ],
1287 1474 'gemini' => [
1288 1475 'name' => 'Google Gemini',
1289 - 'description' => 'Gemini 3.x and 2.x models',
1290 - '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'],
1291 1481 'requires_key' => true,
1292 1482 ],
1293 1483 'openrouter' => [
1294 1484 'name' => 'OpenRouter',
1295 1485 'description' => 'Unified access to many models via one key',
1296 - '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'],
1297 1490 'requires_key' => true,
1298 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 + ],
1299 1501 ];
1300 1502 }
1301 1503
1302 1504 /**
@@ -1304,14 +1506,20 @@
1304 1506 *
1305 1507 * @return array Provider status
1306 1508 */
1307 1509 public function get_provider_status(): array {
1308 - $provider = $this->settings->get('ai_provider', 'openai');
1309 - $api_key = $this->settings->get($provider . '_api_key');
1510 + $provider = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1310 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 +
1311 1519 return [
1312 1520 'provider' => $provider,
1313 - 'configured' => !empty($api_key),
1521 + 'configured' => $configured,
1314 1522 'connected' => $this->client !== null,
1315 1523 ];
1316 1524 }
1317 1525
@@ -1394,9 +1602,9 @@
1394 1602 * @return bool True if within limits.
1395 1603 */
1396 1604 private function check_rate_limit(?int $user_id = null, string $context = 'ai'): bool {
1397 1605 $user_id = $user_id ?? get_current_user_id();
1398 - $max_requests = (int) $this->settings->get('max_requests_per_minute', 10);
1606 + $max_requests = (int) $this->settings->get('max_requests_per_minute', 0);
1399 1607
1400 1608 // A non-positive limit means "unlimited".
1401 1609 if ($max_requests <= 0) {
1402 1610 return true;
@@ -1448,14 +1656,26 @@
1448 1656 [
1449 1657 'user_id' => $user_id,
1450 1658 'action' => $action,
1451 1659 'tokens_used' => $tokens_used,
1452 - 'provider' => $this->settings->get('ai_provider', 'openai'),
1660 + 'provider' => $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE),
1453 1661 'metadata' => !empty($metadata) ? wp_json_encode($metadata) : null,
1454 1662 'created_at' => current_time('mysql'),
1455 1663 ],
1456 1664 ['%d', '%s', '%d', '%s', '%s', '%s']
1457 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);
1458 1678 }
1459 1679
1460 1680 /**
1461 1681 * Cleanup expired cache entries
@@ -1484,16 +1704,16 @@
1484 1704
1485 1705 $user_id = get_current_user_id();
1486 1706
1487 1707 // Ensure user has configured their API key (copying Site Identity pattern)
1488 - $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();
1489 1709
1490 1710 if (!$user_has_api_key) {
1491 - throw new \Exception($this->get_client_unavailable_message());
1711 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1492 1712 }
1493 1713
1494 1714 // Generate cache key using existing pattern
1495 - $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;
1496 1716
1497 1717 // Check existing cache infrastructure
1498 1718 // Cache_Manager::set() wraps entries as ['data' => …], so unwrap
1499 1719 // before inspecting — checking optimized_data on the wrapped array
@@ -1512,9 +1732,9 @@
1512 1732 // Get AI client
1513 1733 $client = $this->get_client();
1514 1734
1515 1735 if (!$client) {
1516 - throw new \Exception($this->get_client_unavailable_message());
1736 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1517 1737 }
1518 1738
1519 1739 // Perform AI optimization
1520 1740 $optimization_results = $client->optimize_homepage_meta($content_data, $options);
@@ -1525,9 +1745,9 @@
1525 1745 }
1526 1746
1527 1747 // Add metadata
1528 1748 $optimization_results['ai_model'] = $client->get_model();
1529 - $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1749 + $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1530 1750 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1531 1751 $optimization_results['user_id'] = $user_id;
1532 1752
1533 1753 // Cache the results (24 hours)
@@ -1562,16 +1782,16 @@
1562 1782
1563 1783 $user_id = get_current_user_id();
1564 1784
1565 1785 // Ensure user has configured their API key (copying Site Identity pattern)
1566 - $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();
1567 1787
1568 1788 if (!$user_has_api_key) {
1569 - throw new \Exception($this->get_client_unavailable_message());
1789 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1570 1790 }
1571 1791
1572 1792 // Generate cache key using existing pattern
1573 - $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;
1574 1794
1575 1795 // Check existing cache infrastructure
1576 1796 // Cache_Manager::set() wraps entries as ['data' => …], so unwrap
1577 1797 // before inspecting — checking optimized_data on the wrapped array
@@ -1590,9 +1810,9 @@
1590 1810 // Get AI client
1591 1811 $client = $this->get_client();
1592 1812
1593 1813 if (!$client) {
1594 - throw new \Exception($this->get_client_unavailable_message());
1814 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1595 1815 }
1596 1816
1597 1817 // Perform AI optimization
1598 1818 $optimization_results = $client->optimize_homepage_hero($hero_data, $options);
@@ -1603,9 +1823,9 @@
1603 1823 }
1604 1824
1605 1825 // Add metadata
1606 1826 $optimization_results['ai_model'] = $client->get_model();
1607 - $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1827 + $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1608 1828 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1609 1829 $optimization_results['user_id'] = $user_id;
1610 1830
1611 1831 // Cache the results (24 hours)