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
thinkrank / includes / ai / class-vision-client.php

class-vision-client.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.9.0, at includes/ai/class-vision-client.php

539 lines 20.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Vision client — describes an image with a multimodal model.
4 *
5 * Kept separate from the text clients on purpose. Every provider expresses
6 * image input differently (OpenAI nests typed content parts, Anthropic wants a
7 * base64 `source` block, Gemini wants `inline_data`), and folding three
8 * incompatible shapes into `generate_completion()` would make the text path
9 * harder to read for no benefit. This class owns that divergence and returns
10 * one thing: a sentence describing the picture.
11 *
12 * Images are sent as base64 rather than by URL because a WordPress upload is
13 * frequently unreachable from the provider — local sites, staging behind auth,
14 * intranets, or a CDN that blocks bots (see the ChatGPT/User-Agent block in the
15 * MCP threads). A URL that works for us often 403s for them.
16 *
17 * @package ThinkRank\AI
18 * @since 1.28.0
19 */
20
21 declare(strict_types=1);
22
23 namespace ThinkRank\AI;
24
25 use ThinkRank\Core\Settings;
26
27 if (!defined('ABSPATH')) {
28 exit;
29 }
30
31 /**
32 * Multimodal image description.
33 */
34 class Vision_Client {
35
36 /**
37 * Largest image we will upload, in bytes.
38 *
39 * Providers cap request size (Anthropic ~5MB per image) and a photo
40 * straight off a phone routinely exceeds it. WordPress already generates
41 * scaled sizes, so we prefer one of those and only fall back to the
42 * original when nothing smaller exists.
43 */
44 private const MAX_IMAGE_BYTES = 3500000;
45
46 /**
47 * Preferred registered image sizes, smallest adequate first. A description
48 * does not need a 4000px original — 'medium_large' is plenty and keeps the
49 * request (and the bill) small.
50 *
51 * @var string[]
52 */
53 private const PREFERRED_SIZES = ['medium_large', 'large', 'medium'];
54
55 /**
56 * Settings accessor.
57 *
58 * @var Settings
59 */
60 private Settings $settings;
61
62 /**
63 * Constructor.
64 *
65 * @param Settings|null $settings Settings instance.
66 */
67 public function __construct(?Settings $settings = null) {
68 $this->settings = $settings ?? Settings::instance();
69 }
70
71 /**
72 * Providers that can accept an image, mapped to the models we send.
73 *
74 * @return array<string, string> provider => default vision model.
75 */
76 public static function capable_providers(): array {
77 return [
78 'openai' => 'gpt-5-mini',
79 'claude' => 'claude-sonnet-5',
80 'gemini' => Settings::DEFAULT_GEMINI_MODEL,
81 ];
82 }
83
84 /**
85 * Whether the configured provider can describe images.
86 *
87 * @return bool
88 */
89 public function is_available(): bool {
90 $provider = (string) $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
91
92 if ('openai_compatible' === $provider) {
93 return $this->compatible_vision_ready();
94 }
95
96 return isset(self::capable_providers()[$provider])
97 && '' !== $this->api_key_for($provider);
98 }
99
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 /**
121 * The stored key for a provider.
122 *
123 * Keys are held per provider (`openai_api_key`, `claude_api_key`,
124 * `gemini_api_key`) — the same names AI_Manager reads. There is no
125 * provider-agnostic `ai_api_key` setting, so asking for one always
126 * returned '' and made every vision call fall back to the filename
127 * template without ever reaching the provider.
128 *
129 * @param string $provider Provider slug.
130 * @return string
131 */
132 private function api_key_for(string $provider): string {
133 return trim((string) $this->settings->get($provider . '_api_key', ''));
134 }
135
136 /**
137 * Describe an attachment in one short sentence suitable for alt text.
138 *
139 * @param int $attachment_id Attachment to describe.
140 * @param string $context Optional context (post title) to disambiguate.
141 * @return string Alt text, or '' when it could not be produced.
142 * @throws \Exception When the provider is unusable or the call fails.
143 */
144 public function describe_attachment(int $attachment_id, string $context = ''): string {
145 $provider = (string) $this->settings->get('ai_provider', Settings::AI_PROVIDER_NONE);
146 $models = self::capable_providers();
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
179 if (!isset($models[$provider])) {
180 throw new \Exception(sprintf(
181 /* translators: %s: AI provider name. */
182 esc_html__('The %s provider cannot describe images. Switch to OpenAI, Anthropic or Gemini in Settings → AI Provider.', 'thinkrank'),
183 esc_html($provider)
184 ));
185 }
186
187 $api_key = $this->api_key_for($provider);
188 if ('' === $api_key) {
189 throw new \Exception(esc_html__('No AI API key configured.', 'thinkrank'));
190 }
191
192 $image = $this->read_image($attachment_id);
193 if (null === $image) {
194 throw new \Exception(esc_html__('The image file could not be read, or is larger than the provider allows.', 'thinkrank'));
195 }
196
197 $model = (string) $this->settings->get('ai_vision_model', $models[$provider]);
198 $prompt = $this->build_prompt($context);
199
200 switch ($provider) {
201 case 'openai':
202 $text = $this->call_openai($api_key, $model, $prompt, $image);
203 break;
204 case 'claude':
205 $text = $this->call_claude($api_key, $model, $prompt, $image);
206 break;
207 default:
208 $text = $this->call_gemini($api_key, $model, $prompt, $image);
209 break;
210 }
211
212 return $this->clean($text);
213 }
214
215 /**
216 * The instruction. Alt text has rules — screen readers already announce
217 * "image", length is capped by convention, and a description that opens
218 * with "an image of" wastes the listener's time.
219 *
220 * @param string $context Surrounding context, if any.
221 * @return string
222 */
223 private function build_prompt(string $context): string {
224 $prompt = "Write alt text for this image.\n\n"
225 . "Rules:\n"
226 . "- One sentence, under 125 characters.\n"
227 . "- Describe what is visibly in the image, factually.\n"
228 . "- Do NOT start with \"image of\", \"picture of\" or \"photo of\".\n"
229 . "- No quotes, no trailing period, no markdown.\n"
230 . "- If the image contains meaningful text, include it.\n"
231 . "- Reply with the alt text only.";
232
233 if ('' !== $context) {
234 $prompt .= "\n\nThe image appears in content about: " . $context;
235 }
236
237 return $prompt;
238 }
239
240 /**
241 * Load an attachment as base64, preferring a scaled size.
242 *
243 * @param int $attachment_id Attachment ID.
244 * @return array{data: string, mime: string}|null
245 */
246 private function read_image(int $attachment_id): ?array {
247 $path = $this->resolve_path($attachment_id);
248 if (null === $path) {
249 return null;
250 }
251
252 if (!is_readable($path)) {
253 return null;
254 }
255
256 $bytes = filesize($path);
257 if (false === $bytes || $bytes > self::MAX_IMAGE_BYTES) {
258 return null;
259 }
260
261 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading a local upload, not a remote fetch.
262 $contents = file_get_contents($path);
263 if (false === $contents || '' === $contents) {
264 return null;
265 }
266
267 $mime = (string) get_post_mime_type($attachment_id);
268 if (!in_array($mime, ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], true)) {
269 return null;
270 }
271
272 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- the multimodal request bodies of OpenAI, Anthropic and Gemini all carry image bytes as base64.
273 return ['data' => base64_encode($contents), 'mime' => $mime];
274 }
275
276 /**
277 * Absolute path to the smallest adequate version of an attachment.
278 *
279 * @param int $attachment_id Attachment ID.
280 * @return string|null
281 */
282 private function resolve_path(int $attachment_id): ?string {
283 $meta = wp_get_attachment_metadata($attachment_id);
284 $original = get_attached_file($attachment_id);
285
286 if (is_array($meta) && !empty($meta['sizes']) && is_string($original)) {
287 $dir = dirname($original);
288 foreach (self::PREFERRED_SIZES as $size) {
289 if (!empty($meta['sizes'][$size]['file'])) {
290 $candidate = $dir . '/' . $meta['sizes'][$size]['file'];
291 if (file_exists($candidate)) {
292 return $candidate;
293 }
294 }
295 }
296 }
297
298 return (is_string($original) && file_exists($original)) ? $original : null;
299 }
300
301 /**
302 * OpenAI: typed content parts with a data: URL.
303 *
304 * @param string $api_key API key.
305 * @param string $model Model id.
306 * @param string $prompt Instruction.
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.
309 * @return string
310 * @throws \Exception On API failure.
311 */
312 private function call_openai(string $api_key, string $model, string $prompt, array $image, string $base_url = OpenAI_Client::API_BASE_URL): string {
313 $body = [
314 'model' => $model,
315 'messages' => [[
316 'role' => 'user',
317 'content' => [
318 ['type' => 'text', 'text' => $prompt],
319 [
320 'type' => 'image_url',
321 'image_url' => ['url' => 'data:' . $image['mime'] . ';base64,' . $image['data']],
322 ],
323 ],
324 ]],
325 // Reasoning-safe ceiling, and minimal effort: alt text needs
326 // observation, not deliberation (see 6094d9d).
327 'max_completion_tokens' => 4000,
328 ];
329
330 if (0 === strpos($model, 'gpt-5')) {
331 $body['reasoning_effort'] = 'minimal';
332 }
333
334 $base_url = rtrim(trim($base_url), '/');
335 if ('' === $base_url) {
336 $base_url = OpenAI_Client::API_BASE_URL;
337 }
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
362 return (string) ($response['choices'][0]['message']['content'] ?? '');
363 }
364
365 /**
366 * Anthropic: base64 `source` block, and the API version header.
367 *
368 * @param string $api_key API key.
369 * @param string $model Model id.
370 * @param string $prompt Instruction.
371 * @param array $image ['data' => base64, 'mime' => string].
372 * @return string
373 * @throws \Exception On API failure.
374 */
375 private function call_claude(string $api_key, string $model, string $prompt, array $image): string {
376 $response = $this->post('https://api.anthropic.com/v1/messages', [
377 'x-api-key' => $api_key,
378 'anthropic-version' => '2023-06-01',
379 'Content-Type' => 'application/json',
380 ], [
381 'model' => $model,
382 'max_tokens' => 300,
383 'messages' => [[
384 'role' => 'user',
385 'content' => [
386 [
387 'type' => 'image',
388 'source' => [
389 'type' => 'base64',
390 'media_type' => $image['mime'],
391 'data' => $image['data'],
392 ],
393 ],
394 ['type' => 'text', 'text' => $prompt],
395 ],
396 ]],
397 ]);
398
399 return (string) ($response['content'][0]['text'] ?? '');
400 }
401
402 /**
403 * Gemini: `inline_data` part, key on the query string.
404 *
405 * @param string $api_key API key.
406 * @param string $model Model id.
407 * @param string $prompt Instruction.
408 * @param array $image ['data' => base64, 'mime' => string].
409 * @return string
410 * @throws \Exception On API failure.
411 */
412 private function call_gemini(string $api_key, string $model, string $prompt, array $image): string {
413 $url = sprintf(
414 'https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent?key=%s',
415 rawurlencode($model),
416 rawurlencode($api_key)
417 );
418
419 $response = $this->post($url, ['Content-Type' => 'application/json'], [
420 'contents' => [[
421 'parts' => [
422 ['text' => $prompt],
423 ['inline_data' => ['mime_type' => $image['mime'], 'data' => $image['data']]],
424 ],
425 ]],
426 // Gemini 2.5 spends output tokens on reasoning before emitting
427 // text, so a tight cap returns an empty candidate.
428 'generationConfig' => ['maxOutputTokens' => 2000, 'temperature' => 0.2],
429 ]);
430
431 return (string) ($response['candidates'][0]['content']['parts'][0]['text'] ?? '');
432 }
433
434 /**
435 * Shared POST + error mapping.
436 *
437 * @param string $url Endpoint.
438 * @param array $headers Headers.
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.
442 * @return array Decoded response.
443 * @throws \Exception On transport or API error.
444 */
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 = [
453 // Vision calls carry a payload and think for a moment; the 30s
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(),
458 'headers' => $headers,
459 'body' => wp_json_encode($body),
460 // The key rides in a header; never let a redirect hand it to
461 // another host.
462 'redirection' => 0,
463 ];
464
465 $response = $guarded
466 ? Endpoint_URL_Validator::guarded_request($url, $args + ['method' => 'POST'])
467 : wp_remote_post($url, $args);
468
469 if (is_wp_error($response)) {
470 throw new \Exception(esc_html($response->get_error_message()));
471 }
472
473 $status = (int) wp_remote_retrieve_response_code($response);
474 $raw = wp_remote_retrieve_body($response);
475 $data = json_decode($raw, true);
476
477 if ($status >= 400) {
478 $message = $data['error']['message'] ?? ($data['error']['status'] ?? 'Unknown API error');
479 throw new \Exception(esc_html(sprintf('Vision API error (%d): %s', $status, (string) $message)));
480 }
481
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));
502 }
503
504 /**
505 * Normalize a model's reply into usable alt text.
506 *
507 * Models still add wrappers despite instructions, so strip them here rather
508 * than storing "Image of a red bicycle." on the attachment forever.
509 *
510 * @param string $text Raw model output.
511 * @return string
512 */
513 private function clean(string $text): string {
514 $text = trim(wp_strip_all_tags($text));
515 $text = trim($text, "\"' \t\n\r");
516 $text = (string) preg_replace('/^(an?\s+)?(image|picture|photo|photograph|screenshot)\s+(of|showing|depicting)\s+/i', '', $text);
517 $text = (string) preg_replace('/\s+/', ' ', $text);
518 $text = rtrim($text, '.');
519
520 if ('' === $text) {
521 return '';
522 }
523
524 // Alt text longer than ~125 chars is read as noise by screen readers;
525 // cut on a word boundary rather than mid-syllable.
526 if (mb_strlen($text) > 125) {
527 $text = mb_substr($text, 0, 125);
528 $space = mb_strrpos($text, ' ');
529 if (false !== $space && $space > 60) {
530 $text = mb_substr($text, 0, $space);
531 }
532 }
533
534 // ucfirst() is not multibyte-safe: on alt text starting with an
535 // accented character ("Éclair…") it corrupts the leading byte.
536 return mb_strtoupper(mb_substr($text, 0, 1)) . mb_substr($text, 1);
537 }
538 }
539