| @@ -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 | |
| @@ -383,8 +479,27 @@ | ||
| 383 | 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. |