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 +110 -41 2.2.02.9.0 View file →
@@ -165,8 +165,28 @@
165 165 $this->client = new OpenRouter_Client($api_key, $model, $timeout);
166 166 }
167 167 break;
168 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 +
169 189 default:
170 190 throw new \Exception("Unsupported AI provider: {$provider}");
171 191 }
172 192 } catch (\Exception $e) {
@@ -180,8 +200,28 @@
180 200 }
181 201 }
182 202
183 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 + /**
184 224 * Get the display name of the currently selected AI provider
185 225 *
186 226 * @return string Provider display name (e.g. "OpenAI")
187 227 */
@@ -191,8 +231,10 @@
191 231 // The vendor, not the model family — matches the settings UI (#572).
192 232 'claude' => 'Anthropic',
193 233 'gemini' => 'Gemini',
194 234 'openrouter' => 'OpenRouter',
235 + // Named by what it is, not by OpenAI — the host is the customer's.
236 + 'openai_compatible' => 'OpenAI-compatible endpoint',
195 237 ];
196 238
197 239 $provider = (string) $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
198 240
@@ -226,8 +268,35 @@
226 268 $settings_link
227 269 );
228 270 }
229 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 +
230 299 if (empty($this->settings->get("{$provider}_api_key"))) {
231 300 return sprintf(
232 301 /* translators: 1: AI provider name (e.g. OpenAI), 2: link to the ThinkRank settings page. */
233 302 __('AI features are not set up yet. To enable them, add your %1$s API key under %2$s.', 'thinkrank'),
@@ -314,9 +383,9 @@
314 383 // Generate metadata using AI
315 384 $metadata = $this->client->generate_seo_metadata($content, $options);
316 385
317 386 // Ensure user has configured their API key
318 - $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();
319 388
320 389 if (!$user_has_api_key) {
321 390 throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
322 391 }
@@ -368,9 +437,9 @@
368 437 }
369 438 }
370 439
371 440 // Ensure user has configured their API key.
372 - $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();
373 442 if (!$user_has_api_key) {
374 443 throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
375 444 }
376 445
@@ -538,9 +607,9 @@
538 607 throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
539 608 }
540 609 }
541 610
542 - $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();
543 612 if (!$user_has_api_key) {
544 613 throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
545 614 }
546 615
@@ -930,9 +999,9 @@
930 999 if (!$this->client) {
931 1000 throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
932 1001 }
933 1002 }
934 - $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();
935 1004 if (!$user_has_api_key) {
936 1005 throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
937 1006 }
938 1007 if (!$this->check_rate_limit()) {
@@ -964,31 +1033,8 @@
964 1033 * @param string $prompt The prompt to send.
965 1034 * @return array{ai_text:string,tokens:int}
966 1035 */
967 1036 /**
968 - * Public plain-text completion against the configured provider.
969 - *
970 - * Thin gate over request_completion() for callers that need a free-form
971 - * answer rather than a structured SEO artifact (e.g. the brand-visibility
972 - * checker, which asks the model a user-style question and inspects the
973 - * reply). Provider differences are already normalized inside.
974 - *
975 - * @since 1.27.0
976 - *
977 - * @param string $prompt Prompt to send.
978 - * @param int $max_tokens Output token ceiling.
979 - * @return array{ai_text:string,tokens:int}
980 - * @throws \Exception When no AI client is configured/available.
981 - */
982 - public function answer_prompt(string $prompt, int $max_tokens = 1024, array $options = []): array {
983 - if (!$this->client) {
984 - throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
985 - }
986 -
987 - return $this->request_completion($prompt, $max_tokens, $options);
988 - }
989 -
990 - /**
991 1037 * Detect a provider-side refusal or content-policy block and fail with
992 1038 * the real reason. Each provider signals these differently, and none of
993 1039 * the signals set the content field the extraction chain looks for — left
994 1040 * unchecked they read as an empty/unusable result with no explanation of
@@ -1070,10 +1116,10 @@
1070 1116 $tokens = $response['usage']['total_tokens']
1071 1117 ?? $response['usage']['output_tokens']
1072 1118 ?? ($response['usageMetadata']['totalTokenCount'] ?? 0);
1073 1119
1074 - // Diagnostics for callers that must explain an empty answer (e.g. the
1075 - // 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
1076 1122 // completion budget hidden reasoning consumed (OpenAI reasoning models).
1077 1123 // All three provider shapes are read — Gemini reports the stop reason
1078 1124 // per candidate, so without that arm the diagnostic was always blank
1079 1125 // for exactly the provider whose truncation it exists to explain.
@@ -1176,9 +1222,9 @@
1176 1222
1177 1223 $user_id = get_current_user_id();
1178 1224
1179 1225 // Ensure user has configured their API key
1180 - $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();
1181 1227
1182 1228 if (!$user_has_api_key) {
1183 1229 throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1184 1230 }
@@ -1266,9 +1312,9 @@
1266 1312
1267 1313 $user_id = get_current_user_id();
1268 1314
1269 1315 // Ensure user has configured their API key
1270 - $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();
1271 1317
1272 1318 if (!$user_has_api_key) {
1273 1319 throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1274 1320 }
@@ -1344,9 +1390,9 @@
1344 1390
1345 1391 $user_id = get_current_user_id();
1346 1392
1347 1393 // Ensure user has configured their API key
1348 - $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();
1349 1395
1350 1396 if (!$user_has_api_key) {
1351 1397 throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1352 1398 }
@@ -1442,8 +1488,17 @@
1442 1488 // gemini-2.0-flash-001 was shut down on 1 Jun 2026 (#572).
1443 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'],
1444 1490 'requires_key' => true,
1445 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 + ],
1446 1501 ];
1447 1502 }
1448 1503
1449 1504 /**
@@ -1452,17 +1507,19 @@
1452 1507 * @return array Provider status
1453 1508 */
1454 1509 public function get_provider_status(): array {
1455 1510 $provider = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1456 - // With no provider chosen there is no "<provider>_api_key" to read;
1457 - // asking for '_api_key' would be a nonsense lookup.
1458 - $api_key = Settings::AI_PROVIDER_NONE === $provider
1459 - ? ''
1460 - : $this->settings->get($provider . '_api_key');
1461 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 +
1462 1519 return [
1463 1520 'provider' => $provider,
1464 - 'configured' => !empty($api_key),
1521 + 'configured' => $configured,
1465 1522 'connected' => $this->client !== null,
1466 1523 ];
1467 1524 }
1468 1525
@@ -1545,9 +1602,9 @@
1545 1602 * @return bool True if within limits.
1546 1603 */
1547 1604 private function check_rate_limit(?int $user_id = null, string $context = 'ai'): bool {
1548 1605 $user_id = $user_id ?? get_current_user_id();
1549 - $max_requests = (int) $this->settings->get('max_requests_per_minute', 10);
1606 + $max_requests = (int) $this->settings->get('max_requests_per_minute', 0);
1550 1607
1551 1608 // A non-positive limit means "unlimited".
1552 1609 if ($max_requests <= 0) {
1553 1610 return true;
@@ -1605,8 +1662,20 @@
1605 1662 'created_at' => current_time('mysql'),
1606 1663 ],
1607 1664 ['%d', '%s', '%d', '%s', '%s', '%s']
1608 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);
1609 1678 }
1610 1679
1611 1680 /**
1612 1681 * Cleanup expired cache entries
@@ -1635,9 +1704,9 @@
1635 1704
1636 1705 $user_id = get_current_user_id();
1637 1706
1638 1707 // Ensure user has configured their API key (copying Site Identity pattern)
1639 - $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();
1640 1709
1641 1710 if (!$user_has_api_key) {
1642 1711 throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1643 1712 }
@@ -1713,9 +1782,9 @@
1713 1782
1714 1783 $user_id = get_current_user_id();
1715 1784
1716 1785 // Ensure user has configured their API key (copying Site Identity pattern)
1717 - $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();
1718 1787
1719 1788 if (!$user_has_api_key) {
1720 1789 throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1721 1790 }