diff --git a/ccproxy/llms/formatters/anthropic_to_openai/requests.py b/ccproxy/llms/formatters/anthropic_to_openai/requests.py index 062122d0..bbb775d7 100644 --- a/ccproxy/llms/formatters/anthropic_to_openai/requests.py +++ b/ccproxy/llms/formatters/anthropic_to_openai/requests.py @@ -2,6 +2,7 @@ from __future__ import annotations +import hashlib import json from typing import Any @@ -10,6 +11,154 @@ from ccproxy.llms.models import openai as openai_models +_MAX_CALL_ID_LEN = 64 + + +def _clamp_call_id(call_id: Any) -> str | None: + """Return a call_id that fits within OpenAI's 64-char limit. + + Deterministic: the same input always yields the same output, so a + tool_use id and its matching tool_result.tool_use_id stay paired after + clamping. + """ + if not isinstance(call_id, str) or not call_id: + return None + if len(call_id) <= _MAX_CALL_ID_LEN: + return call_id + digest = hashlib.sha1(call_id.encode("utf-8")).hexdigest() + return f"call_{digest}" + + +def _block_type(block: Any) -> Any: + """Return ``block.type`` whether ``block`` is a dict or pydantic model.""" + if isinstance(block, dict): + return block.get("type") + return getattr(block, "type", None) + + +def _block_field(block: Any, name: str, default: Any = None) -> Any: + """Return ``block[name]`` / ``block.name`` whether dict or pydantic model.""" + if isinstance(block, dict): + return block.get(name, default) + return getattr(block, name, default) + + +def _stringify_tool_result_content(content: Any) -> str: + if isinstance(content, str): + return content + if isinstance(content, list): + text_parts: list[str] = [] + for part in content: + if _block_type(part) == "text": + text = _block_field(part, "text") + if isinstance(text, str): + text_parts.append(text) + continue + try: + text_parts.append(json.dumps(part, default=str)) + except Exception: + text_parts.append(str(part)) + return "".join(text_parts) + try: + return json.dumps(content, default=str) + except Exception: + return str(content) + + +def _user_message_item(text: str) -> dict[str, Any]: + return { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": text}], + } + + +def _assistant_message_item(text: str) -> dict[str, Any]: + return { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": text}], + } + + +def _function_call_item(block: Any) -> dict[str, Any]: + tool_input = _block_field(block, "input") or {} + try: + args_str = json.dumps(tool_input) + except Exception: + args_str = json.dumps({"arguments": str(tool_input)}) + return { + "type": "function_call", + "call_id": _clamp_call_id(_block_field(block, "id")), + "name": _block_field(block, "name"), + "arguments": args_str, + } + + +def _function_call_output_item(block: Any) -> dict[str, Any]: + return { + "type": "function_call_output", + "call_id": _clamp_call_id(_block_field(block, "tool_use_id")), + "output": _stringify_tool_result_content(_block_field(block, "content", "")), + } + + +def _build_responses_input_items( + messages: list[anthropic_models.Message], +) -> list[dict[str, Any]]: + """Translate Anthropic messages into Responses API input items. + + Preserves the original order of text and tool_use/tool_result blocks + inside each message. An assistant turn that interleaves text and + tool_use blocks produces interleaved ``message`` and ``function_call`` + items, matching the Responses API's expectation that tool calls appear + between the text segments that motivated them. + """ + + items: list[dict[str, Any]] = [] + for msg in messages: + content = msg.content + if isinstance(content, str): + if msg.role == "assistant": + items.append(_assistant_message_item(content)) + else: + items.append(_user_message_item(content)) + continue + + if not isinstance(content, list): + continue + + pending_text: list[str] = [] + make_message = ( + _assistant_message_item if msg.role == "assistant" else _user_message_item + ) + + for block in content: + btype = _block_type(block) + if btype == "text": + text = _block_field(block, "text") + if isinstance(text, str): + pending_text.append(text) + continue + if msg.role == "assistant" and btype == "tool_use": + if pending_text: + items.append(make_message("".join(pending_text))) + pending_text = [] + items.append(_function_call_item(block)) + continue + if msg.role == "user" and btype == "tool_result": + if pending_text: + items.append(make_message("".join(pending_text))) + pending_text = [] + items.append(_function_call_output_item(block)) + continue + + if pending_text: + items.append(make_message("".join(pending_text))) + + return items + + def _build_responses_payload_from_anthropic_request( request: anthropic_models.CreateMessageRequest, ) -> tuple[dict[str, Any], str | None]: @@ -44,47 +193,14 @@ def _build_responses_payload_from_anthropic_request( if joined: payload_data["instructions"] = joined - last_user_text: str | None = None - for msg in reversed(request.messages): - if msg.role != "user": - continue - if isinstance(msg.content, str): - last_user_text = msg.content - elif isinstance(msg.content, list): - texts: list[str] = [] - for block in msg.content: - if isinstance(block, dict): - if block.get("type") == "text" and isinstance( - block.get("text"), str - ): - texts.append(block.get("text") or "") - elif ( - getattr(block, "type", None) == "text" - and hasattr(block, "text") - and isinstance(getattr(block, "text", None), str) - ): - texts.append(block.text or "") - if texts: - last_user_text = " ".join(texts) - break - - if last_user_text: - payload_data["input"] = [ - { - "type": "message", - "role": "user", - "content": [ - {"type": "input_text", "text": last_user_text}, - ], - } - ] - else: - payload_data["input"] = [] + payload_data["input"] = _build_responses_input_items(request.messages) if request.tools: tools: list[dict[str, Any]] = [] for tool in request.tools: - if isinstance(tool, anthropic_models.Tool): + if isinstance( + tool, anthropic_models.Tool | anthropic_models.LegacyCustomTool + ): tools.append( { "type": "function", @@ -175,18 +291,18 @@ def convert__anthropic_message_to_openai_chat__request( tool_calls.append( { - "id": block.id, + "id": getattr(block, "id", None), "type": "function", "function": { - "name": block.name, + "name": getattr(block, "name", None), "arguments": args_str, }, } ) elif block_type == "text": - # Type guard for TextBlock - if hasattr(block, "text"): - text_parts.append(block.text) + btext = getattr(block, "text", None) + if isinstance(btext, str): + text_parts.append(btext) if tool_calls: assistant_msg: dict[str, Any] = { "role": "assistant", @@ -234,7 +350,7 @@ def convert__anthropic_message_to_openai_chat__request( openai_messages.append( { "role": "tool", - "tool_call_id": block.tool_use_id, + "tool_call_id": getattr(block, "tool_use_id", None), "content": result_content, } ) @@ -267,12 +383,9 @@ def convert__anthropic_message_to_openai_chat__request( else: # Pydantic models btype = getattr(block, "type", None) - if ( - btype == "text" - and hasattr(block, "text") - and isinstance(getattr(block, "text", None), str) - ): - text_accum.append(block.text or "") + btext_val = getattr(block, "text", None) + if btype == "text" and isinstance(btext_val, str): + text_accum.append(btext_val) elif btype == "image": source = getattr(block, "source", None) if ( @@ -303,7 +416,9 @@ def convert__anthropic_message_to_openai_chat__request( tools: list[dict[str, Any]] = [] if request.tools: for tool in request.tools: - if isinstance(tool, anthropic_models.Tool): + if isinstance( + tool, anthropic_models.Tool | anthropic_models.LegacyCustomTool + ): tools.append( { "type": "function", diff --git a/ccproxy/llms/formatters/common/streams.py b/ccproxy/llms/formatters/common/streams.py index cabb0547..96a58b83 100644 --- a/ccproxy/llms/formatters/common/streams.py +++ b/ccproxy/llms/formatters/common/streams.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json from collections.abc import Callable from dataclasses import dataclass, field from typing import Any @@ -227,19 +228,42 @@ def emit_anthropic_tool_use_events( *, parser: Callable[[str], dict[str, Any]] | None = None, ) -> list[anthropic_models.MessageStreamEvent]: - """Build start/stop events for a tool-use block at the given index.""" + """Build start/delta/stop events for a tool-use block at the given index. + + Per Anthropic streaming spec, tool_use.input starts empty in + content_block_start and is filled via input_json_delta events; consumers + that follow the spec (e.g. the Anthropic SDK) ignore an input attached + directly to the start event. + """ block = build_anthropic_tool_use_block( state, default_id=f"call_{state.index}", parser=parser, ) - return [ + input_payload = block.input or {} + start_block = block.model_copy(update={"input": {}}) + partial_json = json.dumps(input_payload) if input_payload else "" + + events: list[anthropic_models.MessageStreamEvent] = [ anthropic_models.ContentBlockStartEvent( - type="content_block_start", index=index, content_block=block + type="content_block_start", index=index, content_block=start_block ), - anthropic_models.ContentBlockStopEvent(type="content_block_stop", index=index), ] + if partial_json: + events.append( + anthropic_models.ContentBlockDeltaEvent( + type="content_block_delta", + index=index, + delta=anthropic_models.InputJsonDelta( + type="input_json_delta", partial_json=partial_json + ), + ) + ) + events.append( + anthropic_models.ContentBlockStopEvent(type="content_block_stop", index=index) + ) + return events __all__ = [ diff --git a/ccproxy/llms/formatters/openai_to_anthropic/streams.py b/ccproxy/llms/formatters/openai_to_anthropic/streams.py index 8a4f92fe..afbe399e 100644 --- a/ccproxy/llms/formatters/openai_to_anthropic/streams.py +++ b/ccproxy/llms/formatters/openai_to_anthropic/streams.py @@ -120,7 +120,7 @@ def _parse_tool_input(text: str) -> dict[str, Any]: yield anthropic_models.ContentBlockDeltaEvent( type="content_block_delta", index=current_index, - delta=anthropic_models.TextBlock(type="text", text=text), + delta=anthropic_models.TextDelta(type="text_delta", text=text), ) elif event_type == "response.output_text.done": if text_block_active: @@ -418,8 +418,8 @@ def _parse_tool_input(text: str) -> dict[str, Any]: yield anthropic_models.ContentBlockDeltaEvent( type="content_block_delta", index=current_index, - delta=anthropic_models.TextBlock( - type="text", text=content_text + delta=anthropic_models.TextDelta( + type="text_delta", text=content_text ), ) accumulated_content += content_text diff --git a/ccproxy/llms/formatters/openai_to_openai/streams.py b/ccproxy/llms/formatters/openai_to_openai/streams.py index 239439b2..8a435e85 100644 --- a/ccproxy/llms/formatters/openai_to_openai/streams.py +++ b/ccproxy/llms/formatters/openai_to_openai/streams.py @@ -391,7 +391,7 @@ def create_text_chunk( if not state.initial_emitted: tool_call = openai_models.ToolCallChunk( index=state.index, - id=state.id, + id=state.call_id or state.id, type="function", function=openai_models.FunctionCall( name=state.name or "", @@ -445,10 +445,8 @@ def create_text_chunk( if state.initial_emitted: tool_call = openai_models.ToolCallChunk( index=state.index, - id=state.id, type="function", function=openai_models.FunctionCall( - name=state.name or "", arguments=delta_segment, ), ) @@ -498,10 +496,8 @@ def create_text_chunk( tool_call = openai_models.ToolCallChunk( index=state.index, - id=state.id, type="function", function=openai_models.FunctionCall( - name=state.name or "", arguments=arguments, ), ) diff --git a/ccproxy/llms/models/openai.py b/ccproxy/llms/models/openai.py index 6eec0fa9..e510d0d4 100644 --- a/ccproxy/llms/models/openai.py +++ b/ccproxy/llms/models/openai.py @@ -180,7 +180,7 @@ class Tool(LlmBaseModel): class FunctionCall(LlmBaseModel): - name: str + name: str | None = None arguments: str diff --git a/ccproxy/plugins/codex/adapter.py b/ccproxy/plugins/codex/adapter.py index d86d66d4..d2a1b497 100644 --- a/ccproxy/plugins/codex/adapter.py +++ b/ccproxy/plugins/codex/adapter.py @@ -301,12 +301,16 @@ def _sanitize_provider_body(self, body_data: dict[str, Any]) -> dict[str, Any]: body_data["stream"] = True body_data["store"] = False - # Remove unsupported keys for Codex + # Remove unsupported keys for Codex. The chatgpt.com backend rejects + # "metadata" (which the anthropic.messages -> openai.responses converter + # populates from Anthropic's metadata.user_id) with + # {"detail":"Unsupported parameter: metadata"}. for key in ( "max_output_tokens", "max_completion_tokens", "max_tokens", "temperature", + "metadata", ): body_data.pop(key, None) diff --git a/ccproxy/services/adapters/delta_utils.py b/ccproxy/services/adapters/delta_utils.py index 39bef5be..0a895ed2 100644 --- a/ccproxy/services/adapters/delta_utils.py +++ b/ccproxy/services/adapters/delta_utils.py @@ -44,6 +44,21 @@ def accumulate_delta( result[key] = delta_value continue + if key in ("index", "type", "id", "name", "call_id"): + # Identity/discriminator fields: overwrite instead of merging. + # + # Per OpenAI Chat streaming spec, id/type/function.name only + # appear on the first tool_call chunk; subsequent chunks carry + # function.arguments. But the Codex Responses->Chat adapter + # re-sends these fields on every chunk, so without this branch + # the generic string-concat branch would produce + # "shellshell.../fc_abc_fc_abc_..." and break downstream + # consumers. "index" is included so that non-zero int indices + # (e.g. index=1) are preserved rather than doubled by the + # numeric-add branch. + result[key] = delta_value + continue + current_value = result[key] # Handle different data type combinations diff --git a/ccproxy/services/adapters/simple_converters.py b/ccproxy/services/adapters/simple_converters.py index e5e74e4d..58725d08 100644 --- a/ccproxy/services/adapters/simple_converters.py +++ b/ccproxy/services/adapters/simple_converters.py @@ -6,6 +6,7 @@ from __future__ import annotations +import json from collections.abc import AsyncIterator from typing import TYPE_CHECKING, Any @@ -231,8 +232,38 @@ async def convert_openai_to_anthropic_stream( async def convert_anthropic_to_openai_error(data: FormatDict) -> FormatDict: - """Convert Anthropic error to OpenAI error.""" - # Convert dict to typed model + """Convert Anthropic error to OpenAI error. + + Upstreams sometimes return non-Anthropic error shapes (e.g. the Codex + backend returns FastAPI-style ``{"detail": "..."}``). Coerce unexpected + payloads into a minimal Anthropic ErrorResponse so the format chain can + still surface the upstream status to the client instead of turning a 400 + into a 502. + """ + + 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) + if isinstance(value, str) and value: + message = value + break + if not message: + try: + message = json.dumps(data) + except (TypeError, ValueError): + message = str(data) + else: + message = str(data) + data = { + "type": "error", + "error": { + "type": "invalid_request_error", + "message": message or "upstream error", + }, + } + error = anthropic_models.ErrorResponse.model_validate(data) # Use existing formatter function diff --git a/tests/plugins/codex/unit/test_adapter.py b/tests/plugins/codex/unit/test_adapter.py index b52a5fdf..0d20a8f5 100644 --- a/tests/plugins/codex/unit/test_adapter.py +++ b/tests/plugins/codex/unit/test_adapter.py @@ -598,6 +598,24 @@ async def test_cli_headers_injection( assert result_headers["x-cli-version"] == "1.0.0" assert result_headers["x-session-id"] == "cli-session-123" + def test_sanitize_provider_body_strips_metadata( + self, adapter: CodexAdapter + ) -> None: + """Codex backend rejects metadata; ensure it is stripped (issue #51).""" + body = { + "model": "gpt-5-codex", + "input": [{"type": "message", "role": "user", "content": []}], + "metadata": {"user_id": "abc123"}, + "max_tokens": 100, + "temperature": 0.5, + } + cleaned = adapter._sanitize_provider_body(body) + assert "metadata" not in cleaned + assert "max_tokens" not in cleaned + assert "temperature" not in cleaned + assert cleaned["stream"] is True + assert cleaned["store"] is False + def test_get_instructions_default(self, adapter: CodexAdapter) -> None: """Test default instructions when no detection service data.""" instructions = adapter._get_instructions() diff --git a/tests/unit/llms/formatters/test_anthropic_to_openai_helpers.py b/tests/unit/llms/formatters/test_anthropic_to_openai_helpers.py index b712e55d..02030d23 100644 --- a/tests/unit/llms/formatters/test_anthropic_to_openai_helpers.py +++ b/tests/unit/llms/formatters/test_anthropic_to_openai_helpers.py @@ -319,3 +319,299 @@ async def test_convert__anthropic_message_to_openai_responses__request_basic() - assert resp_req.stream is True assert resp_req.instructions == "sys" assert isinstance(resp_req.input, list) and resp_req.input + + +@pytest.mark.asyncio +async def test_convert__anthropic_message_to_openai_responses__request_tool_cycle() -> ( + None +): + req = anthropic_models.CreateMessageRequest( + model="claude-3", + messages=[ + anthropic_models.Message(role="user", content="list files"), + anthropic_models.Message( + role="assistant", + content=[ + anthropic_models.TextBlock(type="text", text="running ls"), + anthropic_models.ToolUseBlock( + type="tool_use", + id="call_1", + name="shell", + input={"command": ["ls"]}, + ), + ], + ), + anthropic_models.Message( + role="user", + content=[ + anthropic_models.ToolResultBlock( + type="tool_result", + tool_use_id="call_1", + content="a.txt\nb.txt", + ) + ], + ), + ], + max_tokens=64, + ) + + out = convert__anthropic_message_to_openai_responses__request(req) + + assert isinstance(out.input, list) + types = [item.get("type") for item in out.input] + assert types == ["message", "message", "function_call", "function_call_output"] + + user_msg, assistant_msg, fn_call, fn_out = out.input + assert user_msg["role"] == "user" + assert user_msg["content"][0]["type"] == "input_text" + assert user_msg["content"][0]["text"] == "list files" + + assert assistant_msg["role"] == "assistant" + assert assistant_msg["content"][0]["type"] == "output_text" + assert assistant_msg["content"][0]["text"] == "running ls" + + assert fn_call["call_id"] == "call_1" + assert fn_call["name"] == "shell" + assert json.loads(fn_call["arguments"]) == {"command": ["ls"]} + + assert fn_out["call_id"] == "call_1" + assert fn_out["output"] == "a.txt\nb.txt" + + +@pytest.mark.asyncio +async def test_convert__anthropic_message_to_openai_responses__request_long_call_id() -> ( + None +): + long_id = "fc_" + ("0123456789abcdef" * 8) # 131 chars + assert len(long_id) > 64 + + req = anthropic_models.CreateMessageRequest( + model="claude-3", + messages=[ + anthropic_models.Message(role="user", content="run"), + anthropic_models.Message( + role="assistant", + content=[ + anthropic_models.ToolUseBlock( + type="tool_use", + id=long_id, + name="shell", + input={}, + ), + ], + ), + anthropic_models.Message( + role="user", + content=[ + anthropic_models.ToolResultBlock( + type="tool_result", + tool_use_id=long_id, + content="done", + ) + ], + ), + ], + max_tokens=64, + ) + + out = convert__anthropic_message_to_openai_responses__request(req) + assert isinstance(out.input, list) + fn_call = next(item for item in out.input if item.get("type") == "function_call") + fn_out = next( + item for item in out.input if item.get("type") == "function_call_output" + ) + assert fn_call["call_id"] == fn_out["call_id"] + assert len(fn_call["call_id"]) <= 64 + assert fn_call["call_id"] != long_id + + +@pytest.mark.asyncio +async def test_convert__anthropic_message_to_openai_responses__request_legacy_custom_tools() -> ( + None +): + req = anthropic_models.CreateMessageRequest( + model="claude-3", + messages=[anthropic_models.Message(role="user", content="run ls")], + max_tokens=64, + tools=[ + anthropic_models.LegacyCustomTool( + type="custom", + name="Bash", + description="Run a shell command", + input_schema={ + "type": "object", + "properties": {"command": {"type": "string"}}, + "required": ["command"], + }, + ) + ], + ) + + out = convert__anthropic_message_to_openai_responses__request(req) + assert out.tools is not None + assert len(out.tools) == 1 + assert out.tools[0]["type"] == "function" + assert out.tools[0]["name"] == "Bash" + + +@pytest.mark.asyncio +async def test_convert__anthropic_message_to_openai_responses__request_tool_result_mixed_content() -> ( + None +): + """tool_result with a list of text + image parts should stringify.""" + req = anthropic_models.CreateMessageRequest( + model="claude-3", + messages=[ + anthropic_models.Message(role="user", content="screenshot"), + anthropic_models.Message( + role="assistant", + content=[ + anthropic_models.ToolUseBlock( + type="tool_use", + id="call_img", + name="screenshot", + input={}, + ), + ], + ), + anthropic_models.Message( + role="user", + content=[ + anthropic_models.ToolResultBlock( + type="tool_result", + tool_use_id="call_img", + content=[ + anthropic_models.TextBlock(type="text", text="here: "), + anthropic_models.ImageBlock( + type="image", + source=anthropic_models.ImageSource( + type="base64", + media_type="image/png", + data="AAAA", + ), + ), + anthropic_models.TextBlock(type="text", text=" done"), + ], + ) + ], + ), + ], + max_tokens=64, + ) + + out = convert__anthropic_message_to_openai_responses__request(req) + assert isinstance(out.input, list) + fn_out = next( + item for item in out.input if item.get("type") == "function_call_output" + ) + assert isinstance(fn_out["output"], str) + assert fn_out["output"].startswith("here: ") + assert fn_out["output"].endswith(" done") + # The image part must be serialized (as JSON) rather than dropped. + assert "image" in fn_out["output"] + + +@pytest.mark.asyncio +async def test_convert__anthropic_message_to_openai_responses__request_pending_text_after_tool_result() -> ( + None +): + """Text following a tool_result in the same user message is flushed.""" + req = anthropic_models.CreateMessageRequest( + model="claude-3", + messages=[ + anthropic_models.Message(role="user", content="run"), + anthropic_models.Message( + role="assistant", + content=[ + anthropic_models.ToolUseBlock( + type="tool_use", + id="call_1", + name="shell", + input={"cmd": "ls"}, + ), + ], + ), + anthropic_models.Message( + role="user", + content=[ + anthropic_models.ToolResultBlock( + type="tool_result", + tool_use_id="call_1", + content="ok", + ), + anthropic_models.TextBlock(type="text", text="now list again"), + ], + ), + ], + max_tokens=64, + ) + + out = convert__anthropic_message_to_openai_responses__request(req) + assert isinstance(out.input, list) + types = [item.get("type") for item in out.input] + # user, function_call, function_call_output, user (post-result text) + assert types == ["message", "function_call", "function_call_output", "message"] + trailing = out.input[-1] + assert trailing["role"] == "user" + assert trailing["content"][0]["text"] == "now list again" + + +@pytest.mark.asyncio +async def test_convert__anthropic_message_to_openai_responses__request_assistant_interleaved_ordering() -> ( + None +): + """Assistant text/tool_use interleave must preserve original order.""" + req = anthropic_models.CreateMessageRequest( + model="claude-3", + messages=[ + anthropic_models.Message(role="user", content="do it"), + anthropic_models.Message( + role="assistant", + content=[ + anthropic_models.TextBlock(type="text", text="first, "), + anthropic_models.ToolUseBlock( + type="tool_use", + id="call_a", + name="shell", + input={"cmd": "ls"}, + ), + anthropic_models.TextBlock(type="text", text="then, "), + anthropic_models.ToolUseBlock( + type="tool_use", + id="call_b", + name="shell", + input={"cmd": "pwd"}, + ), + anthropic_models.TextBlock(type="text", text="done."), + ], + ), + ], + max_tokens=64, + ) + + out = convert__anthropic_message_to_openai_responses__request(req) + assert isinstance(out.input, list) + types = [item.get("type") for item in out.input] + assert types == [ + "message", # user: do it + "message", # assistant: first, + "function_call", # call_a + "message", # assistant: then, + "function_call", # call_b + "message", # assistant: done. + ] + + assistant_texts = [ + item["content"][0]["text"] + for item in out.input + if item.get("type") == "message" and item.get("role") == "assistant" + ] + assert assistant_texts == ["first, ", "then, ", "done."] + + fn_calls = [item for item in out.input if item.get("type") == "function_call"] + assert [fc["call_id"] for fc in fn_calls] == ["call_a", "call_b"] + assert [fc["name"] for fc in fn_calls] == ["shell", "shell"] + assert [json.loads(fc["arguments"]) for fc in fn_calls] == [ + {"cmd": "ls"}, + {"cmd": "pwd"}, + ] diff --git a/tests/unit/llms/formatters/test_openai_to_anthropic_chat_response.py b/tests/unit/llms/formatters/test_openai_to_anthropic_chat_response.py index 838eb9bb..e46ff1ca 100644 --- a/tests/unit/llms/formatters/test_openai_to_anthropic_chat_response.py +++ b/tests/unit/llms/formatters/test_openai_to_anthropic_chat_response.py @@ -288,7 +288,20 @@ async def gen() -> AsyncIterator[dict[str, Any]]: if isinstance(evt, anthropic_models.ContentBlockStartEvent) and getattr(evt.content_block, "type", None) == "tool_use" ) - assert getattr(tool_start.content_block, "input", None) == { + assert getattr(tool_start.content_block, "input", None) == {} + tool_index = tool_start.index + + import json as _json + + input_delta = next( + evt + for evt in events + if isinstance(evt, anthropic_models.ContentBlockDeltaEvent) + and evt.index == tool_index + and getattr(evt.delta, "type", None) == "input_json_delta" + ) + partial = getattr(input_delta.delta, "partial_json", "") + assert _json.loads(partial) == { "city": "Seattle", "units": "metric", } diff --git a/tests/unit/llms/formatters/test_streaming_converters_samples.py b/tests/unit/llms/formatters/test_streaming_converters_samples.py index 588da3d6..519bca91 100644 --- a/tests/unit/llms/formatters/test_streaming_converters_samples.py +++ b/tests/unit/llms/formatters/test_streaming_converters_samples.py @@ -15,6 +15,7 @@ from ccproxy.llms.formatters.context import register_request from ccproxy.llms.formatters.openai_to_anthropic import ( convert__openai_chat_to_anthropic_messages__stream, + convert__openai_responses_to_anthropic_messages__stream, ) from ccproxy.llms.models import anthropic as anthropic_models from ccproxy.llms.models import openai as openai_models @@ -124,8 +125,18 @@ async def test_openai_chat_stream_to_anthropic_sample() -> None: if isinstance(evt, anthropic_models.ContentBlockStartEvent) and getattr(evt.content_block, "type", None) == "tool_use" ) - assert getattr(tool_event.content_block, "input", None), ( - "tool input should be populated" + # Per Anthropic streaming spec, tool_use.input is empty at start and + # streamed via input_json_delta events. + assert getattr(tool_event.content_block, "input", None) == {} + input_delta = next( + evt + for evt in streamed + if isinstance(evt, anthropic_models.ContentBlockDeltaEvent) + and evt.index == tool_event.index + and getattr(evt.delta, "type", None) == "input_json_delta" + ) + assert getattr(input_delta.delta, "partial_json", ""), ( + "tool input_json_delta should carry the arguments JSON" ) message_delta = next( @@ -134,3 +145,177 @@ async def test_openai_chat_stream_to_anthropic_sample() -> None: assert message_delta.delta.stop_reason == "tool_use" register_request(None) + + +@pytest.mark.asyncio +async def test_openai_responses_stream_emits_text_delta_type() -> None: + """OpenAI Responses -> Anthropic streaming must emit ``text_delta`` on the wire. + + Regression for issue #51 follow-up: Claude Code CLI pointed at ccproxy's + /codex endpoint received 200 OK with well-structured SSE chunks but the + text never rendered, because the converter was emitting + ``ContentBlockDeltaEvent(delta=TextBlock(type="text"))`` instead of + ``TextDelta(type="text_delta")``. The Pydantic model accepts both, but the + real Anthropic wire protocol (and the CLI's parser) requires ``text_delta``. + """ + + events: list[dict[str, Any]] = [ + { + "type": "response.created", + "sequence_number": 1, + "response": { + "id": "resp_1", + "object": "response", + "model": "gpt-5-codex", + "created_at": 0, + "status": "in_progress", + "parallel_tool_calls": False, + "output": [], + }, + }, + { + "type": "response.output_item.added", + "sequence_number": 2, + "output_index": 0, + "item": { + "type": "message", + "id": "msg_1", + "status": "in_progress", + "role": "assistant", + "content": [], + }, + }, + { + "type": "response.output_text.delta", + "sequence_number": 3, + "item_id": "msg_1", + "output_index": 0, + "content_index": 0, + "delta": "Hello", + }, + { + "type": "response.output_text.delta", + "sequence_number": 4, + "item_id": "msg_1", + "output_index": 0, + "content_index": 0, + "delta": "!", + }, + { + "type": "response.output_text.done", + "sequence_number": 5, + "item_id": "msg_1", + "output_index": 0, + "content_index": 0, + "text": "Hello!", + }, + { + "type": "response.completed", + "sequence_number": 6, + "response": { + "id": "resp_1", + "object": "response", + "model": "gpt-5-codex", + "created_at": 0, + "status": "completed", + "parallel_tool_calls": False, + "output": [ + { + "type": "message", + "id": "msg_1", + "status": "completed", + "role": "assistant", + "content": [{"type": "output_text", "text": "Hello!"}], + } + ], + }, + }, + ] + + streamed: list[anthropic_models.MessageStreamEvent] = [] + async for evt in convert__openai_responses_to_anthropic_messages__stream( + _iter_events(events) + ): + streamed.append(evt) + + deltas = [ + evt + for evt in streamed + if isinstance(evt, anthropic_models.ContentBlockDeltaEvent) + ] + assert len(deltas) == 2, "expected two content_block_delta events" + for delta_evt in deltas: + assert delta_evt.delta.type == "text_delta", ( + f"delta.type must be 'text_delta' on the wire, got {delta_evt.delta.type!r}" + ) + dumped = delta_evt.model_dump(mode="json", by_alias=True) + assert dumped["delta"]["type"] == "text_delta" + + combined = "".join( + getattr(evt.delta, "text", "") for evt in deltas if hasattr(evt.delta, "text") + ) + assert combined == "Hello!" + + +@pytest.mark.asyncio +async def test_openai_chat_stream_emits_text_delta_type() -> None: + """OpenAI Chat -> Anthropic streaming must also emit ``text_delta``.""" + + events: list[dict[str, Any]] = [ + { + "id": "chatcmpl-1", + "object": "chat.completion.chunk", + "created": 0, + "model": "gpt-4", + "choices": [ + { + "index": 0, + "delta": {"role": "assistant", "content": "Hi"}, + "finish_reason": None, + } + ], + }, + { + "id": "chatcmpl-1", + "object": "chat.completion.chunk", + "created": 0, + "model": "gpt-4", + "choices": [ + { + "index": 0, + "delta": {"content": " there"}, + "finish_reason": None, + } + ], + }, + { + "id": "chatcmpl-1", + "object": "chat.completion.chunk", + "created": 0, + "model": "gpt-4", + "choices": [ + { + "index": 0, + "delta": {}, + "finish_reason": "stop", + } + ], + }, + ] + + streamed: list[anthropic_models.MessageStreamEvent] = [] + async for evt in convert__openai_chat_to_anthropic_messages__stream( + _iter_events(events) + ): + streamed.append(evt) + + deltas = [ + evt + for evt in streamed + if isinstance(evt, anthropic_models.ContentBlockDeltaEvent) + ] + assert deltas, "expected at least one content_block_delta" + for delta_evt in deltas: + assert delta_evt.delta.type == "text_delta" + dumped = delta_evt.model_dump(mode="json", by_alias=True) + assert dumped["delta"]["type"] == "text_delta" diff --git a/tests/unit/services/adapters/test_delta_utils.py b/tests/unit/services/adapters/test_delta_utils.py new file mode 100644 index 00000000..58145f67 --- /dev/null +++ b/tests/unit/services/adapters/test_delta_utils.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from typing import Any + +from ccproxy.services.adapters.delta_utils import accumulate_delta + + +def test_tool_call_type_is_not_concatenated() -> None: + """Repeated tool_call deltas with type='function' must not concatenate. + + Codex Responses->Chat streaming emits many function_call_arguments.delta + events, each carrying type='function'. If these strings are merged via + the generic string-concat branch they collapse into 'functionfunction...' + which then fails ChatCompletionChunk validation. + """ + + first = { + "choices": [ + { + "index": 0, + "delta": { + "tool_calls": [ + { + "index": 0, + "id": "call_1", + "type": "function", + "function": {"name": "shell", "arguments": "{"}, + } + ] + }, + } + ] + } + second = { + "choices": [ + { + "index": 0, + "delta": { + "tool_calls": [ + { + "index": 0, + "type": "function", + "function": {"arguments": '"cmd":"ls"}'}, + } + ] + }, + } + ] + } + + merged = accumulate_delta(first, second) + tool_call = merged["choices"][0]["delta"]["tool_calls"][0] + assert tool_call["type"] == "function" + assert tool_call["index"] == 0 + assert tool_call["function"]["arguments"] == '{"cmd":"ls"}' + + +def test_tool_call_id_and_name_are_not_concatenated() -> None: + """Tool-call id/name/call_id must not concatenate across chunks. + + The Codex Responses->Chat stream converter re-emits id/name on every + function_call_arguments delta chunk. The previous behaviour concatenated + those strings, producing a 1431-char id and 'shell' x N for the name. + """ + + def make_chunk(arg: str) -> dict[str, Any]: + return { + "choices": [ + { + "index": 0, + "delta": { + "tool_calls": [ + { + "index": 0, + "id": "call_abc", + "type": "function", + "function": { + "name": "shell", + "arguments": arg, + }, + } + ] + }, + } + ] + } + + acc = make_chunk("{") + for arg in ('"cmd"', ':"ls"', "}"): + acc = accumulate_delta(acc, make_chunk(arg)) + + tool_call = acc["choices"][0]["delta"]["tool_calls"][0] + assert tool_call["id"] == "call_abc" + assert tool_call["function"]["name"] == "shell" + assert tool_call["function"]["arguments"] == '{"cmd":"ls"}' + + +def test_call_id_is_not_concatenated() -> None: + """Responses-style function_call items carry call_id; must not concat.""" + + first = {"call_id": "call_xyz", "arguments": "{"} + second = {"call_id": "call_xyz", "arguments": '"a":1}'} + merged = accumulate_delta(first, second) + assert merged["call_id"] == "call_xyz" + assert merged["arguments"] == '{"a":1}' diff --git a/tests/unit/services/adapters/test_simple_converters.py b/tests/unit/services/adapters/test_simple_converters.py index 0583a917..08962e1a 100644 --- a/tests/unit/services/adapters/test_simple_converters.py +++ b/tests/unit/services/adapters/test_simple_converters.py @@ -3,6 +3,7 @@ import pytest from ccproxy.services.adapters.simple_converters import ( + convert_anthropic_to_openai_error, convert_anthropic_to_openai_response, convert_openai_to_anthropic_request, ) @@ -49,3 +50,36 @@ async def test_anthropic_to_openai_response_conversion(): assert "id" in result assert "choices" in result assert "usage" in result + + +@pytest.mark.asyncio +async def test_anthropic_to_openai_error_passes_through_anthropic_shape(): + """A well-formed Anthropic error payload is converted normally.""" + anthropic_error = { + "type": "error", + "error": {"type": "invalid_request_error", "message": "bad things"}, + } + result = await convert_anthropic_to_openai_error(anthropic_error) + assert isinstance(result, dict) + + +@pytest.mark.asyncio +async def test_anthropic_to_openai_error_coerces_fastapi_detail_shape(): + """Codex/FastAPI-style ``{"detail": "..."}`` must not explode (issue #51). + + Before this fix the converter raised ``ValidationError`` on + ``ErrorResponse.model_validate({"detail": ...})``, which turned upstream + 400s into 502s in the codex plugin error path. + """ + result = await convert_anthropic_to_openai_error( + {"detail": "Unsupported parameter: metadata"} + ) + assert isinstance(result, dict) + assert "Unsupported parameter: metadata" in str(result) + + +@pytest.mark.asyncio +async def test_anthropic_to_openai_error_coerces_unknown_shape(): + """Arbitrary dict payloads are wrapped into an Anthropic error envelope.""" + result = await convert_anthropic_to_openai_error({"foo": "bar"}) + assert isinstance(result, dict)