fix(proxy): translate custom tool grammar formats and tool_choice across API surfaces

Cursor's ApplyPatch is a grammar-constrained custom tool; the Responses
surface carries the grammar flat while chat completions wraps the same
fields in a grammar object, so the nested envelope from the previous
commit still 400d at OpenAI (tools[N].custom.format.grammar). Adds a
shared flat to nested format helper pair in prompt_templates/common_utils
used by the cursor messages arm and the chat-to-responses bridge, nests
flat Responses-style tool_choice objects on the cursor arm, flattens chat
custom tool_choice on the chat-to-responses bridge, and maps custom
tool_choice to function tool_choice on the responses-to-chat bridge to
match that bridge's custom-to-function tool downgrade
This commit is contained in:
Tin Chi Lo 2026-07-21 13:59:23 -07:00
parent b45c99f6c5
commit b79b01e38a
8 changed files with 282 additions and 16 deletions

View file

@ -155,17 +155,20 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
pass
def _normalize_tool_choice_for_responses_api(self, tool_choice: Any) -> Any:
"""Chat tool_choice uses function.name; Responses API expects top-level name."""
if not isinstance(tool_choice, dict) or tool_choice.get("type") != "function":
"""Chat tool_choice nests the name under function/custom; Responses API expects top-level name."""
if not isinstance(tool_choice, dict):
return tool_choice
choice_type = tool_choice.get("type")
if choice_type not in ("function", "custom"):
return tool_choice
if isinstance(tool_choice.get("name"), str) and tool_choice.get("name"):
# Return only Responses shape so stray chat ``function`` key is not sent upstream.
return {"type": "function", "name": tool_choice["name"]}
fn = tool_choice.get("function")
if isinstance(fn, dict):
fn_name = fn.get("name")
if isinstance(fn_name, str) and fn_name:
return {"type": "function", "name": fn_name}
# Return only Responses shape so stray chat ``function``/``custom`` keys are not sent upstream.
return {"type": choice_type, "name": tool_choice["name"]}
nested = tool_choice.get(choice_type)
if isinstance(nested, dict):
nested_name = nested.get("name")
if isinstance(nested_name, str) and nested_name:
return {"type": choice_type, "name": nested_name}
return tool_choice
def _handle_raw_dict_response_item(self, item: Dict[str, Any], index: int) -> Tuple[Optional[Any], int]:
@ -896,6 +899,10 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
)
)
elif tool.get("type") == "custom" and isinstance(tool.get("custom"), dict):
from litellm.litellm_core_utils.prompt_templates.common_utils import (
convert_custom_tool_format_to_responses_shape,
)
custom_payload = tool["custom"]
flat_custom: CustomToolParam = {
"type": "custom",
@ -903,8 +910,8 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
}
if custom_payload.get("description") is not None:
flat_custom["description"] = custom_payload["description"]
if custom_payload.get("format") is not None:
flat_custom["format"] = custom_payload["format"]
if isinstance(custom_payload.get("format"), dict):
flat_custom["format"] = convert_custom_tool_format_to_responses_shape(custom_payload["format"])
responses_tools.append(flat_custom)
else:
responses_tools.append(tool) # type: ignore

View file

