diff --git a/src/openai/lib/_parsing/_responses.py b/src/openai/lib/_parsing/_responses.py index fdf5132c3c..0a2e03b69a 100644 --- a/src/openai/lib/_parsing/_responses.py +++ b/src/openai/lib/_parsing/_responses.py @@ -27,6 +27,7 @@ ParsedResponseFunctionToolCall, ) from ...types.chat.completion_create_params import ResponseFormat +from ...types.responses.namespace_tool_param import ToolFunction TextFormatT = TypeVar( "TextFormatT", @@ -168,7 +169,21 @@ def parse_text(text: str, text_format: type[TextFormatT] | Omit, *, phase: str | raise TypeError(f"Unable to automatically parse response format type {text_format}") -def get_input_tool_by_name(*, input_tools: Iterable[ToolParam], name: str) -> FunctionToolParam | None: +def get_input_tool_by_name( + *, input_tools: Iterable[ToolParam], name: str, namespace: str | None = None +) -> FunctionToolParam | ToolFunction | None: + if namespace is not None: + input_tools = list(input_tools) + for tool in input_tools: + if tool["type"] == "namespace" and tool["name"] == namespace: + for nested_tool in tool["tools"]: + if nested_tool["type"] == "function" and nested_tool["name"] == name: + return nested_tool + return None + + # Deferred top-level functions can return their own name as the namespace. + if namespace is not None and namespace != name: + return None for tool in input_tools: if tool["type"] == "function" and tool.get("name") == name: return tool @@ -184,7 +199,9 @@ def parse_function_tool_arguments( if input_tools is None or not is_given(input_tools): return None - input_tool = get_input_tool_by_name(input_tools=input_tools, name=function_call.name) + input_tool = get_input_tool_by_name( + input_tools=input_tools, name=function_call.name, namespace=function_call.namespace + ) if not input_tool: return None diff --git a/src/openai/lib/_response_resources.py b/src/openai/lib/_response_resources.py index 3442821951..76dff89ae6 100644 --- a/src/openai/lib/_response_resources.py +++ b/src/openai/lib/_response_resources.py @@ -609,6 +609,9 @@ def _make_tools(tools: Iterable[ParseableToolParam] | Omit) -> List[ToolParam] | converted_tools: List[ToolParam] = [] for tool in tools: + if tool["type"] == "namespace": + converted_tools.append({**tool, "tools": list(tool["tools"])}) + continue if tool["type"] != "function": converted_tools.append(tool) continue diff --git a/tests/lib/responses/test_deferred_tools.py b/tests/lib/responses/test_deferred_tools.py index 5fcdce38c0..3a3c150243 100644 --- a/tests/lib/responses/test_deferred_tools.py +++ b/tests/lib/responses/test_deferred_tools.py @@ -10,6 +10,7 @@ import openai from openai._types import Omit from openai.types.responses import ToolParam +from openai.types.responses.namespace_tool_param import ToolFunction from .test_commentary_parsing import _response @@ -45,6 +46,8 @@ async def test_deferred_typed_tool_request_and_parsing( "arguments": '{"item_id":"A123"}', "status": "completed", } + if defer_loading is True: + item["namespace"] = "lookup_item" def respond(request: httpx2.Request) -> httpx2.Response: submitted = json.loads(request.content)["tools"][1] @@ -95,6 +98,61 @@ def respond(request: httpx2.Request) -> httpx2.Response: assert call.parsed_arguments.item_id == "A123" +@pytest.mark.parametrize( + "nested_name,top_level,namespace,one_shot", + [ + ("lookup_item", False, "catalog", False), + ("lookup_item", True, "catalog", False), + ("other", True, "catalog", False), + ("lookup_item", True, "unknown", False), + ("lookup_item", True, "catalog", True), + ], +) +def test_namespaced_tool_parsing(nested_name: str, top_level: bool, namespace: str, one_shot: bool) -> None: + class OtherLookup(BaseModel): + item_id: str + + tools: list[ToolParam] = [] + if top_level: + # The same schema must still resolve to the model in the requested namespace. + tools.append(openai.pydantic_responses_function_tool(OtherLookup, name="lookup_item")) + nested_tools = [cast(ToolFunction, openai.pydantic_responses_function_tool(LookupItem, name=nested_name))] + tools.append( + { + "type": "namespace", + "name": "catalog", + "description": "Catalog tools", + "tools": iter(nested_tools) if one_shot else nested_tools, + } + ) + item = { + "id": "fc_test", + "call_id": "call_test", + "type": "function_call", + "name": "lookup_item", + "namespace": namespace, + "arguments": '{"item_id":"A123"}', + "status": "completed", + } + + def respond(request: httpx2.Request) -> httpx2.Response: + assert json.loads(request.content)["tools"][-1]["tools"][0]["name"] == nested_name + return httpx2.Response(200, json=_response([item])) + + transport = httpx2.MockTransport(respond) + with openai.OpenAI(api_key="synthetic", http_client=httpx2.Client(transport=transport, trust_env=False)) as client: + response = client.responses.parse(model="test-model", input="Look up A123", tools=tools) + + call = response.output[0] + assert call.type == "function_call" + if namespace == "catalog" and nested_name == "lookup_item": + assert isinstance(call.parsed_arguments, LookupItem) + assert call.parsed_arguments.item_id == "A123" + else: + # An unknown namespace or missing nested function must not select a top-level tool. + assert call.parsed_arguments is None + + @pytest.mark.parametrize("streaming", [False, True]) def test_deferred_tool_configuration_errors_are_api_errors(streaming: bool) -> None: def reject(request: httpx2.Request) -> httpx2.Response: