Skip to content

feat: add switchyard_model server for router-backed evals with condition provenance - #2141

Merged
ananthsub merged 22 commits into
mainfrom
feat/switchyard-model-server
Aug 26, 2026
Merged

ananthsub merged 22 commits into
mainfrom
feat/switchyard-model-server

Conversation

@linj-glitch

@linj-glitch linj-glitch commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator

Part of #2088. This PR now also absorbs #2600 (routing-condition provenance) so the whole feature reviews in one place.

Adds responses_api_models/switchyard_model/, a model server that routes Gym model calls through a Switchyard proxy, so any benchmark Gym supports can run against a router without harness changes — unlike the per-harness integrations in #2026/#2080, which are right for RL trace capture but not for evaluating the router itself. Gym either hosts the router in-process from a native Switchyard TOML deployment (deployment=routes.toml; a bad deployment fails at startup, nothing outlives the process, and multi-worker configs are rejected since each worker would host its own proxy) or attaches to an externally run proxy via switchyard_base_url, which does support multiple workers. A caller-supplied model is always overridden with the configured route so an agent can't bypass the router, and Gym forwards its rollout-attempt id as the Switchyard session id (x-switchyard-session-id) so routing decisions and costs join back to the rollout that produced them. That id only arrives when model-call capture or token capture is enabled, so the server validates the requirement at startup: a warning under the default config, a refusal to start when forwarding was requested explicitly but no capture is on.

From #2600: set condition_dir and the server records the routing condition the run served under — switchyard-condition.json at startup (route, mode, proxy URL, deployment SHA-256 plus an archive with inline api_key and all extra_headers values redacted, and the proxy's identity: the local wheel version when hosting, a caller-supplied proxy_provenance mapping when attaching) and switchyard-stats.json at shutdown (per-target requests, errors, tokens, and latency plus classifier-side usage from /v1/stats, fetched through Gym's shared async client as the configured caller and hard-capped inside the shutdown window). The snapshot labels its scope, since a hosted proxy's counters cover exactly the run while an attached proxy's aggregate everything it has served. Both writes are best-effort and cannot take down serving. This makes two routed runs comparable and a hosted run reproducible from the archived deployment; the docs page gains a "Compare routing conditions" workflow that aligns rollout rows by task and rollout index, reports rollouts missing a counterpart, and includes the classifier tokens the selected model's usage omits.

The only dependency is nemo-switchyard==0.2.0, pinned exactly in this server's own pyproject.toml (Gym core never imports Switchyard); 0.2.0 is the floor because it is the first release whose Responses translation passes NeMoGymResponse validation (NVIDIA-NeMo/Switchyard#144), and the ceiling because upstream has already removed the Python serve stack this PR originally drove. 47 tests pass with 100% coverage on app.py, including an integration suite that drives the real 0.2.0 wheel with no mocks (chat/responses round trips, route override, stats, provenance files written across a real shutdown), and a live routed rollout ran against this revision with reward 1.0 (results in this comment).

Two reviewer notes. ModelCallRecord.model_ref names the Gym model server, not the routed target, so per-call attribution must be read from captured payloads — promoting it to a first-class record field is the follow-up flagged for #2088 and needs a core-schema design pass. And harbor_agent overwrites response["model"] with the policy model name, so Harbor-run benchmarks report the route name for every call; flagging in case that overwrite is unintentional.

@copy-pr-bot

copy-pr-bot Bot commented Jul 25, 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.

@linj-glitch
linj-glitch marked this pull request as ready for review July 25, 2026 00:27
@linj-glitch
linj-glitch requested a review from a team as a code owner July 25, 2026 00:27
@github-actions

github-actions Bot commented Jul 25, 2026 •

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot added the sla:triage-overdue Review assignment is over the one-business-day SLA label Jul 27, 2026
@github-actions github-actions Bot added sla:review-overdue Review response is over the one-business-day SLA and removed sla:triage-overdue Review assignment is over the one-business-day SLA labels Jul 27, 2026
@linj-glitch
linj-glitch force-pushed the feat/switchyard-model-server branch 2 times, most recently from 1621621 to 8cdac1e Compare August 18, 2026 06:48
@linj-glitch

Copy link
Copy Markdown
Collaborator Author

Live end-to-end validation of the native-server revision (nemo-switchyard==0.2.0 wheel, hosted in-process):

