Skip to content

feat: token-level capture for RL training (Switchyard ↔ NeMo Gym)#63

Open
drifold wants to merge 6 commits into
NVIDIA-NeMo:mainfrom
drifold:drifold/feature/token-capture
Open

feat: token-level capture for RL training (Switchyard ↔ NeMo Gym)#63
drifold wants to merge 6 commits into
NVIDIA-NeMo:mainfrom
drifold:drifold/feature/token-capture

Conversation

@drifold

@drifold drifold commented Jul 14, 2026

Copy link
Copy Markdown

What

Adds opt-in token-level capture to Switchyard's RL-logging path. When a route target declares token_capture_engine: vllm, Switchyard requests exact token data from vLLM, records one unified trace per model call (message text + prompt_token_ids / generation_token_ids / generation_log_probs), groups records by the proxy_x_session_id header, and exposes them via GET /v1/sessions/{session_id}/completions. Streaming-required harnesses (Claude Code) are supported by capturing a non-streaming upstream response and synthesizing the client stream. Implements the agreed Switchyard Token Capture design.

Why

Enables RL training of unmodified coding harnesses (Claude Code, Codex) through Switchyard. RL needs the training signal attached to the exact tokens the policy sampled — re-tokenizing text after the fact drifts the token boundaries and log-probs, making training silently off-policy. Capturing vLLM's exact prompt_token_ids / generation_token_ids / generation_log_probs at the proxy boundary preserves that behavior-policy fidelity. NeMo Gym maps its rollout ID to the session, retrieves the token-faithful records, and builds trajectories. Switchyard stays generic — no Gym dependency.

How tested

  • uv run ruff check . clean
  • uv run mypy switchyard clean
  • uv run pytest tests/ green (2026 passed)
  • Manual smoke: real proxy + fake vLLM across all 3 inbound protocols (Anthropic/OpenAI Chat/Responses), including tool-call turns and stream synthesis
  • Cluster e2e: real vLLM 0.11.0 (H100) + real Claude Code — token-fidelity round-trip verified, multi-turn prompt-contiguity invariant holds

Checklist

  • One class per file; snake_case filenames.
  • New public symbols exported where intended.
  • Unit tests added (50 new across 3 files).
  • --help updated (--enable-rl-logging now documents token-capture activation).
  • Commits signed off (DCO).

Notes for reviewers

  • Layering: 3 commits — engine param → capture core → launcher session injection. Reviewable in order.
  • Per-commit note: commit 2 carries 4 launcher-helper tests whose code lands in commit 3 — PR head is green; only relevant if CI runs per-commit.
  • OpenClaw session grouping (in feat(launchers)): OpenClaw has no request-header surface, so the per-launch session id rides its apiKey field and is resolved server-side only when it matches the launcher session-id shape (guarding against mistaking a real credential for a session).

Summary by CodeRabbit

  • New Features
    • Added optional token-level capture for supported model routes.
    • Added session-based completion retrieval through the /v1/sessions/{session_id}/completions endpoint.
    • Preserved streaming behavior by synthesizing compatible streamed responses after capture.
    • Added support for capture correlation across Claude, Codex, and OpenClaw launches.
  • Bug Fixes
    • Invalid or incomplete capture records are safely marked or skipped without interrupting retrieval.
    • Added validation and secure storage for captured completion records.
  • Tests
    • Added comprehensive coverage for capture, retrieval, routing, translation, and streaming scenarios.

drifold added 3 commits July 14, 2026 11:44
…capture

Signed-off-by: drifold <drifold@nvidia.com>
Signed-off-by: drifold <drifold@nvidia.com>
@drifold
drifold requested a review from a team as a code owner July 14, 2026 10:28
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Adds configurable token capture for routed LLM requests, including session propagation, token-record persistence and retrieval, launcher integration, target-specific parameters, synthetic streaming, and extensive tests.

Changes

Token capture contracts and configuration

Layer / File(s) Summary
Capture contracts and configuration
switchyard/cli/route_bundle.py, switchyard/lib/backends/llm_target.py, switchyard/cli/launchers/launch_intake_config.py
Route bundles and LLM targets recognize token-capture engines, while launcher helpers construct capture processors and session identifiers.

Capture processing and retrieval

