Skip to content

feat(vllm_model): serve Codex through the Responses-to-chat conversion - #5155

Merged
gwarmstrong merged 13 commits into
NVIDIA-NeMo:mainfrom
gwarmstrong:georgea/vllm-model-codex
Oct 28, 2026
Merged

gwarmstrong merged 13 commits into
NVIDIA-NeMo:mainfrom
gwarmstrong:georgea/vllm-model-codex

Conversation

@gwarmstrong

@gwarmstrong gwarmstrong commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

What does this PR do?

Let Codex run against chat-completions models (e.g. self-hosted on vLLM) through vllm_model's Responses-to-chat conversion. Codex speaks the Responses API, and today that conversion refuses or loses part of every Codex request. #4880 covered one part: with custom_tool_strategy: function_bridge, Codex's freeform apply_patch reaches the model as a function tool and the call comes back as a custom_tool_call. This PR adds the rest, as new settings, all off by default:

  • forward_unvalidated_reasoning_effort: pass an effort the pinned SDK doesn't list (Codex sends max) to the chat route as reasoning_effort instead of answering 422, and render it the same way when measuring context (/context/measure) so the measured prompt matches generation. drop_unrepresentable_request_fields (feat(model): optionally omit unsupported Responses fields #3976) now also drops reasoning.context auto/all_turns, since the conversion already forwards every reasoning item; current_turn is still refused.
  • inline_tool_output_images: view_image output goes to the model as an image_url part of the chat tool message (feat(model): accept image parts in chat tool messages #4688 added image parts there) instead of being refused or replaced by a placeholder. GenRM, which builds its own converter, honors it too.
  • stream_keepalive_s (model servers that build their own responses: this conversion, azure_openai_model, inference_provider): keepalive events while a buffered streaming response is computed. A buffered response sends no headers until the answer is ready, and Codex's stream idle timer starts only once a stream has begun (checked with Codex 0.154.0 at its stock 300 s timeout and a 400 s turn), so a direct connection doesn't need this; it is for a client behind something that opens the stream early or drops idle connections, such as a proxy or a relaying gateway. The events name the response before it exists, so the response takes that id, and capture records the same id. They are liveness, not output: a checkpoint holds the call as undelivered and its capture row is published before the first output or terminal event. Model-call capture records Gym's own usage for these streams too, as fix(capture): keep unknown usage counts for streamed /v1/responses calls #3317 does for buffered ones.

Saved model-call records also list custom_tool_call items among a response's tool calls, so a bridged apply_patch call is recorded like any other.

Options that cannot take effect are refused at startup: forward_unvalidated_reasoning_effort with use_completions_api (the completions body has no reasoning_effort) or on vllm_model_with_compaction, the conversion options with is_responses_native, and stream_keepalive_s on a server whose responses carry an upstream Responses API's ids (is_responses_native, openai_model, switchyard_model), which must reach the client unchanged.

No issue: opt-in conversion settings for one harness's requests; no default changes.

Validation

  • vllm_model: 896 passed; vllm_model_with_compaction: 9 passed; genrm_model: 12 passed; converter, streaming, base-model, model-call and function-bridge unit tests: 579 passed; checkpoint unit tests: 786 passed. pre-commit clean.
  • A Codex-shaped streaming request (effort max, context: all_turns, include, and apply_patch with its lark grammar) round-trips through vllm_model with these options and custom_tool_strategy: function_bridge: the call comes back as a custom_tool_call, and its replay on the next turn reaches the engine as the matching chat tool call and tool message.
  • Codex 0.160.1's first request captured at a local stub: the DeepSeek catalog sends effort max and include; the GPT-6 catalog adds context: all_turns.
  • Earlier runs on an older main: an earlier version of this change, with its own custom-tool bridge in place of feat(model): bridge Responses custom tools over function-only chat backends #4880's, served Codex 0.154.0 with self-hosted DeepSeek V4.1 Flash on Terminal-Bench-Science, 70 tasks × 3 trials, 8 h: 31/210, against the public leaderboard's 33/210 for the same model and harness. A 2-task, 30-minute smoke: 202 model calls, all completed, effort max on every turn, 21 view_image calls passed inline.

Rollouts

N/A: model-server options.

Compatibility and benchmark impact

Defaults unchanged. Users who already set drop_unrepresentable_request_fields will now also have reasoning.context auto/all_turns dropped instead of refused.

Streaming /v1/responses requests are buffered: the converted response is
re-emitted as SSE only once responses() returns, so the client sees no
event while a long generation runs. A client that bounds the silence
between events abandons the turn and re-sends it. The Codex CLI does
this after stream_idle_timeout_ms (300 s by default), which a high
reasoning effort turn can easily outlast. SSE comments, which the chat
streaming path sends, do not help here: Codex's event parser drops them
before its idle timer sees them.

Add stream_keepalive_s to BaseResponsesAPIModelConfig (default null,
today's behavior). When set and a call is still running after that many
seconds, the stream opens with response.created and then carries
response.in_progress every interval for the same placeholder response
id. Once responses() returns, the item and terminal events follow
exactly as the buffered path sends them, minus the response.created the
keepalives already sent.

A call that finishes within the first interval streams exactly as
before, HTTP error statuses included. A failure after the stream opened
ends with response.failed for the announced response id, carrying what
the client would have seen before the stream opened: a refusal the
server propagates keeps its OpenAI code (context_length_exceeded), an
upstream HTTP error follows the server's propagation policy, and no
upstream request URL is reported.

Model-call capture measured time to first token from the first body
chunk, which with keepalives would be the opening response.created one
interval in. The Responses lifecycle events (response.created and
response.in_progress) carry no model output, so they no longer count
toward it; on the buffered path they are sent in the same instant as the
first item, so its measurement is unchanged.

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
…text

The Codex CLI sends reasoning efforts its model catalog offers, and a
catalog may offer one the pinned OpenAI SDK does not list, such as
"max". Request validation types reasoning.effort with the SDK's literal,
so vllm_model answers such a request with 422 before any conversion,
although the engine behind the chat route accepts the level.

Add forward_unvalidated_reasoning_effort to VLLMModelConfig (default
false, today's behavior; requires is_responses_native=false). When set,
a string effort the SDK does not list is taken out of the raw Responses
body before validation and put verbatim on the converted chat request
as reasoning_effort; the engine validates it. Efforts the SDK lists,
"none" included, keep their existing path, and an empty effort is still
refused.

Codex also sends reasoning.context "all_turns" when its catalog enables
reasoning context, which drop_unrepresentable_request_fields still
refuses. The conversion passes every reasoning item the request carries
to the engine and leaves what is rendered to its chat template, which is
what "all_turns" (render them all) and "auto" (the provider's choice)
ask for. With drop_unrepresentable_request_fields those two values are
now dropped like the summary fields. "current_turn" asks for earlier
turns' reasoning to be left out, which the conversion does not do, so
it is still refused, and every value is still refused by default.

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
Two things the Codex CLI sends cannot reach a vLLM chat route through
the Responses to Chat Completions conversion today.

Codex's apply_patch is a Responses custom (freeform) tool with a lark
grammar. The conversion forwards it as a Chat Completions custom tool,
which a vLLM chat route rejects, so the only option was
drop_custom_tools: the model then has no apply_patch, tries it in the
shell, and falls back to editing with sed.

Add custom_tools_as_functions (VLLMModelConfig and ResponsesConverter,
default false). Each custom tool is presented to the engine as a
function tool with one required string parameter, input; its
description is the tool's description, a note to pass the raw input as
input, and the grammar when the tool has one, since a string parameter
cannot constrain the input. The engine's calls to that function come
back as custom_tool_call items (call_id, name, input, status) on the
non-streaming and the streaming path alike. Arguments that are not an
object with a string input are passed through as the input, so the
client's tool reports the bad call to the model. Replayed
custom_tool_call and custom_tool_call_output items become the chat tool
call (arguments {"input": ...}) and tool message, on the same assistant
turn as the replayed reasoning. A named tool_choice of a custom tool
selects its function. The option takes precedence over
drop_custom_tools, and a custom tool named like a function tool fails
the request. It needs is_responses_native=false and
return_token_id_information=false: custom_tool_call has no variant that
carries token IDs. Saved model-call records now list custom_tool_call
items among a response's tool calls.

When the model catalog lists image input, Codex offers view_image,
whose output is a function_call_output holding an input_image part.
Chat tool messages accept image parts since NVIDIA-NeMo#4688, but the conversion
still refuses the part, or replaces it with a text placeholder under
drop_unrepresentable_request_fields, so the model never sees the image.

Add inline_tool_output_images (VLLMModelConfig and ResponsesConverter,
default false; needs is_responses_native=false). Each image part of a
tool call output becomes an image_url part of the chat tool message,
which vLLM accepts and leaves to the chat template, and it takes
precedence over the placeholder. Detail "original", which Codex sends
when the model asks view_image for full resolution and which chat
lacks, goes as "high". Other part types a tool message cannot carry
are still refused.

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
… cannot apply

The completions path builds its body from a fixed list of chat fields, and
reasoning_effort is not one of them, so with use_completions_api=true the
forwarded effort was dropped without a word. The compaction server has its
own dispatch, which never sets the unlisted effort aside. Both now refuse
the option at startup instead of ignoring it.

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
NVIDIA-NeMo#4880 added custom_tool_strategy: function_bridge, which lowers each
Responses custom tool to a function tool with one string input and lifts
the calls back, on the same conversion this branch extended. Drop this
branch's custom_tools_as_functions, which did the same job: the config
field, the converter helpers and restore step, the name-clash check,
the streaming drop override and their tests.

The Codex tests now run apply_patch through function_bridge, streaming
and non-streaming, with drop_custom_tools off: it is still the kill
switch and would drop the tool before the bridge sees it.

Saved model-call records keep listing custom_tool_call items, which the
bridge returns too.

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
… forwards

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
@copy-pr-bot

copy-pr-bot Bot commented Oct 27, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@gwarmstrong

Copy link
Copy Markdown
Contributor Author

/ok to test ff92c43

@nemo-automation-bot
nemo-automation-bot Bot requested review from a team October 27, 2026 22:03
@svcnvidia-nemo-ci svcnvidia-nemo-ci added area:model Model servers, inference providers, and model adapters feature New capabilities, enhancements, or enablement work needs-review PR is ready for code review and waiting on a reviewer labels Oct 27, 2026
@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

@svcnvidia-nemo-ci svcnvidia-nemo-ci left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HOLD — 2 BLOCKERs and 1 RISK.

The new Responses keepalives bypass checkpoint capture publication and announce a different response ID from the eventual successful completion. The inherited image-inlining option is also ignored by GenRM's converter override.

Reviewed all 14 changed files and directly affected conversion, capture, checkpoint, and subclass paths. This was a static review; no tests were executed.

Note: formal approval is not available for this request, so this review is posted as a comment only.


Inline comments (escaped v1):

  • nemo_gym/_checkpoint/model.py line 933: BLOCKER — Lifecycle keepalives bypass capture publication

    With checkpoint.policy, external token staging, and stream_keepalive_s enabled, let a streaming Responses call exceed its first interval. The new created/progress events enter this branch before generation finishes. deliver_response() marks the ticket started, but publish() returns because no deferred commit exists yet. Finalization subsequently defers the completed ledger row, while every answer chunk skips publication because ticket.response_started is already true. Thus the client receives an answer without its capture row. Source-history capture leaves a pending intent, and the cleanup sweep skips deferred commits. Checkpoint preparation also incorrectly waits for this still-generating call as a draining answer.

    Could you classify lifecycle-only events as liveness, not answer delivery? For the inspected producer's complete, one-event-per-chunk framing, replace this condition with:

    lifecycle_only = (
        event_stream
        and body.startswith((b"event: response.created\n", b"event: response.in_progress\n"))
        and body.endswith(b"\n\n")
        and body.count(b"\n\n") == 1
    )
    if body and not (event_stream and body == SSE_KEEPALIVE) and not lifecycle_only:

    This suggestion needs verification before adoption. The single-event guard preserves delivery/publication when a chunk contains both lifecycle and answer events. The invariant is that the first actual output or terminal event—not liveness—starts delivery and publishes the deferred row.

    Proposed acceptance: delay generation behind the real gate/external-capture harness. After created/progress events, assert the ticket remains undelivered and checkpoint prepare completes without releasing generation. Resume and finish; assert exactly one committed ledger row exists before output/terminal bytes leave, with no pending intent. Also check an empty-output terminal response triggers publication. Retain comment-keepalive cutting and held-output assertions, publication-before-answer assertions, and draining of already-started output while admission is closed. These checks were not executed.

  • nemo_gym/base_responses_api_model.py line 660: BLOCKER — Successful keepalive streams change response identity

    For a call that outlasts stream_keepalive_s, this generator announces a freshly minted resp_A in created/progress events, then passes the backend response unchanged to _serve_responses_sse(). If that response has ID resp_B, the terminal synthesizer emits resp_B, with no terminal event for resp_A. A client tracking response state by the announced ID therefore receives completion for an unknown response and leaves the announced response in progress. This affects slow successful and incomplete turns; the exception path correctly reuses the announced ID.

    Minimal reproduction: configure a 0.02-second interval and have responses() return a response with fixed ID resp_final after 0.3 seconds. The opening ID is a generated UUID-based ID, while response.completed.response.id is resp_final. The current slow-success test checks ID consistency only among keepalives, so it accepts this mismatch.

    Could you arrange for lifecycle and terminal events to identify one actual served response? Select/reserve its identity before announcing it, or use a liveness representation that does not announce a different response. This needs coordination with capture rather than a terminal-only ID rewrite: _invoke_responses() captures before this point, and external finalization records the served envelope ID. Preserve provider-issued IDs on native Responses paths and the equality between client-visible IDs and capture IDs. I cannot recommend a safe one-line replacement without resolving those identity contracts.

    Proposed acceptance: extend the delayed-response test to assert that every lifecycle and terminal envelope has the same ID, for completed and incomplete responses, and that a client keyed by the created ID reaches a terminal state. Retain the existing late-failure same-ID assertion and fast-call event sequence. For captured calls, assert the ledger/token entry ID equals the terminal wire ID; for native Responses, assert the provider's ID remains unchanged. These are proposed checks, not executed tests.

  • responses_api_models/genrm_model/app.py line 45: RISK — GenRM silently ignores the inherited image-inlining option

    GenRMModelConfig inherits inline_tool_output_images, but this converter override does not forward it. The PR adds forwarding only in VLLMModel.get_converter(), which GenRM bypasses. Consequently, setting inline_tool_output_images: true on GenRM still rejects a function_call_output containing an input_image; with dropping enabled it substitutes the placeholder instead. The accepted option should retain the image as an image_url tool-message part.

    Could you add the missing keyword to GenRMModelMixin.get_converter()?

                custom_tool_strategy=self.config.custom_tool_strategy,
                inline_tool_output_images=self.config.inline_tool_output_images,
    

    Verify before adopting. This preserves the existing token-ID, reasoning-parser, and custom-tool strategy arguments and does not change GenRM's role preprocessing.

    Proposed acceptance: extend the existing converter-wiring test setup with inline_tool_output_images=True; convert a tool output containing {"type":"input_image","image_url":"https://example.com/a.png","detail":"high"} and assert its content is [{"type":"image_url","image_url":{"url":"https://example.com/a.png","detail":"high"}}], including when dropping is enabled. With the option false, retain strict rejection and placeholder behavior. Keep the existing function_bridge assertion and the GenRM custom-role assertions. Static reasoning only; tests were not run.

Review generated by codex. This was a full review. Request a full review with /review reset=true.

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
@gwarmstrong

Copy link
Copy Markdown
Contributor Author

/ok to test 66d4c71

@gwarmstrong

Copy link
Copy Markdown
Contributor Author

All three findings reproduced, and each now has a test that fails on the previous head (ff92c43).

  1. Lifecycle keepalives and capture publication (BLOCKER). The checkpoint gate now treats a chunk that is exactly one response.created or response.in_progress event as liveness, like a keep-alive comment (a47b5d3). The check is a shared helper, is_responses_lifecycle_chunk, which time-to-first-token now uses as well; a chunk that carries any other event still starts delivery. test_responses_lifecycle_events_are_liveness_not_the_answer sends the lifecycle events before generation finishes: the call does not start delivery, checkpoint prepare holds it instead of waiting for it, and after resume its ledger row is published before the terminal event leaves. The keep-alive cut, publication-before-answer and draining tests are unchanged. test_checkpoint_gate_keeps_framework_capture_pending_until_delivery now lets the buffered stream's response.created precede publication, as a keep-alive comment may (f672e78).

  2. One response id across a keepalive stream (BLOCKER). The keepalive path now reserves the id before it announces it, and _invoke_responses gives the response that id before capture, so the lifecycle events, the terminal event, model-call capture and token capture all carry one id (9bcefb3). With stream_keepalive_s set, every streamed response takes its reserved id, fast ones included, so the choice cannot race the first interval. Provider-issued ids stay unchanged: a server opts in through _mints_response_ids(). vllm_model's Chat Completions conversion, azure_openai_model and inference_provider do; a server that serves an upstream Responses API's ids (vllm_model with is_responses_native, openai_model, switchyard_model) refuses stream_keepalive_s at startup. Tests: test_every_event_names_the_response_the_stream_ends_with[completed|incomplete]; test_capture_records_the_id_the_stream_announced (the id passed to token capture equals the wire id); test_refused_on_a_server_that_serves_upstream_ids; vllm_model's slow Codex turn now asserts one id across the stream; and test_keepalive_streams_are_refused_on_the_native_responses_path. The late-failure same-id and fast-call sequence tests are unchanged.

  3. GenRM and inline_tool_output_images (RISK). GenRMModelMixin.get_converter() now forwards it (66d4c71). test_get_converter_forwards_inline_tool_output_images[False|True] converts a tool output with an image and checks its image_url part, with and without drop_unrepresentable_request_fields. The function_bridge and custom-role tests are unchanged.

Tests: vllm_model 896, vllm_model_with_compaction 9, genrm_model 12, azure_openai_model 7, inference_provider 30; PR 6's unit files 579; checkpoint unit tests 786. pre-commit and detect-secrets clean.

@yaoyu-33 yaoyu-33 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this, @gwarmstrong. The new conversion options are tightly scoped, and the startup refusals make misconfiguration loud.

Earlier review. We re-checked the three findings from the review at ff92c43c5 against 66d4c71fa, and all three are resolved:

The named regression tests pass at head and fail against ff92c43c5's sources. We also streamed slow calls through vllm_model with external staging, with the checkpoint gate both on and off. The wire events, the token entry and the ledger row all carried one id.

This pass: no blockers. Five notes are inline:

  • The keepalive task can outlive a request that is cancelled before the stream body starts. The chat keepalive path has the same pre-existing shape.
  • With inlining on, a tool-output image without a usable URL now fails the request.
  • Two option combinations could be refused at config time.
  • Two test gaps: the keepalive cleanup paths, and the ledger id on a keepalive stream.

Two asks for the PR body:

  • A short Codex + vllm_model smoke on this head. For example, the 2-task smoke from the body, with all four options on and one turn longer than 300 s, confirming that the streamed ids match the captured ids. The real rollouts in the body ran before the switch to #4880's bridge and before the keepalive id and lifecycle-gate changes, and AGENTS.md asks for rollout evidence on behavior changes.
  • Which harness needs the server-side keepalive. Gym's own codex_agent already sets Codex's stream_idle_timeout_ms from its timeout (codex_agent/app.py#L1226). Keepalive is the largest part of the change, so a line on the harness it serves would help, for example an external harness that runs Codex with its own config, as in the Terminal-Bench-Science comparison.

What we ran (macOS, no GPU):

Suite Passed
The PR's core unit test files 620
vllm_model 896
vllm_model_with_compaction 9
genrm_model 12
azure_openai_model 7
inference_provider 30

genrm_model ran with vllm stubbed. Not verified: a real vLLM engine on GPU, and a real Codex binary.

raise
if done:
return await self._stream_buffered_responses(request, pending, ns_map)
return StreamingResponse(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nemo_gym/base_responses_api_model.py:624-638

pending is cancelled only in _responses_keepalive_events's finally, and that block never runs if the generator never starts. Production servers install ClientDisconnectCancellationMiddleware (server_utils.py#L2007). If a disconnect lands after this return but before Starlette's first __anext__, _invoke_responses keeps generating with nothing waiting on it. We reproduced this with the middleware; without it, the task is cleaned up.

The window is small, and the chat keepalive path (#L845) already has the same shape, so this isn't blocking. Could the response own the task instead? For example, a StreamingResponse subclass whose __call__ wraps super().__call__() in a try/finally that cancels and gathers pending.

# Chat has no "original" detail (full resolution). The Codex CLI sends it when the model
# asks view_image for it; "high" is the closest chat level.
part = {**part, "detail": "high"}
return self._input_image_to_chat(part)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nemo_gym/responses_converter.py:622-630

With inline_tool_output_images on, a tool-output image that has only a file_id, or an empty image_url, now raises in _input_image_to_chat. That happens even under drop_unrepresentable_request_fields, which on its own turns the image into the text placeholder. Because the transcript is re-converted on every turn, the rest of that rollout fails too.

Codex's view_image always sends a data URL, so only other clients hit this. Could this fall back to _IMAGE_OUTPUT_PLACEHOLDER when dropping is on and the part has no usable URL?

Comment on lines +547 to +548
)
return self

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

responses_api_models/vllm_model/app.py:533-548

Two more combinations could be refused here at config time, like the others:

  • stream_keepalive_s with is_responses_native is refused only in setup_webserver (base_responses_api_model.py#L403). LocalVLLMModel and GenRM reach that only after start_vllm_server() (local_vllm_model/app.py#L92-L96), so the GPU engine starts before the config is refused. Adding "stream_keepalive_s" to the is_responses_native tuple above would refuse it first.
  • inline_tool_output_images with use_completions_api isn't refused at startup. Each inlined image then fails its request in the text-only completions path (#L1967):
Suggested change
)
return self
)
if self.inline_tool_output_images and self.use_completions_api:
raise ValueError(
"inline_tool_output_images requires use_completions_api=false: /v1/completions takes text-only "
"messages, so every inlined image would be refused"
)
return self

assert not is_responses_lifecycle_chunk(b": keep-alive\n\n")


class TestResponsesStreamKeepalive:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tests/unit_tests/test_responses_api_model_streaming.py:1264

Three of the Responses keepalive cleanup paths have no test yet:

The chat suite has tests to port for the first two: test_closing_stream_cancels_pending_backend and test_cancel_before_headers_cancels_backend (test_chat_completions_streaming.py#L342, #L428). Running one of them through the real middleware stack (setup_cancellation_middleware) would also cover the disconnect case in the comment on base_responses_api_model.py:635.

delivering.set()
await deliver(ticket)

def answer(body) -> bool:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

responses_api_models/vllm_model/tests/test_framework_context.py:768

No test runs a keepalive stream through external capture, where vllm_model normally keeps the chatcmpl-* envelope id. So nothing checks that the ledger row's response_id equals the id the stream announced. That id is the join key for terminal attribution (external_capture.py#L291-L296), and the earlier review's acceptance criteria asked for this check.

Our probe shows it holds today. Could you add a case here with stream_keepalive_s=0.02 and a slow worker, asserting that manifest.records[0].response_id equals every event's id, with and without the checkpoint gate?

@yaoyu-33 yaoyu-33 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving at 66d4c71fa. The inline notes are non-blocking; the head smoke rollout asked for in the review summary would still be good to add to the PR body.

@yaoyu-33 yaoyu-33 added ready-to-merge PR is approved, current, and waiting for final CI or merge and removed needs-review PR is ready for code review and waiting on a reviewer labels Oct 28, 2026
@gwarmstrong
gwarmstrong added this pull request to the merge queue Oct 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🔄 Merge queue validation started! You can track the progress here: https://github.com/NVIDIA-NeMo/Gym/actions/runs/40733684068

Merged via the queue into NVIDIA-NeMo:main with commit 1b4458a Oct 28, 2026
55 of 58 checks passed
@gwarmstrong
gwarmstrong deleted the georgea/vllm-model-codex branch October 28, 2026 17:53
@gwarmstrong

Copy link
Copy Markdown
Contributor Author

Smoke on this head (66d4c71, in a build with PR 4's 04ed490 and the Terminal-Bench-Science Codex profile, on main c155b77): Codex 0.154.0 in two Terminal-Bench-Science task sandboxes on OpenSandbox, through vllm_model with all four options and custom_tool_strategy: function_bridge, against one self-hosted DeepSeek V4.1 Flash replica behind a router, 30 minutes per trial.

221 model calls, all 200 except the one in flight when the cap stopped Codex. Every request had effort max, and replaying one through a recorder showed reasoning_effort: "max" in the chat body the engine receives, with include and reasoning.summary dropped. Codex called view_image 80 times and the images went inline in later requests without a rejection. For all 220 completed calls, every streamed event carries the captured response id; 39 calls sent keepalive events. Both trials were graded.

No turn here passed 300 s (the longest was 69 s), so I checked the long-turn case on a local rig: a real Codex 0.154.0 binary at its stock 300 s idle timeout and a model turn that takes 400 s. With keepalive on, the turn completes and every event carries the captured id. With it off, it also completes: the buffered path sends no headers until the answer is ready, and Codex's idle timer only starts once a stream has begun. The keepalive matters when something between Codex and the server opens the stream early or drops idle connections; I've corrected the body's sentence about which harness needs it.

gwarmstrong added a commit to gwarmstrong/Gym that referenced this pull request Oct 28, 2026
…Mo#5155

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
khazic pushed a commit to khazic/Gym-lao that referenced this pull request Oct 30, 2026
…eMo#5219)

* feat(responses): opt-in keepalive events for long streaming responses

Streaming /v1/responses requests are buffered: the converted response is
re-emitted as SSE only once responses() returns, so the client sees no
event while a long generation runs. A client that bounds the silence
between events abandons the turn and re-sends it. The Codex CLI does
this after stream_idle_timeout_ms (300 s by default), which a high
reasoning effort turn can easily outlast. SSE comments, which the chat
streaming path sends, do not help here: Codex's event parser drops them
before its idle timer sees them.

Add stream_keepalive_s to BaseResponsesAPIModelConfig (default null,
today's behavior). When set and a call is still running after that many
seconds, the stream opens with response.created and then carries
response.in_progress every interval for the same placeholder response
id. Once responses() returns, the item and terminal events follow
exactly as the buffered path sends them, minus the response.created the
keepalives already sent.

A call that finishes within the first interval streams exactly as
before, HTTP error statuses included. A failure after the stream opened
ends with response.failed for the announced response id, carrying what
the client would have seen before the stream opened: a refusal the
server propagates keeps its OpenAI code (context_length_exceeded), an
upstream HTTP error follows the server's propagation policy, and no
upstream request URL is reported.

Model-call capture measured time to first token from the first body
chunk, which with keepalives would be the opening response.created one
interval in. The Responses lifecycle events (response.created and
response.in_progress) carry no model output, so they no longer count
toward it; on the buffered path they are sent in the same instant as the
first item, so its measurement is unchanged.

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>

* feat(vllm_model): accept unlisted reasoning efforts and all_turns context

The Codex CLI sends reasoning efforts its model catalog offers, and a
catalog may offer one the pinned OpenAI SDK does not list, such as
"max". Request validation types reasoning.effort with the SDK's literal,
so vllm_model answers such a request with 422 before any conversion,
although the engine behind the chat route accepts the level.

Add forward_unvalidated_reasoning_effort to VLLMModelConfig (default
false, today's behavior; requires is_responses_native=false). When set,
a string effort the SDK does not list is taken out of the raw Responses
body before validation and put verbatim on the converted chat request
as reasoning_effort; the engine validates it. Efforts the SDK lists,
"none" included, keep their existing path, and an empty effort is still
refused.

Codex also sends reasoning.context "all_turns" when its catalog enables
reasoning context, which drop_unrepresentable_request_fields still
refuses. The conversion passes every reasoning item the request carries
to the engine and leaves what is rendered to its chat template, which is
what "all_turns" (render them all) and "auto" (the provider's choice)
ask for. With drop_unrepresentable_request_fields those two values are
now dropped like the summary fields. "current_turn" asks for earlier
turns' reasoning to be left out, which the conversion does not do, so
it is still refused, and every value is still refused by default.

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>

* feat(vllm_model): bridge custom tools and inline tool output images

Two things the Codex CLI sends cannot reach a vLLM chat route through
the Responses to Chat Completions conversion today.

Codex's apply_patch is a Responses custom (freeform) tool with a lark
grammar. The conversion forwards it as a Chat Completions custom tool,
which a vLLM chat route rejects, so the only option was
drop_custom_tools: the model then has no apply_patch, tries it in the
shell, and falls back to editing with sed.

Add custom_tools_as_functions (VLLMModelConfig and ResponsesConverter,
default false). Each custom tool is presented to the engine as a
function tool with one required string parameter, input; its
description is the tool's description, a note to pass the raw input as
input, and the grammar when the tool has one, since a string parameter
cannot constrain the input. The engine's calls to that function come
back as custom_tool_call items (call_id, name, input, status) on the
non-streaming and the streaming path alike. Arguments that are not an
object with a string input are passed through as the input, so the
client's tool reports the bad call to the model. Replayed
custom_tool_call and custom_tool_call_output items become the chat tool
call (arguments {"input": ...}) and tool message, on the same assistant
turn as the replayed reasoning. A named tool_choice of a custom tool
selects its function. The option takes precedence over
drop_custom_tools, and a custom tool named like a function tool fails
the request. It needs is_responses_native=false and
return_token_id_information=false: custom_tool_call has no variant that
carries token IDs. Saved model-call records now list custom_tool_call
items among a response's tool calls.

When the model catalog lists image input, Codex offers view_image,
whose output is a function_call_output holding an input_image part.
Chat tool messages accept image parts since NVIDIA-NeMo#4688, but the conversion
still refuses the part, or replaces it with a text placeholder under
drop_unrepresentable_request_fields, so the model never sees the image.

Add inline_tool_output_images (VLLMModelConfig and ResponsesConverter,
default false; needs is_responses_native=false). Each image part of a
tool call output becomes an image_url part of the chat tool message,
which vLLM accepts and leaves to the chat template, and it takes
precedence over the placeholder. Detail "original", which Codex sends
when the model asks view_image for full resolution and which chat
lacks, goes as "high". Other part types a tool message cannot carry
are still refused.

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>

* fix(vllm_model): reject forward_unvalidated_reasoning_effort where it cannot apply

The completions path builds its body from a fixed list of chat fields, and
reasoning_effort is not one of them, so with use_completions_api=true the
forwarded effort was dropped without a word. The compaction server has its
own dispatch, which never sets the unlisted effort aside. Both now refuse
the option at startup instead of ignoring it.

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>

* feat(codex): opt-ins to launch Codex in a sandbox as a plain codex exec does

Evaluating Codex in a task sandbox should send the requests the Codex
CLI sends when a harness such as Harbor 0.24 launches `codex exec`
directly in the task container. Sandbox sessions refuse extra_config, so
add explicit settings. Every default keeps the current behavior:

- model_server_forwards_reasoning_effort: sandbox sessions keep
  rejecting reasoning_effort by default, because most Gym model servers
  cannot guarantee it reaches the backend. The flag records the
  operator's check that the model server forwards Codex's `reasoning`
  field unchanged; the effective value is then visible in captured model
  calls.
- inherit_sandbox_environment: give Codex the sandbox's whole
  environment instead of PATH, the locale, HOME and the XDG directories,
  keeping only CODEX_HOME private. Task images set variables such as
  VIRTUAL_ENV, PYTHONHASHSEED or OMP_NUM_THREADS that a harness started
  in the container sees. Intended for remote providers, where that
  environment is the task's.
- features: the generated [features] table, which turns off multi_agent
  and code_mode by default. A null entry is left out, so the CLI default
  applies, as in a launch without a Gym config.
- ephemeral: false drops --ephemeral, so Codex keeps session state and
  offers its goal tools (create_goal, get_goal, update_goal). A correlated
  local rollout no longer tries to remove a flag that is not there.
- request_max_retries, stream_max_retries: the provider's resends before
  a stream starts and reconnects after one drops, beside
  stream_idle_timeout_ms; null leaves Codex's own defaults.

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>

* feat(codex): grade a failed run while its sandbox still runs

Harbor runs the verifier after any agent exit that is not a timeout: a
trial whose agent died while its sandbox still answers is graded, not
rerun. codex_agent instead raised an execution error for every failed
Codex run, so the Environment Server skipped verification and the
episode was retried from scratch: an agent the kernel's OOM kill hit
(once the sandbox kills a single process instead of itself), an agent
whose own pkill hit the runner, or a Codex exit 1.

verify_after_agent_error (off by default) returns such a run as a
`failed` response, with the error in metadata.graded_agent_error, which
Resources grades as a nonzero agent exit. It applies only when the
supervisor confirmed cleanup, the failure that ended the run names no
model-endpoint transport or 429/5xx error, and the sandbox still
reports running and answers a probe command. A lost or unresponsive
sandbox, an unconfirmed cleanup and an endpoint outage stay execution
errors and are retried.

The endpoint check reads only the terminal failure after the run's last
model output (turn.failed and the last error event before it, or the
last event when Codex was killed without turn.failed), so a retry that
recovered no longer turns a later unrelated failure into a rerun. It
recognizes Codex 0.154.0's messages: unexpected status 429/5xx, the
"high demand" message for a 500, exceeded retry limit, the
waiting-for-network notice, and stream disconnects from a transport
error, a closed stream, a failed send or the SSE idle timeout. The
output-cap disconnect (reason: max_output_tokens) stays gradable.

raise_oom_score_adj (off by default) makes Codex the OOM killer's
preferred victim in its sandbox. In Docker, the leaderboard's runtime,
the daemon that runs the agent lives outside the container's memory
cgroup, so an OOM kill can only pick the agent's own processes. In an
OpenSandbox sandbox the exec daemon and Gym's supervisor share the
cgroup and the oom_score_adj with Codex. The runner raises its own
score, which Codex and every tool inherit, to 1000, or to 0 when the
sandbox starts it below zero (a Guaranteed pod's -997), so the sandbox
never becomes the node's first victim in a global OOM. The exec daemon
and the supervisor keep the sandbox's own score.

TB4's resources-session test covers the `failed` response status: it is
graded as a nonzero agent exit.

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>

* feat(codex): keep session rollouts in the trial dir

codex_agent removes its session directory, and with it CODEX_HOME, when
a sandbox session closes. Without the model-call capture, Gym then keeps
no per-call token counts for a Codex run: the response usage comes from
turn.completed, which a run stopped at its time limit never emits. With
the capture on, every request payload is also copied into the rollout
row (one image-heavy trial's row was 2.5 GB after 20 minutes), which a
full 210-trial run cannot hold in memory or on disk.

New sandbox_logs_dir setting: after Codex stops, the session rollouts
(CODEX_HOME/sessions/**/rollout-*.jsonl, with per-call token counts and
timestamps) are copied to <sandbox_logs_dir>/sessions before the session
directory is removed. A failed copy is logged and never fails
collection. Harbor's Codex agent copies them to /logs/agent/sessions the
same way; with /logs/agent, the TB4 resources server copies them into
the trial directory (agent/sessions/). It only acts without --ephemeral.

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>

* fix(codex): run tasks whose workdir is the root

A task image without WORKDIR (some Terminal-Bench-Science tasks) lends "/"
as its workdir. codex_agent refused it with HTTP 422 ("runtime/session
files must be outside SandboxAccess.workdir"), so every trial of those
tasks failed at session start, while Harbor runs the agent there.

The root holds every path, so it is accepted. The runtime and session
files stay under /tmp, and any other workdir must still be disjoint from
them: /tmp itself and paths under the adapter's /tmp prefixes are refused
as before, and the in-sandbox check still rejects a workdir that contains
or lies inside an adapter path after resolving symlinks.

Tests: six new cases in codex_agent/tests/test_sandbox_sessions.py cover
the root workdir (in-sandbox check and seeding) and the workdirs that are
still refused.

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>

* refactor(vllm_model): use main's function bridge for custom tools

NVIDIA-NeMo#4880 added custom_tool_strategy: function_bridge, which lowers each
Responses custom tool to a function tool with one string input and lifts
the calls back, on the same conversion this branch extended. Drop this
branch's custom_tools_as_functions, which did the same job: the config
field, the converter helpers and restore step, the name-clash check,
the streaming drop override and their tests.

The Codex tests now run apply_patch through function_bridge, streaming
and non-streaming, with drop_custom_tools off: it is still the kill
switch and would drop the tool before the bridge sees it.

Saved model-call records keep listing custom_tool_call items, which the
bridge returns too.

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>

* fix(codex): drop the ephemeral setting now that sandbox sessions keep session state

Main's sandbox sessions always run Codex without --ephemeral, to keep its
native session transcript, so the setting no longer changes them. Local runs
keep main's --ephemeral.

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>

* feat(tb4): lend task sandboxes through SandboxAccess sessions

Serve the shared resources-session protocol so harnesses that borrow a
task sandbox (codex_agent behind single_agent_turn or
single_agent_turn_legacy) can run TB4-pinned datasets, as NVIDIA-NeMo#3902 does for
Terminal-Bench 2.1:

- /seed_session with a ResourcesSeedSessionRequest provisions the task
  through TB4's existing seed lifecycle and returns SandboxAccess: a
  direct connection through the new `sandbox_provider_ref` block and the
  task workdir as its user sees it. Seeds are idempotent per
  resources_session_id.
- /verify with the task row and the agent response grades once through
  the existing finalizer (artifact collection, separate offline verifier,
  test counts and partial credit, EFS logs) and replays the verdict on
  identical retries. Termination follows response.status.
- /close_session fences the session before it waits, as Terminal-Bench
  2.1 does: a seed still provisioning releases its sandboxes instead of
  lending them, even when the close's caller gives up. It then waits for
  provisioning or grading, releases anything still running without
  grading, fences late seeds, and fails (keeping the fence) when a
  sandbox cannot be stopped.
- Completed close records expire after
  session_close_retry_window_seconds (default 300, the Terminal-Bench
  2.1 setting), so the fence set no longer grows with every episode.
  Failed or pending cleanup never expires, and close retries do not
  extend the window.

Tasks that declare agent.user are rejected, since the borrower runs as
the sandbox default user. TB4's own mini-SWE seed/verify contract is
unchanged.

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
(cherry picked from commit 1775136793828102e9826467f67736d9849b1ef8)
Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>

* docs(tb4): grade an agent that died while its sandbox runs as a nonzero exit

A harness that returns a failed response while its sandbox still runs
(codex_agent's verify_after_agent_error) is graded with termination
nonzero_exit. Document it and cover it in the sessions test.

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
(cherry picked from commit a61790adffe459b9c1153887134f1f4cd78c065c)
Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>

* feat(terminal-bench-science): add a Codex profile and a DeepSeek V4.1 Flash run config

The public leaderboard's DeepSeek V4.1 Flash and GPT-6 Astra rows ran
Codex at reasoning effort max. Add `terminal_bench_science/codex`: the
resources server lends each task sandbox through SandboxAccess,
codex_agent installs and runs Codex 0.155.1 there, and
single_agent_turn_legacy seeds both, closes Codex before grading, then
closes Resources. Grading, partial credit, artifacts and EFS logs are
the same as for mini-SWE.

- Codex's timeout is the task's 8-hour agent budget; prepare_codex.py
  checks that every selected task has it. The episode deadline covers
  provisioning, installation, the budget and the slowest verifier.
- Codex sees the task image's environment and HOME, as in Harbor.
- The model name Codex sees, its version and its reasoning effort are
  run overrides; effort also needs model_server_forwards_reasoning_effort.
  Codex's provider retries keep the CLI defaults unless
  tbsci_codex_request_max_retries / tbsci_codex_stream_max_retries set
  them.
- A Codex run that dies while its sandbox still runs is graded as a
  nonzero agent exit, as Harbor grades it; Codex and its tools are the
  OOM killer's preferred victims; and without --ephemeral Codex's
  session rollouts are kept under /logs/agent/sessions, which the trial
  directory collects.
- prepare_codex.py writes rows whose prompt is the package's
  instruction.md, read and hash-checked by the resources server's
  loader, because an Environment Server passes the row's prompt to the
  agent. prepare.py now exposes its task selection.

codex_deepseek_flash.yaml is the run config for the leaderboard's
DeepSeek V4.1 Flash / Codex / effort max row with a self-hosted model:
Codex 0.154.0 with no model catalog, effort max, the full 28800 s
budget, and an OpenAI Responses endpoint serving deepseek-flash in
front of a chat-completions server; the endpoint URL and its key are
overrides (--model-url; POLICY_API_KEY or ++tbsci_model_api_key_env).
openai_model relays Codex's requests unchanged and adds no time limit of
its own, and the agent keeps Codex's feature defaults and session state,
so the first request matches a plain `codex exec` launch as Harbor 0.24
starts it: same instructions, input items, reasoning settings and tools,
including the multi-agent and goal tools.

The README documents both setups, including the leaderboard's catalog
model and effort max through openai_model's
responses_stream_passthrough.

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
(cherry picked from commit d131d1d30391cefb7c2c2ca0a4b17758501ba6db)
Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>

* fix(terminal-bench-science): match the Codex profile to the updated agents

- Run one trial per task by default, as the mini-SWE profile does; ++num_repeats=3 runs the
  leaderboard's three.
- Drop codex_agent's ephemeral setting: sandbox sessions always run Codex without --ephemeral.
- Name the vllm_model settings Codex needs, with NVIDIA-NeMo#4880's custom_tool_strategy: function_bridge.
- Describe how a provider failure Codex reports itself reaches the outcome policy (NVIDIA-NeMo#4834).
- Check the Codex manifest against its runtime composition.

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
(cherry picked from commit 34cb6349bb10531fdc28f66a50a928fbdaf894a7)
Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>

* feat(terminal-bench-science): serve the DeepSeek Codex run through vllm_model

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>

* fix(vllm_model): measure context with the reasoning effort generation forwards

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>

* fix(codex_agent): retry Codex's capacity and request-timeout endpoint errors

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>

* fix(codex_agent): keep usage and observations of a graded failed run

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>

* fix(codex_agent): fit the session rollout copy inside the capture budget

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>

* fix(checkpoint): treat Responses lifecycle events as liveness

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>

* test(vllm_model): let Responses lifecycle events precede publication

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>

* fix(model): keep one response id across a keepalive stream

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>

* fix(genrm_model): forward inline_tool_output_images to the converter

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>

* fix(terminal_bench_4): finish close cleanup after its caller disconnects

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>

* fix(terminal_bench_4): retry a failed sandbox teardown on the next close

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>

* fix(terminal_bench_4): mask the verify of a lost or expired resources session

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>

* docs(terminal_bench_4): describe close teardown retries

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>

* docs: link the Terminal-Bench-Science Codex profile from the harness guide

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>

* docs(terminal_bench_science): replace a PR placeholder with NVIDIA-NeMo#5155

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>

* fix(terminal_bench_science): prepare Codex rows through gym eval prepare

Without pathspec, which Gym's own environment leaves out, prepare_codex runs itself in the terminal_bench_4 server's environment, setting that up first as gym env start does. The selection is checked before the hand-off.

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>

---------

Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
Co-authored-by: gwarmstrong <gwarmstrong@users.noreply.github.com>

This branch was previously deployed

1 inactive deployment
public — 66d4c71f Deployed Oct 27, 2026 by copy-pr-bot[bot] via release / finalize / notify #7711
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:model Model servers, inference providers, and model adapters feature New capabilities, enhancements, or enablement work ready-to-merge PR is approved, current, and waiting for final CI or merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants