From be53f534d396789d93e2bc512db7e9f23b9cbfba Mon Sep 17 00:00:00 2001 From: Chai Zhenhua Date: Sun, 29 Mar 2026 14:01:21 +0800 Subject: [PATCH 1/4] fix(claude-adapter): handle tool_calls and tool_result in message conversion --- ccproxy/plugins/claude_api/adapter.py | 35 +++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/ccproxy/plugins/claude_api/adapter.py b/ccproxy/plugins/claude_api/adapter.py index b012e1c9..71499ff9 100644 --- a/ccproxy/plugins/claude_api/adapter.py +++ b/ccproxy/plugins/claude_api/adapter.py @@ -304,6 +304,7 @@ def _convert_openai_to_anthropic(self, payload: dict[str, Any]) -> dict[str, Any continue role = msg.get("role") content = msg.get("content", "") + tool_calls = msg.get("tool_calls") if role == "system": block = self._normalize_text_block(content) @@ -314,6 +315,40 @@ def _convert_openai_to_anthropic(self, payload: dict[str, Any]) -> dict[str, Any system_blocks.append(block) continue + if role == "assistant" and tool_calls: + # Convert OpenAI tool_calls to Anthropic tool_use blocks + blocks: list[dict[str, Any]] = [] + if content: + blocks.append({"type": "text", "text": str(content)}) + for tc in tool_calls: + func_info = tc.get("function", {}) + import json as _json + try: + tool_input = _json.loads(func_info.get("arguments", "{}")) + except (ValueError, TypeError): + tool_input = {} + blocks.append({ + "type": "tool_use", + "id": tc.get("id", ""), + "name": func_info.get("name", ""), + "input": tool_input, + }) + anthropic_messages.append({"role": "assistant", "content": blocks}) + continue + + if role == "tool": + # Convert OpenAI tool result to Anthropic tool_result block + tool_call_id = msg.get("tool_call_id", "") + anthropic_messages.append({ + "role": "user", + "content": [{ + "type": "tool_result", + "tool_use_id": tool_call_id, + "content": str(content) if content else "", + }], + }) + continue + block = self._normalize_text_block(content) if block is None: block = [] From 75912283d1e3d1fda3e84ce59739dc741476741d Mon Sep 17 00:00:00 2001 From: Chai Zhenhua Date: Sun, 29 Mar 2026 14:07:35 +0800 Subject: [PATCH 2/4] fix(formatters): convert full message history including tool_calls and tool results to Responses API input --- .../formatters/openai_to_openai/requests.py | 113 ++++++++++++------ 1 file changed, 79 insertions(+), 34 deletions(-) diff --git a/ccproxy/llms/formatters/openai_to_openai/requests.py b/ccproxy/llms/formatters/openai_to_openai/requests.py index 30bb7ae1..07e634c6 100644 --- a/ccproxy/llms/formatters/openai_to_openai/requests.py +++ b/ccproxy/llms/formatters/openai_to_openai/requests.py @@ -240,42 +240,87 @@ def _build_responses_payload_from_chat_request( if request.max_completion_tokens is not None: payload_data["max_output_tokens"] = int(request.max_completion_tokens) - user_text: str | None = None - for msg in reversed(request.messages): - if msg.role != "user": + # Convert ALL chat messages to Responses API input items. + # This preserves the full conversation history including tool calls and results. + input_items: list[dict[str, Any]] = [] + + for msg in request.messages or []: + role = msg.role + content = msg.content + tool_calls = getattr(msg, "tool_calls", None) + + if role in ("system", "developer"): + # System/developer messages become instructions (handled below) continue - if isinstance(msg.content, str): - 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 + + if role == "user": + text = "" + if isinstance(content, str): + text = content + elif isinstance(content, list): + texts: list[str] = [] + for block in 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.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: - user_text = " ".join(texts) - break - - if user_text: - payload_data["input"] = [ - { - "type": "message", - "role": "user", - "content": [ - {"type": "input_text", "text": user_text}, - ], - } - ] - else: - payload_data["input"] = [] + texts.append(block.text or "") + text = " ".join(texts) + if text: + input_items.append({ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": text}], + }) + + elif role == "assistant": + # Add text content if present + if content and not tool_calls: + input_items.append({ + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": str(content)}], + }) + elif content and tool_calls: + input_items.append({ + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": str(content)}], + }) + # Convert tool_calls to function_call items + if tool_calls: + for tc in tool_calls: + func_info = getattr(tc, "function", None) + if func_info is None and isinstance(tc, dict): + func_info = tc.get("function", {}) + tc_id = getattr(tc, "id", None) or (tc.get("id") if isinstance(tc, dict) else None) or "" + func_name = getattr(func_info, "name", None) or (func_info.get("name") if isinstance(func_info, dict) else None) or "" + func_args = getattr(func_info, "arguments", None) or (func_info.get("arguments") if isinstance(func_info, dict) else None) or "{}" + input_items.append({ + "type": "function_call", + "id": str(tc_id), + "call_id": str(tc_id), + "name": str(func_name), + "arguments": str(func_args), + }) + + elif role == "tool": + # Convert tool result to function_call_output + tool_call_id = getattr(msg, "tool_call_id", None) or "" + output_text = str(content) if content else "" + input_items.append({ + "type": "function_call_output", + "call_id": str(tool_call_id), + "output": output_text, + }) + + payload_data["input"] = input_items instruction_segments = _collect_chat_instruction_segments(request.messages) instructions_text = "\n\n".join( From 92c47cae4a45a751a5799cc8f7f24ffef1570038 Mon Sep 17 00:00:00 2001 From: Caddy Glow Date: Tue, 31 Mar 2026 09:40:38 +0200 Subject: [PATCH 3/4] style(formatters): satisfy lint formatting - Reformat payload builders for cleaner multiline structures - Simplify assistant content checks in request conversion --- .../formatters/openai_to_openai/requests.py | 86 ++++++++++++------- ccproxy/plugins/claude_api/adapter.py | 35 +++++--- 2 files changed, 75 insertions(+), 46 deletions(-) diff --git a/ccproxy/llms/formatters/openai_to_openai/requests.py b/ccproxy/llms/formatters/openai_to_openai/requests.py index 07e634c6..46293bb3 100644 --- a/ccproxy/llms/formatters/openai_to_openai/requests.py +++ b/ccproxy/llms/formatters/openai_to_openai/requests.py @@ -273,52 +273,74 @@ def _build_responses_payload_from_chat_request( texts.append(block.text or "") text = " ".join(texts) if text: - input_items.append({ - "type": "message", - "role": "user", - "content": [{"type": "input_text", "text": text}], - }) + input_items.append( + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": text}], + } + ) elif role == "assistant": # Add text content if present - if content and not tool_calls: - input_items.append({ - "type": "message", - "role": "assistant", - "content": [{"type": "output_text", "text": str(content)}], - }) - elif content and tool_calls: - input_items.append({ - "type": "message", - "role": "assistant", - "content": [{"type": "output_text", "text": str(content)}], - }) + if content and not tool_calls or content and tool_calls: + input_items.append( + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": str(content)}], + } + ) # Convert tool_calls to function_call items if tool_calls: for tc in tool_calls: func_info = getattr(tc, "function", None) if func_info is None and isinstance(tc, dict): func_info = tc.get("function", {}) - tc_id = getattr(tc, "id", None) or (tc.get("id") if isinstance(tc, dict) else None) or "" - func_name = getattr(func_info, "name", None) or (func_info.get("name") if isinstance(func_info, dict) else None) or "" - func_args = getattr(func_info, "arguments", None) or (func_info.get("arguments") if isinstance(func_info, dict) else None) or "{}" - input_items.append({ - "type": "function_call", - "id": str(tc_id), - "call_id": str(tc_id), - "name": str(func_name), - "arguments": str(func_args), - }) + tc_id = ( + getattr(tc, "id", None) + or (tc.get("id") if isinstance(tc, dict) else None) + or "" + ) + func_name = ( + getattr(func_info, "name", None) + or ( + func_info.get("name") + if isinstance(func_info, dict) + else None + ) + or "" + ) + func_args = ( + getattr(func_info, "arguments", None) + or ( + func_info.get("arguments") + if isinstance(func_info, dict) + else None + ) + or "{}" + ) + input_items.append( + { + "type": "function_call", + "id": str(tc_id), + "call_id": str(tc_id), + "name": str(func_name), + "arguments": str(func_args), + } + ) elif role == "tool": # Convert tool result to function_call_output tool_call_id = getattr(msg, "tool_call_id", None) or "" output_text = str(content) if content else "" - input_items.append({ - "type": "function_call_output", - "call_id": str(tool_call_id), - "output": output_text, - }) + input_items.append( + { + "type": "function_call_output", + "call_id": str(tool_call_id), + "output": output_text, + } + ) payload_data["input"] = input_items diff --git a/ccproxy/plugins/claude_api/adapter.py b/ccproxy/plugins/claude_api/adapter.py index 71499ff9..8152345b 100644 --- a/ccproxy/plugins/claude_api/adapter.py +++ b/ccproxy/plugins/claude_api/adapter.py @@ -323,30 +323,37 @@ def _convert_openai_to_anthropic(self, payload: dict[str, Any]) -> dict[str, Any for tc in tool_calls: func_info = tc.get("function", {}) import json as _json + try: tool_input = _json.loads(func_info.get("arguments", "{}")) except (ValueError, TypeError): tool_input = {} - blocks.append({ - "type": "tool_use", - "id": tc.get("id", ""), - "name": func_info.get("name", ""), - "input": tool_input, - }) + blocks.append( + { + "type": "tool_use", + "id": tc.get("id", ""), + "name": func_info.get("name", ""), + "input": tool_input, + } + ) anthropic_messages.append({"role": "assistant", "content": blocks}) continue if role == "tool": # Convert OpenAI tool result to Anthropic tool_result block tool_call_id = msg.get("tool_call_id", "") - anthropic_messages.append({ - "role": "user", - "content": [{ - "type": "tool_result", - "tool_use_id": tool_call_id, - "content": str(content) if content else "", - }], - }) + anthropic_messages.append( + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": tool_call_id, + "content": str(content) if content else "", + } + ], + } + ) continue block = self._normalize_text_block(content) From 2a4e48d852d719ac790280516644173e17613e61 Mon Sep 17 00:00:00 2001 From: Caddy Glow Date: Tue, 31 Mar 2026 10:03:09 +0200 Subject: [PATCH 4/4] fix(formatters): use tool call chunks in streams - Emit streaming tool deltas with `ToolCallChunk` metadata - Reuse shared parsing for message content and tool arguments --- .../anthropic_to_openai/_helpers.py | 30 ++++++++ .../formatters/anthropic_to_openai/streams.py | 11 +-- .../openai_to_anthropic/_helpers.py | 12 ---- .../formatters/openai_to_openai/requests.py | 71 +++---------------- .../formatters/openai_to_openai/streams.py | 15 ++-- ccproxy/llms/formatters/utils.py | 26 +++++++ ccproxy/llms/models/openai.py | 13 +++- ccproxy/plugins/claude_api/adapter.py | 10 ++- 8 files changed, 99 insertions(+), 89 deletions(-) diff --git a/ccproxy/llms/formatters/anthropic_to_openai/_helpers.py b/ccproxy/llms/formatters/anthropic_to_openai/_helpers.py index 331b1275..ec5ed695 100644 --- a/ccproxy/llms/formatters/anthropic_to_openai/_helpers.py +++ b/ccproxy/llms/formatters/anthropic_to_openai/_helpers.py @@ -42,3 +42,33 @@ def build_openai_tool_call( arguments=str(args_str), ), ) + + +def build_openai_tool_call_chunk( + *, + index: int, + tool_id: str | None, + tool_name: str | None, + tool_input: Any, + arguments: Any = None, + fallback_index: int = 0, +) -> openai_models.ToolCallChunk: + args_str = ( + arguments + if isinstance(arguments, str) and arguments + else serialize_tool_arguments(tool_input) + ) + call_id = ( + tool_id if isinstance(tool_id, str) and tool_id else f"call_{fallback_index}" + ) + name = tool_name if isinstance(tool_name, str) and tool_name else "function" + + return openai_models.ToolCallChunk( + index=index, + id=str(call_id), + type="function", + function=openai_models.FunctionCall( + name=str(name), + arguments=str(args_str), + ), + ) diff --git a/ccproxy/llms/formatters/anthropic_to_openai/streams.py b/ccproxy/llms/formatters/anthropic_to_openai/streams.py index 3aff75f0..9710dc14 100644 --- a/ccproxy/llms/formatters/anthropic_to_openai/streams.py +++ b/ccproxy/llms/formatters/anthropic_to_openai/streams.py @@ -27,7 +27,7 @@ from ccproxy.llms.models import openai as openai_models from ccproxy.llms.streaming.accumulators import ClaudeAccumulator -from ._helpers import build_openai_tool_call +from ._helpers import build_openai_tool_call_chunk from .requests import _build_responses_payload_from_anthropic_request from .responses import convert__anthropic_usage_to_openai_responses__usage @@ -88,10 +88,10 @@ def _anthropic_delta_to_text( return None -def _build_openai_tool_call( +def _build_openai_tool_call_chunk( accumulator: ClaudeAccumulator, block_index: int, -) -> openai_models.ToolCall | None: +) -> openai_models.ToolCallChunk | None: for tool_call in accumulator.get_complete_tool_calls(): if tool_call.get("index") != block_index: continue @@ -102,7 +102,8 @@ def _build_openai_tool_call( tool_name = function_payload.get("name") or tool_call.get("name") arguments = function_payload.get("arguments") - return build_openai_tool_call( + return build_openai_tool_call_chunk( + index=tool_call.get("index", block_index), tool_id=tool_call.get("id"), tool_name=tool_name, tool_input=tool_call.get("input", {}), @@ -1413,7 +1414,7 @@ async def generator() -> AsyncGenerator[ continue if block_index in emitted_tool_indices: continue - tool_call = _build_openai_tool_call(accumulator, block_index) + tool_call = _build_openai_tool_call_chunk(accumulator, block_index) if tool_call is None: continue emitted_tool_indices.add(block_index) diff --git a/ccproxy/llms/formatters/openai_to_anthropic/_helpers.py b/ccproxy/llms/formatters/openai_to_anthropic/_helpers.py index 68b2c46e..9f51c4d1 100644 --- a/ccproxy/llms/formatters/openai_to_anthropic/_helpers.py +++ b/ccproxy/llms/formatters/openai_to_anthropic/_helpers.py @@ -63,17 +63,6 @@ def _normalize_text_and_images( return text_parts, image_blocks -def _stringify_content(content: Any) -> str: - if content is None: - return "" - if isinstance(content, str): - return content - if isinstance(content, list): - text_parts, _ = _normalize_text_and_images(content) - return " ".join(text_parts) - return str(content) - - def _coerce_system_content(content: Any) -> str | None: if isinstance(content, str): return content @@ -134,7 +123,6 @@ def _build_assistant_blocks( __all__ = [ "_to_mapping", "_normalize_text_and_images", - "_stringify_content", "_coerce_system_content", "_build_user_blocks", "_build_assistant_blocks", diff --git a/ccproxy/llms/formatters/openai_to_openai/requests.py b/ccproxy/llms/formatters/openai_to_openai/requests.py index 46293bb3..7e1c1f73 100644 --- a/ccproxy/llms/formatters/openai_to_openai/requests.py +++ b/ccproxy/llms/formatters/openai_to_openai/requests.py @@ -12,6 +12,7 @@ register_request, register_request_tools, ) +from ccproxy.llms.formatters.utils import stringify_content from ccproxy.llms.models import openai as openai_models from ._helpers import ( @@ -247,31 +248,12 @@ def _build_responses_payload_from_chat_request( for msg in request.messages or []: role = msg.role content = msg.content - tool_calls = getattr(msg, "tool_calls", None) if role in ("system", "developer"): - # System/developer messages become instructions (handled below) continue if role == "user": - text = "" - if isinstance(content, str): - text = content - elif isinstance(content, list): - texts: list[str] = [] - for block in 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 "") - text = " ".join(texts) + text = stringify_content(content) if text: input_items.append( { @@ -282,8 +264,7 @@ def _build_responses_payload_from_chat_request( ) elif role == "assistant": - # Add text content if present - if content and not tool_calls or content and tool_calls: + if content: input_items.append( { "type": "message", @@ -291,54 +272,24 @@ def _build_responses_payload_from_chat_request( "content": [{"type": "output_text", "text": str(content)}], } ) - # Convert tool_calls to function_call items - if tool_calls: - for tc in tool_calls: - func_info = getattr(tc, "function", None) - if func_info is None and isinstance(tc, dict): - func_info = tc.get("function", {}) - tc_id = ( - getattr(tc, "id", None) - or (tc.get("id") if isinstance(tc, dict) else None) - or "" - ) - func_name = ( - getattr(func_info, "name", None) - or ( - func_info.get("name") - if isinstance(func_info, dict) - else None - ) - or "" - ) - func_args = ( - getattr(func_info, "arguments", None) - or ( - func_info.get("arguments") - if isinstance(func_info, dict) - else None - ) - or "{}" - ) + if msg.tool_calls: + for tc in msg.tool_calls: input_items.append( { "type": "function_call", - "id": str(tc_id), - "call_id": str(tc_id), - "name": str(func_name), - "arguments": str(func_args), + "id": tc.id, + "call_id": tc.id, + "name": tc.function.name, + "arguments": tc.function.arguments, } ) elif role == "tool": - # Convert tool result to function_call_output - tool_call_id = getattr(msg, "tool_call_id", None) or "" - output_text = str(content) if content else "" input_items.append( { "type": "function_call_output", - "call_id": str(tool_call_id), - "output": output_text, + "call_id": msg.tool_call_id or "", + "output": str(content) if content else "", } ) diff --git a/ccproxy/llms/formatters/openai_to_openai/streams.py b/ccproxy/llms/formatters/openai_to_openai/streams.py index 8a50ee2d..239439b2 100644 --- a/ccproxy/llms/formatters/openai_to_openai/streams.py +++ b/ccproxy/llms/formatters/openai_to_openai/streams.py @@ -389,7 +389,8 @@ def create_text_chunk( # Emit initial tool call chunk to surface id/name information if not state.initial_emitted: - tool_call = openai_models.ToolCall( + tool_call = openai_models.ToolCallChunk( + index=state.index, id=state.id, type="function", function=openai_models.FunctionCall( @@ -442,7 +443,8 @@ def create_text_chunk( state.name = guessed if state.initial_emitted: - tool_call = openai_models.ToolCall( + tool_call = openai_models.ToolCallChunk( + index=state.index, id=state.id, type="function", function=openai_models.FunctionCall( @@ -494,7 +496,8 @@ def create_text_chunk( if guessed: state.name = guessed - tool_call = openai_models.ToolCall( + tool_call = openai_models.ToolCallChunk( + index=state.index, id=state.id, type="function", function=openai_models.FunctionCall( @@ -586,7 +589,8 @@ def create_text_chunk( if guessed: state.name = guessed if not state.arguments_emitted: - tool_call = openai_models.ToolCall( + tool_call = openai_models.ToolCallChunk( + index=state.index, id=state.id, type="function", function=openai_models.FunctionCall( @@ -616,7 +620,8 @@ def create_text_chunk( # Emit a patch chunk if the name was never surfaced earlier if state.name and not state.name_emitted: - tool_call = openai_models.ToolCall( + tool_call = openai_models.ToolCallChunk( + index=state.index, id=state.id, type="function", function=openai_models.FunctionCall( diff --git a/ccproxy/llms/formatters/utils.py b/ccproxy/llms/formatters/utils.py index c3557c9f..2253cd1f 100644 --- a/ccproxy/llms/formatters/utils.py +++ b/ccproxy/llms/formatters/utils.py @@ -294,6 +294,31 @@ def strict_parse_tool_arguments( return {"arguments": str(arguments)} +def stringify_content(content: Any) -> str: + """Extract plain text from message content (str, list of content parts, or None).""" + if content is None: + return "" + if isinstance(content, str): + return content + if isinstance(content, list): + texts: list[str] = [] + for part in content: + if isinstance(part, dict): + if part.get("type") in {"text", "input_text"}: + t = part.get("text") + if isinstance(t, str) and t: + texts.append(t) + elif hasattr(part, "type") and getattr(part, "type", None) in { + "text", + "input_text", + }: + t = getattr(part, "text", None) + if isinstance(t, str) and t: + texts.append(t) + return " ".join(texts) + return str(content) + + __all__ = [ "UsageSnapshot", "anthropic_usage_snapshot", @@ -303,4 +328,5 @@ def strict_parse_tool_arguments( "build_obfuscation_token", "map_openai_finish_to_anthropic_stop", "strict_parse_tool_arguments", + "stringify_content", ] diff --git a/ccproxy/llms/models/openai.py b/ccproxy/llms/models/openai.py index 34b3f9ec..6eec0fa9 100644 --- a/ccproxy/llms/models/openai.py +++ b/ccproxy/llms/models/openai.py @@ -185,11 +185,22 @@ class FunctionCall(LlmBaseModel): class ToolCall(LlmBaseModel): + """Non-streaming tool call (ChatCompletionMessageToolCall).""" + id: str type: Literal["function"] = Field(default="function") function: FunctionCall +class ToolCallChunk(LlmBaseModel): + """Streaming tool call delta (ChoiceDeltaToolCall).""" + + index: int + id: str | None = None + type: Literal["function"] | None = None + function: FunctionCall | None = None + + class ChatMessage(LlmBaseModel): """ A message within a chat conversation. @@ -309,7 +320,7 @@ class ChatCompletionResponse(LlmBaseModel): class DeltaMessage(LlmBaseModel): role: Literal["assistant"] | None = None content: str | list[Any] | None = None - tool_calls: list[ToolCall] | None = None + tool_calls: list[ToolCallChunk] | None = None audio: dict[str, Any] | None = None reasoning: ResponseMessageReasoning | None = None diff --git a/ccproxy/plugins/claude_api/adapter.py b/ccproxy/plugins/claude_api/adapter.py index 8152345b..3b5cd023 100644 --- a/ccproxy/plugins/claude_api/adapter.py +++ b/ccproxy/plugins/claude_api/adapter.py @@ -12,6 +12,7 @@ DetectionServiceProtocol, TokenManagerProtocol, ) +from ccproxy.llms.formatters.utils import strict_parse_tool_arguments from ccproxy.services.adapters.http_adapter import BaseHTTPAdapter from ccproxy.utils.headers import ( extract_response_headers, @@ -322,12 +323,9 @@ def _convert_openai_to_anthropic(self, payload: dict[str, Any]) -> dict[str, Any blocks.append({"type": "text", "text": str(content)}) for tc in tool_calls: func_info = tc.get("function", {}) - import json as _json - - try: - tool_input = _json.loads(func_info.get("arguments", "{}")) - except (ValueError, TypeError): - tool_input = {} + tool_input = strict_parse_tool_arguments( + func_info.get("arguments", "{}") + ) blocks.append( { "type": "tool_use",