From 16ac0388047b99a652e3b293de9a1afa85ccc52a Mon Sep 17 00:00:00 2001 From: Atharva-Kanherkar <142440039+Atharva-Kanherkar@users.noreply.github.com> Date: Sat, 8 Aug 2026 18:19:16 +0530 Subject: [PATCH 1/6] fix(openai_utils): accept Responses API output-call items The OpenAI Responses API emits additional output item variants for hosted tools and client-executed custom tools. None of these variants were in NeMoGymResponseInputItem, and NeMoGymResponseOutputItem is an alias of it, so NeMoGymResponse validation rejected valid upstream payloads and the openai_model proxy turned a successful response into HTTP 500. Add NeMoGym wrappers for the seven missing variants, following the existing hosted-MCP item pattern and inheriting the pinned SDK models. The SDK includes these items in both ResponseOutputItem and ResponseInputItemParam, so extending the shared union also permits later turns to echo prior output items as input. Verified against api.openai.com with a forced web-search request: the model server returned 500 before this change and 200 after it. Fixes #2436 Signed-off-by: Atharva-Kanherkar <142440039+Atharva-Kanherkar@users.noreply.github.com> --- nemo_gym/openai_utils.py | 64 ++++++++++++ tests/unit_tests/test_openai_utils.py | 141 ++++++++++++++++++++++++++ 2 files changed, 205 insertions(+) diff --git a/nemo_gym/openai_utils.py b/nemo_gym/openai_utils.py index 47746223f36..2d804f1a79f 100644 --- a/nemo_gym/openai_utils.py +++ b/nemo_gym/openai_utils.py @@ -56,6 +56,11 @@ from openai.types.responses import ( FunctionToolParam, Response, + ResponseCodeInterpreterToolCall, + ResponseComputerToolCall, + ResponseCustomToolCall, + ResponseFileSearchToolCall, + ResponseFunctionWebSearch, ResponseInputTextParam, ) from openai.types.responses.response_create_params import ( @@ -72,6 +77,8 @@ ResponseInputMessageContentListParam, ) from openai.types.responses.response_output_item import ( + ImageGenerationCall, + LocalShellCall, McpApprovalRequest, McpCall, McpListTools, @@ -252,6 +259,53 @@ class NeMoGymResponseMcpApprovalRequest(McpApprovalRequest): server_label: Optional[str] = None +class NeMoGymResponseFileSearchToolCall(ResponseFileSearchToolCall): + """A hosted file-search call (OpenAI Responses ``file_search_call`` output item). + + Like ``mcp_call``, hosted-tool calls are executed *server-side* and arrive in + ``response.output`` already resolved, so there is no client-side execution and + hence no training variant. Inherits the upstream typing unchanged. + """ + + type: Literal["file_search_call"] = "file_search_call" + + +class NeMoGymResponseFunctionWebSearch(ResponseFunctionWebSearch): + """A hosted web-search call (OpenAI Responses ``web_search_call`` output item).""" + + type: Literal["web_search_call"] = "web_search_call" + + +class NeMoGymResponseComputerToolCall(ResponseComputerToolCall): + """A hosted computer-use call (OpenAI Responses ``computer_call`` output item).""" + + type: Literal["computer_call"] = "computer_call" + + +class NeMoGymImageGenerationCall(ImageGenerationCall): + """A hosted image-generation call (OpenAI Responses ``image_generation_call`` output item).""" + + type: Literal["image_generation_call"] = "image_generation_call" + + +class NeMoGymResponseCodeInterpreterToolCall(ResponseCodeInterpreterToolCall): + """A hosted code-interpreter call (OpenAI Responses ``code_interpreter_call`` output item).""" + + type: Literal["code_interpreter_call"] = "code_interpreter_call" + + +class NeMoGymLocalShellCall(LocalShellCall): + """A hosted local-shell call (OpenAI Responses ``local_shell_call`` output item).""" + + type: Literal["local_shell_call"] = "local_shell_call" + + +class NeMoGymResponseCustomToolCall(ResponseCustomToolCall): + """A custom tool call (OpenAI Responses ``custom_tool_call`` output item).""" + + type: Literal["custom_tool_call"] = "custom_tool_call" + + class NeMoGymResponseInputText(ResponseInputTextParam): pass @@ -295,6 +349,16 @@ class NeMoGymResponseReasoningItemForTraining(NeMoGymResponseReasoningItem, Toke NeMoGymResponseMcpCall, NeMoGymResponseMcpListTools, NeMoGymResponseMcpApprovalRequest, + # Hosted-tool call items. The upstream SDK includes these in both + # ResponseOutputItem and ResponseInputItemParam (outputs are echoed back as + # input on subsequent turns), so they belong in this shared union: + NeMoGymResponseFileSearchToolCall, + NeMoGymResponseFunctionWebSearch, + NeMoGymResponseComputerToolCall, + NeMoGymImageGenerationCall, + NeMoGymResponseCodeInterpreterToolCall, + NeMoGymLocalShellCall, + NeMoGymResponseCustomToolCall, # For training: NeMoGymEasyInputMessageForTraining, NeMoGymMessageForTraining, diff --git a/tests/unit_tests/test_openai_utils.py b/tests/unit_tests/test_openai_utils.py index e05bb274580..d653930f51b 100644 --- a/tests/unit_tests/test_openai_utils.py +++ b/tests/unit_tests/test_openai_utils.py @@ -13,7 +13,16 @@ # See the License for the specific language governing permissions and # limitations under the License. import pytest +from openai.types.responses import ( + ResponseCodeInterpreterToolCall, + ResponseComputerToolCall, + ResponseCustomToolCall, + ResponseFileSearchToolCall, + ResponseFunctionWebSearch, +) from openai.types.responses.response_output_item import ( + ImageGenerationCall, + LocalShellCall, McpApprovalRequest, McpCall, McpListTools, @@ -22,8 +31,15 @@ from nemo_gym.openai_utils import ( NeMoGymAsyncOpenAI, + NeMoGymImageGenerationCall, + NeMoGymLocalShellCall, NeMoGymResponse, + NeMoGymResponseCodeInterpreterToolCall, + NeMoGymResponseComputerToolCall, NeMoGymResponseCreateParamsNonStreaming, + NeMoGymResponseCustomToolCall, + NeMoGymResponseFileSearchToolCall, + NeMoGymResponseFunctionWebSearch, NeMoGymResponseInputTokensDetails, NeMoGymResponseMcpApprovalRequest, NeMoGymResponseMcpCall, @@ -130,6 +146,131 @@ def test_hosted_mcp_items_inherit_upstream_types(self) -> None: assert issubclass(NeMoGymResponseMcpApprovalRequest, McpApprovalRequest) +class TestNeMoGymResponseHostedToolItems: + """Hosted-tool output items (``web_search_call`` etc.) must validate rather than 500. + + The OpenAI Responses API emits these in ``response.output`` when a hosted + tool (web search, file search, computer use, image generation, code + interpreter, local shell, custom tools) runs server-side; before they were + in the union, ``NeMoGymResponse.model_validate`` raised and the model + server returned a 500 for an upstream response that succeeded (issue #2436). + """ + + def test_web_search_call_in_response_output_validates(self) -> None: + response = NeMoGymResponse.model_validate( + _response_with_output( + [ + { + "type": "web_search_call", + "id": "ws_1", + "action": {"type": "search", "query": "official OpenAI homepage domain"}, + "status": "completed", + }, + {"type": "reasoning", "id": "r1", "summary": []}, + { + "type": "message", + "id": "m1", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": "openai.com", "annotations": []}], + }, + ] + ) + ) + call = response.output[0] + assert isinstance(call, NeMoGymResponseFunctionWebSearch) + assert call.type == "web_search_call" + assert call.status == "completed" + + def test_remaining_hosted_tool_items_validate(self) -> None: + response = NeMoGymResponse.model_validate( + _response_with_output( + [ + { + "type": "file_search_call", + "id": "fs_1", + "queries": ["quarterly revenue"], + "status": "completed", + }, + { + "type": "computer_call", + "id": "cu_1", + "call_id": "call_1", + "action": {"type": "screenshot"}, + "pending_safety_checks": [], + "status": "completed", + }, + { + "type": "image_generation_call", + "id": "ig_1", + "result": None, + "status": "completed", + }, + { + "type": "code_interpreter_call", + "id": "ci_1", + "code": "print(42)", + "container_id": "cntr_1", + "outputs": [{"type": "logs", "logs": "42\n"}], + "status": "completed", + }, + { + "type": "local_shell_call", + "id": "ls_1", + "call_id": "call_2", + "action": {"type": "exec", "command": ["echo", "42"], "env": {}}, + "status": "completed", + }, + { + "type": "custom_tool_call", + "id": "ct_1", + "call_id": "call_3", + "name": "my_tool", + "input": "{}", + }, + ] + ) + ) + assert isinstance(response.output[0], NeMoGymResponseFileSearchToolCall) + assert isinstance(response.output[1], NeMoGymResponseComputerToolCall) + assert isinstance(response.output[2], NeMoGymImageGenerationCall) + assert isinstance(response.output[3], NeMoGymResponseCodeInterpreterToolCall) + assert isinstance(response.output[4], NeMoGymLocalShellCall) + assert isinstance(response.output[5], NeMoGymResponseCustomToolCall) + + def test_hosted_tool_items_accepted_as_input(self) -> None: + # The upstream SDK also allows hosted-tool items in ResponseInputItemParam: + # a rollout echoes response.output back as input on the next turn, so + # request validation must accept them too. + params = NeMoGymResponseCreateParamsNonStreaming( + input=[ + { + "type": "web_search_call", + "id": "ws_1", + "action": {"type": "search", "query": "official OpenAI homepage domain"}, + "status": "completed", + }, + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "What did you find?"}], + }, + ] + ) + assert isinstance(params.input[0], NeMoGymResponseFunctionWebSearch) + + def test_hosted_tool_items_inherit_upstream_types(self) -> None: + # These must inherit the upstream openai typing rather than redefine it + # from scratch, so schema drift is caught when the openai pin moves. + assert issubclass(NeMoGymResponseFileSearchToolCall, ResponseFileSearchToolCall) + assert issubclass(NeMoGymResponseFunctionWebSearch, ResponseFunctionWebSearch) + assert issubclass(NeMoGymResponseComputerToolCall, ResponseComputerToolCall) + assert issubclass(NeMoGymImageGenerationCall, ImageGenerationCall) + assert issubclass(NeMoGymResponseCodeInterpreterToolCall, ResponseCodeInterpreterToolCall) + assert issubclass(NeMoGymLocalShellCall, LocalShellCall) + assert issubclass(NeMoGymResponseCustomToolCall, ResponseCustomToolCall) + + class TestRoutedExpertsWireFormats: _BASE = { "prompt_token_ids": [1, 2], From e564e2be1eca71714b327d2b1b762f3271707321 Mon Sep 17 00:00:00 2001 From: Atharva-Kanherkar <142440039+Atharva-Kanherkar@users.noreply.github.com> Date: Sat, 8 Aug 2026 18:51:08 +0530 Subject: [PATCH 2/6] test: lock hosted-tool response review contract Checkpoint: define discriminator, transcript-boundary, canonicalization, and metadata expectations before applying review fixes. Signed-off-by: Atharva-Kanherkar <142440039+Atharva-Kanherkar@users.noreply.github.com> --- .../fix-responses-output-hosted-tool-items.md | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 testing/fix-responses-output-hosted-tool-items.md diff --git a/testing/fix-responses-output-hosted-tool-items.md b/testing/fix-responses-output-hosted-tool-items.md new file mode 100644 index 00000000000..159103f4c42 --- /dev/null +++ b/testing/fix-responses-output-hosted-tool-items.md @@ -0,0 +1,37 @@ +# fix/responses-output-hosted-tool-items — Test Contract + +## Functional Behavior + +- Every hosted-tool or custom-tool output variant supported by the pinned OpenAI SDK validates in `NeMoGymResponse.output`. +- Each wrapper preserves the upstream requirement that the `type` discriminator be present; malformed items without it are rejected. +- `split_responses_input_output_items` starts the output partition at every model-output item type, including hosted MCP, hosted tools, and custom tool calls. +- A transcript containing only input items stays entirely on the input side. +- Conversational transcript canonicalization retains a tool-only model response instead of moving it into request input. +- The PR body and commit metadata contain none of the prohibited attribution terms requested by the contributor. + +## Unit Tests + +- `TestNeMoGymResponseHostedToolItems.test_hosted_tool_items_require_type_discriminator` rejects every new item when `type` is absent. +- `test_split_on_model_output_item_type` covers every non-message output discriminator. +- `test_split_input_only_items` covers the no-output boundary. +- `test_canonicalize_run_transcript_preserves_tool_only_model_output` covers tool-only transcript canonicalization. + +## Integration / Functional Tests + +- The OpenAI model-server tests and response-schema tests pass together. +- The conversational-tool-use simulation tests pass with the shared splitter change. + +## Smoke Tests + +- `uv run pytest tests/unit_tests/test_openai_utils.py tests/unit_tests/test_responses_converter.py responses_api_models/openai_model/tests/test_app.py responses_api_agents/conversational_tool_use/simulation/tests/test_app.py -q` +- `uv run pre-commit run --files nemo_gym/openai_utils.py nemo_gym/responses_converter.py tests/unit_tests/test_openai_utils.py tests/unit_tests/test_responses_converter.py responses_api_agents/conversational_tool_use/simulation/app.py responses_api_agents/conversational_tool_use/simulation/tests/test_app.py testing/fix-responses-output-hosted-tool-items.md` + +## E2E Tests + +N/A — the live provider response was already reproduced for issue #2436; this review adds deterministic regressions for the local validation and transcript paths. + +## Manual / cURL Tests + +- Run a local malformed-item probe and verify the pinned SDK and Gym both reject every new item without `type`. +- Run a local splitter probe and verify `[user, web_search_call, assistant]` becomes one input item and two output items. +- Inspect PR #2438 body and all branch commit metadata for the prohibited attribution terms before pushing. From 95adb65b7a98ea620a9050120a7acb56340fbc92 Mon Sep 17 00:00:00 2001 From: Atharva-Kanherkar <142440039+Atharva-Kanherkar@users.noreply.github.com> Date: Sat, 8 Aug 2026 18:53:14 +0530 Subject: [PATCH 3/6] fix(openai_utils): step 1 - require output item discriminators Checkpoint: preserve upstream required type fields and reject untagged output items before union coercion. Reviewed against testing/fix-responses-output-hosted-tool-items.md. Signed-off-by: Atharva-Kanherkar <142440039+Atharva-Kanherkar@users.noreply.github.com> --- nemo_gym/openai_utils.py | 33 ++++++++++------------ tests/unit_tests/test_openai_utils.py | 40 +++++++++++++++++++++------ 2 files changed, 47 insertions(+), 26 deletions(-) diff --git a/nemo_gym/openai_utils.py b/nemo_gym/openai_utils.py index 2d804f1a79f..d8b5681a9c0 100644 --- a/nemo_gym/openai_utils.py +++ b/nemo_gym/openai_utils.py @@ -15,6 +15,7 @@ import json from asyncio import sleep from typing import ( + Annotated, Any, Dict, List, @@ -92,7 +93,7 @@ from openai.types.responses.response_usage import ResponseUsage from openai.types.shared.chat_model import ChatModel from openai.types.shared_params import FunctionDefinition -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, BeforeValidator, ConfigDict, Field from typing_extensions import TypedDict from nemo_gym.server_utils import ( @@ -267,43 +268,29 @@ class NeMoGymResponseFileSearchToolCall(ResponseFileSearchToolCall): hence no training variant. Inherits the upstream typing unchanged. """ - type: Literal["file_search_call"] = "file_search_call" - class NeMoGymResponseFunctionWebSearch(ResponseFunctionWebSearch): """A hosted web-search call (OpenAI Responses ``web_search_call`` output item).""" - type: Literal["web_search_call"] = "web_search_call" - class NeMoGymResponseComputerToolCall(ResponseComputerToolCall): """A hosted computer-use call (OpenAI Responses ``computer_call`` output item).""" - type: Literal["computer_call"] = "computer_call" - class NeMoGymImageGenerationCall(ImageGenerationCall): """A hosted image-generation call (OpenAI Responses ``image_generation_call`` output item).""" - type: Literal["image_generation_call"] = "image_generation_call" - class NeMoGymResponseCodeInterpreterToolCall(ResponseCodeInterpreterToolCall): """A hosted code-interpreter call (OpenAI Responses ``code_interpreter_call`` output item).""" - type: Literal["code_interpreter_call"] = "code_interpreter_call" - class NeMoGymLocalShellCall(LocalShellCall): """A hosted local-shell call (OpenAI Responses ``local_shell_call`` output item).""" - type: Literal["local_shell_call"] = "local_shell_call" - class NeMoGymResponseCustomToolCall(ResponseCustomToolCall): - """A custom tool call (OpenAI Responses ``custom_tool_call`` output item).""" - - type: Literal["custom_tool_call"] = "custom_tool_call" + """A client-executed custom tool call (OpenAI Responses ``custom_tool_call`` output item).""" class NeMoGymResponseInputText(ResponseInputTextParam): @@ -349,7 +336,7 @@ class NeMoGymResponseReasoningItemForTraining(NeMoGymResponseReasoningItem, Toke NeMoGymResponseMcpCall, NeMoGymResponseMcpListTools, NeMoGymResponseMcpApprovalRequest, - # Hosted-tool call items. The upstream SDK includes these in both + # Responses API output-call items. The upstream SDK includes these in both # ResponseOutputItem and ResponseInputItemParam (outputs are echoed back as # input on subsequent turns), so they belong in this shared union: NeMoGymResponseFileSearchToolCall, @@ -409,7 +396,17 @@ class NeMoGymResponseCreateParamsNonStreaming(BaseModel): ######################################## -NeMoGymResponseOutputItem = NeMoGymResponseInputItem +def _require_response_output_item_type(value: Any) -> Any: + """Prevent an untagged output item from being coerced into the wrong union member.""" + if isinstance(value, dict) and "type" not in value: + raise ValueError("Responses API output items must include a type discriminator") + return value + + +NeMoGymResponseOutputItem = Annotated[ + NeMoGymResponseInputItem, + BeforeValidator(_require_response_output_item_type), +] class NeMoGymResponseInputTokensDetails(ResponseInputTokensDetails): diff --git a/tests/unit_tests/test_openai_utils.py b/tests/unit_tests/test_openai_utils.py index d653930f51b..f3d02060545 100644 --- a/tests/unit_tests/test_openai_utils.py +++ b/tests/unit_tests/test_openai_utils.py @@ -146,14 +146,13 @@ def test_hosted_mcp_items_inherit_upstream_types(self) -> None: assert issubclass(NeMoGymResponseMcpApprovalRequest, McpApprovalRequest) -class TestNeMoGymResponseHostedToolItems: - """Hosted-tool output items (``web_search_call`` etc.) must validate rather than 500. - - The OpenAI Responses API emits these in ``response.output`` when a hosted - tool (web search, file search, computer use, image generation, code - interpreter, local shell, custom tools) runs server-side; before they were - in the union, ``NeMoGymResponse.model_validate`` raised and the model - server returned a 500 for an upstream response that succeeded (issue #2436). +class TestNeMoGymResponseToolCallItems: + """Responses API output-call items (``web_search_call`` etc.) must validate rather than 500. + + The OpenAI Responses API emits these in ``response.output`` for hosted tools + and client-executed custom tools. Before they were in the union, + ``NeMoGymResponse.model_validate`` raised and the model server returned a + 500 for an upstream response that succeeded (issue #2436). """ def test_web_search_call_in_response_output_validates(self) -> None: @@ -259,6 +258,31 @@ def test_hosted_tool_items_accepted_as_input(self) -> None: ) assert isinstance(params.input[0], NeMoGymResponseFunctionWebSearch) + def test_hosted_tool_items_require_type_discriminator(self) -> None: + with pytest.raises(ValidationError): + NeMoGymResponse.model_validate( + _response_with_output( + [ + { + "id": "ws_1", + "action": {"type": "search", "query": "official OpenAI homepage domain"}, + "status": "completed", + } + ] + ) + ) + + pairs = ( + (NeMoGymResponseFileSearchToolCall, ResponseFileSearchToolCall), + (NeMoGymResponseFunctionWebSearch, ResponseFunctionWebSearch), + (NeMoGymResponseComputerToolCall, ResponseComputerToolCall), + (NeMoGymImageGenerationCall, ImageGenerationCall), + (NeMoGymResponseCodeInterpreterToolCall, ResponseCodeInterpreterToolCall), + (NeMoGymLocalShellCall, LocalShellCall), + (NeMoGymResponseCustomToolCall, ResponseCustomToolCall), + ) + assert all(gym_cls.model_fields["type"].is_required() for gym_cls, _ in pairs) + def test_hosted_tool_items_inherit_upstream_types(self) -> None: # These must inherit the upstream openai typing rather than redefine it # from scratch, so schema drift is caught when the openai pin moves. From 33e2e06229d9ad231aacc2273452c2bb31d5f534 Mon Sep 17 00:00:00 2001 From: Atharva-Kanherkar <142440039+Atharva-Kanherkar@users.noreply.github.com> Date: Sat, 8 Aug 2026 18:54:22 +0530 Subject: [PATCH 4/6] fix(responses): step 2 - preserve hosted-tool transcript boundaries Checkpoint: classify every model-output call type, keep input-only transcripts on the input side, and retain tool-only model output. Reviewed against testing/fix-responses-output-hosted-tool-items.md. Signed-off-by: Atharva-Kanherkar <142440039+Atharva-Kanherkar@users.noreply.github.com> --- nemo_gym/responses_converter.py | 30 ++++++++++++---- .../conversational_tool_use/simulation/app.py | 10 +----- .../simulation/tests/test_app.py | 22 ++++++++++++ tests/unit_tests/test_responses_converter.py | 36 +++++++++++++++++++ 4 files changed, 82 insertions(+), 16 deletions(-) diff --git a/nemo_gym/responses_converter.py b/nemo_gym/responses_converter.py index a798d36dc24..e8f1b98b90b 100644 --- a/nemo_gym/responses_converter.py +++ b/nemo_gym/responses_converter.py @@ -574,25 +574,41 @@ def chat_completion_to_response( ) +_RESPONSE_OUTPUT_BOUNDARY_TYPES = frozenset( + { + "code_interpreter_call", + "computer_call", + "custom_tool_call", + "file_search_call", + "function_call", + "image_generation_call", + "local_shell_call", + "mcp_approval_request", + "mcp_call", + "mcp_list_tools", + "reasoning", + "reasoning_item", + "web_search_call", + } +) + + def split_responses_input_output_items( items: List[NeMoGymResponseOutputItem], ) -> Tuple[List[NeMoGymResponseOutputItem], List[NeMoGymResponseOutputItem]]: if not items: return [], [] + split_at = len(items) for i, item in enumerate(items): if ( getattr(item, "role", None) == "assistant" - or getattr(item, "type", None) - in { - "reasoning", - "reasoning_item", - } - or getattr(item, "type", None) in ("function_call",) + or getattr(item, "type", None) in _RESPONSE_OUTPUT_BOUNDARY_TYPES ): + split_at = i break - return items[:i], items[i:] + return items[:split_at], items[split_at:] # Backward-compatible aliases diff --git a/responses_api_agents/conversational_tool_use/simulation/app.py b/responses_api_agents/conversational_tool_use/simulation/app.py index 04aa385ad56..53ef0cfac24 100644 --- a/responses_api_agents/conversational_tool_use/simulation/app.py +++ b/responses_api_agents/conversational_tool_use/simulation/app.py @@ -542,15 +542,7 @@ def _canonicalize_run_transcript( response_payload: Dict[str, Any], ) -> tuple[NeMoGymResponseCreateParamsNonStreaming, Dict[str, Any]]: response = NeMoGymResponse.model_validate(response_payload) - has_model_output = any( - getattr(item, "role", None) == "assistant" - or getattr(item, "type", None) in {"reasoning", "reasoning_item", "function_call"} - for item in response.output - ) - if has_model_output: - input_items, output_items = split_responses_input_output_items(response.output) - else: - input_items, output_items = list(response.output), [] + input_items, output_items = split_responses_input_output_items(response.output) if not input_items: return responses_create_params, response_payload diff --git a/responses_api_agents/conversational_tool_use/simulation/tests/test_app.py b/responses_api_agents/conversational_tool_use/simulation/tests/test_app.py index 5e31c956f40..86e024515eb 100644 --- a/responses_api_agents/conversational_tool_use/simulation/tests/test_app.py +++ b/responses_api_agents/conversational_tool_use/simulation/tests/test_app.py @@ -179,6 +179,28 @@ def test_canonicalize_run_transcript_moves_user_only_terminal_output_to_input() assert canonical_response["output"] == [] +def test_canonicalize_run_transcript_preserves_tool_only_model_output() -> None: + agent = make_agent() + responses_create_params = NeMoGymResponseCreateParamsNonStreaming( + input=[NeMoGymEasyInputMessage(role="user", content="Search for the answer.")], + ) + payload = response_payload( + [ + { + "type": "web_search_call", + "id": "ws_1", + "action": {"type": "search", "query": "answer"}, + "status": "completed", + } + ] + ) + + canonical_params, canonical_response = agent._canonicalize_run_transcript(responses_create_params, payload) + + assert canonical_params is responses_create_params + assert canonical_response == payload + + def test_normalize_response_input_items_preserves_default_message_type() -> None: agent = make_agent() diff --git a/tests/unit_tests/test_responses_converter.py b/tests/unit_tests/test_responses_converter.py index ad93c5475d8..6572b944004 100644 --- a/tests/unit_tests/test_responses_converter.py +++ b/tests/unit_tests/test_responses_converter.py @@ -14,6 +14,8 @@ # limitations under the License. """Unit tests for the shared Responses API <-> Chat Completions converter.""" +from types import SimpleNamespace + import pytest from openai.types.completion_usage import CompletionTokensDetails, CompletionUsage, PromptTokensDetails @@ -633,6 +635,40 @@ def test_split_on_reasoning(): assert outputs == [reasoning] +@pytest.mark.parametrize( + "output_type", + [ + "code_interpreter_call", + "computer_call", + "custom_tool_call", + "file_search_call", + "function_call", + "image_generation_call", + "local_shell_call", + "mcp_approval_request", + "mcp_call", + "mcp_list_tools", + "reasoning", + "reasoning_item", + "web_search_call", + ], +) +def test_split_on_model_output_item_type(output_type: str): + user = NeMoGymEasyInputMessage(role="user", content="hi", type="message") + output_item = SimpleNamespace(type=output_type) + inputs, outputs = split_responses_input_output_items([user, output_item]) + assert inputs == [user] + assert outputs == [output_item] + + +def test_split_input_only_items(): + system = NeMoGymEasyInputMessage(role="system", content="policy", type="message") + user = NeMoGymEasyInputMessage(role="user", content="hi", type="message") + inputs, outputs = split_responses_input_output_items([system, user]) + assert inputs == [system, user] + assert outputs == [] + + def test_round_trip_with_tool_calls(converter: ResponsesConverter): """A chat message with reasoning + tool calls survives a round trip back to chat params.""" choice = NeMoGymChoice( From 3b6c4c5874f2d2271a5d42bab23219d8999d5507 Mon Sep 17 00:00:00 2001 From: Atharva-Kanherkar <142440039+Atharva-Kanherkar@users.noreply.github.com> Date: Sat, 8 Aug 2026 18:58:20 +0530 Subject: [PATCH 5/6] docs(responses): clarify output-call execution semantics Signed-off-by: Atharva-Kanherkar <142440039+Atharva-Kanherkar@users.noreply.github.com> --- nemo_gym/openai_utils.py | 9 ++++----- .../fix-responses-output-hosted-tool-items.md | 6 +++--- tests/unit_tests/test_openai_utils.py | 18 +++++++++--------- 3 files changed, 16 insertions(+), 17 deletions(-) diff --git a/nemo_gym/openai_utils.py b/nemo_gym/openai_utils.py index d8b5681a9c0..f1b2dd84343 100644 --- a/nemo_gym/openai_utils.py +++ b/nemo_gym/openai_utils.py @@ -263,9 +263,8 @@ class NeMoGymResponseMcpApprovalRequest(McpApprovalRequest): class NeMoGymResponseFileSearchToolCall(ResponseFileSearchToolCall): """A hosted file-search call (OpenAI Responses ``file_search_call`` output item). - Like ``mcp_call``, hosted-tool calls are executed *server-side* and arrive in - ``response.output`` already resolved, so there is no client-side execution and - hence no training variant. Inherits the upstream typing unchanged. + The provider executes the search and returns the call in ``response.output``. + Inherits the upstream typing unchanged. """ @@ -274,7 +273,7 @@ class NeMoGymResponseFunctionWebSearch(ResponseFunctionWebSearch): class NeMoGymResponseComputerToolCall(ResponseComputerToolCall): - """A hosted computer-use call (OpenAI Responses ``computer_call`` output item).""" + """A computer-use action for the client to execute (``computer_call`` output item).""" class NeMoGymImageGenerationCall(ImageGenerationCall): @@ -286,7 +285,7 @@ class NeMoGymResponseCodeInterpreterToolCall(ResponseCodeInterpreterToolCall): class NeMoGymLocalShellCall(LocalShellCall): - """A hosted local-shell call (OpenAI Responses ``local_shell_call`` output item).""" + """A local-shell command for the client to execute (``local_shell_call`` output item).""" class NeMoGymResponseCustomToolCall(ResponseCustomToolCall): diff --git a/testing/fix-responses-output-hosted-tool-items.md b/testing/fix-responses-output-hosted-tool-items.md index 159103f4c42..0ae0760f0df 100644 --- a/testing/fix-responses-output-hosted-tool-items.md +++ b/testing/fix-responses-output-hosted-tool-items.md @@ -2,16 +2,16 @@ ## Functional Behavior -- Every hosted-tool or custom-tool output variant supported by the pinned OpenAI SDK validates in `NeMoGymResponse.output`. +- Every provider-executed or client-executed output-call variant supported by the pinned OpenAI SDK validates in `NeMoGymResponse.output`. - Each wrapper preserves the upstream requirement that the `type` discriminator be present; malformed items without it are rejected. -- `split_responses_input_output_items` starts the output partition at every model-output item type, including hosted MCP, hosted tools, and custom tool calls. +- `split_responses_input_output_items` starts the output partition at every model-output item type, including hosted MCP and provider- or client-executed calls. - A transcript containing only input items stays entirely on the input side. - Conversational transcript canonicalization retains a tool-only model response instead of moving it into request input. - The PR body and commit metadata contain none of the prohibited attribution terms requested by the contributor. ## Unit Tests -- `TestNeMoGymResponseHostedToolItems.test_hosted_tool_items_require_type_discriminator` rejects every new item when `type` is absent. +- `TestNeMoGymResponseToolCallItems.test_output_call_items_require_type_discriminator` rejects every new item when `type` is absent. - `test_split_on_model_output_item_type` covers every non-message output discriminator. - `test_split_input_only_items` covers the no-output boundary. - `test_canonicalize_run_transcript_preserves_tool_only_model_output` covers tool-only transcript canonicalization. diff --git a/tests/unit_tests/test_openai_utils.py b/tests/unit_tests/test_openai_utils.py index f3d02060545..29ed30e92b0 100644 --- a/tests/unit_tests/test_openai_utils.py +++ b/tests/unit_tests/test_openai_utils.py @@ -149,10 +149,10 @@ def test_hosted_mcp_items_inherit_upstream_types(self) -> None: class TestNeMoGymResponseToolCallItems: """Responses API output-call items (``web_search_call`` etc.) must validate rather than 500. - The OpenAI Responses API emits these in ``response.output`` for hosted tools - and client-executed custom tools. Before they were in the union, - ``NeMoGymResponse.model_validate`` raised and the model server returned a - 500 for an upstream response that succeeded (issue #2436). + The OpenAI Responses API emits these in ``response.output`` for provider- + executed tools and client-executed actions. Before they were in the union, + ``NeMoGymResponse.model_validate`` raised and the model server returned a 500 + for an upstream response that succeeded (issue #2436). """ def test_web_search_call_in_response_output_validates(self) -> None: @@ -181,7 +181,7 @@ def test_web_search_call_in_response_output_validates(self) -> None: assert call.type == "web_search_call" assert call.status == "completed" - def test_remaining_hosted_tool_items_validate(self) -> None: + def test_remaining_output_call_items_validate(self) -> None: response = NeMoGymResponse.model_validate( _response_with_output( [ @@ -237,8 +237,8 @@ def test_remaining_hosted_tool_items_validate(self) -> None: assert isinstance(response.output[4], NeMoGymLocalShellCall) assert isinstance(response.output[5], NeMoGymResponseCustomToolCall) - def test_hosted_tool_items_accepted_as_input(self) -> None: - # The upstream SDK also allows hosted-tool items in ResponseInputItemParam: + def test_output_call_items_accepted_as_input(self) -> None: + # The upstream SDK also allows output-call items in ResponseInputItemParam: # a rollout echoes response.output back as input on the next turn, so # request validation must accept them too. params = NeMoGymResponseCreateParamsNonStreaming( @@ -258,7 +258,7 @@ def test_hosted_tool_items_accepted_as_input(self) -> None: ) assert isinstance(params.input[0], NeMoGymResponseFunctionWebSearch) - def test_hosted_tool_items_require_type_discriminator(self) -> None: + def test_output_call_items_require_type_discriminator(self) -> None: with pytest.raises(ValidationError): NeMoGymResponse.model_validate( _response_with_output( @@ -283,7 +283,7 @@ def test_hosted_tool_items_require_type_discriminator(self) -> None: ) assert all(gym_cls.model_fields["type"].is_required() for gym_cls, _ in pairs) - def test_hosted_tool_items_inherit_upstream_types(self) -> None: + def test_output_call_items_inherit_upstream_types(self) -> None: # These must inherit the upstream openai typing rather than redefine it # from scratch, so schema drift is caught when the openai pin moves. assert issubclass(NeMoGymResponseFileSearchToolCall, ResponseFileSearchToolCall) From 465cdd7e7d0a6c03c1973a8cfe037164daed7056 Mon Sep 17 00:00:00 2001 From: Atharva-Kanherkar <142440039+Atharva-Kanherkar@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:46:45 +0530 Subject: [PATCH 6/6] chore: remove internal review artifact Signed-off-by: Atharva-Kanherkar <142440039+Atharva-Kanherkar@users.noreply.github.com> --- .../fix-responses-output-hosted-tool-items.md | 37 ------------------- 1 file changed, 37 deletions(-) delete mode 100644 testing/fix-responses-output-hosted-tool-items.md diff --git a/testing/fix-responses-output-hosted-tool-items.md b/testing/fix-responses-output-hosted-tool-items.md deleted file mode 100644 index 0ae0760f0df..00000000000 --- a/testing/fix-responses-output-hosted-tool-items.md +++ /dev/null @@ -1,37 +0,0 @@ -# fix/responses-output-hosted-tool-items — Test Contract - -## Functional Behavior - -- Every provider-executed or client-executed output-call variant supported by the pinned OpenAI SDK validates in `NeMoGymResponse.output`. -- Each wrapper preserves the upstream requirement that the `type` discriminator be present; malformed items without it are rejected. -- `split_responses_input_output_items` starts the output partition at every model-output item type, including hosted MCP and provider- or client-executed calls. -- A transcript containing only input items stays entirely on the input side. -- Conversational transcript canonicalization retains a tool-only model response instead of moving it into request input. -- The PR body and commit metadata contain none of the prohibited attribution terms requested by the contributor. - -## Unit Tests - -- `TestNeMoGymResponseToolCallItems.test_output_call_items_require_type_discriminator` rejects every new item when `type` is absent. -- `test_split_on_model_output_item_type` covers every non-message output discriminator. -- `test_split_input_only_items` covers the no-output boundary. -- `test_canonicalize_run_transcript_preserves_tool_only_model_output` covers tool-only transcript canonicalization. - -## Integration / Functional Tests - -- The OpenAI model-server tests and response-schema tests pass together. -- The conversational-tool-use simulation tests pass with the shared splitter change. - -## Smoke Tests - -- `uv run pytest tests/unit_tests/test_openai_utils.py tests/unit_tests/test_responses_converter.py responses_api_models/openai_model/tests/test_app.py responses_api_agents/conversational_tool_use/simulation/tests/test_app.py -q` -- `uv run pre-commit run --files nemo_gym/openai_utils.py nemo_gym/responses_converter.py tests/unit_tests/test_openai_utils.py tests/unit_tests/test_responses_converter.py responses_api_agents/conversational_tool_use/simulation/app.py responses_api_agents/conversational_tool_use/simulation/tests/test_app.py testing/fix-responses-output-hosted-tool-items.md` - -## E2E Tests - -N/A — the live provider response was already reproduced for issue #2436; this review adds deterministic regressions for the local validation and transcript paths. - -## Manual / cURL Tests - -- Run a local malformed-item probe and verify the pinned SDK and Gym both reject every new item without `type`. -- Run a local splitter probe and verify `[user, web_search_call, assistant]` becomes one input item and two output items. -- Inspect PR #2438 body and all branch commit metadata for the prohibited attribution terms before pushing.