Repository navigation
feat(vllm_model): serve Codex through the Responses-to-chat conversion - #5155
Conversation
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>
|
/ok to test ff92c43 |
|
🌿 Preview your docs: https://nvidia-preview-pr-5155-66d4c71fa5ec.docs.buildwithfern.com/nemo/gym |
svcnvidia-nemo-ci
left a comment
There was a problem hiding this comment.
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.pyline 933: BLOCKER — Lifecycle keepalives bypass capture publicationWith
checkpoint.policy, external token staging, andstream_keepalive_senabled, 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, butpublish()returns because no deferred commit exists yet. Finalization subsequently defers the completed ledger row, while every answer chunk skips publication becauseticket.response_startedis 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.pyline 660: BLOCKER — Successful keepalive streams change response identityFor a call that outlasts
stream_keepalive_s, this generator announces a freshly mintedresp_Ain created/progress events, then passes the backend response unchanged to_serve_responses_sse(). If that response has IDresp_B, the terminal synthesizer emitsresp_B, with no terminal event forresp_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 IDresp_finalafter 0.3 seconds. The opening ID is a generated UUID-based ID, whileresponse.completed.response.idisresp_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.pyline 45: RISK — GenRM silently ignores the inherited image-inlining optionGenRMModelConfiginheritsinline_tool_output_images, but this converter override does not forward it. The PR adds forwarding only inVLLMModel.get_converter(), which GenRM bypasses. Consequently, settinginline_tool_output_images: trueon GenRM still rejects afunction_call_outputcontaining aninput_image; with dropping enabled it substitutes the placeholder instead. The accepted option should retain the image as animage_urltool-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 existingfunction_bridgeassertion 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>
|
/ok to test 66d4c71 |
|
All three findings reproduced, and each now has a test that fails on the previous head (ff92c43).
Tests: |
yaoyu-33
left a comment
There was a problem hiding this comment.
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:
- A lone
response.created/response.in_progresschunk is now liveness at the checkpoint gate (_checkpoint/model.py#L934-L937). - The keepalive id is reserved before it is announced and applied before capture (
#L621-L624,#L940-L953), and servers that serve upstream ids refuse the setting. - GenRM forwards
inline_tool_output_images(genrm_model/app.py#L46).
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_modelsmoke 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_agentalready sets Codex'sstream_idle_timeout_msfrom 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( |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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?
| ) | ||
| return self |
There was a problem hiding this comment.
responses_api_models/vllm_model/app.py:533-548
Two more combinations could be refused here at config time, like the others:
stream_keepalive_swithis_responses_nativeis refused only insetup_webserver(base_responses_api_model.py#L403).LocalVLLMModeland GenRM reach that only afterstart_vllm_server()(local_vllm_model/app.py#L92-L96), so the GPU engine starts before the config is refused. Adding"stream_keepalive_s"to theis_responses_nativetuple above would refuse it first.inline_tool_output_imageswithuse_completions_apiisn't refused at startup. Each inlined image then fails its request in the text-only completions path (#L1967):
| ) | |
| 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: |
There was a problem hiding this comment.
tests/unit_tests/test_responses_api_model_streaming.py:1264
Three of the Responses keepalive cleanup paths have no test yet:
- a request cancelled during the first interval (
base_responses_api_model.py#L629-L632); - a stream closed mid-keepalive (
#L690-L693); - an
HTTPExceptionraised after keepalives (#L716).
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: |
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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.
|
🔄 Merge queue validation started! You can track the progress here: https://github.com/NVIDIA-NeMo/Gym/actions/runs/40733684068 |
|
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 221 model calls, all 200 except the one in flight when the cap stopped Codex. Every request had effort 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. |
…Mo#5155 Signed-off-by: gwarmstrong <gwarmstrong@users.noreply.github.com>
…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>
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: withcustom_tool_strategy: function_bridge, Codex's freeformapply_patchreaches the model as a function tool and the call comes back as acustom_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 sendsmax) to the chat route asreasoning_effortinstead 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 dropsreasoning.contextauto/all_turns, since the conversion already forwards every reasoning item;current_turnis still refused.inline_tool_output_images:view_imageoutput goes to the model as animage_urlpart 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_callitems among a response's tool calls, so a bridgedapply_patchcall is recorded like any other.Options that cannot take effect are refused at startup:
forward_unvalidated_reasoning_effortwithuse_completions_api(the completions body has noreasoning_effort) or onvllm_model_with_compaction, the conversion options withis_responses_native, andstream_keepalive_son 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-commitclean.max,context: all_turns,include, andapply_patchwith its lark grammar) round-trips throughvllm_modelwith these options andcustom_tool_strategy: function_bridge: the call comes back as acustom_tool_call, and its replay on the next turn reaches the engine as the matching chat tool call and tool message.maxandinclude; the GPT-6 catalog addscontext: all_turns.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, effortmaxon every turn, 21view_imagecalls passed inline.Rollouts
N/A: model-server options.
Compatibility and benchmark impact
Defaults unchanged. Users who already set
drop_unrepresentable_request_fieldswill now also havereasoning.contextauto/all_turnsdropped instead of refused.