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 +96 -16 2.8.0 → 2.10.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()) {
@@ -1153,9 +1222,9 @@
1153 1222
1154 1223 $user_id = get_current_user_id();
1155 1224
1156 1225 // Ensure user has configured their API key
1157 - $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();
1158 1227
1159 1228 if (!$user_has_api_key) {
1160 1229 throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1161 1230 }
@@ -1243,9 +1312,9 @@
1243 1312
1244 1313 $user_id = get_current_user_id();
1245 1314
1246 1315 // 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'));
1316 + $user_has_api_key = $this->has_provider_credentials();
1248 1317
1249 1318 if (!$user_has_api_key) {
1250 1319 throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1251 1320 }
@@ -1321,9 +1390,9 @@
1321 1390
1322 1391 $user_id = get_current_user_id();
1323 1392
1324 1393 // Ensure user has configured their API key
1325 - $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();
1326 1395
1327 1396 if (!$user_has_api_key) {
1328 1397 throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1329 1398 }
@@ -1419,8 +1488,17 @@
1419 1488 // gemini-2.0-flash-001 was shut down on 1 Jun 2026 (#572).
1420 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'],
1421 1490 'requires_key' => true,
1422 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 + ],
1423 1501 ];
1424 1502 }
1425 1503
1426 1504 /**
@@ -1429,17 +1507,19 @@
1429 1507 * @return array Provider status
1430 1508 */
1431 1509 public function get_provider_status(): array {
1432 1510 $provider = $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
1433 - // With no provider chosen there is no "<provider>_api_key" to read;
1434 - // asking for '_api_key' would be a nonsense lookup.
1435 - $api_key = Settings::AI_PROVIDER_NONE === $provider
1436 - ? ''
1437 - : $this->settings->get($provider . '_api_key');
1438 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 +
1439 1519 return [
1440 1520 'provider' => $provider,
1441 - 'configured' => !empty($api_key),
1521 + 'configured' => $configured,
1442 1522 'connected' => $this->client !== null,
1443 1523 ];
1444 1524 }
1445 1525
@@ -1522,9 +1602,9 @@
1522 1602 * @return bool True if within limits.
1523 1603 */
1524 1604 private function check_rate_limit(?int $user_id = null, string $context = 'ai'): bool {
1525 1605 $user_id = $user_id ?? get_current_user_id();
1526 - $max_requests = (int) $this->settings->get('max_requests_per_minute', 10);
1606 + $max_requests = (int) $this->settings->get('max_requests_per_minute', 0);
1527 1607
1528 1608 // A non-positive limit means "unlimited".
1529 1609 if ($max_requests <= 0) {
1530 1610 return true;
@@ -1624,9 +1704,9 @@
1624 1704
1625 1705 $user_id = get_current_user_id();
1626 1706
1627 1707 // Ensure user has configured their API key (copying Site Identity pattern)
1628 - $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();
1629 1709
1630 1710 if (!$user_has_api_key) {
1631 1711 throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1632 1712 }
@@ -1702,9 +1782,9 @@
1702 1782
1703 1783 $user_id = get_current_user_id();
1704 1784
1705 1785 // Ensure user has configured their API key (copying Site Identity pattern)
1706 - $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();
1707 1787
1708 1788 if (!$user_has_api_key) {
1709 1789 throw new \Exception(wp_kses_post($this->get_client_unavailable_message()));
1710 1790 }