Layer / File(s) Summary
Capture processing and retrieval
switchyard/lib/processors/*token_capture*, switchyard/lib/processors/rl_logging_response_processor.py, switchyard/lib/endpoints/token_capture_endpoint.py
Request and response processors capture token records, validate and persist them atomically, synthesize streams, and serve sorted session completions.

CLI and launcher integration

Layer / File(s) Summary
CLI and launcher integration
switchyard/cli/switchyard_cli.py, switchyard/cli/launchers/*, switchyard/cli/launch_command.py
Serve wiring and Claude, Codex, and OpenClaw launchers conditionally enable capture and propagate session headers or identifiers.

End-to-end capture validation

Layer / File(s) Summary
End-to-end capture validation
tests/test_token_capture*.py
Tests cover gating, processors, persistence, retrieval, launcher propagation, target derivation, and Anthropic/Responses stream translation.

Estimated code review effort: 4 (Complex) | ~60 minutes

Poem

I’m a rabbit with tokens tucked neat,
Captured in files where sessions meet.
Streams bloom softly, then hop along,
Tool calls and traces become a song.
Through routes and launchers, the records run—
A carrot-powered capture is done!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 27.62% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: adding token-level capture for RL training in Switchyard.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
switchyard/cli/switchyard_cli.py (1)

905-916: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

CLI help text: grammar and scope inaccuracy.

"an token_capture_engine" should be "a token_capture_engine"; also, the flag applies to serve as well (per _cmd_serve's resolve_rl_log_dir usage), not just launch sessions as stated.

✏️ Proposed fix
-            "file per request/response pair) for `launch` sessions. When the "
-            "route bundle declares an `token_capture_engine` target, traces are "
+            "file per request/response pair) for `launch` and `serve` sessions. "
+            "When the route bundle declares a `token_capture_engine` target, traces are "
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@switchyard/cli/switchyard_cli.py` around lines 905 - 916, Update the help
text for the --enable-rl-logging argument to use “a `token_capture_engine`” and
describe the flag as applying to both launch and serve sessions, consistent with
_cmd_serve’s resolve_rl_log_dir usage; preserve the existing trace and placement
guidance.
switchyard/lib/processors/rl_logging_response_processor.py (1)

192-199: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Newly public format_trace_tool_choice is missing a docstring.

Renamed from private _format_tool_choice to a public helper but no docstring was added, unlike its sibling format_trace_tools/build_trace_messages/build_trace_token_count. As per coding guidelines, switchyard/**/*.py code requires "docstrings for public APIs."

📝 Proposed fix
 def format_trace_tool_choice(tool_choice: object) -> str:
+    """Extract the tool-choice ``type`` string, defaulting to ``"auto"``."""
     if isinstance(tool_choice, str):
         return tool_choice
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@switchyard/lib/processors/rl_logging_response_processor.py` around lines 192
- 199, Add a concise docstring to the public format_trace_tool_choice function
describing its accepted tool-choice input and returned choice string, including
the "auto" fallback behavior. Keep the existing logic unchanged and match the
documentation style of format_trace_tools, build_trace_messages, and
build_trace_token_count.

Source: Coding guidelines

🧹 Nitpick comments (3)
tests/test_token_capture.py (1)

158-196: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use pytest-mock instead of monkeypatch.

This is general mocking and should use the repository-standard mocker fixture.

Proposed change
+from pytest_mock import MockerFixture
+
 def test_serve_installs_capture_pair_for_declaring_bundle(
-    monkeypatch: pytest.MonkeyPatch,
+    mocker: MockerFixture,
     tmp_path: Path,
 ) -> None:
...
-    monkeypatch.setattr(cli, "load_route_bundle_table", _fake_load)
-    monkeypatch.setattr(cli, "build_and_serve", lambda *a, **k: None)
+    mocker.patch.object(cli, "load_route_bundle_table", side_effect=_fake_load)
+    mocker.patch.object(cli, "build_and_serve")

As per coding guidelines, tests/**/*.py must “use pytest-mock for general mocking.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_token_capture.py` around lines 158 - 196, Replace the monkeypatch
fixture and its setattr calls in
test_serve_installs_capture_pair_for_declaring_bundle with the
repository-standard pytest-mock mocker fixture, using mocker.patch for
cli.load_route_bundle_table and cli.build_and_serve while preserving the
existing fake behavior and assertions.

Source: Coding guidelines

switchyard/lib/endpoints/token_capture_endpoint.py (1)

66-75: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Blocking file I/O inside an async route handler.

Path.is_dir(), session_dir.glob(...), and record_path.read_text() are synchronous disk calls executed directly on the event loop inside async def get_session_completions. Under concurrent requests or a session with many records, this can stall other in-flight requests on the same worker.

