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
{