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.
This commit is contained in:
Alessandro 2026-06-26 14:48:41 +02:00
parent a4b2848172
commit 272a0d8dfa
6 changed files with 139 additions and 11 deletions

View file

@ -640,13 +640,11 @@ class ResponsesTransport:
response_builtin_tools: Any = None, response_builtin_tools: Any = None,
) -> list[Any]: ) -> list[Any]:
merged: list[Any] = [] merged: list[Any] = []
if isinstance(tools, list): for source in (tools, response_function_tools, response_builtin_tools):
merged.extend(tools) source_tools = (
elif tools: source if isinstance(source, list) else [source] if source else []
merged.append(tools) )
for tool in source_tools:
for source in (response_function_tools, response_builtin_tools):
for tool in _as_list(source):
normalized = cls.normalize_response_tool(tool) normalized = cls.normalize_response_tool(tool)
if normalized: if normalized:
merged.append(normalized) merged.append(normalized)
@ -658,7 +656,12 @@ class ResponsesTransport:
tool = {"type": tool} tool = {"type": tool}
if not isinstance(tool, dict): if not isinstance(tool, dict):
return None return None
return dict(tool) normalized = dict(tool)
if normalized.get("type") == "function":
normalized["parameters"] = _normalize_function_parameters(
normalized.get("parameters")
)
return normalized
@staticmethod @staticmethod
def prepare_prompt_caching( def prepare_prompt_caching(
@ -800,7 +803,9 @@ class ResponsesTransport:
"type": "function", "type": "function",
"name": function.get("name", ""), "name": function.get("name", ""),
"description": function.get("description", ""), "description": function.get("description", ""),
"parameters": function.get("parameters", {}), "parameters": _normalize_function_parameters(
function.get("parameters")
),
} }
if "strict" in function: if "strict" in function:
response_tool["strict"] = function["strict"] response_tool["strict"] = function["strict"]
@ -1206,6 +1211,23 @@ def _response_tool_type(tool: Any) -> str:
return "" return ""
def _normalize_function_parameters(parameters: Any) -> dict[str, Any]:
if not isinstance(parameters, dict):
return _permissive_function_parameters()
normalized = dict(parameters)
normalized.setdefault("type", "object")
if normalized.get("type") == "object" and not isinstance(
normalized.get("properties"), dict
):
normalized["properties"] = {}
return normalized or _permissive_function_parameters()
def _permissive_function_parameters() -> dict[str, Any]:
return {"type": "object", "properties": {}, "additionalProperties": True}
def apply_chat_prompt_cache_markers( def apply_chat_prompt_cache_markers(
messages: list[dict[str, Any]], messages: list[dict[str, Any]],
*, *,

View file

@ -25,6 +25,7 @@
- Keep provider selection and provider-specific defaults outside this helper; callers pass a resolved LiteLLM model name and kwargs. - Keep provider selection and provider-specific defaults outside this helper; callers pass a resolved LiteLLM model name and kwargs.
- Strip Agent Zero internal kwargs before sending requests to LiteLLM. - Strip Agent Zero internal kwargs before sending requests to LiteLLM.
- Do not send orphan tool controls when no tools are present; strict OpenAI-compatible servers can reject empty `tools` arrays. - Do not send orphan tool controls when no tools are present; strict OpenAI-compatible servers can reject empty `tools` arrays.
- 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.
- Prefer Responses API when configured, but fallback to Chat Completions when the provider does not support Responses. - Prefer Responses API when configured, but fallback to Chat Completions when the provider does not support Responses.
- 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. - 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.
- Preserve provider-state metadata when Responses API calls succeed, and fall back to local replay when provider state is unsupported. - Preserve provider-state metadata when Responses API calls succeed, and fall back to local replay when provider state is unsupported.

View file

@ -217,13 +217,17 @@ def _schema_from_any(schema: Any) -> dict[str, Any]:
if isinstance(schema, dict): if isinstance(schema, dict):
normalized = dict(schema) normalized = dict(schema)
normalized.setdefault("type", "object") normalized.setdefault("type", "object")
if normalized.get("type") == "object" and not isinstance(
normalized.get("properties"), dict
):
normalized["properties"] = {}
normalized.setdefault("additionalProperties", True) normalized.setdefault("additionalProperties", True)
return normalized return normalized
return _permissive_schema() return _permissive_schema()
def _permissive_schema() -> dict[str, Any]: def _permissive_schema() -> dict[str, Any]:
return {"type": "object", "additionalProperties": True} return {"type": "object", "properties": {}, "additionalProperties": True}
def _balanced_json_object(text: str) -> str: def _balanced_json_object(text: str) -> str:

View file

@ -14,6 +14,7 @@
- Build local function tools from enabled `agent.system.tool.*.md` prompt files. - Build local function tools from enabled `agent.system.tool.*.md` prompt files.
- 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. - 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.
- Function parameter schemas are object schemas with an explicit `properties` object so OpenAI-compatible servers that validate chat-style tool payloads accept permissive tools.
- Preserve original Agent Zero tool names through the native Responses name map. - Preserve original Agent Zero tool names through the native Responses name map.
- Keep MCP tool schemas merged after local prompt-derived tools. - Keep MCP tool schemas merged after local prompt-derived tools.
- Connector remote tools are advertised only when `_a0_connector` runtime metadata says the matching connected CLI capability is currently available. - Connector remote tools are advertised only when `_a0_connector` runtime metadata says the matching connected CLI capability is currently available.

View file

@ -101,3 +101,48 @@ def test_responses_function_tools_use_prompt_declared_names(monkeypatch, tmp_pat
assert name_map["call_subordinate"] == "call_subordinate" assert name_map["call_subordinate"] == "call_subordinate"
assert name_map["behaviour_adjustment"] == "behaviour_adjustment" assert name_map["behaviour_adjustment"] == "behaviour_adjustment"
assert name_map["filename_only"] == "filename_only" assert name_map["filename_only"] == "filename_only"
assert all(isinstance(tool["parameters"].get("properties"), dict) for tool in tools)
def test_responses_function_tools_add_empty_properties_to_mcp_schemas(
monkeypatch,
tmp_path,
):
prompt_root = tmp_path / "prompts"
prompt_root.mkdir()
monkeypatch.setattr(
responses_tools.subagents,
"get_paths",
lambda *args, **kwargs: [str(prompt_root)],
)
monkeypatch.setattr(
responses_tools,
"_mcp_tools",
lambda agent: [
(
"remote_noop",
{
"description": "Remote noop",
"input_schema": {"type": "object"},
},
)
],
)
tools, _name_map = responses_tools.build_responses_function_tools(
FakeAgent(prompt_root)
)
assert tools == [
{
"type": "function",
"name": "remote_noop",
"description": "Remote noop",
"parameters": {
"type": "object",
"properties": {},
"additionalProperties": True,
},
}
]

View file

@ -780,7 +780,7 @@ def test_responses_request_translates_messages_and_params():
"type": "function", "type": "function",
"name": "lookup", "name": "lookup",
"description": "Search", "description": "Search",
"parameters": {"type": "object"}, "parameters": {"type": "object", "properties": {}},
"strict": True, "strict": True,
} }
] ]
@ -839,6 +839,61 @@ def test_responses_request_normalizes_reasoning_and_orphan_tool_choice():
assert "reasoning" not in request assert "reasoning" not in request
def test_responses_request_normalizes_function_tool_parameter_shapes():
request = litellm_transport.ResponsesTransport.from_chat(
[],
{
"functions": [
{
"name": "legacy_noop",
"description": "Legacy function",
"parameters": {},
}
],
},
)
assert request["tools"] == [
{
"type": "function",
"name": "legacy_noop",
"description": "Legacy function",
"parameters": {
"type": "object",
"properties": {},
},
}
]
request = litellm_transport.ResponsesTransport.from_chat(
[],
{
"a0_responses_function_tools": [
{
"type": "function",
"name": "native_noop",
"description": "Native function",
"parameters": {"type": "object"},
}
],
"responses_builtin_tools": [{"type": "web_search"}],
},
)
assert request["tools"] == [
{
"type": "function",
"name": "native_noop",
"description": "Native function",
"parameters": {
"type": "object",
"properties": {},
},
},
{"type": "web_search"},
]
def test_chat_completions_kwargs_omit_empty_tools(): def test_chat_completions_kwargs_omit_empty_tools():
kwargs = litellm_transport.ChatCompletionsTransport.prepare_kwargs( kwargs = litellm_transport.ChatCompletionsTransport.prepare_kwargs(
{ {