Normalize Responses tool schemas

Ensure function parameter schemas include an explicit properties object before Responses requests are dispatched through LiteLLM. Keep prompt/MCP permissive schemas compatible with stricter OpenAI-compatible chat validators and cover chat, legacy function, and native Responses tool inputs with regressions.

Alessandro committed Jun 26, 2026 at 14:48 UTC 272a0d8dfab655fc86fabb7c7e72802f4d1cfe8e
6 files changed +139 -11
helpers/litellm_transport.py
+31 -9
@@ -640,13 +640,11 @@ class ResponsesTransport:
640 response_builtin_tools: Any = None,
641 ) -> list[Any]:
642 merged: list[Any] = []
643 - if isinstance(tools, list):
644 - merged.extend(tools)
645 - elif tools:
646 - merged.append(tools)
647 -
648 - for source in (response_function_tools, response_builtin_tools):
649 - for tool in _as_list(source):
643 + for source in (tools, response_function_tools, response_builtin_tools):
644 + source_tools = (
645 + source if isinstance(source, list) else [source] if source else []
646 + )
647 + for tool in source_tools:
648 normalized = cls.normalize_response_tool(tool)
649 if normalized:
650 merged.append(normalized)
@@ -658,7 +656,12 @@ class ResponsesTransport:
656 tool = {"type": tool}
657 if not isinstance(tool, dict):
658 return None
661 - return dict(tool)
659 + normalized = dict(tool)
660 + if normalized.get("type") == "function":
661 + normalized["parameters"] = _normalize_function_parameters(
662 + normalized.get("parameters")
663 + )
664 + return normalized
665
666 @staticmethod
667 def prepare_prompt_caching(
@@ -800,7 +803,9 @@ class ResponsesTransport:
803 "type": "function",
804 "name": function.get("name", ""),
805 "description": function.get("description", ""),
803 - "parameters": function.get("parameters", {}),
806 + "parameters": _normalize_function_parameters(
807 + function.get("parameters")
808 + ),
809 }
810 if "strict" in function:
811 response_tool["strict"] = function["strict"]
@@ -1206,6 +1211,23 @@ def _response_tool_type(tool: Any) -> str:
1211 return ""
1212
1213
1214 +def _normalize_function_parameters(parameters: Any) -> dict[str, Any]:
1215 + if not isinstance(parameters, dict):
1216 + return _permissive_function_parameters()
1217 +
1218 + normalized = dict(parameters)
1219 + normalized.setdefault("type", "object")
1220 + if normalized.get("type") == "object" and not isinstance(
1221 + normalized.get("properties"), dict
1222 + ):
1223 + normalized["properties"] = {}
1224 + return normalized or _permissive_function_parameters()
1225 +
1226 +
1227 +def _permissive_function_parameters() -> dict[str, Any]:
1228 + return {"type": "object", "properties": {}, "additionalProperties": True}
1229 +
1230 +
1231 def apply_chat_prompt_cache_markers(
1232 messages: list[dict[str, Any]],
1233 *,
helpers/litellm_transport.py.dox.md
+1
@@ -25,6 +25,7 @@
25 - Keep provider selection and provider-specific defaults outside this helper; callers pass a resolved LiteLLM model name and kwargs.
26 - Strip Agent Zero internal kwargs before sending requests to LiteLLM.
27 - Do not send orphan tool controls when no tools are present; strict OpenAI-compatible servers can reject empty `tools` arrays.
28 +- Normalize function tool parameter schemas with an explicit object `properties` field before Responses requests so OpenAI-compatible chat backends reached through LiteLLM can validate them.
29 - Prefer Responses API when configured, but fallback to Chat Completions when the provider does not support Responses.
30 - Fall back to Chat Completions when a Responses request is rejected before any output by an endpoint-specific or shape-specific Bad Request indicating the provider cannot parse Responses payloads.
31 - Preserve provider-state metadata when Responses API calls succeed, and fall back to local replay when provider state is unsupported.
helpers/responses_tools.py
+5 -1
@@ -217,13 +217,17 @@ def _schema_from_any(schema: Any) -> dict[str, Any]:
217 if isinstance(schema, dict):
218 normalized = dict(schema)
219 normalized.setdefault("type", "object")
220 + if normalized.get("type") == "object" and not isinstance(
221 + normalized.get("properties"), dict
222 + ):
223 + normalized["properties"] = {}
224 normalized.setdefault("additionalProperties", True)
225 return normalized
226 return _permissive_schema()
227
228
229 def _permissive_schema() -> dict[str, Any]:
226 - return {"type": "object", "additionalProperties": True}
230 + return {"type": "object", "properties": {}, "additionalProperties": True}
231
232
233 def _balanced_json_object(text: str) -> str:
helpers/responses_tools.py.dox.md
+1
@@ -14,6 +14,7 @@
14
15 - Build local function tools from enabled `agent.system.tool.*.md` prompt files.
16 - Local prompt-derived function names prefer explicit `"tool_name"` examples, then the first prompt heading, and only fall back to the prompt filename when the prompt declares no callable name.
17 +- Function parameter schemas are object schemas with an explicit `properties` object so OpenAI-compatible servers that validate chat-style tool payloads accept permissive tools.
18 - Preserve original Agent Zero tool names through the native Responses name map.
19 - Keep MCP tool schemas merged after local prompt-derived tools.
20 - Connector remote tools are advertised only when `_a0_connector` runtime metadata says the matching connected CLI capability is currently available.
tests/test_responses_tools.py
+45
@@ -101,3 +101,48 @@ def test_responses_function_tools_use_prompt_declared_names(monkeypatch, tmp_pat
101 assert name_map["call_subordinate"] == "call_subordinate"
102 assert name_map["behaviour_adjustment"] == "behaviour_adjustment"
103 assert name_map["filename_only"] == "filename_only"
104 + assert all(isinstance(tool["parameters"].get("properties"), dict) for tool in tools)
105 +
106 +
107 +def test_responses_function_tools_add_empty_properties_to_mcp_schemas(
108 + monkeypatch,
109 + tmp_path,
110 +):
111 + prompt_root = tmp_path / "prompts"
112 + prompt_root.mkdir()
113 +
114 + monkeypatch.setattr(
115 + responses_tools.subagents,
116 + "get_paths",
117 + lambda *args, **kwargs: [str(prompt_root)],
118 + )
119 + monkeypatch.setattr(
120 + responses_tools,
121 + "_mcp_tools",
122 + lambda agent: [
123 + (
124 + "remote_noop",
125 + {
126 + "description": "Remote noop",
127 + "input_schema": {"type": "object"},
128 + },
129 + )
130 + ],
131 + )
132 +
133 + tools, _name_map = responses_tools.build_responses_function_tools(
134 + FakeAgent(prompt_root)
135 + )
136 +
137 + assert tools == [
138 + {
139 + "type": "function",
140 + "name": "remote_noop",
141 + "description": "Remote noop",
142 + "parameters": {
143 + "type": "object",
144 + "properties": {},
145 + "additionalProperties": True,
146 + },
147 + }
148 + ]
tests/test_stream_tool_early_stop.py
+56 -1
@@ -780,7 +780,7 @@ def test_responses_request_translates_messages_and_params():
780 "type": "function",
781 "name": "lookup",
782 "description": "Search",
783 - "parameters": {"type": "object"},
783 + "parameters": {"type": "object", "properties": {}},
784 "strict": True,
785 }
786 ]
@@ -839,6 +839,61 @@ def test_responses_request_normalizes_reasoning_and_orphan_tool_choice():
839 assert "reasoning" not in request
840
841
842 +def test_responses_request_normalizes_function_tool_parameter_shapes():
843 + request = litellm_transport.ResponsesTransport.from_chat(
844 + [],
845 + {
846 + "functions": [
847 + {
848 + "name": "legacy_noop",
849 + "description": "Legacy function",
850 + "parameters": {},
851 + }
852 + ],
853 + },
854 + )
855 +
856 + assert request["tools"] == [
857 + {
858 + "type": "function",
859 + "name": "legacy_noop",
860 + "description": "Legacy function",
861 + "parameters": {
862 + "type": "object",
863 + "properties": {},
864 + },
865 + }
866 + ]
867 +
868 + request = litellm_transport.ResponsesTransport.from_chat(
869 + [],
870 + {
871 + "a0_responses_function_tools": [
872 + {
873 + "type": "function",
874 + "name": "native_noop",
875 + "description": "Native function",
876 + "parameters": {"type": "object"},
877 + }
878 + ],
879 + "responses_builtin_tools": [{"type": "web_search"}],
880 + },
881 + )
882 +
883 + assert request["tools"] == [
884 + {
885 + "type": "function",
886 + "name": "native_noop",
887 + "description": "Native function",
888 + "parameters": {
889 + "type": "object",
890 + "properties": {},
891 + },
892 + },
893 + {"type": "web_search"},
894 + ]
895 +
896 +
897 def test_chat_completions_kwargs_omit_empty_tools():
898 kwargs = litellm_transport.ChatCompletionsTransport.prepare_kwargs(
899 {