Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
215 changes: 165 additions & 50 deletions ccproxy/llms/formatters/anthropic_to_openai/requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import hashlib
import json
from typing import Any

Expand All @@ -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]:
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
}
)
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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",
Expand Down
32 changes: 28 additions & 4 deletions ccproxy/llms/formatters/common/streams.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import json
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
Expand Down Expand Up @@ -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__ = [
Expand Down
6 changes: 3 additions & 3 deletions ccproxy/llms/formatters/openai_to_anthropic/streams.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
6 changes: 1 addition & 5 deletions ccproxy/llms/formatters/openai_to_openai/streams.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "",
Expand Down Expand Up @@ -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,
),
)
Comment on lines 445 to 452
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tool-call continuation chunks still set type="function". Per the OpenAI Chat streaming spec (and as noted in the PR description), id/type/function.name should only be present on the first chunk for a given tool call; subsequent chunks should generally include only function.arguments (plus index). Consider omitting type on continuation chunks here to avoid strict client parsers rejecting the stream.

Copilot uses AI. Check for mistakes.
Expand Down Expand Up @@ -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,
),
)
Expand Down
2 changes: 1 addition & 1 deletion ccproxy/llms/models/openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ class Tool(LlmBaseModel):


class FunctionCall(LlmBaseModel):
name: str
name: str | None = None
arguments: str


Expand Down
Loading
Loading