Skip to content
Open
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
35 changes: 24 additions & 11 deletions ccproxy/llms/formatters/anthropic_to_openai/streams.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
ObfuscationTokenFactory,
ToolCallState,
ensure_identifier,
ensure_responses_function_call_identifiers,
)
from ccproxy.llms.formatters.constants import ANTHROPIC_TO_OPENAI_FINISH_REASON
from ccproxy.llms.formatters.context import (
Expand Down Expand Up @@ -558,6 +559,18 @@ def ensure_tool_state(block_index: int) -> ToolCallState:
next_output_index += 1
return state

def normalize_tool_state_identifiers(
state: ToolCallState,
) -> tuple[str, str]:
item_id, call_id = ensure_responses_function_call_identifiers(
item_id=state.item_id,
call_id=state.call_id,
fallback_index=state.index,
)
state.item_id = item_id
state.call_id = call_id
return item_id, call_id

def emit_tool_item_added(
block_index: int, state: ToolCallState
) -> list[openai_models.StreamEventType]:
Expand All @@ -574,8 +587,7 @@ def emit_tool_item_added(
if not state.call_id:
state.call_id = tool_entry.get("id")

item_id = state.item_id or state.call_id or f"call_{state.index}"
state.item_id = item_id
item_id, call_id = normalize_tool_state_identifiers(state)

name = state.name or "function"

Expand All @@ -592,7 +604,7 @@ def emit_tool_item_added(
status="in_progress",
name=str(name),
arguments="",
call_id=state.call_id,
call_id=call_id,
),
)
)
Expand All @@ -606,7 +618,7 @@ def emit_tool_arguments_delta(
sequence_counter += 1
event_sequence = sequence_counter
state.add_arguments_part(delta_text)
item_identifier = str(state.item_id or f"call_{state.index}")
item_identifier, _ = normalize_tool_state_identifiers(state)
return openai_models.ResponseFunctionCallArgumentsDeltaEvent(
type="response.function_call_arguments.delta",
sequence_number=event_sequence,
Expand All @@ -631,8 +643,7 @@ def emit_tool_finalize(
if not state.item_id:
state.item_id = tool_entry.get("id")

item_id = state.item_id or state.call_id or f"call_{state.index}"
state.item_id = item_id
item_id, call_id = normalize_tool_state_identifiers(state)
name = state.name or "function"

args_str = "".join(state.arguments_parts)
Expand Down Expand Up @@ -674,7 +685,7 @@ def emit_tool_finalize(
status="completed",
name=str(name),
arguments=args_str,
call_id=state.call_id,
call_id=call_id,
),
)
)
Expand Down Expand Up @@ -1103,6 +1114,7 @@ def make_response_object(
state.call_id = tool_entry.get("id")
if not state.item_id:
state.item_id = state.call_id or f"call_{state.index}"
item_id, call_id = normalize_tool_state_identifiers(state)

final_args = state.final_arguments
if final_args is None:
Expand All @@ -1123,10 +1135,10 @@ def make_response_object(
state.output_index,
openai_models.FunctionCallOutput(
type="function_call",
id=state.item_id,
id=item_id,
status="completed",
name=state.name,
call_id=state.call_id,
call_id=call_id,
arguments=final_args,
),
)
Expand Down Expand Up @@ -1254,6 +1266,7 @@ def make_response_object(
state.call_id = tool_entry.get("id")
if not state.item_id:
state.item_id = state.call_id or f"call_{state.index}"
item_id, call_id = normalize_tool_state_identifiers(state)
final_args = state.final_arguments
if final_args is None:
combined = "".join(state.arguments_parts)
Expand All @@ -1270,10 +1283,10 @@ def make_response_object(
state.output_index,
openai_models.FunctionCallOutput(
type="function_call",
id=state.item_id,
id=item_id,
status="completed",
name=state.name,
call_id=state.call_id,
call_id=call_id,
arguments=final_args,
),
)
Expand Down
11 changes: 10 additions & 1 deletion ccproxy/llms/formatters/common/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
"""Shared helpers used by formatter adapters."""

from .identifiers import ensure_identifier, normalize_suffix
from .identifiers import (
ensure_identifier,
ensure_responses_function_call_identifiers,
normalize_responses_function_call_ids,
normalize_responses_sse_event_bytes,
normalize_suffix,
)
from .streams import (
IndexedToolCallTracker,
ObfuscationTokenFactory,
Expand Down Expand Up @@ -29,6 +35,9 @@

__all__ = [
"ensure_identifier",
"ensure_responses_function_call_identifiers",
"normalize_responses_function_call_ids",
"normalize_responses_sse_event_bytes",
"normalize_suffix",
"THINKING_PATTERN",
"THINKING_OPEN_PATTERN",
Expand Down
152 changes: 151 additions & 1 deletion ccproxy/llms/formatters/common/identifiers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,18 @@

from __future__ import annotations

import json
import re
import uuid
from typing import Any, TypeVar


_SAFE_ID_CHARS = re.compile(r"[^A-Za-z0-9_-]+")
_FUNCTION_ARGUMENT_EVENT_TYPES = {
"response.function_call_arguments.delta",
"response.function_call_arguments.done",
}
_Payload = TypeVar("_Payload")


def normalize_suffix(identifier: str) -> str:
Expand Down Expand Up @@ -45,4 +56,143 @@ def ensure_identifier(prefix: str, existing: str | None = None) -> tuple[str, st
return f"{prefix}_{suffix}", suffix


__all__ = ["ensure_identifier", "normalize_suffix"]
def _safe_identifier_suffix(identifier: str | None, fallback: str) -> str:
"""Return a compact suffix suitable for generated Responses identifiers."""

if isinstance(identifier, str) and identifier:
suffix = normalize_suffix(identifier)
suffix = _SAFE_ID_CHARS.sub("_", suffix).strip("_")
if suffix:
return suffix
return fallback


def ensure_responses_function_call_identifiers(
*,
item_id: str | None,
call_id: str | None,
fallback_index: int | str = 0,
) -> tuple[str, str]:
"""Return OpenAI Responses-compatible function-call item and call IDs.

Responses function-call output items use an ``fc_*`` item id. The model call
correlation id remains a distinct ``call_*`` value and is reused by
``function_call_output`` input items on subsequent turns.
"""

fallback = str(fallback_index)
item_suffix = _safe_identifier_suffix(item_id or call_id, fallback)
call_suffix = _safe_identifier_suffix(call_id or item_id, fallback)

normalized_item_id = (
item_id if isinstance(item_id, str) and item_id.startswith("fc_") else None
)
if normalized_item_id is None:
normalized_item_id = f"fc_{item_suffix}"

normalized_call_id = (
call_id if isinstance(call_id, str) and call_id.startswith("call_") else None
)
if normalized_call_id is None:
normalized_call_id = f"call_{call_suffix}"

return normalized_item_id, normalized_call_id


def normalize_responses_function_call_ids(payload: _Payload) -> _Payload:
"""Normalize Responses function-call ids in a JSON-like payload.

This intentionally skips ``function_call_output`` entries; they are input
items carrying user code output and should keep their own item identifiers.
"""

if isinstance(payload, list):
for item in payload:
normalize_responses_function_call_ids(item)
return payload

if not isinstance(payload, dict):
return payload

payload_type = payload.get("type")
fallback_value = payload.get("output_index", payload.get("index", 0))
fallback_index = fallback_value if isinstance(fallback_value, int | str) else 0

if payload_type == "function_call":
item_id, call_id = ensure_responses_function_call_identifiers(
item_id=payload.get("id") if isinstance(payload.get("id"), str) else None,
call_id=payload.get("call_id")
if isinstance(payload.get("call_id"), str)
else None,
fallback_index=fallback_index,
)
payload["id"] = item_id
payload["call_id"] = call_id

elif (
isinstance(payload_type, str) and payload_type in _FUNCTION_ARGUMENT_EVENT_TYPES
):
item_id, call_id = ensure_responses_function_call_identifiers(
item_id=payload.get("item_id")
if isinstance(payload.get("item_id"), str)
else None,
call_id=payload.get("call_id")
if isinstance(payload.get("call_id"), str)
else None,
fallback_index=fallback_index,
)
payload["item_id"] = item_id
if isinstance(payload.get("call_id"), str):
payload["call_id"] = call_id

for value in payload.values():
normalize_responses_function_call_ids(value)

return payload


def normalize_responses_sse_event_bytes(event_data: bytes) -> bytes:
"""Normalize a complete SSE event carrying a Responses JSON payload."""

try:
text = event_data.decode("utf-8")
except UnicodeDecodeError:
return event_data

lines = text.splitlines()
passthrough_lines: list[str] = []
data_lines: list[str] = []
for line in lines:
if line.startswith("data:"):
data_value = line[5:]
if data_value.startswith(" "):
data_value = data_value[1:]
data_lines.append(data_value)
elif line:
passthrough_lines.append(line)

if not data_lines:
return event_data

data_payload = "\n".join(data_lines)
if data_payload.strip() == "[DONE]":
return event_data

try:
parsed = json.loads(data_payload)
except json.JSONDecodeError:
return event_data

normalized = normalize_responses_function_call_ids(parsed)
compact = json.dumps(normalized, ensure_ascii=False, separators=(",", ":"))
normalized_lines = [*passthrough_lines, f"data: {compact}", ""]
return ("\n".join(normalized_lines) + "\n").encode("utf-8")


__all__ = [
"ensure_identifier",
"ensure_responses_function_call_identifiers",
"normalize_responses_function_call_ids",
"normalize_responses_sse_event_bytes",
"normalize_suffix",
]
11 changes: 9 additions & 2 deletions ccproxy/llms/formatters/openai_to_openai/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
ThinkingSegment,
convert_openai_completion_usage_to_responses_usage,
convert_openai_responses_usage_to_completion_usage,
ensure_responses_function_call_identifiers,
merge_thinking_segments,
)
from ccproxy.llms.formatters.context import get_openai_thinking_xml
Expand Down Expand Up @@ -551,13 +552,19 @@ def flush_message() -> None:
arguments_value: str | dict[str, Any] | None = arguments
else:
arguments_value = str(arguments) if arguments is not None else None
source_call_id = getattr(tool_call, "id", None)
item_id, call_id = ensure_responses_function_call_identifiers(
item_id=source_call_id if isinstance(source_call_id, str) else None,
call_id=source_call_id if isinstance(source_call_id, str) else None,
fallback_index=idx,
)
outputs.append(
openai_models.FunctionCallOutput(
type="function_call",
id=getattr(tool_call, "id", f"call_{idx}"),
id=item_id,
status="completed",
name=name,
call_id=getattr(tool_call, "id", None),
call_id=call_id,
arguments=arguments_value,
)
)
Expand Down
Loading
Loading