From d8bdd7552c6ecb49f82519d35c07f161be7d6c0f Mon Sep 17 00:00:00 2001 From: Guo-Yixin Date: Mon, 5 Oct 2026 19:11:05 -0500 Subject: [PATCH] fix(models): tolerate null Codex response usage (#6335) --- README.md | 1 + .../harness/deerflow/models/AGENTS.md | 6 ++ .../deerflow/models/openai_codex_provider.py | 2 +- .../test_codex_stream_terminal_events.py | 62 +++++++++++++++++++ 4 files changed, 70 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 691fb1fb2f..201c0a44aa 100644 --- a/README.md +++ b/README.md @@ -309,6 +309,7 @@ For Google's official Gemini OpenAI-compatible endpoint, use the ``` - Codex CLI reads `~/.codex/auth.json` + - Completed Codex responses still return their text and tool calls when token usage is null, omitted, or empty; usage metadata remains unavailable. - The Codex model provider returns completed responses without waiting for the SSE connection to close. Failed or incomplete responses report the provider's error or reason; partial output is not returned as a successful answer. Non-object error details are reported as text. - Claude Code accepts `CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_AUTH_TOKEN`, `CLAUDE_CODE_CREDENTIALS_PATH`, or `~/.claude/.credentials.json` - `CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR` accepts a UTF-8 token handoff and reuses it for later model instances in the same process. Undecodable handoffs are skipped so Claude Code can still try its override or default credentials file. diff --git a/backend/packages/harness/deerflow/models/AGENTS.md b/backend/packages/harness/deerflow/models/AGENTS.md index 3a7af77160..87fd4ee143 100644 --- a/backend/packages/harness/deerflow/models/AGENTS.md +++ b/backend/packages/harness/deerflow/models/AGENTS.md @@ -78,6 +78,12 @@ Coverage: `tests/test_codex_provider.py`. ### Codex SSE termination (`packages/harness/deerflow/models/openai_codex_provider.py`) +Completed responses with null, omitted, or empty `usage` retain their text, +reasoning, and tool calls. Normalize unavailable usage to the existing empty +mapping fallback while keeping `AIMessage.usage_metadata` as `None`; populated +usage mappings, including zero counts and cached/reasoning token details, remain +unchanged. + `response.completed` ends stream consumption immediately, before transport EOF; retain the output-item recovery path for empty completed output. Terminal `response.failed`, `response.incomplete`, and `error` events raise with their diff --git a/backend/packages/harness/deerflow/models/openai_codex_provider.py b/backend/packages/harness/deerflow/models/openai_codex_provider.py index 99b3a13e62..b691afca7b 100644 --- a/backend/packages/harness/deerflow/models/openai_codex_provider.py +++ b/backend/packages/harness/deerflow/models/openai_codex_provider.py @@ -421,7 +421,7 @@ class CodexChatModel(BaseChatModel): } ) - usage = response.get("usage", {}) + usage = response.get("usage") or {} usage_metadata = _build_usage_metadata(usage) if usage else None additional_kwargs = {} if reasoning_content: diff --git a/backend/tests/test_codex_stream_terminal_events.py b/backend/tests/test_codex_stream_terminal_events.py index 3dd99e3e50..944df259d4 100644 --- a/backend/tests/test_codex_stream_terminal_events.py +++ b/backend/tests/test_codex_stream_terminal_events.py @@ -211,3 +211,65 @@ def test_completed_response_recovers_tool_calls_and_reasoning_before_closing(mod assert result.additional_kwargs["reasoning_content"] == "Save the completed report" assert stream.closed assert not stream.read_past_events + + +@pytest.mark.parametrize("usage_shape", ["null", "omitted", "empty"]) +@pytest.mark.parametrize("output_kind", ["text", "tool", "mixed"]) +@pytest.mark.parametrize("recover_streamed_output", [False, True]) +def test_completed_response_without_usage_preserves_output(model, serve_events, usage_shape, output_kind, recover_streamed_output): + tool_call = {"type": "function_call", "name": "save_report", "call_id": "call-report", "arguments": '{"name": "report.md"}'} + reasoning = {"type": "reasoning", "summary": [{"type": "summary_text", "text": "Save the completed report"}]} + items = { + "text": [message_item("Report ready")], + "tool": [tool_call], + "mixed": [reasoning, message_item("Report ready"), tool_call], + }[output_kind] + response = {"id": "resp-no-usage", "status": "completed", "model": "gpt-5.4", "output": [] if recover_streamed_output else items} + if usage_shape != "omitted": + response["usage"] = None if usage_shape == "null" else {} + events = [{"type": "response.output_item.done", "output_index": index, "item": item} for index, item in enumerate(items)] if recover_streamed_output else [] + events.append({"type": "response.completed", "response": response}) + stream, requests = serve_events(events, tail_error=httpx.ReadTimeout("Connection stayed open after completion")) + + result = model.invoke([HumanMessage(content="Prepare a report")]) + + assert result.content == ("Report ready" if output_kind != "tool" else "") + expected_calls = [{"type": "tool_call", "name": "save_report", "id": "call-report", "args": {"name": "report.md"}}] if output_kind != "text" else [] + assert result.tool_calls == expected_calls + assert result.invalid_tool_calls == [] + assert result.additional_kwargs == ({"reasoning_content": "Save the completed report"} if output_kind == "mixed" else {}) + assert result.usage_metadata is None + assert result.response_metadata["usage"] == {} + assert result.response_metadata["model"] == "gpt-5.4" + assert result.response_metadata["token_usage"] == {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0} + assert len(requests) == 1 + assert stream.closed + assert not stream.read_past_events + + +@pytest.mark.asyncio +async def test_async_completed_response_without_usage_preserves_output(model, serve_events): + stream, requests = serve_events([{"type": "response.completed", "response": {"status": "completed", "output": [message_item("Report ready")], "usage": None}}]) + + result = await model.ainvoke([HumanMessage(content="Prepare a report")]) + + assert result.content == "Report ready" + assert result.usage_metadata is None + assert result.response_metadata["usage"] == {} + assert len(requests) == 1 + assert stream.closed + assert not stream.read_past_events + + +@pytest.mark.parametrize("tokens", [0, 7]) +def test_completed_response_usage_mapping_preserves_counts(model, serve_events, tokens): + usage = {"input_tokens": tokens, "output_tokens": tokens, "total_tokens": 2 * tokens, "input_tokens_details": {"cached_tokens": tokens}, "output_tokens_details": {"reasoning_tokens": tokens}} + stream, _ = serve_events([{"type": "response.completed", "response": {"status": "completed", "output": [message_item("Report ready")], "usage": usage}}]) + + result = model.invoke([HumanMessage(content="Prepare a report")]) + + assert result.usage_metadata == {"input_tokens": tokens, "output_tokens": tokens, "total_tokens": 2 * tokens, "input_token_details": {"cache_read": tokens}, "output_token_details": {"reasoning": tokens}} + assert result.response_metadata["usage"] == usage + assert result.response_metadata["token_usage"] == {"prompt_tokens": tokens, "completion_tokens": tokens, "total_tokens": 2 * tokens} + assert stream.closed + assert not stream.read_past_events