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
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
Handles transforming from Responses API -> LiteLLM completion (Chat Completion API)
"""

import hashlib
import json
import re
import uuid
Expand Down Expand Up @@ -117,6 +118,30 @@
ResponseTools: TypeAlias = Sequence[Mapping[str, object]] | None
ChatToolParam: TypeAlias = ChatCompletionToolParam | OpenAIMcpServerTool
NAMESPACE_DESCRIPTION_SEPARATOR: Final = "\n\n"
# Bedrock Converse and OpenAI Chat Completions reject function names longer than 64 characters.
MAX_FLATTENED_TOOL_NAME_LENGTH: Final = 64
FLATTENED_TOOL_NAME_DIGEST_LENGTH: Final = 8


def flatten_namespace_tool_name(namespace: str, tool_name: str) -> str:
"""Join a Responses namespace and tool name into one chat function name.

A name over the provider limit keeps the tool name and shortens the
namespace to a prefix plus a digest of the full name, so the model can
still tell the tools apart. A tool name too long on its own is truncated
before the digest instead. The result depends only on the inputs, so
outbound tools, replayed history, and the reverse map always agree.
"""
flattened: Final = f"{namespace}__{tool_name}"
if len(flattened) <= MAX_FLATTENED_TOOL_NAME_LENGTH:
return flattened
digest: Final = hashlib.sha256(flattened.encode()).hexdigest()[:FLATTENED_TOOL_NAME_DIGEST_LENGTH]
namespace_budget: Final = MAX_FLATTENED_TOOL_NAME_LENGTH - len(f"_{digest}__{tool_name}")
if namespace_budget > 0:
return f"{namespace[:namespace_budget]}_{digest}__{tool_name}"
return f"{tool_name[: MAX_FLATTENED_TOOL_NAME_LENGTH - FLATTENED_TOOL_NAME_DIGEST_LENGTH - 1]}_{digest}"


NAMESPACE_MEMBER_TYPES_WITH_CHAT_TOOLS: Final = frozenset({"function", "custom"})
_INCOMPLETE_REASON_BY_FINISH_REASON: Final[Mapping[str, Literal["max_output_tokens", "content_filter"]]] = (
MappingProxyType({"length": "max_output_tokens", "content_filter": "content_filter", "refusal": "content_filter"})
Expand Down Expand Up @@ -1754,7 +1779,7 @@ def _transform_responses_api_function_call_to_chat_completion_message(
id=function_call.get("call_id") or function_call.get("id") or "",
type="function",
function=ChatCompletionToolCallFunctionChunk(
name=f"{namespace}__{raw_name}" if qualify else raw_name,
name=flatten_namespace_tool_name(namespace, raw_name) if qualify else raw_name,
arguments=serialize_tool_call_arguments(raw_arguments),
),
index=0,
Expand Down Expand Up @@ -1945,7 +1970,7 @@ def _build_ns_chat_tool(
parameters if parameters and "type" in parameters else MappingProxyType({**parameters, "type": "object"})
)
tool_name: Final = str(namespace_tool.get("name") or "")
chat_tool_name: Final = f"{namespace}__{tool_name}" if nested else tool_name
chat_tool_name: Final = flatten_namespace_tool_name(namespace, tool_name) if nested else tool_name
function: Final = ChatCompletionToolParamFunctionChunk(
name=chat_tool_name,
description=description,
Expand Down Expand Up @@ -1988,7 +2013,7 @@ def _validate_namespace_name_collisions(tools: ResponseTools) -> None:
str(tool.get("name") or "") for tool in tools or () if tool.get("type") == "function"
)
flattened_namespace_names: Final = frozenset(
f"{(tool.get('name') or '')!s}__{(namespace_tool.get('name') or '')!s}"
flatten_namespace_tool_name(f"{tool.get('name') or ''!s}", f"{namespace_tool.get('name') or ''!s}")
for tool in tools or ()
if tool.get("type") == "namespace"
for namespace_tools in (tool.get("tools"),)
Expand Down Expand Up @@ -2156,7 +2181,8 @@ def namespace_tool_name_map(tools: ResponseTools) -> NamespaceNameMap:
if tool_name not in top_level_function_names and unqualified_counts[tool_name] == 1
)
qualified_entries: Final = tuple(
(f"{namespace}__{tool_name}", (namespace, tool_name)) for namespace, tool_name in namespace_entries
(flatten_namespace_tool_name(namespace, tool_name), (namespace, tool_name))
for namespace, tool_name in namespace_entries
)
return MappingProxyType(dict(qualified_entries + unambiguous_entries))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2352,6 +2352,58 @@ def test_function_call_echo_requalifies_namespace_tool_name(self):

assert messages[0]["tool_calls"][0]["function"]["name"] == "mcp__everything__get_sum"

def test_long_namespace_tool_names_fit_limit_and_round_trip(self):
"""Codex app connector tools flatten to names past the 64-character
function name limit, which Bedrock Converse rejects with a 400. Each
flattened name must fit, stay unique, keep the tool name the model reads
when it fits, match on history echo, and restore to its namespace."""
namespace: Final = "mcp__codex_apps__atlassian_rovo__legacy"
tool_names: Final = (
"atlassian_rovo_legacy_addcommenttojiraissue",
"atlassian_rovo_legacy_addworklogtojiraissue",
"x" * 60,
)
request_tools: Final = [
{
"type": "namespace",
"name": namespace,
"description": "Atlassian tools",
"tools": [
{"type": "function", "name": name, "parameters": {"type": "object", "properties": {}}}
for name in tool_names
],
}
]

chat_tools, _ = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(
tools=request_tools
)
chat_names: Final = [tool["function"]["name"] for tool in chat_tools]

assert all(len(name) <= 64 for name in chat_names), chat_names
assert len(set(chat_names)) == len(tool_names), chat_names
assert chat_names[0].endswith("__" + tool_names[0]), chat_names[0]
assert chat_names[1].endswith("__" + tool_names[1]), chat_names[1]

echoed: Final = (
LiteLLMCompletionResponsesConfig._transform_responses_api_function_call_to_chat_completion_message(
function_call={
"type": "function_call",
"name": tool_names[0],
"namespace": namespace,
"call_id": "call_long",
"arguments": "{}",
}
)
)
assert echoed[0]["tool_calls"][0]["function"]["name"] == chat_names[0]

names: Final = LiteLLMCompletionResponsesConfig.namespace_tool_name_map(request_tools)
restored: Final = [
LiteLLMCompletionResponsesConfig._restore_namespace_tool_name(name, names) for name in chat_names
]
assert restored == [(name, namespace) for name in tool_names]

def test_custom_tool_call_echo_keeps_short_name(self):
"""Custom tools stay advertised under their short name, so a namespace on
a custom_tool_call echo is routing metadata and must not be prefixed."""
Expand Down
Loading