| @@ -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. |