diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 4f15d1b3ce..d9224281db 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -918,7 +918,39 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): anthropic_tools = [] mcp_servers = [] for tool in tools: - if "input_schema" in tool: # assume in anthropic format + if tool.get("type") == "namespace": + # Namespace is a grouping container (e.g. codex's multi_agent_v1). + # Extract its nested tools and map them individually. + for nested in tool.get("tools") or []: + if "input_schema" in nested: + # Already in Anthropic format. + anthropic_tools.append(nested) + elif "function" not in nested and "name" in nested: + # Flat format: {type, name, description, parameters, ...}. + # Normalize to OpenAI-wrapped format before mapping. + wrapped = cast( + ChatCompletionToolParam, + { + "type": nested.get("type", "function"), + "function": { + k: v for k, v in nested.items() if k != "type" + }, + }, + ) + nested_tool, nested_mcp = self._map_tool_helper(wrapped) + if nested_tool is not None: + anthropic_tools.append(nested_tool) + if nested_mcp is not None: + mcp_servers.append(nested_mcp) + elif "function" in nested: + nested_tool, nested_mcp = self._map_tool_helper( + cast(ChatCompletionToolParam, nested) + ) + if nested_tool is not None: + anthropic_tools.append(nested_tool) + if nested_mcp is not None: + mcp_servers.append(nested_mcp) + elif "input_schema" in tool: # assume in anthropic format anthropic_tools.append(tool) else: # assume openai tool call new_tool, mcp_server_tool = self._map_tool_helper(tool) @@ -1978,6 +2010,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): # Remove internal LiteLLM parameters that should not be sent to Anthropic API optional_params.pop("is_vertex_request", None) + optional_params.pop("client_metadata", None) data = { "model": model, diff --git a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py index d501ae0f79..4c33031293 100644 --- a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py +++ b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py @@ -5090,3 +5090,101 @@ def test_map_tool_helper_collision_prefers_definitions_over_components_schemas() # Cross-namespace ref *also* resolves to the `definitions` body because # ``unpack_defs`` keys by last path segment -- documented limitation. assert transformed["input_schema"]["properties"]["from_components"] == expected + + +def test_namespace_tool_flat_nested_tools_are_extracted(): + """Codex sends nested tools in flat format {type, name, description, parameters} with no 'function' wrapper. + These must be normalized and mapped without raising KeyError: 'function'.""" + config = AnthropicConfig() + tools = [ + { + "type": "namespace", + "name": "multi_agent_v1", + "tools": [ + { + "type": "function", + "name": "close_agent", + "description": "Close an agent.", + "strict": False, + "parameters": { + "type": "object", + "properties": {"target": {"type": "string"}}, + "required": ["target"], + "additionalProperties": False, + }, + }, + ], + } + ] + anthropic_tools, _ = config._map_tools(tools) + assert len(anthropic_tools) == 1 + assert anthropic_tools[0]["name"] == "close_agent" + + +def test_namespace_tool_nested_tools_are_extracted(): + """Codex sends type='namespace' wrapping nested tools in Anthropic format. + The namespace container must be dropped and its nested tools extracted individually. + """ + config = AnthropicConfig() + tools = [ + { + "type": "namespace", + "name": "multi_agent_v1", + "description": "Tools for spawning and managing sub-agents.", + "tools": [ + { + "name": "close_agent", + "type": "custom", + "description": "Close an agent.", + "input_schema": { + "type": "object", + "properties": {"target": {"type": "string"}}, + "required": ["target"], + }, + }, + { + "name": "resume_agent", + "type": "custom", + "description": "Resume a closed agent.", + "input_schema": { + "type": "object", + "properties": {"id": {"type": "string"}}, + "required": ["id"], + }, + }, + ], + }, + { + "type": "function", + "function": { + "name": "exec_command", + "description": "Run a command.", + "parameters": { + "type": "object", + "properties": {"cmd": {"type": "string"}}, + "required": ["cmd"], + }, + }, + }, + ] + anthropic_tools, mcp_servers = config._map_tools(tools) + names = [t["name"] for t in anthropic_tools] + assert "close_agent" in names + assert "resume_agent" in names + assert "exec_command" in names + assert "multi_agent_v1" not in names + assert len(anthropic_tools) == 3 + assert mcp_servers == [] + + +def test_client_metadata_stripped_from_anthropic_request(): + """client_metadata passed by codex must not reach the Anthropic (or Vertex Anthropic) payload.""" + config = AnthropicConfig() + result = config.transform_request( + model="claude-3-5-haiku-20241022", + messages=[{"role": "user", "content": "hello"}], + optional_params={"max_tokens": 10, "client_metadata": {"originator": "codex"}}, + litellm_params={}, + headers={}, + ) + assert "client_metadata" not in result