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-vision-client.php +117 -11 2.6.0 → 2.10.0 View file →
@@ -88,13 +88,37 @@
88 88 */
89 89 public function is_available(): bool {
90 90 $provider = (string) $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
91 91
92 + if ('openai_compatible' === $provider) {
93 + return $this->compatible_vision_ready();
94 + }
95 +
92 96 return isset(self::capable_providers()[$provider])
93 97 && '' !== $this->api_key_for($provider);
94 98 }
95 99
96 100 /**
101 + * Can the user's own OpenAI-compatible endpoint describe an image?
102 + *
103 + * Only the administrator knows: an Ollama box running llama3.1 cannot,
104 + * the same box running llava can, and there is no reliable way to ask the
105 + * server. So it is a declared capability (`openai_compatible_supports_images`,
106 + * default off) rather than a guess — sending a vision payload to a
107 + * text-only model returns a confusing 400, and AI alt text stays hidden
108 + * until the user says the model handles images (#721).
109 + *
110 + * @since 2.8.0
111 + *
112 + * @return bool
113 + */
114 + private function compatible_vision_ready(): bool {
115 + return (bool) $this->settings->get('openai_compatible_supports_images', false)
116 + && '' !== (string) $this->settings->get('openai_compatible_base_url', '')
117 + && '' !== trim((string) $this->settings->get('openai_compatible_model', ''));
118 + }
119 +
120 + /**
97 121 * The stored key for a provider.
98 122 *
99 123 * Keys are held per provider (`openai_api_key`, `claude_api_key`,
100 124 * `gemini_api_key`) — the same names AI_Manager reads. There is no
@@ -129,8 +153,30 @@
129 153 'thinkrank'
130 154 ));
131 155 }
132 156
157 + if ('openai_compatible' === $provider) {
158 + if (!$this->compatible_vision_ready()) {
159 + throw new \Exception(esc_html__(
160 + 'Your OpenAI-compatible endpoint is not set up for images. Set a base URL and model id, and turn on "This model can describe images" in Settings → AI Provider.',
161 + 'thinkrank'
162 + ));
163 + }
164 +
165 + $image = $this->read_image($attachment_id);
166 + if (null === $image) {
167 + throw new \Exception(esc_html__('The image file could not be read, or is larger than the provider allows.', 'thinkrank'));
168 + }
169 +
170 + return $this->clean($this->call_openai(
171 + (string) $this->settings->get('openai_compatible_api_key', ''),
172 + trim((string) $this->settings->get('openai_compatible_model', '')),
173 + $this->build_prompt($context),
174 + $image,
175 + (string) $this->settings->get('openai_compatible_base_url', '')
176 + ));
177 + }
178 +
133 179 if (!isset($models[$provider])) {
134 180 throw new \Exception(sprintf(
135 181 /* translators: %s: AI provider name. */
136 182 esc_html__('The %s provider cannot describe images. Switch to OpenAI, Anthropic or Gemini in Settings → AI Provider.', 'thinkrank'),
@@ -257,13 +303,14 @@
257 303 *
258 304 * @param string $api_key API key.
259 305 * @param string $model Model id.
260 306 * @param string $prompt Instruction.
261 - * @param array $image ['data' => base64, 'mime' => string].
307 + * @param array $image ['data' => base64, 'mime' => string].
308 + * @param string $base_url Base URL, for an OpenAI-compatible endpoint that is not OpenAI's.
262 309 * @return string
263 310 * @throws \Exception On API failure.
264 311 */
265 - private function call_openai(string $api_key, string $model, string $prompt, array $image): string {
312 + private function call_openai(string $api_key, string $model, string $prompt, array $image, string $base_url = OpenAI_Client::API_BASE_URL): string {
266 313 $body = [
267 314 'model' => $model,
268 315 'messages' => [[
269 316 'role' => 'user',
@@ -283,13 +330,36 @@
283 330 if (0 === strpos($model, 'gpt-5')) {
284 331 $body['reasoning_effort'] = 'minimal';
285 332 }
286 333
287 - $response = $this->post('https://api.openai.com/v1/chat/completions', [
288 - 'Authorization' => 'Bearer ' . $api_key,
289 - 'Content-Type' => 'application/json',
290 - ], $body);
334 + $base_url = rtrim(trim($base_url), '/');
335 + if ('' === $base_url) {
336 + $base_url = OpenAI_Client::API_BASE_URL;
337 + }
291 338
339 + // Ollama, LM Studio and vLLM reject max_completion_tokens — it is a
340 + // parameter OpenAI added for its reasoning models, not part of the
341 + // Chat Completions shape they implement — so a custom endpoint gets
342 + // plain max_tokens.
343 + if (OpenAI_Client::API_BASE_URL !== $base_url) {
344 + unset($body['max_completion_tokens'], $body['reasoning_effort']);
345 + $body['max_tokens'] = 300;
346 + }
347 +
348 + $headers = ['Content-Type' => 'application/json'];
349 + // A local server usually wants no key at all.
350 + if ('' !== $api_key) {
351 + $headers['Authorization'] = 'Bearer ' . $api_key;
352 + $headers['api-key'] = $api_key;
353 + }
354 +
355 + $response = $this->post(
356 + Endpoint_URL_Validator::route($base_url, 'chat/completions'),
357 + $headers,
358 + $body,
359 + OpenAI_Client::API_BASE_URL !== $base_url
360 + );
361 +
292 362 return (string) ($response['choices'][0]['message']['content'] ?? '');
293 363 }
294 364
295 365 /**
@@ -366,20 +436,37 @@
366 436 *
367 437 * @param string $url Endpoint.
368 438 * @param array $headers Headers.
369 439 * @param array $body Payload.
440 + * @param bool $guarded Whether this is a user-named endpoint, which has its
441 + * destination resolved, pinned and its body capped.
370 442 * @return array Decoded response.
371 443 * @throws \Exception On transport or API error.
372 444 */
373 - private function post(string $url, array $headers, array $body): array {
374 - $response = wp_remote_post($url, [
445 + private function post(string $url, array $headers, array $body, bool $guarded = false): array {
446 + // The user's daily ceiling and kill switch are enforced here, at the
447 + // one place every outbound vision call passes through, so no feature
448 + // path can bypass them by forgetting to ask first (#448).
449 + Spend_Guard::guard();
450 + Spend_Guard::record();
451 +
452 + $args = [
375 453 // Vision calls carry a payload and think for a moment; the 30s
376 - // default is too tight for a large image on a slow link.
377 - 'timeout' => 60,
454 + // default is too tight for a large image on a slow link. A local
455 + // vision model is slower still, so honour the user's own timeout
456 + // when they configured one (#721).
457 + 'timeout' => $this->request_timeout(),
378 458 'headers' => $headers,
379 459 'body' => wp_json_encode($body),
380 - ]);
460 + // The key rides in a header; never let a redirect hand it to
461 + // another host.
462 + 'redirection' => 0,
463 + ];
381 464
465 + $response = $guarded
466 + ? Endpoint_URL_Validator::guarded_request($url, $args + ['method' => 'POST'])
467 + : wp_remote_post($url, $args);
468 +
382 469 if (is_wp_error($response)) {
383 470 throw new \Exception(esc_html($response->get_error_message()));
384 471 }
385 472
@@ -392,8 +479,27 @@
392 479 throw new \Exception(esc_html(sprintf('Vision API error (%d): %s', $status, (string) $message)));
393 480 }
394 481
395 482 return is_array($data) ? $data : [];
483 + }
484 +
485 + /**
486 + * Timeout for a vision request.
487 + *
488 + * 60s for the hosted providers. A local vision model on CPU is slower than
489 + * anything hosted, so an OpenAI-compatible endpoint gets the timeout the
490 + * administrator configured for it, floored at 60 (#721).
491 + *
492 + * @since 2.8.0
493 + *
494 + * @return int Seconds.
495 + */
496 + private function request_timeout(): int {
497 + if ('openai_compatible' !== (string) $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE)) {
498 + return 60;
499 + }
500 +
501 + return max(60, (int) $this->settings->get('openai_compatible_timeout', Settings::DEFAULT_OPENAI_COMPATIBLE_TIMEOUT));
396 502 }
397 503
398 504 /**
399 505 * Normalize a model's reply into usable alt text.