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 30bb7ae1..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 ( @@ -240,42 +241,59 @@ 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 + + if role in ("system", "developer"): 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 - ): - 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"] = [] + + if role == "user": + text = stringify_content(content) + if text: + input_items.append( + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": text}], + } + ) + + elif role == "assistant": + if content: + input_items.append( + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": str(content)}], + } + ) + if msg.tool_calls: + for tc in msg.tool_calls: + input_items.append( + { + "type": "function_call", + "id": tc.id, + "call_id": tc.id, + "name": tc.function.name, + "arguments": tc.function.arguments, + } + ) + + elif role == "tool": + input_items.append( + { + "type": "function_call_output", + "call_id": msg.tool_call_id or "", + "output": str(content) if content else "", + } + ) + + payload_data["input"] = input_items instruction_segments = _collect_chat_instruction_segments(request.messages) instructions_text = "\n\n".join( 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 b012e1c9..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, @@ -304,6 +305,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 +316,44 @@ 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", {}) + tool_input = strict_parse_tool_arguments( + func_info.get("arguments", "{}") + ) + 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 = []