@ -1252,6 +1252,31 @@ def is_function_call(optional_params: dict) -> bool:
return False
def convert_custom_tool_format_to_chat_shape(format_obj: dict) -> dict:
"""
Responses API grammar formats are flat ({"type": "grammar", "definition", "syntax"});
Chat Completions wraps the same fields in a "grammar" object. Text formats are
identical on both surfaces and pass through, as does anything unrecognized.
"""
if format_obj.get("type") == "grammar" and "grammar" not in format_obj:
return {
"type": "grammar",
"grammar": {k: format_obj[k] for k in ("definition", "syntax") if k in format_obj},
}
return format_obj
def convert_custom_tool_format_to_responses_shape(format_obj: dict) -> dict:
"""
Inverse of convert_custom_tool_format_to_chat_shape: unwrap the Chat Completions
"grammar" object into the flat Responses API grammar shape.
"""
grammar = format_obj.get("grammar")
if format_obj.get("type") == "grammar" and isinstance(grammar, dict):
return {"type": "grammar", **{k: grammar[k] for k in ("definition", "syntax") if k in grammar}}
return format_obj
def get_file_ids_from_messages(messages: List[AllMessageValues]) -> List[str]:
"""
Gets file ids from messages

View file

@ -29,10 +29,17 @@ _FLAT_FUNCTION_TOOL_KEYS = ("name", "description", "parameters", "strict")
def _nest_flat_chat_tool(tool: object) -> object:
from litellm.litellm_core_utils.prompt_templates.common_utils import (
convert_custom_tool_format_to_chat_shape,
)
if not isinstance(tool, dict) or "name" not in tool:
return tool
if tool.get("type") == "custom" and "custom" not in tool:
return {"type": "custom", "custom": {k: tool[k] for k in _FLAT_CUSTOM_TOOL_KEYS if k in tool}}
payload = {k: tool[k] for k in _FLAT_CUSTOM_TOOL_KEYS if k in tool}
if isinstance(payload.get("format"), dict):
payload = {**payload, "format": convert_custom_tool_format_to_chat_shape(payload["format"])}
return {"type": "custom", "custom": payload}
if tool.get("type") == "function" and "function" not in tool:
return {"type": "function", "function": {k: tool[k] for k in _FLAT_FUNCTION_TOOL_KEYS if k in tool}}
return tool
@ -42,6 +49,16 @@ def _nest_flat_chat_tools(tools: list) -> list:
return [_nest_flat_chat_tool(tool) for tool in tools]
def _nest_flat_chat_tool_choice(tool_choice: object) -> object:
if not isinstance(tool_choice, dict) or "name" not in tool_choice:
return tool_choice
if tool_choice.get("type") == "custom" and "custom" not in tool_choice:
return {"type": "custom", "custom": {"name": tool_choice["name"]}}
if tool_choice.get("type") == "function" and "function" not in tool_choice:
return {"type": "function", "function": {"name": tool_choice["name"]}}
return tool_choice
@router.post(
"/v1/responses",
dependencies=[Depends(user_api_key_auth)],
@ -391,10 +408,17 @@ async def cursor_chat_completions(
# Genuine chat completions body (Cursor sends these for models whose BYOK it
# already fixed); delegate so behavior matches /chat/completions exactly
tools = data.get("tools")
tool_choice = data.get("tool_choice")
normalized: dict = {}
if isinstance(tools, list):
nested_tools = _nest_flat_chat_tools(tools)
if nested_tools != tools:
_safe_set_request_parsed_body(request=request, parsed_body={**data, "tools": nested_tools})
normalized["tools"] = nested_tools
nested_tool_choice = _nest_flat_chat_tool_choice(tool_choice)
if nested_tool_choice != tool_choice:
normalized["tool_choice"] = nested_tool_choice
if normalized:
_safe_set_request_parsed_body(request=request, parsed_body={**data, **normalized})
return await chat_completion(
request=request,
fastapi_response=fastapi_response,

View file

@ -162,6 +162,12 @@ class LiteLLMCompletionResponsesConfig:
if function_name:
return {"type": "function", "function": {"name": function_name}}
return "required"
elif tool_choice_type == "custom":
custom = tool_choice.get("custom")
custom_name = tool_choice.get("name") or (custom.get("name") if isinstance(custom, dict) else None)
if custom_name:
return {"type": "function", "function": {"name": custom_name}}
return "required"
# Return as-is for unknown formats
return tool_choice

View file

@ -2475,6 +2475,15 @@ def test_map_optional_params_tool_choice_chat_nested_to_responses_api():
{"type": "function", "name": "foo"},
),
({"type": "required"}, {"type": "required"}),
(
{"type": "custom", "custom": {"name": "ApplyPatch"}},
{"type": "custom", "name": "ApplyPatch"},
),
(
{"type": "custom", "name": "ApplyPatch"},
{"type": "custom", "name": "ApplyPatch"},
),
({"type": "custom"}, {"type": "custom"}),
],
)
def test_normalize_tool_choice_for_responses_api(tool_choice, expected):
@ -3249,3 +3258,42 @@ def test_convert_tools_to_responses_format_flattens_custom_tool_without_optional
handler = LiteLLMResponsesTransformationHandler()
converted = handler._convert_tools_to_responses_format([{"type": "custom", "custom": {"name": "Minimal"}}])
assert converted[0] == {"type": "custom", "name": "Minimal"}
def test_convert_tools_to_responses_format_unwraps_nested_grammar_format():
from litellm.completion_extras.litellm_responses_transformation.transformation import (
LiteLLMResponsesTransformationHandler,
)
handler = LiteLLMResponsesTransformationHandler()
converted = handler._convert_tools_to_responses_format(
[
{
"type": "custom",
"custom": {
"name": "ApplyPatch",
"format": {
"type": "grammar",
"grammar": {"definition": "start: patch", "syntax": "lark"},
},
},
}
]
)
assert converted[0] == {
"type": "custom",
"name": "ApplyPatch",
"format": {"type": "grammar", "definition": "start: patch", "syntax": "lark"},
}
def test_convert_tools_to_responses_format_text_format_passes_through():
from litellm.completion_extras.litellm_responses_transformation.transformation import (
LiteLLMResponsesTransformationHandler,
)
handler = LiteLLMResponsesTransformationHandler()
converted = handler._convert_tools_to_responses_format(
[{"type": "custom", "custom": {"name": "A", "format": {"type": "text"}}}]
)
assert converted[0] == {"type": "custom", "name": "A", "format": {"type": "text"}}

View file

@ -721,3 +721,48 @@ class TestUnpackLegacyDefs:
out = unpack_legacy_defs(schema)
assert "components" not in out
assert out["properties"]["r0"]["properties"]["p0"] == {"type": "string"}
class TestCustomToolFormatShapeConversion:
def test_flat_grammar_to_chat_shape(self):
from litellm.litellm_core_utils.prompt_templates.common_utils import (
convert_custom_tool_format_to_chat_shape,
)
assert convert_custom_tool_format_to_chat_shape(
{"type": "grammar", "definition": "start: patch", "syntax": "lark"}
) == {"type": "grammar", "grammar": {"definition": "start: patch", "syntax": "lark"}}
def test_nested_grammar_to_responses_shape(self):
from litellm.litellm_core_utils.prompt_templates.common_utils import (
convert_custom_tool_format_to_responses_shape,
)
assert convert_custom_tool_format_to_responses_shape(
{"type": "grammar", "grammar": {"definition": "start: patch", "syntax": "regex"}}
) == {"type": "grammar", "definition": "start: patch", "syntax": "regex"}
def test_both_directions_are_idempotent_and_pass_text_through(self):
from litellm.litellm_core_utils.prompt_templates.common_utils import (
convert_custom_tool_format_to_chat_shape,
convert_custom_tool_format_to_responses_shape,
)
flat = {"type": "grammar", "definition": "d", "syntax": "lark"}
nested = {"type": "grammar", "grammar": {"definition": "d", "syntax": "lark"}}
text = {"type": "text"}
assert convert_custom_tool_format_to_chat_shape(nested) == nested
assert convert_custom_tool_format_to_responses_shape(flat) == flat
assert convert_custom_tool_format_to_chat_shape(text) == text
assert convert_custom_tool_format_to_responses_shape(text) == text
assert convert_custom_tool_format_to_chat_shape(convert_custom_tool_format_to_responses_shape(nested)) == nested
def test_unrecognized_formats_pass_through(self):
from litellm.litellm_core_utils.prompt_templates.common_utils import (
convert_custom_tool_format_to_chat_shape,
convert_custom_tool_format_to_responses_shape,
)
for weird in ({}, {"type": "grammar"}, {"type": "future_format", "x": 1}):
assert convert_custom_tool_format_to_chat_shape(dict(weird)) in (weird, {"type": "grammar", "grammar": {}})
assert convert_custom_tool_format_to_responses_shape(dict(weird)) == weird

View file

@ -981,9 +981,18 @@ class TestCursorMessagesArmToolNormalization:
"type": "function",
"function": {"name": "read_file", "parameters": {"type": "object"}},
},
{"type": "custom", "name": "ApplyPatch", "description": "V4A patch"},
{
"type": "custom",
"name": "ApplyPatch",
"description": "V4A patch",
"format": {
"type": "grammar",
"definition": "start: patch",
"syntax": "lark",
},
},
],
"tool_choice": "required",
"tool_choice": {"type": "custom", "name": "ApplyPatch"},
},
headers={"Authorization": "Bearer sk-1234"},
)
@ -993,8 +1002,19 @@ class TestCursorMessagesArmToolNormalization:
assert response.status_code == 200
assert seen["body"]["tools"] == [
{"type": "function", "function": {"name": "read_file", "parameters": {"type": "object"}}},
{"type": "custom", "custom": {"name": "ApplyPatch", "description": "V4A patch"}},
{
"type": "custom",
"custom": {
"name": "ApplyPatch",
"description": "V4A patch",
"format": {
"type": "grammar",
"grammar": {"definition": "start: patch", "syntax": "lark"},
},
},
},
]
assert seen["body"]["tool_choice"] == {"type": "custom", "custom": {"name": "ApplyPatch"}}
assert seen["body"]["messages"] == [{"role": "user", "content": "use ApplyPatch"}]
@pytest.mark.asyncio
@ -1030,3 +1050,73 @@ class TestCursorMessagesArmToolNormalization:
assert response.status_code == 200
assert seen["body"]["tools"] == body["tools"]
assert seen["body"]["messages"] == body["messages"]
class TestNestFlatChatToolGrammarFormat:
def test_flat_grammar_format_is_wrapped_for_chat(self):
from litellm.proxy.response_api_endpoints.endpoints import _nest_flat_chat_tools
result = _nest_flat_chat_tools(
[
{
"type": "custom",
"name": "ApplyPatch",
"description": "V4A patch",
"format": {"type": "grammar", "definition": "start: patch", "syntax": "lark"},
}
]
)
assert result == [
{
"type": "custom",
"custom": {
"name": "ApplyPatch",
"description": "V4A patch",
"format": {
"type": "grammar",
"grammar": {"definition": "start: patch", "syntax": "lark"},
},
},
}
]
def test_flat_text_format_is_copied_unchanged(self):
from litellm.proxy.response_api_endpoints.endpoints import _nest_flat_chat_tools
result = _nest_flat_chat_tools(
[{"type": "custom", "name": "A", "format": {"type": "text"}}]
)
assert result == [{"type": "custom", "custom": {"name": "A", "format": {"type": "text"}}}]
class TestNestFlatChatToolChoice:
def test_flat_custom_tool_choice_is_nested(self):
from litellm.proxy.response_api_endpoints.endpoints import _nest_flat_chat_tool_choice
assert _nest_flat_chat_tool_choice({"type": "custom", "name": "ApplyPatch"}) == {
"type": "custom",
"custom": {"name": "ApplyPatch"},
}
def test_flat_function_tool_choice_is_nested(self):
from litellm.proxy.response_api_endpoints.endpoints import _nest_flat_chat_tool_choice
assert _nest_flat_chat_tool_choice({"type": "function", "name": "f"}) == {
"type": "function",
"function": {"name": "f"},
}
def test_non_flat_tool_choice_values_pass_through(self):
from litellm.proxy.response_api_endpoints.endpoints import _nest_flat_chat_tool_choice
for unchanged in (
"auto",
"required",
None,
{"type": "custom", "custom": {"name": "x"}},
{"type": "function", "function": {"name": "f"}},
{"type": "auto"},
{"name": "typeless"},
42,
):
assert _nest_flat_chat_tool_choice(unchanged) == unchanged

View file

@ -959,6 +959,27 @@ class TestToolChoiceTransformation:
)
assert result == {"type": "function", "function": {"name": "get_weather"}}
def test_transform_tool_choice_custom_follows_function_downgrade(self):
"""
This bridge downgrades custom tools to function tools
(convert_custom_tool_to_function_tool), so a custom tool_choice must become a
function tool_choice naming the same tool or it references a tool type absent
from the converted request.
"""
flat = LiteLLMCompletionResponsesConfig._transform_tool_choice(
{"type": "custom", "name": "ApplyPatch"}
)
assert flat == {"type": "function", "function": {"name": "ApplyPatch"}}
nested = LiteLLMCompletionResponsesConfig._transform_tool_choice(
{"type": "custom", "custom": {"name": "ApplyPatch"}}
)
assert nested == {"type": "function", "function": {"name": "ApplyPatch"}}
def test_transform_tool_choice_custom_without_name_falls_back_to_required(self):
result = LiteLLMCompletionResponsesConfig._transform_tool_choice({"type": "custom"})
assert result == "required"
def test_transform_tool_choice_function_without_name_falls_back_to_required(self):
"""A function-type dict with no name still falls back to required"""
result = LiteLLMCompletionResponsesConfig._transform_tool_choice(