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 +258 -74 1.28.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');
@@ -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 }
@@ -921,28 +1033,51 @@
921 1033 * @param string $prompt The prompt to send.
922 1034 * @return array{ai_text:string,tokens:int}
923 1035 */
924 1036 /**
925 - * Public plain-text completion against the configured provider.
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.
926 1043 *
927 - * Thin gate over request_completion() for callers that need a free-form
928 - * answer rather than a structured SEO artifact (e.g. the brand-visibility
929 - * checker, which asks the model a user-style question and inspects the
930 - * reply). Provider differences are already normalized inside.
931 - *
932 - * @since 1.27.0
933 - *
934 - * @param string $prompt Prompt to send.
935 - * @param int $max_tokens Output token ceiling.
936 - * @return array{ai_text:string,tokens:int}
937 - * @throws \Exception When no AI client is configured/available.
1044 + * @param array $response Raw response from the AI client.
1045 + * @throws \Exception If the response is a refusal or policy block.
938 1046 */
939 - public function answer_prompt(string $prompt, int $max_tokens = 1024, array $options = []): array {
940 - if (!$this->client) {
941 - throw new \Exception(esc_html($this->get_client_unavailable_message()));
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 + }
942 1061 }
943 1062
944 - return $this->request_completion($prompt, $max_tokens, $options);
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 + }
945 1080 }
946 1081
947 1082 private function request_completion(string $prompt, int $max_tokens = 2048, array $options = []): array {
948 1083 // "Thinking" providers (e.g. Gemini 2.5) spend output tokens on reasoning
@@ -955,8 +1090,17 @@
955 1090 'max_tokens' => $max_tokens,
956 1091 'temperature' => 0.4,
957 1092 ]));
958 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 +
959 1103 $ai_text = '';
960 1104 if (isset($response['choices'][0]['message']['content'])) {
961 1105 $ai_text = is_array($response['choices'][0]['message']['content'])
962 1106 ? implode(' ', array_map(static fn($part) => is_array($part) ? ($part['text'] ?? '') : (string) $part, $response['choices'][0]['message']['content']))
@@ -972,12 +1116,17 @@
972 1116 $tokens = $response['usage']['total_tokens']
973 1117 ?? $response['usage']['output_tokens']
974 1118 ?? ($response['usageMetadata']['totalTokenCount'] ?? 0);
975 1119
976 - // Diagnostics for callers that must explain an empty answer (e.g. the
977 - // brand-visibility probe): why generation stopped, and how much of the
1120 + // Diagnostics for callers that must explain an empty answer: why
1121 + // generation stopped, and how much of the
978 1122 // completion budget hidden reasoning consumed (OpenAI reasoning models).
979 - $finish_reason = (string) ($response['choices'][0]['finish_reason'] ?? ($response['stop_reason'] ?? ''));
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'] ?? '')));
980 1129 $reasoning_tokens = (int) ($response['usage']['completion_tokens_details']['reasoning_tokens'] ?? 0);
981 1130
982 1131 return [
983 1132 'ai_text' => $ai_text,
@@ -1067,18 +1216,18 @@
1067 1216 * @throws \Exception If analysis fails
1068 1217 */
1069 1218 public function analyze_content(string $content, array $metadata = []): array {
1070 1219 if (!$this->client) {
1071 - throw new \Exception($this->get_client_unavailable_message());
1220 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1072 1221 }
1073 1222
1074 1223 $user_id = get_current_user_id();
1075 1224
1076 1225 // Ensure user has configured their API key
1077 - $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();
1078 1227
1079 1228 if (!$user_has_api_key) {
1080 - throw new \Exception($this->get_client_unavailable_message());
1229 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1081 1230 }
1082 1231
1083 1232 // Check rate limits
1084 1233 if (!$this->check_rate_limit($user_id, 'content_analysis')) {
@@ -1085,9 +1234,9 @@
1085 1234 throw new \Exception('Rate limit exceeded. Please try again later.');
1086 1235 }
1087 1236
1088 1237 // Check cache first
1089 - $cache_key = 'content_analysis_' . md5($content . serialize($metadata));
1238 + $cache_key = 'content_analysis_' . md5($content . wp_json_encode($metadata));
1090 1239 $cached_result = $this->cache->get($cache_key);
1091 1240 if ($cached_result) {
1092 1241 return $cached_result['data'] ?? $cached_result;
1093 1242 }
@@ -1163,16 +1312,16 @@
1163 1312
1164 1313 $user_id = get_current_user_id();
1165 1314
1166 1315 // Ensure user has configured their API key
1167 - $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();
1168 1317
1169 1318 if (!$user_has_api_key) {
1170 - throw new \Exception($this->get_client_unavailable_message());
1319 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1171 1320 }
1172 1321
1173 1322 // Generate cache key using existing pattern
1174 - $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;
1175 1324
1176 1325 // Check existing cache infrastructure
1177 1326 // Cache_Manager::set() wraps entries as ['data' => …], so unwrap
1178 1327 // before inspecting — checking optimized_data on the wrapped array
@@ -1191,9 +1340,9 @@
1191 1340 // Get AI client
1192 1341 $client = $this->get_client();
1193 1342
1194 1343 if (!$client) {
1195 - throw new \Exception($this->get_client_unavailable_message());
1344 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1196 1345 }
1197 1346
1198 1347 // Perform AI optimization
1199 1348 $optimization_results = $client->optimize_site_identity($site_data, $options);
@@ -1204,9 +1353,9 @@
1204 1353 }
1205 1354
1206 1355 // Add metadata
1207 1356 $optimization_results['ai_model'] = $client->get_model();
1208 - $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1357 + $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1209 1358 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1210 1359 $optimization_results['user_id'] = $user_id;
1211 1360
1212 1361 // Cache the results (24 hours)
@@ -1241,16 +1390,16 @@
1241 1390
1242 1391 $user_id = get_current_user_id();
1243 1392
1244 1393 // Ensure user has configured their API key
1245 - $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();
1246 1395
1247 1396 if (!$user_has_api_key) {
1248 - throw new \Exception($this->get_client_unavailable_message());
1397 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1249 1398 }
1250 1399
1251 1400 // Generate cache key
1252 - $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;
1253 1402
1254 1403 // Check cache first
1255 1404 // Cache_Manager::set() wraps entries as ['data' => …], so unwrap
1256 1405 // before inspecting — checking optimized_data on the wrapped array
@@ -1269,9 +1418,9 @@
1269 1418 // Get AI client
1270 1419 $client = $this->get_client();
1271 1420
1272 1421 if (!$client) {
1273 - throw new \Exception($this->get_client_unavailable_message());
1422 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1274 1423 }
1275 1424
1276 1425 // Perform AI optimization
1277 1426 $optimization_results = $client->optimize_llms_txt($website_data, $options);
@@ -1282,9 +1431,9 @@
1282 1431 }
1283 1432
1284 1433 // Add metadata
1285 1434 $optimization_results['ai_model'] = $client->get_model();
1286 - $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1435 + $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1287 1436 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1288 1437 $optimization_results['user_id'] = $user_id;
1289 1438
1290 1439 // Cache the results (24 hours)
@@ -1314,25 +1463,42 @@
1314 1463 'models' => ['gpt-5-nano', 'gpt-5-mini', 'gpt-5', 'gpt-4o'],
1315 1464 'requires_key' => true,
1316 1465 ],
1317 1466 'claude' => [
1318 - 'name' => 'Claude (Anthropic)',
1319 - 'description' => 'Claude Opus 4.8, Sonnet 5, and Haiku 4.5',
1320 - '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'],
1321 1472 'requires_key' => true,
1322 1473 ],
1323 1474 'gemini' => [
1324 1475 'name' => 'Google Gemini',
1325 - 'description' => 'Gemini 3.x and 2.x models',
1326 - 'models' => ['gemini-3.1-pro', 'gemini-3.5-flash', 'gemini-3.1-flash-lite', 'gemini-2.5-flash-lite', 'gemini-2.5-pro'],
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'],
1327 1481 'requires_key' => true,
1328 1482 ],
1329 1483 'openrouter' => [
1330 1484 'name' => 'OpenRouter',
1331 1485 'description' => 'Unified access to many models via one key',
1332 - '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'],
1333 1490 'requires_key' => true,
1334 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 + ],
1335 1501 ];
1336 1502 }
1337 1503
1338 1504 /**
@@ -1340,14 +1506,20 @@
1340 1506 *
1341 1507 * @return array Provider status
1342 1508 */
1343 1509 public function get_provider_status(): array {
1344 - $provider = $this->settings->get('ai_provider', 'openai');
1345 - $api_key = $this->settings->get($provider . '_api_key');
1510 + $provider = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1346 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 +
1347 1519 return [
1348 1520 'provider' => $provider,
1349 - 'configured' => !empty($api_key),
1521 + 'configured' => $configured,
1350 1522 'connected' => $this->client !== null,
1351 1523 ];
1352 1524 }
1353 1525
@@ -1430,9 +1602,9 @@
1430 1602 * @return bool True if within limits.
1431 1603 */
1432 1604 private function check_rate_limit(?int $user_id = null, string $context = 'ai'): bool {
1433 1605 $user_id = $user_id ?? get_current_user_id();
1434 - $max_requests = (int) $this->settings->get('max_requests_per_minute', 10);
1606 + $max_requests = (int) $this->settings->get('max_requests_per_minute', 0);
1435 1607
1436 1608 // A non-positive limit means "unlimited".
1437 1609 if ($max_requests <= 0) {
1438 1610 return true;
@@ -1484,14 +1656,26 @@
1484 1656 [
1485 1657 'user_id' => $user_id,
1486 1658 'action' => $action,
1487 1659 'tokens_used' => $tokens_used,
1488 - 'provider' => $this->settings->get('ai_provider', 'openai'),
1660 + 'provider' => $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE),
1489 1661 'metadata' => !empty($metadata) ? wp_json_encode($metadata) : null,
1490 1662 'created_at' => current_time('mysql'),
1491 1663 ],
1492 1664 ['%d', '%s', '%d', '%s', '%s', '%s']
1493 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);
1494 1678 }
1495 1679
1496 1680 /**
1497 1681 * Cleanup expired cache entries
@@ -1520,16 +1704,16 @@
1520 1704
1521 1705 $user_id = get_current_user_id();
1522 1706
1523 1707 // Ensure user has configured their API key (copying Site Identity pattern)
1524 - $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();
1525 1709
1526 1710 if (!$user_has_api_key) {
1527 - throw new \Exception($this->get_client_unavailable_message());
1711 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1528 1712 }
1529 1713
1530 1714 // Generate cache key using existing pattern
1531 - $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;
1532 1716
1533 1717 // Check existing cache infrastructure
1534 1718 // Cache_Manager::set() wraps entries as ['data' => …], so unwrap
1535 1719 // before inspecting — checking optimized_data on the wrapped array
@@ -1548,9 +1732,9 @@
1548 1732 // Get AI client
1549 1733 $client = $this->get_client();
1550 1734
1551 1735 if (!$client) {
1552 - throw new \Exception($this->get_client_unavailable_message());
1736 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1553 1737 }
1554 1738
1555 1739 // Perform AI optimization
1556 1740 $optimization_results = $client->optimize_homepage_meta($content_data, $options);
@@ -1561,9 +1745,9 @@
1561 1745 }
1562 1746
1563 1747 // Add metadata
1564 1748 $optimization_results['ai_model'] = $client->get_model();
1565 - $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1749 + $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1566 1750 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1567 1751 $optimization_results['user_id'] = $user_id;
1568 1752
1569 1753 // Cache the results (24 hours)
@@ -1598,16 +1782,16 @@
1598 1782
1599 1783 $user_id = get_current_user_id();
1600 1784
1601 1785 // Ensure user has configured their API key (copying Site Identity pattern)
1602 - $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();
1603 1787
1604 1788 if (!$user_has_api_key) {
1605 - throw new \Exception($this->get_client_unavailable_message());
1789 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1606 1790 }
1607 1791
1608 1792 // Generate cache key using existing pattern
1609 - $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;
1610 1794
1611 1795 // Check existing cache infrastructure
1612 1796 // Cache_Manager::set() wraps entries as ['data' => …], so unwrap
1613 1797 // before inspecting — checking optimized_data on the wrapped array
@@ -1626,9 +1810,9 @@
1626 1810 // Get AI client
1627 1811 $client = $this->get_client();
1628 1812
1629 1813 if (!$client) {
1630 - throw new \Exception($this->get_client_unavailable_message());
1814 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1631 1815 }
1632 1816
1633 1817 // Perform AI optimization
1634 1818 $optimization_results = $client->optimize_homepage_hero($hero_data, $options);
@@ -1639,9 +1823,9 @@
1639 1823 }
1640 1824
1641 1825 // Add metadata
1642 1826 $optimization_results['ai_model'] = $client->get_model();
1643 - $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1827 + $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1644 1828 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1645 1829 $optimization_results['user_id'] = $user_id;
1646 1830
1647 1831 // Cache the results (24 hours)