| @@ -153,11 +153,31 @@ | ||
| 153 | 153 | if ($api_key) { |
| 154 | 154 | $model = $this->settings->get('openrouter_model', Settings::DEFAULT_OPENROUTER_MODEL); |
| 155 | 155 | $this->ai_client = new OpenRouter_Client($api_key, $model, self::AI_REQUEST_TIMEOUT); |
| 156 | 156 | } |
| 157 | + } elseif ($provider === 'openai_compatible') { | |
| 158 | + // Same client as OpenAI, different host — and the key is optional, | |
| 159 | + // so the URL and model id are what gate it (#721). The user's own | |
| 160 | + // timeout applies: a local model writing a brief on CPU is slow, | |
| 161 | + // and the setting exists for exactly that. | |
| 162 | + $base_url = (string) $this->settings->get('openai_compatible_base_url', ''); | |
| 163 | + $model = trim((string) $this->settings->get('openai_compatible_model', '')); | |
| 164 | + if ('' !== $base_url && '' !== $model) { | |
| 165 | + $this->ai_client = new OpenAI_Client( | |
| 166 | + (string) $this->settings->get('openai_compatible_api_key', ''), | |
| 167 | + $model, | |
| 168 | + (int) $this->settings->get('openai_compatible_timeout', Settings::DEFAULT_OPENAI_COMPATIBLE_TIMEOUT), | |
| 169 | + $base_url | |
| 170 | + ); | |
| 171 | + $this->ai_client->set_json_mode((bool) $this->settings->get('openai_compatible_json_mode', false)); | |
| 172 | + } | |
| 157 | 173 | } |
| 158 | 174 | |
| 159 | 175 | if (!$this->ai_client) { |
| 176 | + if ('openai_compatible' === $provider) { | |
| 177 | + throw new \Exception('Please set the base URL and model id for your OpenAI-compatible endpoint in ThinkRank settings.'); | |
| 178 | + } | |
| 179 | + | |
| 160 | 180 | throw new \Exception('Please configure your AI provider API key in ThinkRank settings.'); |
| 161 | 181 | } |
| 162 | 182 | } |
| 163 | 183 | |
| @@ -185,8 +205,10 @@ | ||
| 185 | 205 | } elseif ($provider === 'gemini') { |
| 186 | 206 | return $this->settings->get('gemini_model', Settings::DEFAULT_GEMINI_MODEL); |
| 187 | 207 | } elseif ($provider === 'openrouter') { |
| 188 | 208 | return $this->settings->get('openrouter_model', Settings::DEFAULT_OPENROUTER_MODEL); |
| 209 | + } elseif ($provider === 'openai_compatible') { | |
| 210 | + return (string) $this->settings->get('openai_compatible_model', ''); | |
| 189 | 211 | } else { |
| 190 | 212 | return $this->settings->get('openai_model', Settings::DEFAULT_OPENAI_MODEL); |
| 191 | 213 | } |
| 192 | 214 | } |
| @@ -245,9 +267,9 @@ | ||
| 245 | 267 | */ |
| 246 | 268 | private function extract_token_usage(array $ai_response): int { |
| 247 | 269 | $provider = $this->get_current_provider(); |
| 248 | 270 | |
| 249 | - if ($provider === 'openai' || $provider === 'openrouter') { | |
| 271 | + if ($provider === 'openai' || $provider === 'openrouter' || $provider === 'openai_compatible') { | |
| 250 | 272 | // OpenAI-compatible format: response['usage']['total_tokens'] |
| 251 | 273 | return (int) ($ai_response['usage']['total_tokens'] ?? 0); |
| 252 | 274 | } elseif ($provider === 'claude') { |
| 253 | 275 | // Claude format: response['usage']['input_tokens'] + response['usage']['output_tokens'] |
| @@ -347,8 +369,11 @@ | ||
| 347 | 369 | // max_completion_tokens internally. Temperature is intentionally |
| 348 | 370 | // omitted: every client defaults it to 0.7, and reasoning models |
| 349 | 371 | // reject it outright, so passing it here was misleading no-op. |
| 350 | 372 | 'max_tokens' => $max_tokens, |
| 373 | + // The brief is one JSON object. Only a compatible endpoint | |
| 374 | + // with JSON mode on reads this; every other client ignores it. | |
| 375 | + 'json_object' => true, | |
| 351 | 376 | ]; |
| 352 | 377 | if ('' !== $reasoning_effort) { |
| 353 | 378 | $completion_options['reasoning_effort'] = $reasoning_effort; |
| 354 | 379 | } |
| @@ -360,9 +385,9 @@ | ||
| 360 | 385 | // truncation) BEFORE attempting text extraction. Otherwise a |
| 361 | 386 | // refusal — which OpenAI returns as HTTP 200 with content=null — |
| 362 | 387 | // slips past every isset() branch and gets serialized into the |
| 363 | 388 | // brief body instead of being reported to the user. |
| 364 | - $this->guard_against_non_answer($ai_response); | |
| 389 | + $this->guard_against_non_answer($ai_response, $max_tokens); | |
| 365 | 390 | |
| 366 | 391 | // Extract text content from AI response |
| 367 | 392 | $ai_text = ''; |
| 368 | 393 | |
| @@ -510,12 +535,13 @@ | ||
| 510 | 535 | * don't catch them here they fall through to the "unexpected format" path |
| 511 | 536 | * (or, historically, were serialized into the brief body). All messages |
| 512 | 537 | * start with "The AI " so the outer catch passes them through unchanged. |
| 513 | 538 | * |
| 514 | - * @param mixed $ai_response Raw response from the AI client. | |
| 539 | + * @param mixed $ai_response Raw response from the AI client. | |
| 540 | + * @param int $requested_tokens The max_tokens this request asked for; 0 when unknown. | |
| 515 | 541 | * @throws \Exception If the response is a refusal, policy block, or truncation. |
| 516 | 542 | */ |
| 517 | - private function guard_against_non_answer($ai_response): void { | |
| 543 | + private function guard_against_non_answer($ai_response, int $requested_tokens = 0): void { | |
| 518 | 544 | if (!is_array($ai_response)) { |
| 519 | 545 | return; |
| 520 | 546 | } |
| 521 | 547 | |
| @@ -534,8 +560,20 @@ | ||
| 534 | 560 | ))); |
| 535 | 561 | } |
| 536 | 562 | if ('content_filter' === $finish) { |
| 537 | 563 | throw new \Exception('The AI blocked this request under its content policy. Try a different topic or less sensitive keywords.'); |
| 564 | + } | |
| 565 | + // A self-hosted server can stop short of max_tokens because the | |
| 566 | + // prompt and the answer together filled its context window | |
| 567 | + // (Ollama loads models at 4096 by default). A bigger output budget | |
| 568 | + // cannot fix that, so say what can. | |
| 569 | + $completion_tokens = (int) ($ai_response['usage']['completion_tokens'] ?? 0); | |
| 570 | + if ('length' === $finish && $requested_tokens > 0 && $completion_tokens > 0 && $completion_tokens < $requested_tokens) { | |
| 571 | + throw new \Exception(esc_html(sprintf( | |
| 572 | + 'The AI stopped after %1$d tokens, short of the %2$d allowed, because the server ran out of context window before finishing the brief. Raise the context length on your AI server (for Ollama, set OLLAMA_CONTEXT_LENGTH to 16384 or more) and try again.', | |
| 573 | + $completion_tokens, | |
| 574 | + $requested_tokens | |
| 575 | + ))); | |
| 538 | 576 | } |
| 539 | 577 | if ('length' === $finish) { |
| 540 | 578 | throw new \Exception('The AI stopped at its output token limit before finishing the brief. Try fewer competitor URLs, or a model with a larger output limit. A shorter content length will not help: it asks for a smaller budget, not a smaller answer.'); |
| 541 | 579 | } |