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:
Tejas Chopra 2026-07-08 00:08:11 -04:00 committed by GitHub
parent 37a12dd833
commit 285808b90e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 78 additions and 0 deletions

View file

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

View 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]