fix(codex): strip metadata and harden error converter (#51)#58
fix(codex): strip metadata and harden error converter (#51)#58
Conversation
…er (#51) When Claude Code CLI points at ccproxy's /codex endpoint, the anthropic.messages -> openai.responses converter copies Anthropic's metadata.user_id into the Responses payload as "metadata". The Codex upstream (chatgpt.com/backend-api/codex/responses) rejects this with "Unsupported parameter: metadata", so every Claude Code -> codex request was failing with HTTP 400. - codex adapter: add "metadata" to the unsupported-key strip list in _sanitize_provider_body so it is removed before the upstream call, same as max_tokens/temperature. - simple_converters: convert_anthropic_to_openai_error now coerces non-Anthropic error shapes (e.g. Codex's FastAPI-style {"detail": "..."}) into a minimal ErrorResponse envelope instead of raising ValidationError. Without this, upstream 400s were being upgraded to 502s in the non-streaming error path. Adds regression tests for both the metadata strip and the error converter (three shapes: Anthropic-native, FastAPI detail, and arbitrary dict).
There was a problem hiding this comment.
Pull request overview
This PR fixes Codex /codex request failures caused by sending unsupported metadata to the Codex upstream and hardens Anthropic→OpenAI error conversion so non-Anthropic error payloads (e.g., FastAPI {"detail": ...}) don’t raise validation exceptions and incorrectly surface as 5xx errors.
Changes:
- Strip
metadatafrom Codex outbound request bodies in_sanitize_provider_body. - Update
convert_anthropic_to_openai_errorto coerce unexpected upstream error payload shapes into a minimal Anthropic error envelope before conversion. - Add regression tests for both the Codex body sanitization and error-shape coercion.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
ccproxy/plugins/codex/adapter.py |
Removes metadata (and other unsupported keys) from provider payloads sent to Codex. |
ccproxy/services/adapters/simple_converters.py |
Coerces non-Anthropic error payloads into a valid Anthropic ErrorResponse prior to conversion. |
tests/plugins/codex/unit/test_adapter.py |
Adds a regression test ensuring metadata is stripped by the Codex adapter sanitization step. |
tests/unit/services/adapters/test_simple_converters.py |
Adds regression tests for Anthropic-native and non-Anthropic error payload shapes. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if not isinstance(data, dict) or "error" not in data: | ||
| message = "" | ||
| if isinstance(data, dict): | ||
| for key in ("detail", "message", "error_description"): | ||
| value = data.get(key) |
There was a problem hiding this comment.
The coercion guard only runs when the payload is not a dict or is missing the top-level error key. If an upstream returns { "error": ... } in a non-Anthropic shape (e.g., error is a string or missing the discriminator type), ErrorResponse.model_validate(data) below will still raise ValidationError and can still upgrade an upstream 4xx into a 5xx. Consider try/except ValidationError around model_validate and falling back to the minimal envelope on validation failure.
The OpenAI Responses -> Anthropic and OpenAI Chat -> Anthropic stream converters were emitting ContentBlockDeltaEvent with delta=TextBlock(type="text") instead of delta=TextDelta(type="text_delta"). The Pydantic model accepts either (TextBlock is a tolerated fallback), but the real Anthropic wire protocol and the Claude Code CLI's SDK parser require type="text_delta". The effect was that Claude Code CLI pointed at ccproxy's /codex endpoint received a 200 OK stream, parsed message_start/content_block_start/content_block_stop/message_stop correctly, but silently dropped every text delta — the user saw nothing. Adds two regression tests pinning the on-the-wire type to text_delta for both the Responses and Chat converters, including a check against model_dump(by_alias=True) so the serialized payload can't drift.
When Claude Code CLI targets /codex, conversations with history and
tool-use cycles were dropped or mangled, and tool streaming events did
not match the official specs. Rewrite the Anthropic -> Responses input
translation and align tool streaming with Anthropic/OpenAI specs.
- anthropic_to_openai/requests.py: translate the full message list
into Responses API input items (message / function_call /
function_call_output), preserving interleaved text and tool_use
ordering within assistant turns. Add a deterministic
_clamp_call_id so tool_use/tool_result pairs stay intact when ids
exceed OpenAI's 64-char limit. Accept LegacyCustomTool alongside
Tool in the custom-tool mapping.
- common/streams.py: emit tool_use content_block_start with empty
input {} and stream arguments via input_json_delta.partial_json,
per Anthropic streaming spec. Official SDKs ignore input attached
directly to the start event.
- openai_to_openai/streams.py: tool_call continuation chunks no
longer re-emit id/name. Per OpenAI Chat streaming spec, those
fields only appear on the first chunk for a given tool call.
- models/openai.py: FunctionCall.name is now Optional to support
the continuation chunks above.
- services/adapters/delta_utils.py: identity fields (index, type,
id, name, call_id) are overwritten instead of merged. Without
this, providers that re-send these per chunk (e.g. the Codex
Responses->Chat adapter) produced "shellshell..." /
"fc_abc_fc_abc..." and broke downstream validation.
Tests cover the full tool cycle, interleaved assistant ordering,
list-form tool_result content, pending user text after a tool
result, long call_id clamping, LegacyCustomTool acceptance, tool_use
streaming events, and delta_utils identity handling.
Summary
Test plan
Known follow-up (not in this PR)
Even with this fix, there's a separate streaming-display issue when Claude Code CLI targets `/codex`: the server emits valid Anthropic SSE chunks (`message_start`, `content_block_delta`, `message_stop`) and returns 200, but the CLI shows no visible response. Worth tracking separately — it's orthogonal to the metadata 400 this PR fixes.