fix(chatgpt): forward max_output_tokens to the Codex backend and return incomplete non-stream responses

The Codex backend accepts and enforces max_output_tokens, so the allowlist now keeps it
instead of dropping it. A structured-output request could otherwise stream until the
backend gave up, ignoring the caller's max_tokens.

A capped request ends with response.incomplete, so the non-stream SSE aggregator now
treats that event as terminal and returns the partial response instead of an error.
This commit is contained in:
mateo-berri 2026-11-06 05:29:21 -08:00
parent b1bb819969
commit f951508410
3 changed files with 55 additions and 8 deletions

View file

@ -144,6 +144,7 @@ class ChatGPTResponsesAPIConfig(OpenAIResponsesAPIConfig):
"previous_response_id",
"truncation",
"text",
"max_output_tokens",
}
filtered: Final = {k: v for k, v in request.items() if k in allowed_keys}
@ -219,7 +220,10 @@ class ChatGPTResponsesAPIConfig(OpenAIResponsesAPIConfig):
)
continue
if event_type == ResponsesAPIStreamEvents.RESPONSE_COMPLETED:
if event_type in (
ResponsesAPIStreamEvents.RESPONSE_COMPLETED,
ResponsesAPIStreamEvents.RESPONSE_INCOMPLETE,
):
# Real OUTPUT_ITEM_DONE events take precedence at any given
# output_index, but text-only items at indices without a
# matching OUTPUT_ITEM_DONE must still be preserved (e.g.

View file

@ -46,6 +46,9 @@ FORWARDED_KEYS: Final = frozenset(
"reasoning",
"previous_response_id",
"truncation",
"text",
"service_tier",
"max_output_tokens",
}
)

View file

@ -19,6 +19,7 @@ from litellm.llms.chatgpt.authenticator import Authenticator
from litellm.llms.custom_httpx.http_handler import HTTPHandler
from litellm.llms.chatgpt.responses.transformation import ChatGPTResponsesAPIConfig
from litellm.llms.openai.common_utils import OpenAIError
from litellm.llms.openai.responses.transformation import OPENAI_RESPONSES_API_MIN_MAX_OUTPUT_TOKENS
from litellm.main import responses_api_bridge_check
from litellm.types.router import GenericLiteLLMParams
from litellm.types.utils import LlmProviders
@ -59,7 +60,7 @@ class TestChatGPTResponsesAPITransformation:
assert request["reasoning"] == {"effort": effort}
assert request["stream"] is True
assert request["store"] is False
assert "max_output_tokens" not in request
assert request["max_output_tokens"] == 16
assert "prompt_cache_options" not in request
@pytest.mark.parametrize("requested_tier", [None, "auto", "flex", "unknown"])
@ -236,9 +237,9 @@ class TestChatGPTResponsesAPITransformation:
"top_p": 0.9,
"context_management": [{"type": "compaction", "compact_threshold": 200000}],
"metadata": {"foo": "bar"},
"max_output_tokens": 123,
"stream_options": {"include_usage": True},
# supported and should be preserved
"max_output_tokens": 123,
"truncation": "auto",
"previous_response_id": "resp_123",
"reasoning": {"effort": "medium"},
@ -254,9 +255,9 @@ class TestChatGPTResponsesAPITransformation:
assert "top_p" not in request
assert "context_management" not in request
assert "metadata" not in request
assert "max_output_tokens" not in request
assert "stream_options" not in request
assert request["max_output_tokens"] == 123
assert request["truncation"] == "auto"
assert request["previous_response_id"] == "resp_123"
assert request["reasoning"] == {"effort": "medium"}
@ -627,14 +628,16 @@ def chatgpt_token_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("CHATGPT_AUTH_FILE", raising=False)
def _completed_codex_upstream(output_text: str) -> MagicMock:
def _completed_codex_upstream(output_text: str, incomplete_reason: str | None = None) -> MagicMock:
status: Final = "completed" if incomplete_reason is None else "incomplete"
completed: Final = {
"type": "response.completed",
"type": f"response.{status}",
"response": {
"id": "resp_1",
"object": "response",
"created_at": 1,
"status": "completed",
"status": status,
"incomplete_details": None if incomplete_reason is None else {"reason": incomplete_reason},
"model": "gpt-5.5",
"output": [
{
@ -652,7 +655,7 @@ def _completed_codex_upstream(output_text: str) -> MagicMock:
side_effect=lambda _request: httpx.Response(
200,
headers={"content-type": "text/event-stream"},
content=f"event: response.completed\ndata: {json.dumps(completed)}\n\n".encode(),
content=f"event: response.{status}\ndata: {json.dumps(completed)}\n\n".encode(),
)
)
@ -723,3 +726,40 @@ def test_chat_response_format_reaches_the_codex_backend_as_text_format(
)
assert json.loads(upstream.call_args.args[0].content)["text"]["format"] == expected_format
@pytest.mark.usefixtures("chatgpt_token_dir")
@pytest.mark.parametrize(
("max_tokens", "expected_max_output_tokens"),
[(300, 300), (OPENAI_RESPONSES_API_MIN_MAX_OUTPUT_TOKENS - 1, OPENAI_RESPONSES_API_MIN_MAX_OUTPUT_TOKENS)],
)
def test_chat_max_tokens_reaches_the_codex_backend_no_lower_than_its_minimum(
max_tokens: int, expected_max_output_tokens: int
) -> None:
upstream: Final = _completed_codex_upstream("OK")
client: Final = HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(upstream)))
litellm.completion(
model="chatgpt/gpt-5.5",
messages=[{"role": "user", "content": "hi"}],
max_tokens=max_tokens,
client=client,
)
assert json.loads(upstream.call_args.args[0].content)["max_output_tokens"] == expected_max_output_tokens
@pytest.mark.usefixtures("chatgpt_token_dir")
def test_non_stream_chat_cut_off_by_max_tokens_returns_the_partial_answer() -> None:
upstream: Final = _completed_codex_upstream('{"capitals": ["Paris", "Ber', incomplete_reason="max_output_tokens")
client: Final = HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(upstream)))
response: Final = litellm.completion(
model="chatgpt/gpt-5.5",
messages=[{"role": "user", "content": "List capitals as JSON."}],
max_tokens=16,
client=client,
)
assert response.choices[0].finish_reason == "length"
assert response.choices[0].message.content == '{"capitals": ["Paris", "Ber'