♻️ Proposed direction
-            completions: list[dict[str, Any]] = []
-            for record_path in sorted(session_dir.glob("*.json")):
-                try:
-                    completions.append(json.loads(record_path.read_text()))
-                except (OSError, json.JSONDecodeError) as exc:
-                    ...
+            def _read_all() -> list[dict[str, Any]]:
+                out: list[dict[str, Any]] = []
+                for record_path in sorted(session_dir.glob("*.json")):
+                    try:
+                        out.append(json.loads(record_path.read_text()))
+                    except (OSError, json.JSONDecodeError) as exc:
+                        logger.warning(...)
+                return out
+            completions = await asyncio.to_thread(_read_all)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@switchyard/lib/endpoints/token_capture_endpoint.py` around lines 66 - 75,
Update the async get_session_completions handler so all synchronous filesystem
operations, including Path.is_dir(), session_dir.glob(), and
record_path.read_text(), run off the event loop via the project’s established
async file-I/O or thread-offloading mechanism. Preserve sorted record
processing, JSON parsing, and warning behavior for unreadable records.
switchyard/lib/processors/token_capture_response_processor.py (1)

149-169: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Blocking file I/O runs inside the async request path.

_write_record performs synchronous open/os.replace/os.chmod on every captured turn from within process(), blocking the event loop. Mirrors the existing RlLoggingResponseProcessor._write_entry pattern, so not a regression, but worth moving off the loop (e.g., asyncio.to_thread) given the "async-only code" guideline. As per coding guidelines, switchyard/**/*.py code should be "async-only code."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@switchyard/lib/processors/token_capture_response_processor.py` around lines
149 - 169, The synchronous file operations in _write_record block the async
request path. Update the token capture processor’s process flow to invoke
_write_record through asyncio.to_thread (or an equivalent async offloading
mechanism), preserving its existing arguments and record-writing behavior while
keeping filesystem I/O off the event loop.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@switchyard/lib/backends/llm_target.py`:
- Line 125: Reformat the ValueError message in the token-capture validation near
token_capture_engine so every physical line stays within the 100-character
limit, while preserving the existing error text and supported-values details.
- Around line 105-134: The llm_target_with_token_capture function drops backend
connection settings when rebuilding the LlmTarget. Preserve target.base_url,
target.api_key, and target.timeout_secs by forwarding each field into the new
LlmTarget alongside the existing properties.

In `@switchyard/lib/processors/token_capture_request_processor.py`:
- Around line 28-33: The session-ID trust check rejects valid custom re-run IDs,
causing token capture to silently drop them. In
switchyard/lib/processors/token_capture_request_processor.py:28-33 and :80-91,
and switchyard/cli/launchers/launch_intake_config.py:205-222, change the
launcher-to-capture flow so intake.session_id is explicitly validated or marked
as launcher-generated before capture_session_id_for_api_key() and
_resolve_session_id() use it; preserve rejection of untrusted credential-like
values without silently discarding valid custom session IDs.

In `@switchyard/lib/processors/token_capture_response_processor.py`:
- Around line 246-329: Update _synthesize_stream so
CompletionUsage.model_validate is guarded against partial or malformed usage
dictionaries; when validation fails, set final_usage to None and continue
yielding the synthesized stream normally.

In `@tests/test_token_capture.py`:
- Around line 667-668: Update the traversal test around the client request to
use an encoded parent segment (%2E%2E) so the path reaches the handler, and
create a sentinel record outside the sessions directory under tmp_path. Assert
the request still returns 404, ensuring a vulnerable path join cannot read the
sentinel and return 200.

---

Outside diff comments:
In `@switchyard/cli/switchyard_cli.py`:
- Around line 905-916: Update the help text for the --enable-rl-logging argument
to use “a `token_capture_engine`” and describe the flag as applying to both
launch and serve sessions, consistent with _cmd_serve’s resolve_rl_log_dir
usage; preserve the existing trace and placement guidance.

In `@switchyard/lib/processors/rl_logging_response_processor.py`:
- Around line 192-199: Add a concise docstring to the public
format_trace_tool_choice function describing its accepted tool-choice input and
returned choice string, including the "auto" fallback behavior. Keep the
existing logic unchanged and match the documentation style of
format_trace_tools, build_trace_messages, and build_trace_token_count.

---

Nitpick comments:
In `@switchyard/lib/endpoints/token_capture_endpoint.py`:
- Around line 66-75: Update the async get_session_completions handler so all
synchronous filesystem operations, including Path.is_dir(), session_dir.glob(),
and record_path.read_text(), run off the event loop via the project’s
established async file-I/O or thread-offloading mechanism. Preserve sorted
record processing, JSON parsing, and warning behavior for unreadable records.

In `@switchyard/lib/processors/token_capture_response_processor.py`:
- Around line 149-169: The synchronous file operations in _write_record block
the async request path. Update the token capture processor’s process flow to
invoke _write_record through asyncio.to_thread (or an equivalent async
offloading mechanism), preserving its existing arguments and record-writing
behavior while keeping filesystem I/O off the event loop.

In `@tests/test_token_capture.py`:
- Around line 158-196: Replace the monkeypatch fixture and its setattr calls in
test_serve_installs_capture_pair_for_declaring_bundle with the
repository-standard pytest-mock mocker fixture, using mocker.patch for
cli.load_route_bundle_table and cli.build_and_serve while preserving the
existing fake behavior and assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: b88bd523-4e03-4b97-bcf5-ad10301f09b9

📥 Commits

Reviewing files that changed from the base of the PR and between a2a939d and fb8cd51.

📒 Files selected for processing (15)
  • switchyard/cli/launch_command.py
  • switchyard/cli/launchers/claude_code_launcher.py
  • switchyard/cli/launchers/codex_cli_launcher.py
  • switchyard/cli/launchers/launch_intake_config.py
  • switchyard/cli/launchers/openclaw_launcher.py
  • switchyard/cli/route_bundle.py
  • switchyard/cli/switchyard_cli.py
  • switchyard/lib/backends/llm_target.py
  • switchyard/lib/endpoints/token_capture_endpoint.py
  • switchyard/lib/processors/rl_logging_response_processor.py
  • switchyard/lib/processors/token_capture_request_processor.py
  • switchyard/lib/processors/token_capture_response_processor.py
  • tests/test_token_capture.py
  • tests/test_token_capture_target.py
  • tests/test_token_capture_translation.py

Comment thread switchyard/lib/backends/llm_target.py
Comment thread switchyard/lib/backends/llm_target.py Outdated
Comment thread switchyard/lib/processors/token_capture_request_processor.py Outdated
Comment thread switchyard/lib/processors/token_capture_response_processor.py
Comment thread tests/test_token_capture.py Outdated
…t, traversal test

Signed-off-by: drifold <drifold@nvidia.com>
@linj-glitch

linj-glitch commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Dafna:

Could you take a look at this to see if they should be fixed? Feel free to leverage the possible fix or use anything you find appropriate.

  1. Session isolation is not preserved. session_dir_name() replaces every unsafe character with _, so distinct opaque IDs can share a directory. For example, tenant:a and tenant*a both map to tenant_a; retrieving either session returns both sessions' records because the endpoint reads every JSON file in that directory. This can disclose sensitive training traces across sessions.

    Possible Clean fix: derive the directory name from a collision-resistant, fixed-length key such as sha256(session_id.encode()).hexdigest(), using one shared helper in the writer and reader. As defense in depth, retrieval should also ignore any record whose stored session_id does not exactly equal the requested ID. Add a regression test with two IDs that collide under the current sanitizer.

  2. The YAML shown in the design document is rejected. The documented shape places token_capture_engine: vllm beside the string target, but _MODEL_ROUTE_KEYS only permits the field inside a target mapping. The exact documented configuration fails with RouteBundleConfigError: unknown key(s) for route 'policy-model': token_capture_engine.

    Possible Clean fix: choose one canonical syntax and make implementation and documentation agree. Since the PR says it implements the design, the least surprising fix is to allow token_capture_engine on model/passthrough routes and normalize it into that route's target before coerce_llm_target(). Keep multi-target routes using nested per-target declarations so opt-in remains target-specific. Add a parser test using the exact YAML snippet from the design doc.

  3. Records missing required provenance are marked valid. _build_record() writes request_id and model, but _validate_token_fields() never validates them. A response with valid token arrays but no response ID or model produces {"request_id": null, "model": null, "is_valid": true}, contrary to the design's correctness requirements.

    Possible Clean fix: extract request_id and model before validation, pass them into the validator, and require non-empty strings before setting is_valid: true. Preserve the current fail-open behavior by storing malformed records as is_valid: false, and add separate missing-ID and missing-model tests.

@ryan-lempka

Copy link
Copy Markdown
Collaborator

@drifold can you please make sure CodeRabbit comments are addressed and resolve them if so?

…y field

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

Signed-off-by: drifold <drifold@nvidia.com>
@drifold

drifold commented Jul 15, 2026

Copy link
Copy Markdown
Author

@linj-glitch - All fixed as suggested

@drifold

drifold commented Jul 15, 2026

Copy link
Copy Markdown
Author

@ryan-lempka - done

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants