fix(responses): classify streamed tool calls on the chat name and strip guardrail edits around the grammar block

The streaming bridge restored the namespace before deciding whether a tool call was a custom tool, so a namespaced function sharing a short name with a nested custom tool streamed back as a custom_tool_call. Classify on the raw chat tool name first, the way the non-streaming path already does.

The guardrail merge only stripped the namespace prefix and grammar suffix from the ends of the edited description, so a guardrail appending text after the grammar block left the block in the member description and the chat conversion appended it a second time. Strip the first occurrence of each instead.
This commit is contained in:
mateo-berri 2026-09-14 22:58:25 -07:00
parent 7ea19eccc7
commit e3152c011d
4 changed files with 105 additions and 15 deletions

View file

@ -72,7 +72,7 @@ def _function_fields(tool: Tool) -> Tool:
def _member_description(key: str, value: object, prefix: str, suffix: str) -> object:
if key != "description" or not isinstance(value, str):
return value
return value.removeprefix(prefix).removesuffix(suffix)
return value.replace(prefix, "", 1).replace(suffix, "", 1)
def _rebuilt_member(member: Tool, flattened: Tool, guardrailed: Tool, namespace_description: str) -> Tool:

View file

@ -8,6 +8,7 @@ from litellm.main import stream_chunk_builder
from litellm.responses.litellm_completion_transformation.custom_tools import (
build_tool_call_item_kwargs,
extract_custom_tool_names,
is_custom_tool_call,
serialize_tool_call_arguments,
)
from litellm.responses.litellm_completion_transformation.transformation import (
@ -166,6 +167,14 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
return tool_name, namespace
return fn_name, None
def _tool_call_item_kwargs(self, call_id: str, fn_name: str, arguments: str, status: str) -> dict[str, str]:
item_kwargs: Final = build_tool_call_item_kwargs(call_id, fn_name, arguments, status, self._custom_tool_names)
if is_custom_tool_call(fn_name, self._custom_tool_names):
return item_kwargs
tool_name, tool_namespace = self._responses_namespace_tool_call_fields(fn_name)
namespace_kwargs: Final = {"namespace": tool_namespace} if tool_namespace else {}
return {**item_kwargs, "name": tool_name, **namespace_kwargs}
def _is_reasoning_end(self, chunk):
delta: Final = chunk.choices[0].delta
@ -244,17 +253,13 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
else:
fn_name = str(getattr(fn, "name", "") or "")
fn_args_delta = serialize_tool_call_arguments(getattr(fn, "arguments", ""))
tool_name, tool_namespace = self._responses_namespace_tool_call_fields(fn_name)
output_index = self._get_or_assign_tool_output_index(call_id)
if call_id not in self._tool_args_by_call_id:
self._tool_args_by_call_id[call_id] = ""
self._sequence_number += 1
names = self._custom_tool_names
item_kwargs = build_tool_call_item_kwargs(call_id, tool_name, "", "in_progress", names)
item_kwargs = self._tool_call_item_kwargs(call_id, fn_name, "", "in_progress")
self._tool_item_id_by_call_id[call_id] = item_kwargs["id"]
if tool_namespace:
item_kwargs["namespace"] = tool_namespace
event = OutputItemAddedEvent(
type=ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED,
output_index=output_index,
@ -315,7 +320,6 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
else:
fn_name = str(getattr(fn, "name", "") or "")
fn_args = serialize_tool_call_arguments(getattr(fn, "arguments", ""))
tool_name, tool_namespace = self._responses_namespace_tool_call_fields(fn_name)
web_search_call = self._web_search_calls.get(call_id)
if web_search_call is not None:
if call_id not in self._queued_web_search_call_ids:
@ -330,11 +334,8 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
if is_new_tool_call:
self._tool_args_by_call_id[call_id] = ""
self._sequence_number += 1
names = self._custom_tool_names
item_kwargs = build_tool_call_item_kwargs(call_id, tool_name, "", "in_progress", names)
item_kwargs = self._tool_call_item_kwargs(call_id, fn_name, "", "in_progress")
self._tool_item_id_by_call_id[call_id] = item_kwargs["id"]
if tool_namespace:
item_kwargs["namespace"] = tool_namespace
event = OutputItemAddedEvent(
type=ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED,
output_index=output_index,
@ -376,11 +377,8 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
self._pending_tool_events.append(done_event)
self._sequence_number += 1
names = self._custom_tool_names
item_kwargs = build_tool_call_item_kwargs(call_id, tool_name, final_args, "completed", names)
item_kwargs = self._tool_call_item_kwargs(call_id, fn_name, final_args, "completed")
item_kwargs["id"] = self._tool_item_id_by_call_id.setdefault(call_id, item_kwargs["id"])
if tool_namespace:
item_kwargs["namespace"] = tool_namespace
item_done_event = OutputItemDoneEvent(
type=ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE,
output_index=output_index,

View file

@ -174,6 +174,21 @@ def test_custom_member_description_edit_lands_without_the_namespace_prefix_or_gr
assert list(merged) == [{"type": "namespace", "name": "shell", "description": "Shell", "tools": [guarded_member]}]
def test_text_appended_after_the_grammar_block_lands_on_the_member_without_the_block():
grammar = {"type": "grammar", "syntax": "lark", "definition": "start: X"}
custom_member = {"type": "custom", "name": "exec", "description": "Run a command", "format": grammar}
original = [{"type": "namespace", "name": "shell", "description": "Shell", "tools": [custom_member]}]
groups = _groups(original)
edited = copy.deepcopy(_flat(groups))
edited[0]["function"]["description"] = edited[0]["function"]["description"] + " [checked]"
merged = merge_guardrailed_tools(original, groups, edited)
assert merged[0]["tools"][0]["description"] == "Run a command [checked]"
reflattened = _flat(_groups(merged))
assert reflattened[0]["function"]["description"] == "Shell\n\nRun a command [checked]\n\nFormat:\n```lark\nstart: X\n```"
def test_member_extras_edited_by_the_guardrail_land_on_that_member():
original = [{"type": "namespace", "name": "ns", "tools": [_function("read")]}]
groups = _groups(original)

View file

@ -3877,6 +3877,83 @@ class TestEnsureOutputItemContentPartAdded:
assert done.item.type == "custom_tool_call"
assert done.item.input == "ls"
def test_streaming_namespaced_function_sharing_a_nested_custom_short_name_stays_a_function_call(self):
from litellm.responses.litellm_completion_transformation.custom_tools import extract_custom_tool_names
iterator = self._make_iterator()
iterator.responses_api_request = {
"tools": [
{
"type": "namespace",
"name": "alpha",
"tools": [
{
"type": "custom",
"name": "run",
"format": {"type": "grammar", "syntax": "lark", "definition": "start: /.+/"},
}
],
},
{
"type": "namespace",
"name": "beta",
"tools": [
{
"type": "function",
"name": "run",
"parameters": {"type": "object", "properties": {"job_id": {"type": "string"}}},
}
],
},
]
}
iterator._custom_tool_names = extract_custom_tool_names(iterator.responses_api_request.get("tools"))
iterator._namespace_tool_names = LiteLLMCompletionResponsesConfig.namespace_tool_name_map(
iterator.responses_api_request.get("tools")
)
function_call = {"id": "call_fn", "function": {"name": "beta__run", "arguments": '{"job_id":"42"}'}}
custom_call = {"id": "call_custom", "function": {"name": "run", "arguments": '{"content":"echo hi"}'}}
iterator._queue_tool_call_delta_events([{"index": 0, **function_call}, {"index": 1, **custom_call}])
iterator._queue_final_tool_call_done_events(
ModelResponse(
id="chatcmpl-run",
created=1,
model="us.openai.gpt-5.6",
object="chat.completion",
choices=[
Choices(
finish_reason="tool_calls",
index=0,
message=Message(
content=None,
role="assistant",
tool_calls=[
ChatCompletionMessageToolCall(
id=call["id"], type="function", function=Function(**call["function"])
)
for call in (function_call, custom_call)
],
),
)
],
)
)
items = [
event.item
for event in iterator._pending_tool_events
if event.type in ("response.output_item.added", "response.output_item.done")
]
function_items = [item for item in items if item.call_id == "call_fn"]
custom_items = [item for item in items if item.call_id == "call_custom"]
assert len(function_items) == 2 and len(custom_items) == 2
assert all((item.type, item.name, item.namespace) == ("function_call", "run", "beta") for item in function_items)
assert function_items[-1].arguments == '{"job_id":"42"}'
assert all(item.type == "custom_tool_call" and item.name == "run" for item in custom_items)
assert all(getattr(item, "namespace", None) is None for item in custom_items)
assert custom_items[-1].input == "echo hi"
def test_streaming_unqualified_namespace_tool_calls_restore_namespace(self):
"""A unique nested tool name without the namespace still maps back."""
iterator = self._make_iterator()