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 +131 -16 1.32.0 → 2.10.0 View file →
@@ -76,9 +76,9 @@
76 76 public static function capable_providers(): array {
77 77 return [
78 78 'openai' => 'gpt-5-mini',
79 79 'claude' => 'claude-sonnet-5',
80 - 'gemini' => 'gemini-2.5-flash',
80 + 'gemini' => Settings::DEFAULT_GEMINI_MODEL,
81 81 ];
82 82 }
83 83
84 84 /**
@@ -86,15 +86,39 @@
86 86 *
87 87 * @return bool
88 88 */
89 89 public function is_available(): bool {
90 - $provider = (string) $this->settings->get('ai_provider', 'openai');
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
@@ -117,15 +141,46 @@
117 141 * @return string Alt text, or '' when it could not be produced.
118 142 * @throws \Exception When the provider is unusable or the call fails.
119 143 */
120 144 public function describe_attachment(int $attachment_id, string $context = ''): string {
121 - $provider = (string) $this->settings->get('ai_provider', 'openai');
145 + $provider = (string) $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
122 146 $models = self::capable_providers();
123 147
148 + // Distinguish "no provider chosen" from "this provider can't do vision":
149 + // interpolating an empty provider name reads as a broken string (#572).
150 + if (Settings::AI_PROVIDER_NONE === $provider) {
151 + throw new \Exception(esc_html__(
152 + 'No AI provider is selected. Choose OpenAI, Anthropic or Gemini in Settings → AI Provider.',
153 + 'thinkrank'
154 + ));
155 + }
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 +
124 179 if (!isset($models[$provider])) {
125 180 throw new \Exception(sprintf(
126 181 /* translators: %s: AI provider name. */
127 - esc_html__('The %s provider cannot describe images. Switch to OpenAI, Claude or Gemini in Settings → AI Provider.', 'thinkrank'),
182 + esc_html__('The %s provider cannot describe images. Switch to OpenAI, Anthropic or Gemini in Settings → AI Provider.', 'thinkrank'),
128 183 esc_html($provider)
129 184 ));
130 185 }
131 186
@@ -248,13 +303,14 @@
248 303 *
249 304 * @param string $api_key API key.
250 305 * @param string $model Model id.
251 306 * @param string $prompt Instruction.
252 - * @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.
253 309 * @return string
254 310 * @throws \Exception On API failure.
255 311 */
256 - 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 {
257 313 $body = [
258 314 'model' => $model,
259 315 'messages' => [[
260 316 'role' => 'user',
@@ -274,13 +330,36 @@
274 330 if (0 === strpos($model, 'gpt-5')) {
275 331 $body['reasoning_effort'] = 'minimal';
276 332 }
277 333
278 - $response = $this->post('https://api.openai.com/v1/chat/completions', [
279 - 'Authorization' => 'Bearer ' . $api_key,
280 - 'Content-Type' => 'application/json',
281 - ], $body);
334 + $base_url = rtrim(trim($base_url), '/');
335 + if ('' === $base_url) {
336 + $base_url = OpenAI_Client::API_BASE_URL;
337 + }
282 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 +
283 362 return (string) ($response['choices'][0]['message']['content'] ?? '');
284 363 }
285 364
286 365 /**
@@ -357,20 +436,37 @@
357 436 *
358 437 * @param string $url Endpoint.
359 438 * @param array $headers Headers.
360 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.
361 442 * @return array Decoded response.
362 443 * @throws \Exception On transport or API error.
363 444 */
364 - private function post(string $url, array $headers, array $body): array {
365 - $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 = [
366 453 // Vision calls carry a payload and think for a moment; the 30s
367 - // default is too tight for a large image on a slow link.
368 - '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(),
369 458 'headers' => $headers,
370 459 'body' => wp_json_encode($body),
371 - ]);
460 + // The key rides in a header; never let a redirect hand it to
461 + // another host.
462 + 'redirection' => 0,
463 + ];
372 464
465 + $response = $guarded
466 + ? Endpoint_URL_Validator::guarded_request($url, $args + ['method' => 'POST'])
467 + : wp_remote_post($url, $args);
468 +
373 469 if (is_wp_error($response)) {
374 470 throw new \Exception(esc_html($response->get_error_message()));
375 471 }
376 472
@@ -379,12 +475,31 @@
379 475 $data = json_decode($raw, true);
380 476
381 477 if ($status >= 400) {
382 478 $message = $data['error']['message'] ?? ($data['error']['status'] ?? 'Unknown API error');
383 - throw new \Exception(sprintf('Vision API error (%d): %s', $status, esc_html((string) $message)));
479 + throw new \Exception(esc_html(sprintf('Vision API error (%d): %s', $status, (string) $message)));
384 480 }
385 481
386 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));
387 502 }
388 503
389 504 /**
390 505 * Normalize a model's reply into usable alt text.