fix(llm): send max_completion_tokens for reasoning models and Azure OpenAI (#979)

PR #858 made the openai provider fall back to max_tokens whenever a custom
base_url was set, to support Mistral/Together-style endpoints. This regressed
two important setups:

1. Reasoning models (GPT-5, o1, o3) reject max_tokens outright with a 400
   ("Unsupported parameter: 'max_tokens' is not supported with this model.
   Use 'max_completion_tokens' instead.").
2. Azure OpenAI is fully OpenAI-API-compatible — it was only classified as
   "third-party compatible" because it requires a custom base_url.

The combination of the two — Azure OpenAI + GPT-5 — is the exact setup the
reporter hit in issue #978 and fails connection verification on startup.

Fix _max_tokens_param_name() so it:

- Always returns max_completion_tokens for reasoning models, regardless of
  base_url (they only support the new parameter name).
- Detects Azure OpenAI endpoints by the *.openai.azure.com hostname and
  treats them as native OpenAI.

The Mistral/Together behavior from #858 is preserved for non-reasoning
models on non-Azure custom base URLs.

Fixes #978
This commit is contained in:
Nicolò Boschi
2026-04-13 08:56:16 +02:00
committed by GitHub
parent d054b88403
commit 7b2263ba3b
2 changed files with 89 additions and 4 deletions
@@ -196,16 +196,29 @@ class OpenAICompatibleLLM(LLMInterface):
def _max_tokens_param_name(self) -> str:
"""Return the correct parameter name for limiting response tokens.
Native OpenAI and Groq accept 'max_completion_tokens'. Mistral and other
OpenAI-compatible endpoints that haven't adopted the newer parameter name
require 'max_tokens'. Using a custom base_url with the openai provider
signals a third-party compatible API, so fall back to 'max_tokens'.
Native OpenAI, Azure OpenAI, Groq, and llamacpp accept 'max_completion_tokens'.
Mistral and other OpenAI-compatible endpoints that haven't adopted the newer
parameter name require 'max_tokens', so when the openai provider is configured
with a non-Azure custom base_url we fall back to the widely-supported
'max_tokens'.
Reasoning models (GPT-5, o1, o3) only accept 'max_completion_tokens' and reject
'max_tokens' outright, so they always use the new parameter name regardless of
base_url.
"""
# Reasoning models (GPT-5, o1, o3, ...) only accept max_completion_tokens.
# Azure OpenAI + GPT-5 is the canonical example: issue #978.
if self._supports_reasoning_model():
return "max_completion_tokens"
# Native OpenAI (no custom base URL), Groq, and llamacpp use max_completion_tokens
if self.provider in ("groq", "llamacpp"):
return "max_completion_tokens"
if self.provider == "openai" and not self.base_url:
return "max_completion_tokens"
# Azure OpenAI is fully OpenAI-API-compatible — detect it by hostname so users
# can keep provider=openai + an Azure base_url (the documented setup).
if self.provider == "openai" and self.base_url and ".openai.azure.com" in self.base_url:
return "max_completion_tokens"
# openai with custom base_url, ollama, lmstudio, minimax, volcano —
# use the widely-supported max_tokens
return "max_tokens"
@@ -0,0 +1,72 @@
"""
Tests for OpenAICompatibleLLM._max_tokens_param_name.
Regression coverage for issue #978: Azure OpenAI + GPT-5 models were failing with
"'max_tokens' is not supported with this model. Use 'max_completion_tokens' instead."
because PR #858 started sending 'max_tokens' whenever the openai provider had a
custom base_url. Reasoning models only accept 'max_completion_tokens', and Azure
OpenAI is fully OpenAI-API-compatible, so both cases must keep using the new
parameter name.
"""
from hindsight_api.engine.providers.openai_compatible_llm import OpenAICompatibleLLM
def _make(provider: str, model: str, base_url: str = "") -> OpenAICompatibleLLM:
return OpenAICompatibleLLM(
provider=provider,
api_key="test-key",
base_url=base_url,
model=model,
)
class TestMaxTokensParamName:
def test_native_openai_uses_max_completion_tokens(self):
llm = _make("openai", "gpt-4o-mini")
assert llm._max_tokens_param_name() == "max_completion_tokens"
def test_openai_custom_base_url_falls_back_to_max_tokens(self):
"""Mistral/Together-style OpenAI-compatible endpoints need max_tokens (PR #858)."""
llm = _make("openai", "mistral-large-latest", base_url="https://api.mistral.ai/v1")
assert llm._max_tokens_param_name() == "max_tokens"
def test_azure_openai_uses_max_completion_tokens(self):
"""Regression for #978: Azure is fully OpenAI-API-compatible, not a third-party clone."""
llm = _make(
"openai",
"gpt-4o-mini",
base_url="https://my-resource.openai.azure.com/openai/v1/",
)
assert llm._max_tokens_param_name() == "max_completion_tokens"
def test_reasoning_model_always_uses_max_completion_tokens(self):
"""Regression for #978: GPT-5/o1/o3 reject max_tokens outright, base_url must not matter."""
# Azure + GPT-5 (exact reporter setup)
azure_gpt5 = _make(
"openai",
"gpt-5.4-nano",
base_url="https://my-resource.openai.azure.com/openai/v1/",
)
assert azure_gpt5._max_tokens_param_name() == "max_completion_tokens"
# Even a Mistral-style custom base_url must not downgrade a reasoning model
for model in ("gpt-5", "gpt-5-mini", "o1-mini", "o3", "deepseek-r1"):
llm = _make("openai", model, base_url="https://some-proxy.example.com/v1")
assert llm._max_tokens_param_name() == "max_completion_tokens", model
def test_groq_uses_max_completion_tokens(self):
llm = _make("groq", "openai/gpt-oss-120b", base_url="https://api.groq.com/openai/v1")
assert llm._max_tokens_param_name() == "max_completion_tokens"
def test_llamacpp_uses_max_completion_tokens(self):
llm = _make("llamacpp", "some-model", base_url="http://localhost:8080/v1")
assert llm._max_tokens_param_name() == "max_completion_tokens"
def test_ollama_uses_max_tokens(self):
llm = _make("ollama", "gemma3:12b", base_url="http://localhost:11434/v1")
assert llm._max_tokens_param_name() == "max_tokens"
def test_lmstudio_uses_max_tokens(self):
llm = _make("lmstudio", "openai/gpt-oss-20b", base_url="http://localhost:1234/v1")
assert llm._max_tokens_param_name() == "max_tokens"