diff --git a/helpers/litellm_transport.py b/helpers/litellm_transport.py index 9e7a290f9..a66ed63c6 100644 --- a/helpers/litellm_transport.py +++ b/helpers/litellm_transport.py @@ -640,13 +640,11 @@ class ResponsesTransport: response_builtin_tools: Any = None, ) -> list[Any]: merged: list[Any] = [] - if isinstance(tools, list): - merged.extend(tools) - elif tools: - merged.append(tools) - - for source in (response_function_tools, response_builtin_tools): - for tool in _as_list(source): + for source in (tools, response_function_tools, response_builtin_tools): + source_tools = ( + source if isinstance(source, list) else [source] if source else [] + ) + for tool in source_tools: normalized = cls.normalize_response_tool(tool) if normalized: merged.append(normalized) @@ -658,7 +656,12 @@ class ResponsesTransport: tool = {"type": tool} if not isinstance(tool, dict): return None - return dict(tool) + normalized = dict(tool) + if normalized.get("type") == "function": + normalized["parameters"] = _normalize_function_parameters( + normalized.get("parameters") + ) + return normalized @staticmethod def prepare_prompt_caching( @@ -800,7 +803,9 @@ class ResponsesTransport: "type": "function", "name": function.get("name", ""), "description": function.get("description", ""), - "parameters": function.get("parameters", {}), + "parameters": _normalize_function_parameters( + function.get("parameters") + ), } if "strict" in function: response_tool["strict"] = function["strict"] @@ -1206,6 +1211,23 @@ def _response_tool_type(tool: Any) -> str: 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( messages: list[dict[str, Any]], *, diff --git a/helpers/litellm_transport.py.dox.md b/helpers/litellm_transport.py.dox.md index 938af1432..a3b7bb73f 100644 --- a/helpers/litellm_transport.py.dox.md +++ b/helpers/litellm_transport.py.dox.md @@ -25,6 +25,7 @@ - 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. - 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. - 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. diff --git a/helpers/responses_tools.py b/helpers/responses_tools.py index e0d0e6fc3..453e7d731 100644 --- a/helpers/responses_tools.py +++ b/helpers/responses_tools.py @@ -217,13 +217,17 @@ def _schema_from_any(schema: Any) -> dict[str, Any]: if isinstance(schema, dict): normalized = dict(schema) normalized.setdefault("type", "object") + if normalized.get("type") == "object" and not isinstance( + normalized.get("properties"), dict + ): + normalized["properties"] = {} normalized.setdefault("additionalProperties", True) return normalized return _permissive_schema() def _permissive_schema() -> dict[str, Any]: - return {"type": "object", "additionalProperties": True} + return {"type": "object", "properties": {}, "additionalProperties": True} def _balanced_json_object(text: str) -> str: diff --git a/helpers/responses_tools.py.dox.md b/helpers/responses_tools.py.dox.md index 094f4b522..e7ec0e760 100644 --- a/helpers/responses_tools.py.dox.md +++ b/helpers/responses_tools.py.dox.md @@ -14,6 +14,7 @@ - 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. +- 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. - 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. diff --git a/tests/test_responses_tools.py b/tests/test_responses_tools.py index f6aa5155b..fc187d1f7 100644 --- a/tests/test_responses_tools.py +++ b/tests/test_responses_tools.py @@ -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["behaviour_adjustment"] == "behaviour_adjustment" 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, + }, + } + ] diff --git a/tests/test_stream_tool_early_stop.py b/tests/test_stream_tool_early_stop.py index a1109948f..2b73c9503 100644 --- a/tests/test_stream_tool_early_stop.py +++ b/tests/test_stream_tool_early_stop.py @@ -780,7 +780,7 @@ def test_responses_request_translates_messages_and_params(): "type": "function", "name": "lookup", "description": "Search", - "parameters": {"type": "object"}, + "parameters": {"type": "object", "properties": {}}, "strict": True, } ] @@ -839,6 +839,61 @@ def test_responses_request_normalizes_reasoning_and_orphan_tool_choice(): 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(): kwargs = litellm_transport.ChatCompletionsTransport.prepare_kwargs( {