mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
fix(proxy/openai): translate max_tokens -> max_completion_tokens on chat path (#1774)
## Description GPT-5 / o-series chat models reject the legacy `max_tokens` — `AI_APICallError: Unsupported parameter: 'max_tokens' is not supported with this model. Use 'max_completion_tokens' instead.` — while gpt-4o/4.1 accept `max_completion_tokens` too. openai-compatible clients (opencode via `@ai-sdk/openai-compatible`, older SDKs) still send `max_tokens`, so requests for GPT-5 models fail at the proxy's OpenAI upstream. This is a blocker for any such client pointed at a GPT-5 model through Headroom. The proxy already owns the outbound `/v1/chat/completions` body (it rewrites `messages` to compress them), so translate the token param there: rename `max_tokens` → `max_completion_tokens` when the newer form isn't already set, then drop the rejected legacy key. One-way, safe for current OpenAI models; no-op when the client already sends `max_completion_tokens`. The Responses path (`max_output_tokens`) is unaffected. Closes # ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - New `_normalize_openai_max_tokens(body)` helper + call in `handle_openai_chat` after body finalization, before upstream forward. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added ### Test Output ```text tests/test_openai_max_completion_tokens.py .... 6 passed ruff check ... All checks passed! mypy headroom/proxy/handlers/openai.py ... Success: no issues found ``` ## Real Behavior Proof - Environment: local worktree, Python 3.12. - Exact command / steps: reproduced live — opencode (`@ai-sdk/openai-compatible` → Headroom proxy) targeting `gpt-5.3-chat-latest` failed with `Unsupported parameter: 'max_tokens' ... Use 'max_completion_tokens'` in the DEBUG stream log. The shim renames the param on the outbound body. - Observed result: unit tests confirm the rename/drop/no-op cases. - Not tested: full live opencode completion (its headless `run` stalls for unrelated reasons in this env — separate from this param fix). ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Additional Notes Discovered while debugging why opencode wouldn't run through the proxy: three layered blockers — (1) missing `models` map in the injected provider config [PR #1716], (2) no `apiKey` in the injected config / HTTP path doesn't inject `OPENAI_API_KEY` like the WS path does, (3) this `max_tokens` vs `max_completion_tokens` mismatch. This PR addresses (3).
This commit is contained in:
parent
37a12dd833
commit
285808b90e
2 changed files with 78 additions and 0 deletions
|
|
@ -84,6 +84,24 @@ _OPENAI_BASE_URL_HEADER = "x-headroom-base-url"
|
|||
_OPENCODE_ZEN_HOSTS = {"opencode.ai", "www.opencode.ai"}
|
||||
|
||||
|
||||
def _normalize_openai_max_tokens(body: dict[str, Any]) -> None:
|
||||
"""Rename the legacy ``max_tokens`` to ``max_completion_tokens`` in-place.
|
||||
|
||||
GPT-5 / o-series chat models reject ``max_tokens`` and require
|
||||
``max_completion_tokens``; gpt-4o/4.1 accept the latter too. So translating
|
||||
is a safe, one-way shim for current OpenAI models that lets openai-compatible
|
||||
clients (opencode, older SDKs) which still send ``max_tokens`` work unchanged.
|
||||
No-op when there is no ``max_tokens``; keeps an already-set
|
||||
``max_completion_tokens`` and just drops the rejected legacy key.
|
||||
"""
|
||||
if not isinstance(body, dict) or "max_tokens" not in body:
|
||||
return
|
||||
legacy = body.get("max_tokens")
|
||||
if legacy is not None and body.get("max_completion_tokens") is None:
|
||||
body["max_completion_tokens"] = legacy
|
||||
body.pop("max_tokens", None)
|
||||
|
||||
|
||||
def _header_get(headers: dict[str, str], name: str) -> str | None:
|
||||
"""Case-insensitive header lookup for plain dicts."""
|
||||
lowered = name.lower()
|
||||
|
|
@ -2593,6 +2611,15 @@ class OpenAIHandlerMixin:
|
|||
optimized_tokens = tokenizer.count_messages(body["messages"])
|
||||
tokens_saved = original_tokens - optimized_tokens
|
||||
|
||||
# Compatibility shim: GPT-5 / o-series chat models REJECT the legacy
|
||||
# `max_tokens` ("Unsupported parameter … Use 'max_completion_tokens'
|
||||
# instead"); gpt-4o/4.1 accept `max_completion_tokens` too. openai-
|
||||
# compatible clients (opencode, older SDKs) still send `max_tokens`, so
|
||||
# translate it here — the proxy already owns the outbound body — and
|
||||
# those requests work unchanged. No-op when the caller already set
|
||||
# `max_completion_tokens`.
|
||||
_normalize_openai_max_tokens(body)
|
||||
|
||||
# Route through LiteLLM/any-llm backend if configured
|
||||
if self.anthropic_backend is not None:
|
||||
try:
|
||||
|
|
|
|||
51
tests/test_openai_max_completion_tokens.py
Normal file
51
tests/test_openai_max_completion_tokens.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"""OpenAI chat-path compatibility shim: max_tokens -> max_completion_tokens.
|
||||
|
||||
GPT-5 / o-series chat models reject the legacy ``max_tokens`` and require
|
||||
``max_completion_tokens`` ("Unsupported parameter: 'max_tokens' is not supported
|
||||
with this model. Use 'max_completion_tokens' instead."). openai-compatible
|
||||
clients (opencode, older SDKs) still send ``max_tokens``, so the proxy — which
|
||||
already owns the outbound request body — translates it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from headroom.proxy.handlers.openai import _normalize_openai_max_tokens
|
||||
|
||||
|
||||
def test_renames_legacy_max_tokens():
|
||||
body = {"model": "gpt-5.3-chat-latest", "max_tokens": 256, "messages": []}
|
||||
_normalize_openai_max_tokens(body)
|
||||
assert "max_tokens" not in body
|
||||
assert body["max_completion_tokens"] == 256
|
||||
|
||||
|
||||
def test_preserves_existing_max_completion_tokens_and_drops_legacy():
|
||||
body = {"max_tokens": 256, "max_completion_tokens": 100}
|
||||
_normalize_openai_max_tokens(body)
|
||||
assert "max_tokens" not in body
|
||||
assert body["max_completion_tokens"] == 100 # explicit value wins
|
||||
|
||||
|
||||
def test_noop_when_only_max_completion_tokens():
|
||||
body = {"max_completion_tokens": 128}
|
||||
_normalize_openai_max_tokens(body)
|
||||
assert body == {"max_completion_tokens": 128}
|
||||
|
||||
|
||||
def test_noop_when_neither_present():
|
||||
body = {"model": "gpt-4o", "messages": []}
|
||||
_normalize_openai_max_tokens(body)
|
||||
assert "max_completion_tokens" not in body
|
||||
assert "max_tokens" not in body
|
||||
|
||||
|
||||
def test_null_max_tokens_is_dropped_without_setting_completion():
|
||||
body = {"max_tokens": None}
|
||||
_normalize_openai_max_tokens(body)
|
||||
assert "max_tokens" not in body
|
||||
assert body.get("max_completion_tokens") is None
|
||||
|
||||
|
||||
def test_non_dict_is_safe():
|
||||
_normalize_openai_max_tokens(None) # type: ignore[arg-type]
|
||||
_normalize_openai_max_tokens("nope") # type: ignore[arg-type]
|
||||
Loading…
Add table
Add a link
Reference in a new issue