Setup: gym eval run --resources-server example_single_tool_call --model-type switchyard_model --model policy-model --agent example_single_tool_call_simple_agent, with deployment pointing at a TOML that routes policy-model (passthrough) onto llama-3.1-8b-instruct behind an NVIDIA-hosted OpenAI-compatible endpoint.

What was verified against live traffic:

  • Hosting: Gym started the in-process native server from the TOML, served the eval through it, and tore it down cleanly with the app. (Earlier iterations also confirmed the failure modes: a bad deployment fails at startup with Switchyard's validation error, and an unauthorized upstream key surfaces as a clear 401 in the proxy's request log.)
  • Routing: every model call logged by Switchyard as requested_model="policy-model" selected_model="nvidia/meta/llama-3.1-8b-instruct" status=200 — the route name in, the routed target out. 80+ live calls, no errors.
  • Rollout completes and verifies: reward 1.0, with the routed target (not the route name) on the rollout response model, and usage carrying the detail objects the 0.2.0 floor exists for: {"input_tokens": 8221, "input_tokens_details": {"cached_tokens": 0}, "output_tokens": 337, "output_tokens_details": {"reasoning_tokens": 0}, ...}.
  • Session correlation, live: with ++observability_enabled=true ++model_call_capture_dir=..., every routed call shows Gym's rollout-attempt id in Switchyard's request log (session_id="0-0"), and the capture record for that rollout shows model_ref naming the Gym server while response.model carries the routed target — the attribution split called out in the reviewer notes.

One upstream observation from the run: Switchyard 0.2.0 forwards the x-switchyard-session-id header to the upstream provider after parsing it (it strips nothing it doesn't recognize). Harmless for an opaque rollout id, but worth an upstream issue; the integration test pins the current behavior so an upgrade that changes it is caught.

🤖 Generated with Claude Code

@linj-glitch

Copy link
Copy Markdown
Collaborator Author

Test results

26 passed in 3.35s
responses_api_models/switchyard_model/app.py    109 stmts    0 miss    100%

ruff check, ruff format, and pre-commit all clean on the touched files.

What the suite covers, by layer:

Layer Against Covers
Config & handlers (13 tests) mocked client mode inference and its failure case, route-name override, session-header forwarding on/off, extra headers, extra_body merge, semaphore
Rollout middleware (3) pure ASGI id published from /ng-rollout/<id>/ paths, off-charset ids dropped (header-injection guard), non-HTTP scopes untouched
Proxy lifecycle (7) fake native server hosting only at app startup, missing-dependency guidance, deployment+port reaching the server, close-once idempotence, close on failed startup
Full chain (5) real 0.2.0 wheel, no mocks Gym app → hosted native router → stub upstream: chat↔responses translation with the usage-detail objects the pin exists for, route override on the wire, credential wiring, /v1/stats counting traffic, rollout id landing in Switchyard's routing metadata, invalid TOML failing at startup with the validator's error

What writing the real-wheel tests caught (both fixed in this PR):

  1. The native server reads x-switchyard-session-id, not proxy_x_session_id — the old default silently correlated nothing and leaked the header to the upstream provider.
  2. The PyO3 Server must be closed by the thread that created it — hosting now lives in the app lifespan so both ends share the loop thread.

The full-chain tests skip when nemo-switchyard isn't installed (Gym's core venv) and run in the server's own venv, so CI exercises them.

Live end-to-end rollout results (real upstream, reward 1.0, live session correlation) are in the comment above.

🤖 Generated with Claude Code

@linj-glitch

Copy link
Copy Markdown
Collaborator Author

Follow-up #2600 (stacked on this branch) adds the routing-condition record — provenance manifest + /v1/stats snapshot per run — and the documented two-condition comparison workflow, closing the remaining #2088 criteria that were out of scope here.

