fix(models): tolerate null Codex response usage (#6335)

This commit is contained in:
Guo-Yixin 2026-10-05 19:11:05 -05:00 committed by GitHub
parent 2a81f913a5
commit d8bdd7552c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 70 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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