diff --git a/litellm/proxy/response_api_endpoints/endpoints.py b/litellm/proxy/response_api_endpoints/endpoints.py index 7b64bcda7ce..a980f85d406 100644 --- a/litellm/proxy/response_api_endpoints/endpoints.py +++ b/litellm/proxy/response_api_endpoints/endpoints.py @@ -33,14 +33,21 @@ def _nest_flat_chat_tool(tool: object) -> object: convert_custom_tool_format_to_chat_shape, ) - if not isinstance(tool, dict) or "name" not in tool: + if not isinstance(tool, dict): return tool - if tool.get("type") == "custom" and "custom" not in tool: - payload = {k: tool[k] for k in _FLAT_CUSTOM_TOOL_KEYS if k in tool} + if tool.get("type") == "custom": + if isinstance(tool.get("custom"), dict): + envelope = tool + payload = tool["custom"] + elif "name" in tool: + envelope = {"type": "custom"} + payload = {k: tool[k] for k in _FLAT_CUSTOM_TOOL_KEYS if k in tool} + else: + return 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 {**envelope, "custom": payload} + if tool.get("type") == "function" and "function" not in tool and "name" in tool: return {"type": "function", "function": {k: tool[k] for k in _FLAT_FUNCTION_TOOL_KEYS if k in tool}} return tool @@ -364,9 +371,13 @@ async def cursor_chat_completions( custom tools) to the chat/completions path while expecting chat completions responses; those are routed through the Responses API pipeline and converted back. Genuine chat completions bodies (`messages` present) are routed through the standard chat completions - pipeline, after nesting any flat Responses-style tool defs Cursor mixes into the chat - `tools` array (e.g. `{"type": "custom", "name": "ApplyPatch", ...}`) into the chat - completions shape OpenAI requires (`{"type": "custom", "custom": {...}}`). + pipeline, after normalizing each level of the `tools` array and `tool_choice` to the chat + completions shapes OpenAI requires. Cursor mixes Responses API shapes into chat bodies + per level, independently: a flat tool def (`{"type": "custom", "name": "ApplyPatch", ...}`) + gets nested under `custom`, and a flat grammar format + (`{"type": "grammar", "definition", "syntax"}`) gets wrapped as + `{"type": "grammar", "grammar": {...}}` wherever it appears, including inside tool defs + Cursor already sent pre-nested. ```bash curl -X POST http://localhost:4000/cursor/chat/completions \ diff --git a/tests/test_litellm/proxy/response_api_endpoints/test_endpoints.py b/tests/test_litellm/proxy/response_api_endpoints/test_endpoints.py index 86fafa40811..b8699d6ef8c 100644 --- a/tests/test_litellm/proxy/response_api_endpoints/test_endpoints.py +++ b/tests/test_litellm/proxy/response_api_endpoints/test_endpoints.py @@ -1052,26 +1052,56 @@ class TestCursorMessagesArmToolNormalization: assert seen["body"]["messages"] == body["messages"] -class TestNestFlatChatToolGrammarFormat: - def test_flat_grammar_format_is_wrapped_for_chat(self): +class TestNestFlatChatToolShapeMatrix: + """ + Cursor mixes Responses API shapes into chat bodies PER LEVEL, independently + (live-captured: a pre-nested custom envelope carrying a flat grammar format). + Every cell of envelope x format must land on the canonical chat shape. + """ + + FLAT_GRAMMAR = {"type": "grammar", "definition": "start: patch", "syntax": "lark"} + NESTED_GRAMMAR = {"type": "grammar", "grammar": {"definition": "start: patch", "syntax": "lark"}} + TEXT = {"type": "text"} + + @pytest.mark.parametrize("envelope", ["flat", "nested"]) + @pytest.mark.parametrize("format_shape", ["absent", "text", "flat_grammar", "nested_grammar"]) + def test_every_envelope_and_format_combination_lands_canonical(self, envelope, format_shape): 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 == [ + format_value = { + "absent": None, + "text": self.TEXT, + "flat_grammar": self.FLAT_GRAMMAR, + "nested_grammar": self.NESTED_GRAMMAR, + }[format_shape] + payload = {"name": "ApplyPatch", "description": "V4A patch"} + if format_value is not None: + payload["format"] = format_value + tool = {"type": "custom", "custom": payload} if envelope == "nested" else {"type": "custom", **payload} + + canonical_payload = {"name": "ApplyPatch", "description": "V4A patch"} + if format_shape in ("flat_grammar", "nested_grammar"): + canonical_payload["format"] = self.NESTED_GRAMMAR + elif format_shape == "text": + canonical_payload["format"] = self.TEXT + + assert _nest_flat_chat_tools([tool]) == [{"type": "custom", "custom": canonical_payload}] + + def test_nested_envelope_with_flat_grammar_matches_live_cursor_capture(self): + from litellm.proxy.response_api_endpoints.endpoints import _nest_flat_chat_tools + + cursor_tool = { + "type": "custom", + "custom": { + "name": "ApplyPatch", + "format": {"type": "grammar", "definition": "start: patch", "syntax": "lark"}, + }, + } + assert _nest_flat_chat_tools([cursor_tool]) == [ { "type": "custom", "custom": { "name": "ApplyPatch", - "description": "V4A patch", "format": { "type": "grammar", "grammar": {"definition": "start: patch", "syntax": "lark"}, @@ -1080,13 +1110,14 @@ class TestNestFlatChatToolGrammarFormat: } ] - def test_flat_text_format_is_copied_unchanged(self): + def test_canonical_nested_tool_is_returned_equal(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"}}}] + canonical = { + "type": "custom", + "custom": {"name": "A", "format": {"type": "grammar", "grammar": {"definition": "d", "syntax": "lark"}}}, + } + assert _nest_flat_chat_tools([canonical]) == [canonical] class TestNestFlatChatToolChoice: diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index bf3cc75c796..94f633c676e 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -2628,9 +2628,13 @@ export interface paths { * custom tools) to the chat/completions path while expecting chat completions responses; * those are routed through the Responses API pipeline and converted back. Genuine chat * completions bodies (`messages` present) are routed through the standard chat completions - * pipeline, after nesting any flat Responses-style tool defs Cursor mixes into the chat - * `tools` array (e.g. `{"type": "custom", "name": "ApplyPatch", ...}`) into the chat - * completions shape OpenAI requires (`{"type": "custom", "custom": {...}}`). + * pipeline, after normalizing each level of the `tools` array and `tool_choice` to the chat + * completions shapes OpenAI requires. Cursor mixes Responses API shapes into chat bodies + * per level, independently: a flat tool def (`{"type": "custom", "name": "ApplyPatch", ...}`) + * gets nested under `custom`, and a flat grammar format + * (`{"type": "grammar", "definition", "syntax"}`) gets wrapped as + * `{"type": "grammar", "grammar": {...}}` wherever it appears, including inside tool defs + * Cursor already sent pre-nested. * * ```bash * curl -X POST http://localhost:4000/cursor/chat/completions -H "Content-Type: application/json" -H "Authorization: Bearer sk-1234" -d '{