@linj-glitch
linj-glitch force-pushed the feat/switchyard-model-server branch from e9f7192 to 748acd9 Compare August 25, 2026 06:05
@linj-glitch linj-glitch changed the title feat: add switchyard_model server for router-backed evals feat: add switchyard_model server for router-backed evals with condition provenance Aug 25, 2026
a copy with the session header merged in. The copy is a plain Pydantic model over Gym's
shared aiohttp client, so this costs an object allocation, not a connection.
"""
rollout_id = _ROLLOUT_ID.get()

@ananthsub ananthsub Aug 25, 2026 •

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.

This model server gets the rollout ID from the /ng-rollout/<id>/ URL prefix. BaseResponsesAPIAgent adds that prefix only when observability or token capture is enabled. In an ordinary eval with both features disabled, the request is unprefixed, _ROLLOUT_ID remains None, and no Switchyard session header is sent. As a result, the documented rollout correlation and session-affinity behavior depend on enabling an unrelated feature.

@Glorf IMO we should propagate rollout identity independently of capture: #2783

@linj-glitch for now, this requirement should be explicit and validated at startup

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Validated at startup in 5ac91a5: check_rollout_correlation reads the same global-config keys the agents consult. Under the default config it logs a warning naming the fix; when forward_session_id was set explicitly and no capture is enabled, the server refuses to start. Verified live in both directions — an explicit-forwarding run without observability refused with the documented message, and an observability run showed session_id 0-0 in Switchyard's request log, matching the rollout row's indexes. This surfaced a follow-on bug: the shipped yaml restated the default, which made every run look explicit; fixed in 66f877f with a regression test that reads the yaml. Agreed that propagating rollout identity independently of capture is the better long-term shape.

deployment_sha256 = hashlib.sha256(deployment_bytes).hexdigest()
# The native schema references credentials by environment variable name, but an
# inline literal would be a secret -- drop the value, keep the line visible.
deployment_toml = re.sub(

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.

The manifest archives the deployment TOML, but this redaction covers only lines named api_key.

Valid Switchyard deployments can store credentials in llm_clients.*.extra_headers, such as an Authorization header (upstream schema). Those values do not match this expression and are copied unchanged into switchyard-condition.json.

Please redact all extra_headers values after parsing the TOML, or avoid storing the raw deployment.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 5ac91a5. The archive is now redacted by parsing the TOML and blanking every extra_headers value wherever it appears (including inside arrays of tables) plus any api_key value; a deployment that does not parse cannot be checked, so it is not archived at all and only its hash is kept. Verified live: a deployment with a planted Authorization-style extra_headers value came out of the manifest as with the api_key_env reference intact. Confirmed while testing that the 0.2.0 client schema has no inline api_key field at all — extra_headers is exactly where a live secret would sit.

return NeMoGymChatCompletion.model_validate(response_dict)


if __name__ == "__main__":

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.

When num_workers > 1, Gym starts Uvicorn with an import string and expects this module to expose an app object (server startup). This module only starts the server under if __name__ == "__main__", so a multi-worker configuration fails during startup. Adding the normal import guard is sufficient for attached mode, where all workers can share one external proxy. In hosted mode, however, each worker would start a separate proxy, splitting session affinity and statistics and potentially colliding on a fixed port.

Please add the worker entrypoint for attached mode and reject hosted configurations with more than one worker unless the proxy is shared explicitly.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done in 5ac91a5: the module now exposes the worker entrypoint (elif is_nemo_gym_fastapi_entrypoint), so attach mode works under num_workers > 1 with all workers sharing the external proxy, and hosted configurations with more than one worker are rejected at config validation with guidance to attach instead. Verified live: a hosted num_workers=2 run failed fast with the validation message.

if not self.config.condition_dir or root_url is None:
return
try:
with urllib.request.urlopen(f"{root_url}/v1/stats", timeout=5) as response:

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.

The async lifespan calls synchronous urllib.request.urlopen() during shutdown. If the proxy is slow or unreachable, this blocks the event loop for up to five seconds, while Gym waits only one second before escalating to SIGKILL (shutdown window). This request also omits switchyard_api_key and default_headers, so an authenticated attached proxy can serve model calls successfully but return 401 for the stats snapshot. Please use Gym's shared async HTTP path, send the configured authentication headers, and keep the request within the shutdown budget.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 5ac91a5. The snapshot now goes through nemo_gym.server_utils.request on the shared aiohttp client, sends the configured default_headers plus the switchyard_api_key as the bearer token (same identity as the model path), and is hard-capped at 0.5s with asyncio.wait_for — the outer cap also matters because the shared client retries connection errors indefinitely. Any failure inside the cap logs a warning and shutdown proceeds.

extra_body: Dict[str, Any] = Field(default_factory=dict)
default_headers: Dict[str, str] = Field(default_factory=dict)

max_concurrent_requests: Optional[int] = Field(

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.

nit: please validate the value as strictly positive when set with Field(default=None, gt=0).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done in 5ac91a5, with a validation test.

"route": self.config.switchyard_model,
"mode": "hosted" if self.config.launches_proxy else "attached",
"proxy_root_url": self.proxy_root_url(),
"nemo_switchyard_version": self._nemo_switchyard_version(),

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.

When Gym hosts the proxy, the locally installed nemo-switchyard version identifies the code serving requests. In attached mode, requests go to an external proxy, but this field still reports the local wheel even if the external proxy runs a different version or commit. The attached manifest also has no deployment hash, so it cannot identify or reproduce the routing condition that actually served the run.

Could attached mode record remote or caller-supplied proxy provenance?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 5ac91a5: attached manifests now report nemo_switchyard_version as null (the local wheel never served a request) and take identity from a new proxy_provenance config mapping copied verbatim into the manifest, so the proxy's operator can record the build commit and deployment hash. 0.2.0 exposes no version or config-identity endpoint to query remotely, so caller-supplied is the honest option available. Verified live: an attached run against a standalone proxy produced a manifest with the null version and the supplied provenance.

deployment_sha256: Optional[str] = None
deployment_toml: Optional[str] = None
if self.config.launches_proxy:
deployment_bytes = Path(str(self.config.deployment)).read_bytes()

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.

this Path.read_bytes() call is outside the error handler. If the deployment becomes unreadable after the native server loads it, the read raises and stops server startup instead of logging a warning.

The manifest is also written before the lifespan enters the cleanup try/finally, so the already-started proxy is not explicitly closed.

All manifest preparation should be in the error handler, and the cleanup handling should take effect immediately after starting the proxy

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Restructured in 5ac91a5: the lifespan's cleanup scope now begins the moment the proxy is serving, so anything failing between proxy start and finished startup still closes it, and all manifest preparation including the read_bytes sits inside the best-effort handler — an unreadable or unparseable deployment costs a warning (and, for unparseable, the archive), never startup.

import json

for condition in ("strong-only", "policy-model"):
rows = [json.loads(line) for line in open(f"results/{condition}/rollouts.jsonl")]

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.

The example undercounts the cost of the llm_classifier route. response.usage.total_tokens covers the selected model call, while the classifier model's tokens are reported separately in switchyard-stats.json.

The script also computes each average independently, so failures can cause the two conditions to contain different tasks. Please include classifier tokens, align rows by task and rollout ID, and report failures before comparing reward and token cost.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Rewritten in eb5e34f. The example now joins the two runs' rollout rows on _ng_task_index and _ng_rollout_index, reports rollouts that have no counterpart before comparing, and reads the classifier tokens from the stats snapshot alongside the selected-model usage — with surrounding prose explaining both pitfalls. Verified the classifier accounting live: an llm_classifier-routed call showed its classifier-side tokens only in the stats snapshot's classifier block, not in the response usage.

++policy_model.responses_api_models.switchyard_model.condition_dir=results/policy-model
```

Each results directory now identifies its condition. `switchyard-condition.json` is the provenance manifest — the route, the deployment's SHA-256 and archived contents (credential values redacted), and the `nemo-switchyard` version — written when the proxy starts. `switchyard-stats.json` is the proxy's `/v1/stats` at shutdown: per-target requests, tokens, retries, and latency, which for a hosted proxy would otherwise die with the process. Two runs are fairly comparable when their manifests differ only in `route`; a run is reproducible from the archived deployment alone.

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.

I don't see retry count in /v1/stats - is that a mistake?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

You are right — I dumped /v1/stats from the real 0.2.0 wheel and it reports requests, errors, tokens, and latency plus a classifier block, with no retry counts anywhere. Removed the claim from the docs page, README, and docstrings in eb5e34f.

[llm_clients.nvidia]
format = "openai_chat"
base_url = "https://integrate.api.nvidia.com/v1"
api_key_env = "NVIDIA_API_KEY"

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.

Please add # pragma: allowlist secret to address the secrets-detector test failures in CI

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Added in eb5e34f.

@linj-glitch

Copy link
Copy Markdown
Collaborator Author

Live end-to-end verification of this review round (commits 5ac91a5, eb5e34f, 66f877f), run against a real hosted proxy and a real inference gateway rather than the test stubs.

Hosted leg: gym eval run on example_single_tool_call with this server hosting the native 0.2.0 proxy in-process, route strong-only resolving to llama-3.3-70b-instruct. Reward 1.0. With observability enabled, Switchyard's own request log shows every call carrying session_id="0-0", matching the rollout row's _ng_task_index/_ng_rollout_index, and the capture records carry the routed model in the response body. The condition manifest archived the deployment with its api_key_env reference intact while a deliberately planted extra_headers value came out as <redacted>; the stats snapshot was written across shutdown with scope "this run" and a token total equal to the rollout's own usage.

Attach leg: a standalone native server on its own port, pre-warmed with one llm_classifier-routed call (which the router sent to the weak llama-3.1-8b target). The attached run, also reward 1.0, produced a manifest with a null nemo_switchyard_version and the caller-supplied proxy_provenance mapping, and a stats snapshot labeled proxy-lifetime aggregate that correctly includes the pre-attach call and the classifier-side tokens (1007) alongside this run's traffic — the two behaviors the provenance comments asked to make honest.

Failure legs: setting forward_session_id=true explicitly without observability refused startup with the documented message, and a hosted configuration with num_workers=2 was rejected at config validation with the attach-instead guidance.

The exercise also caught one real bug, fixed in 66f877f: the shipped yaml restated forward_session_id: true, which made every config built from it look like an explicit request for forwarding and turned the intended startup warning into a refusal for any eval running without observability. The yaml now stays silent on that key, and a regression test reads the shipped file to keep it that way. 48 tests, 100% coverage on app.py.

linj-glitch and others added 4 commits August 26, 2026 13:59
Serves Gym model calls through a Switchyard routing proxy, so any benchmark
Gym already supports can be evaluated against a router without touching
harness code:

    gym eval run --benchmark <name> --model-type switchyard_model

Switchyard is integrated at the model layer rather than the agent layer. The
existing Switchyard work in Gym (#2026 token capture, #2080 OpenCode) wires
one harness at a time under responses_api_agents/swe_agents; putting the
router behind a Responses API model instead makes it available to every
benchmark at once, which is what evaluating the router itself requires.

Two modes. Attach (default) points at a proxy run outside Gym, which keeps
proxy lifecycle and image pinning with the eval harness so a run can be
compared against a scaled-evals run of the same Switchyard commit. Launch
starts the proxy from a routing profile and shuts it down at exit, for a
single-command run.

Gym's rollout-attempt id is forwarded as Switchyard's opaque session id, so
proxy-side routing decisions and costs join back to the rollout that produced
them. The correlation prefix is stripped by Gym's capture middleware before
handlers run, so a thin outer middleware republishes the id for the request.

The nemo-switchyard dependency is scoped to this package rather than Gym's
core dependencies: only runs that route through Switchyard pay for it, and
attach mode needs no dependency at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Lin Jia <linj@nvidia.com>
This server never imports Switchyard. It speaks OpenAI-compatible HTTP to the
proxy, and launch_proxy mode shells out to the `switchyard` CLI, which only
needs the executable on PATH. The dependency bought nothing at import time and
made the package unresolvable, since the published nemo-switchyard 0.1.0 pins
openai>=2.34.0 against Gym's openai<=2.7.2.

Leaving the install to whoever wants launch mode also lets an eval pin the exact
Switchyard build its numbers are reported against, rather than inheriting Gym's.

start_proxy now checks PATH up front and explains how to fix a missing
executable, instead of surfacing an opaque FileNotFoundError from Popen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Lin Jia <linj@nvidia.com>
The point of opting into switchyard_model is not having to run a proxy. If the
user has to launch one anyway, openai_model pointed at it already works and this
server barely earns its place. So launching is now the default rather than a
flag: set routing_profiles and Gym starts, health-checks and tears down the
proxy for you. switchyard_base_url stays as the escape hatch for attaching to a
proxy you run yourself.

That removes the launch_proxy boolean -- the mode is inferred from whichever of
the two you configured, and configuring neither now fails naming both.

The spawn also moves from model_post_init into setup_webserver, matching
local_vllm_model. Launching on construction meant any path that merely built the
model -- config resolution, validation, tests -- would start a proxy; the tests
having to stub Popen just to construct an instance was the tell. Regression test
added.

proxy_host now defaults to the wildcard address, as local_vllm_model does for
cross-node reachability, while this server addresses its own child over
loopback, since a wildcard is a bind address and not a destination.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Lin Jia <linj@nvidia.com>
Covers the Gym-managed proxy launch, attaching to a proxy you run yourself,
that --model names a route rather than a model, rollout correlation via
proxy_x_session_id, and the config fields.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Lin Jia <linj@nvidia.com>
linj-glitch and others added 16 commits August 26, 2026 13:59
The proxy was launched as a child process and reaped through atexit, which
never ran: Gym's orchestrator stops a server with SIGINT and escalates to
SIGKILL after one second, and uvicorn's signal handling ends the process
without running interpreter exit hooks. Every orchestrated shutdown therefore
left a switchyard process alive, still holding its port. A single leak is
invisible; an eval fleet cycling model servers accumulates one per run.

Cleanup now has two layers, because no single one covers both shutdowns. A
lifespan wrapper stops the proxy when the app shuts down, which handles the
graceful SIGINT path. That alone is not enough -- a SIGKILLed parent runs no
handler at all -- so the spawn additionally asks the kernel, via
prctl(PR_SET_PDEATHSIG), to SIGTERM the child when its parent dies by any
means. prctl is Linux-only and degrades to a no-op elsewhere, where the
lifespan hook remains the only line of defence. atexit is kept as a backstop.

Verified against a live proxy: SIGINT, SIGKILL, and a full `gym eval run` all
now leave no surviving proxy, where the same eval previously leaked one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Lin Jia <linj@nvidia.com>
The published 0.1.0 cannot serve this server's /v1/responses endpoint at all.
Its chat->responses translation emits a usage object without the
input_tokens_details and output_tokens_details members that the Responses
schema marks required, so every response fails NeMoGymResponse validation and
the endpoint returns 500. A real eval against
integrate.api.nvidia.com failed on every rollout; /v1/chat/completions was
unaffected, which is why this only surfaced for Responses-API agents.

NVIDIA-NeMo/Switchyard#144 makes both members unconditional. It is merged but
unreleased -- PyPI still carries only 0.1.0 -- so this pins the merge commit
directly and should move to `>=0.1.1,<0.2.0` as soon as a release ships.

Installing from git builds Switchyard's Rust extension through maturin, so this
now requires a cargo toolchain on the installing machine. Gym's CI does not
provide one, so `gym env test` for this server will not run there until the pin
moves to a released wheel. That cost is accepted deliberately: the alternative
is shipping a server whose Responses endpoint cannot serve a single request.

The uv override that neutralized 0.1.0's stale openai>=2.34.0 floor is dropped
with it. Switchyard declares openai>=2.7,<3.0 as of #142, which co-installs
with Gym's ceiling directly -- verified by rebuilding the server venv from
scratch, which resolves openai 2.7.2 with no override present.

Verified end to end from a clean venv: 25 unit tests pass, and a
`gym eval run` against a live model returns reward 1.0 rollouts carrying
well-formed usage, where the same run previously failed on every rollout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Lin Jia <linj@nvidia.com>
The pin's comment said Gym's CI has no cargo toolchain and that `gym env
test` for this server therefore would not run there. Both halves are wrong.
CI classifies this PR as server-only and runs `ng_test` against this server,
which builds its venv from this pyproject -- git dependency included. The
runners carry a cargo toolchain, so maturin builds the Rust extension and
the suite runs: 25 passed on the latest run.

The real cost is build time. Bypassing the published cp312-abi3 wheels adds
about a minute to a cold environment build, which is worth stating plainly
so nobody reads the pin as untested in CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Lin Jia <linj@nvidia.com>
The previous note claimed the pin needs a cargo toolchain. It does not.
maturin resolves puccinialin as a build requirement and bootstraps rustup
when it finds no toolchain, so a user who has never installed Rust still
gets a working install with no action on their part. Verified by building
in a venv with PATH stripped of cargo, HOME/CARGO_HOME/RUSTUP_HOME pointed
at an empty directory and a cold uv cache: the extension compiles and
`import switchyard_rust` succeeds.

What the pin does cost is worth saying instead, because neither is
guessable from a failure. The first startup spends an extra minute or two
compiling rather than unpacking a wheel. And the build fetches from
static.rust-lang.org and crates.io -- Switchyard vendors no crates -- which
are two hosts a Gym user has no other reason to allow. On a cluster with
restricted egress that is where this fails, and the error surfaces from
deep inside a build log.

README and the docs page both said "nothing to install" without either
caveat. Both now carry it, scoped to the git pin and noting that the
self-managed proxy mode installs nothing at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Lin Jia <linj@nvidia.com>
Review pass over the whole diff. Five things, two in code.

setup_proxy_shutdown stopped the proxy in a trailing statement after the
wrapped lifespan's `async with`, so it only ran when that context exited
cleanly. The proxy is already serving by the time the app starts, so a
startup that raises left one behind -- the same leak f73e006 set out to
close, through the one door it did not cover. It is a `finally` now, with a
test that a failing lifespan still terminates the child.

_set_parent_death_signal carried `# pragma: no cover - runs post-fork, in
the child`. True of production, wrong about the tests: four of them call it
directly and cover every branch. The pragma was hiding real coverage, so
app.py counts 150 statements now rather than 142, still with one miss --
the `__main__` guard.

The README still described the dependency as pinned `==0.1.0` with a
`[tool.uv] override-dependencies` entry relaxing a stale openai floor. Both
were replaced by the git pin in 474defd; neither exists. It now describes
the pin that is actually there and why.

The docs claimed the Rust build does not apply when you attach to your own
proxy. It does: the dependency is unconditional, so the environment is
built identically in both modes and attaching leaves the CLI unused rather
than uninstalled. Corrected in the README and the docs page.

The config comment told users to `pip install 'nemo-switchyard[server]'`
for launch mode, left over from when this server had no such dependency.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Lin Jia <linj@nvidia.com>
…g it

Merging main brought in nemo_gym.rollout_correlation (#2114), which makes
the rollout-id charset explicit: RolloutContextMiddleware matches
[A-Za-z0-9][A-Za-z0-9._-]* and _validate_rollout_id enforces the same set
on the capture path. This server predates that contract and matched [^/]+.

That is too permissive here specifically. ASGI hands over a percent-decoded
path, and this server does something the core middleware does not -- it
puts the id into an outbound HTTP header. A crafted prefix such as
/ng-rollout/x%0d%0ax-injected:%201/v1/responses parses as a rollout id
carrying CRLF under the old pattern. The client library rejects such a
value, so the practical outcome was a failed request rather than a forged
header, but relying on a downstream library to catch what this regex let
through is the wrong place to draw the line.

Matching the core charset means an off-contract id is simply not published:
the call goes out uncorrelated, exactly as it does for a request that never
carried a prefix. The unused `rest` capture group goes too; the path is
forwarded untouched, so it was never read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Lin Jia <linj@nvidia.com>
…0 pin

Switchyard 0.2.0 deprecates the Python `switchyard serve` stack this server
launched as a subprocess, and upstream main has already removed it. Re-target
the launch mode to the native Rust server hosted in-process
(switchyard_rust.server.Server) from an explicit TOML deployment:

- routing_profiles (YAML) becomes deployment (native TOML), the artifact that
  makes routed runs comparable; env override moves to SWITCHYARD_DEPLOYMENT
- the subprocess machinery (prctl death signal, atexit, health polling, kill
  escalation, PATH checks) is deleted -- the in-process server binds at
  construction and cannot outlive its owner
- nemo-switchyard moves from a git commit to an exact released pin (==0.2.0,
  wheels only, no extras), following the local_vllm_model pinning practice
- attach mode (switchyard_base_url) is unchanged; the session-id header and
  /v1 endpoint contract are identical on the native server
- tests: proxy lifecycle reworked against a fake native server, plus two
  integration tests that host the real 0.2.0 wheel from a real TOML deployment

Signed-off-by: Lin Jia <linj@nvidia.com>
Adds a full-chain integration suite: Gym's FastAPI app -> real hosted native
Switchyard server -> local stub upstream, asserting the responses<->chat
translation (usage detail objects, the reason the pin starts at 0.2.0), route
override, credential wiring, /v1/stats, and the session id landing in
Switchyard's routing metadata.

Writing those tests against the real wheel surfaced two fixes:

- The session header contract changed: the native server parses
  x-switchyard-session-id into routing metadata; proxy_x_session_id is only
  read by switchyard-server's durable routing log (--routing-log-file, attach
  mode) and is otherwise forwarded verbatim to the upstream provider. The
  config knob becomes session_id_headers (a list, default
  [x-switchyard-session-id]) and the docs explain when to add the log name.

- The PyO3 Server binding is thread-affine (unsendable): the thread that
  constructs it must close it. Hosting moves from setup_webserver into the
  lifespan wrapper, so startup and shutdown share the app's loop thread on
  any host, and the proxy exists exactly as long as the app serves.

Signed-off-by: Lin Jia <linj@nvidia.com>
…s source

position: 8 collided with anthropic-messages.mdx -- a documented cause of
preview 404s in auto-discovered folders -- and the attach-mode example
referenced the switchyard-server binary without saying it must be built from
the Switchyard repo (it is not part of the nemo-switchyard wheel).

Signed-off-by: Lin Jia <linj@nvidia.com>
Two gaps against epic #2088's docs criteria: the model-server index had no
Switchyard card, and the page never showed what a deployment TOML looks like,
so a reader could not write one from Gym docs alone. Adds the index card and a
validated example deployment (fixed baseline route + strong/weak classifier
route, loaded against the pinned 0.2.0 wheel), framed as the two routing
conditions a comparison runs under.

Signed-off-by: Lin Jia <linj@nvidia.com>
Two routed runs were previously indistinguishable from their outputs -- no
deployment identity, no switchyard version, and a hosted proxy's stats died
with the process. Set condition_dir (one directory per run) and the server now
writes the run's routing-condition record:

- switchyard-condition.json at startup: route, mode, proxy root URL,
  nemo-switchyard version, deployment SHA-256 and archived contents with
  credential values redacted. Comparability is manifests differing only in
  route or deployment; reproducibility is the archive.
- switchyard-stats.json at shutdown: the proxy's /v1/stats (per-target
  requests, tokens, retries, latency), captured before the in-process server
  goes away. Works in attach mode too.

Both writes are best-effort: provenance failures log a warning rather than
taking down serving. The docs page gains a documented two-condition comparison
workflow built on the record, closing two acceptance criteria of #2088.

Signed-off-by: Lin Jia <linj@nvidia.com>
An unwritable condition_dir must not stop serving, a missing dist still gets
a manifest with version null, and a stats snapshot with no proxy to ask is a
no-op. app.py back to 100% coverage.

Signed-off-by: Lin Jia <linj@nvidia.com>
Redact every credential a deployment can carry (extra_headers values as
well as api_key), or skip the archive when the TOML cannot be parsed.
Fetch the stats snapshot through Gym's shared async client, as the
configured caller, hard-capped inside the shutdown window, and label
whether it covers this run or a shared proxy's lifetime. Take identity
for an attached proxy from caller-supplied proxy_provenance instead of
the local wheel. Validate at startup that rollout correlation can work,
enter the proxy's cleanup scope the moment it serves, expose the worker
entrypoint for attach mode, reject hosted multi-worker configs, and
require max_concurrent_requests to be positive.

Signed-off-by: Lin Jia <linj@nvidia.com>
/v1/stats reports requests, errors, tokens, and latency plus classifier
usage; it has no retry counts. The comparison example now aligns rollout
rows by task and rollout index, reports rollouts missing a counterpart,
and adds the classifier tokens the selected model's usage omits. State
the observability requirement for session correlation, the single-worker
constraint on hosted mode, the aggregate scope of attached-proxy stats,
and the proxy_provenance knob, and appease the secrets detector on the
example's api_key_env line.

Signed-off-by: Lin Jia <linj@nvidia.com>
A live end-to-end run surfaced this: the yaml set forward_session_id to
its default, so every config built from it read as an explicit request
for forwarding, and the startup check refused to serve any eval that ran
without observability. The intended split -- warn on the default, refuse
only a real user override the server cannot honor -- needs the yaml to
stay silent. Verified live in both directions and pinned with a test
that reads the shipped yaml.

Signed-off-by: Lin Jia <linj@nvidia.com>
Both are inert: the inline api_key is the fake secret the redaction test
plants to prove it never reaches the manifest, and the api_key_env lines
name an environment variable rather than holding a value. Marked on the
lines themselves so baseline line drift cannot re-flag them.

Signed-off-by: Lin Jia <linj@nvidia.com>
@ananthsub
ananthsub force-pushed the feat/switchyard-model-server branch from 315de1c to 0288db7 Compare August 26, 2026 20:59
@ananthsub

Copy link
Copy Markdown
Contributor

/ok to test 0288db7

This branch was previously deployed

1 inactive deployment
public — 0288db79 Deployed Aug 26, 2026 by copy-pr-bot[bot] via release / finalize / notify #2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

sla:review-overdue Review response is over the one-business-day SLA

Projects

None yet

Development

Successfully merging this pull request may close these issues.

epic: routing-aware evals

2 participants