PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.0
2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 All 50 releases
← All changes | includes/ai/class-manager.php +251 -69 2.0.02.9.0 View file →
@@ -87,10 +87,17 @@
87 87 *
88 88 * @throws \Exception On failure.
89 89 */
90 90 public function initialize_client(): void {
91 - $provider = $this->settings->get('ai_provider', 'openai');
91 + $provider = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
92 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 +
93 100 try {
94 101 switch ($provider) {
95 102 case 'openai':
96 103 $api_key = $this->settings->get('openai_api_key');
@@ -158,17 +165,63 @@
158 165 $this->client = new OpenRouter_Client($api_key, $model, $timeout);
159 166 }
160 167 break;
161 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 +
162 189 default:
163 190 throw new \Exception("Unsupported AI provider: {$provider}");
164 191 }
165 192 } catch (\Exception $e) {
166 - // 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 + }
167 200 }
168 201 }
169 202
170 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 + /**
171 224 * Get the display name of the currently selected AI provider
172 225 *
173 226 * @return string Provider display name (e.g. "OpenAI")
174 227 */
@@ -174,14 +227,17 @@
174 227 */
175 228 private function get_provider_label(): string {
176 229 $labels = [
177 230 'openai' => 'OpenAI',
178 - 'claude' => 'Claude',
231 + // The vendor, not the model family — matches the settings UI (#572).
232 + 'claude' => 'Anthropic',
179 233 'gemini' => 'Gemini',
180 234 'openrouter' => 'OpenRouter',
235 + // Named by what it is, not by OpenAI — the host is the customer's.
236 + 'openai_compatible' => 'OpenAI-compatible endpoint',
181 237 ];
182 238
183 - $provider = (string) $this->settings->get('ai_provider', 'openai');
239 + $provider = (string) $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
184 240
185 241 return $labels[$provider] ?? ucfirst($provider);
186 242 }
187 243
@@ -193,9 +249,9 @@
193 249 *
194 250 * @return string Actionable error message for end users
195 251 */
196 252 private function get_client_unavailable_message(): string {
197 - $provider = (string) $this->settings->get('ai_provider', 'openai');
253 + $provider = (string) $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
198 254
199 255 // The React admin renders this anchor as a real link via linkifyMessage().
200 256 $settings_link = sprintf(
201 257 '<a href="%s" target="_blank" rel="noopener noreferrer">%s</a>',
@@ -202,12 +258,50 @@
202 258 esc_url(admin_url('admin.php?page=thinkrank-settings')),
203 259 __('ThinkRank → Settings', 'thinkrank')
204 260 );
205 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 +
206 299 if (empty($this->settings->get("{$provider}_api_key"))) {
207 300 return sprintf(
208 - /* translators: %s: link to the ThinkRank settings page. */
209 - __('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(),
210 304 $settings_link
211 305 );
212 306 }
213 307
@@ -242,9 +336,9 @@
242 336 }
243 337
244 338 // If still not available, throw error
245 339 if (!$this->client) {
246 - throw new \Exception($this->get_client_unavailable_message());
340 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
247 341 }
248 342
249 343 return $this->client;
250 344 }
@@ -263,9 +357,9 @@
263 357 $this->initialize_client();
264 358
265 359 // If still not available, throw error
266 360 if (!$this->client) {
267 - throw new \Exception($this->get_client_unavailable_message());
361 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
268 362 }
269 363 }
270 364
271 365 // Check rate limits
@@ -289,12 +383,12 @@
289 383 // Generate metadata using AI
290 384 $metadata = $this->client->generate_seo_metadata($content, $options);
291 385
292 386 // Ensure user has configured their API key
293 - $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();
294 388
295 389 if (!$user_has_api_key) {
296 - throw new \Exception($this->get_client_unavailable_message());
390 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
297 391 }
298 392
299 393 // Cache the result
300 394 $this->cache->set($cache_key, $metadata);
@@ -338,16 +432,16 @@
338 432 public function improve_seo_title(string $content, array $options = []): array {
339 433 if (!$this->client) {
340 434 $this->initialize_client();
341 435 if (!$this->client) {
342 - throw new \Exception($this->get_client_unavailable_message());
436 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
343 437 }
344 438 }
345 439
346 440 // Ensure user has configured their API key.
347 - $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();
348 442 if (!$user_has_api_key) {
349 - throw new \Exception($this->get_client_unavailable_message());
443 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
350 444 }
351 445
352 446 // Check rate limits.
353 447 if (!$this->check_rate_limit()) {
@@ -400,8 +494,9 @@
400 494 $generated = $this->request_title($prompt);
401 495 $title = $generated['title'];
402 496 $total_tokens = $generated['tokens'];
403 497 $ai_text = $generated['ai_text'];
498 + $finish_reason = $generated['finish_reason'];
404 499
405 500 // A reasoning model can still return an empty/truncated title on the
406 501 // first pass; retry once before giving up so the "Apply" action reliably
407 502 // produces a title.
@@ -410,8 +505,9 @@
410 505 $total_tokens += $retry['tokens'];
411 506 if ($retry['ai_text'] !== '') {
412 507 $ai_text = $retry['ai_text'];
413 508 }
509 + $finish_reason = $retry['finish_reason'];
414 510 if ($retry['title'] !== '') {
415 511 $title = $retry['title'];
416 512 }
417 513 }
@@ -425,8 +521,9 @@
425 521 $total_tokens += $retry['tokens'];
426 522 if ($retry['ai_text'] !== '') {
427 523 $ai_text = $retry['ai_text'];
428 524 }
525 + $finish_reason = $retry['finish_reason'];
429 526 if ($retry['title'] !== '' && $this->title_contains_word($retry['title'], $sentiment_words)) {
430 527 $title = $retry['title'];
431 528 }
432 529 }
@@ -431,8 +528,20 @@
431 528 }
432 529 }
433 530
434 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 + }
435 544 throw new \Exception('The AI did not return a usable title. Please try again.');
436 545 }
437 546
438 547 // Log usage.
@@ -466,8 +575,9 @@
466 575 return [
467 576 'title' => $title,
468 577 'ai_text' => $completion['ai_text'],
469 578 'tokens' => $completion['tokens'],
579 + 'finish_reason' => $completion['finish_reason'],
470 580 ];
471 581 }
472 582
473 583 /**
@@ -493,15 +603,15 @@
493 603 public function improve_meta_description(string $content, array $options = []): array {
494 604 if (!$this->client) {
495 605 $this->initialize_client();
496 606 if (!$this->client) {
497 - throw new \Exception($this->get_client_unavailable_message());
607 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
498 608 }
499 609 }
500 610
501 - $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();
502 612 if (!$user_has_api_key) {
503 - throw new \Exception($this->get_client_unavailable_message());
613 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
504 614 }
505 615
506 616 if (!$this->check_rate_limit()) {
507 617 throw new \Exception('Rate limit exceeded. Please try again later.');
@@ -886,14 +996,14 @@
886 996 private function ensure_ready_for_ai(): void {
887 997 if (!$this->client) {
888 998 $this->initialize_client();
889 999 if (!$this->client) {
890 - throw new \Exception($this->get_client_unavailable_message());
1000 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
891 1001 }
892 1002 }
893 - $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();
894 1004 if (!$user_has_api_key) {
895 - throw new \Exception($this->get_client_unavailable_message());
1005 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
896 1006 }
897 1007 if (!$this->check_rate_limit()) {
898 1008 throw new \Exception('Rate limit exceeded. Please try again later.');
899 1009 }
@@ -923,28 +1033,51 @@
923 1033 * @param string $prompt The prompt to send.
924 1034 * @return array{ai_text:string,tokens:int}
925 1035 */
926 1036 /**
927 - * 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.
928 1043 *
929 - * Thin gate over request_completion() for callers that need a free-form
930 - * answer rather than a structured SEO artifact (e.g. the brand-visibility
931 - * checker, which asks the model a user-style question and inspects the
932 - * reply). Provider differences are already normalized inside.
933 - *
934 - * @since 1.27.0
935 - *
936 - * @param string $prompt Prompt to send.
937 - * @param int $max_tokens Output token ceiling.
938 - * @return array{ai_text:string,tokens:int}
939 - * @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.
940 1046 */
941 - public function answer_prompt(string $prompt, int $max_tokens = 1024, array $options = []): array {
942 - if (!$this->client) {
943 - 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 + }
944 1061 }
945 1062
946 - 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 + }
947 1080 }
948 1081
949 1082 private function request_completion(string $prompt, int $max_tokens = 2048, array $options = []): array {
950 1083 // "Thinking" providers (e.g. Gemini 2.5) spend output tokens on reasoning
@@ -957,8 +1090,17 @@
957 1090 'max_tokens' => $max_tokens,
958 1091 'temperature' => 0.4,
959 1092 ]));
960 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 +
961 1103 $ai_text = '';
962 1104 if (isset($response['choices'][0]['message']['content'])) {
963 1105 $ai_text = is_array($response['choices'][0]['message']['content'])
964 1106 ? implode(' ', array_map(static fn($part) => is_array($part) ? ($part['text'] ?? '') : (string) $part, $response['choices'][0]['message']['content']))
@@ -974,12 +1116,17 @@
974 1116 $tokens = $response['usage']['total_tokens']
975 1117 ?? $response['usage']['output_tokens']
976 1118 ?? ($response['usageMetadata']['totalTokenCount'] ?? 0);
977 1119
978 - // Diagnostics for callers that must explain an empty answer (e.g. the
979 - // 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
980 1122 // completion budget hidden reasoning consumed (OpenAI reasoning models).
981 - $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'] ?? '')));
982 1129 $reasoning_tokens = (int) ($response['usage']['completion_tokens_details']['reasoning_tokens'] ?? 0);
983 1130
984 1131 return [
985 1132 'ai_text' => $ai_text,
@@ -1069,18 +1216,18 @@
1069 1216 * @throws \Exception If analysis fails
1070 1217 */
1071 1218 public function analyze_content(string $content, array $metadata = []): array {
1072 1219 if (!$this->client) {
1073 - throw new \Exception($this->get_client_unavailable_message());
1220 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1074 1221 }
1075 1222
1076 1223 $user_id = get_current_user_id();
1077 1224
1078 1225 // Ensure user has configured their API key
1079 - $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();
1080 1227
1081 1228 if (!$user_has_api_key) {
1082 - throw new \Exception($this->get_client_unavailable_message());
1229 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1083 1230 }
1084 1231
1085 1232 // Check rate limits
1086 1233 if (!$this->check_rate_limit($user_id, 'content_analysis')) {
@@ -1165,12 +1312,12 @@
1165 1312
1166 1313 $user_id = get_current_user_id();
1167 1314
1168 1315 // Ensure user has configured their API key
1169 - $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();
1170 1317
1171 1318 if (!$user_has_api_key) {
1172 - throw new \Exception($this->get_client_unavailable_message());
1319 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1173 1320 }
1174 1321
1175 1322 // Generate cache key using existing pattern
1176 1323 $cache_key = 'site_identity_' . md5(wp_json_encode($site_data) . wp_json_encode($options)) . '_' . $user_id;
@@ -1193,9 +1340,9 @@
1193 1340 // Get AI client
1194 1341 $client = $this->get_client();
1195 1342
1196 1343 if (!$client) {
1197 - throw new \Exception($this->get_client_unavailable_message());
1344 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1198 1345 }
1199 1346
1200 1347 // Perform AI optimization
1201 1348 $optimization_results = $client->optimize_site_identity($site_data, $options);
@@ -1206,9 +1353,9 @@
1206 1353 }
1207 1354
1208 1355 // Add metadata
1209 1356 $optimization_results['ai_model'] = $client->get_model();
1210 - $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1357 + $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1211 1358 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1212 1359 $optimization_results['user_id'] = $user_id;
1213 1360
1214 1361 // Cache the results (24 hours)
@@ -1243,12 +1390,12 @@
1243 1390
1244 1391 $user_id = get_current_user_id();
1245 1392
1246 1393 // Ensure user has configured their API key
1247 - $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();
1248 1395
1249 1396 if (!$user_has_api_key) {
1250 - throw new \Exception($this->get_client_unavailable_message());
1397 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1251 1398 }
1252 1399
1253 1400 // Generate cache key
1254 1401 $cache_key = 'llms_txt_' . md5(wp_json_encode($website_data) . wp_json_encode($options)) . '_' . $user_id;
@@ -1271,9 +1418,9 @@
1271 1418 // Get AI client
1272 1419 $client = $this->get_client();
1273 1420
1274 1421 if (!$client) {
1275 - throw new \Exception($this->get_client_unavailable_message());
1422 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1276 1423 }
1277 1424
1278 1425 // Perform AI optimization
1279 1426 $optimization_results = $client->optimize_llms_txt($website_data, $options);
@@ -1284,9 +1431,9 @@
1284 1431 }
1285 1432
1286 1433 // Add metadata
1287 1434 $optimization_results['ai_model'] = $client->get_model();
1288 - $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1435 + $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1289 1436 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1290 1437 $optimization_results['user_id'] = $user_id;
1291 1438
1292 1439 // Cache the results (24 hours)
@@ -1316,25 +1463,42 @@
1316 1463 'models' => ['gpt-5-nano', 'gpt-5-mini', 'gpt-5', 'gpt-4o'],
1317 1464 'requires_key' => true,
1318 1465 ],
1319 1466 'claude' => [
1320 - 'name' => 'Claude (Anthropic)',
1321 - 'description' => 'Claude Opus 4.8, Sonnet 5, and Haiku 4.5',
1322 - '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'],
1323 1472 'requires_key' => true,
1324 1473 ],
1325 1474 'gemini' => [
1326 1475 'name' => 'Google Gemini',
1327 - 'description' => 'Gemini 3.x and 2.x models',
1328 - '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'],
1329 1481 'requires_key' => true,
1330 1482 ],
1331 1483 'openrouter' => [
1332 1484 'name' => 'OpenRouter',
1333 1485 'description' => 'Unified access to many models via one key',
1334 - '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'],
1335 1490 'requires_key' => true,
1336 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 + ],
1337 1501 ];
1338 1502 }
1339 1503
1340 1504 /**
@@ -1342,14 +1506,20 @@
1342 1506 *
1343 1507 * @return array Provider status
1344 1508 */
1345 1509 public function get_provider_status(): array {
1346 - $provider = $this->settings->get('ai_provider', 'openai');
1347 - $api_key = $this->settings->get($provider . '_api_key');
1510 + $provider = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1348 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 +
1349 1519 return [
1350 1520 'provider' => $provider,
1351 - 'configured' => !empty($api_key),
1521 + 'configured' => $configured,
1352 1522 'connected' => $this->client !== null,
1353 1523 ];
1354 1524 }
1355 1525
@@ -1432,9 +1602,9 @@
1432 1602 * @return bool True if within limits.
1433 1603 */
1434 1604 private function check_rate_limit(?int $user_id = null, string $context = 'ai'): bool {
1435 1605 $user_id = $user_id ?? get_current_user_id();
1436 - $max_requests = (int) $this->settings->get('max_requests_per_minute', 10);
1606 + $max_requests = (int) $this->settings->get('max_requests_per_minute', 0);
1437 1607
1438 1608 // A non-positive limit means "unlimited".
1439 1609 if ($max_requests <= 0) {
1440 1610 return true;
@@ -1486,14 +1656,26 @@
1486 1656 [
1487 1657 'user_id' => $user_id,
1488 1658 'action' => $action,
1489 1659 'tokens_used' => $tokens_used,
1490 - 'provider' => $this->settings->get('ai_provider', 'openai'),
1660 + 'provider' => $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE),
1491 1661 'metadata' => !empty($metadata) ? wp_json_encode($metadata) : null,
1492 1662 'created_at' => current_time('mysql'),
1493 1663 ],
1494 1664 ['%d', '%s', '%d', '%s', '%s', '%s']
1495 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);
1496 1678 }
1497 1679
1498 1680 /**
1499 1681 * Cleanup expired cache entries
@@ -1522,12 +1704,12 @@
1522 1704
1523 1705 $user_id = get_current_user_id();
1524 1706
1525 1707 // Ensure user has configured their API key (copying Site Identity pattern)
1526 - $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();
1527 1709
1528 1710 if (!$user_has_api_key) {
1529 - throw new \Exception($this->get_client_unavailable_message());
1711 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1530 1712 }
1531 1713
1532 1714 // Generate cache key using existing pattern
1533 1715 $cache_key = 'homepage_meta_' . md5(wp_json_encode($content_data) . wp_json_encode($options)) . '_' . $user_id;
@@ -1550,9 +1732,9 @@
1550 1732 // Get AI client
1551 1733 $client = $this->get_client();
1552 1734
1553 1735 if (!$client) {
1554 - throw new \Exception($this->get_client_unavailable_message());
1736 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1555 1737 }
1556 1738
1557 1739 // Perform AI optimization
1558 1740 $optimization_results = $client->optimize_homepage_meta($content_data, $options);
@@ -1563,9 +1745,9 @@
1563 1745 }
1564 1746
1565 1747 // Add metadata
1566 1748 $optimization_results['ai_model'] = $client->get_model();
1567 - $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1749 + $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1568 1750 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1569 1751 $optimization_results['user_id'] = $user_id;
1570 1752
1571 1753 // Cache the results (24 hours)
@@ -1600,12 +1782,12 @@
1600 1782
1601 1783 $user_id = get_current_user_id();
1602 1784
1603 1785 // Ensure user has configured their API key (copying Site Identity pattern)
1604 - $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();
1605 1787
1606 1788 if (!$user_has_api_key) {
1607 - throw new \Exception($this->get_client_unavailable_message());
1789 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1608 1790 }
1609 1791
1610 1792 // Generate cache key using existing pattern
1611 1793 $cache_key = 'homepage_hero_' . md5(wp_json_encode($hero_data) . wp_json_encode($options)) . '_' . $user_id;
@@ -1628,9 +1810,9 @@
1628 1810 // Get AI client
1629 1811 $client = $this->get_client();
1630 1812
1631 1813 if (!$client) {
1632 - throw new \Exception($this->get_client_unavailable_message());
1814 + throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1633 1815 }
1634 1816
1635 1817 // Perform AI optimization
1636 1818 $optimization_results = $client->optimize_homepage_hero($hero_data, $options);
@@ -1641,9 +1823,9 @@
1641 1823 }
1642 1824
1643 1825 // Add metadata
1644 1826 $optimization_results['ai_model'] = $client->get_model();
1645 - $optimization_results['provider'] = $this->settings->get('ai_provider', 'openai');
1827 + $optimization_results['provider'] = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1646 1828 $optimization_results['generated_at'] = gmdate('Y-m-d H:i:s');
1647 1829 $optimization_results['user_id'] = $user_id;
1648 1830
1649 1831 // Cache the results (24 hours)