Skip to content

feat(intake): log each router's own model call#74

Open
elyasmnvidian wants to merge 4 commits into
mainfrom
emehtabuddin/switch-707-capture-routing-strategy-sub-model-requestsresponses-in
Open

feat(intake): log each router's own model call#74
elyasmnvidian wants to merge 4 commits into
mainfrom
emehtabuddin/switch-707-capture-routing-strategy-sub-model-requestsresponses-in

Conversation

@elyasmnvidian

@elyasmnvidian elyasmnvidian commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

What this does

Switchyard's routers make an extra model call on every turn to decide where to send the request. The deterministic LLM classifier, the stage-router tier classifier, and the plan/execute planner each call a small model first, before the real answer is generated. That call uses tokens, but it never reaches the routed backend, so the intake sink — which saved one record per routed turn — never saved it. The router's own spend was missing from the usage logs, which made reported cost look lower and savings-vs-baseline look higher than they really were.

This PR saves each router's own model call as its own record, next to the routed turn, through the same transport, so the numbers reflect the full cost of routing.

How it works

  • Each router records its own call on the request context in Rust: ctx.record_submodel_call(model, prompt_tokens, completion_tokens, cached_tokens, router_type, routed_to). It appends to a list, so a chain that runs more than one router (classifier + planner) records each, not just the last.
  • The Rust IntakeResponseProcessor sends one record per recorded call, right after the routed turn, reusing the same payload builder. A routing-call record looks the same as a routed turn to the store, minus the message text.
  • Every record is anonymous: model name and token counts only, never the routing call's input messages.
  • Each record gets a distinct _id ({session}-{ts} for the turn, {session}-{ts}-{routed_to}-{index} for each routing call), so records that share a session and timestamp never overwrite each other.

Both the routed turn and the routing-call records now go out through the same Rust intake sink. That closes the two gaps an earlier draft left open:

  • One opt-in, honored fully. The gate lives in the Rust sink (IntakeRequestState.skip), which combines the x-switchyard-intake-enabled header opt-in with the OpenAI store: true body opt-in. A raw client that only sets store: true now gets its routing records too. A turn that did not opt in sends nothing, so a routing record is never sent for a caller who opted out.
  • One destination. The routed turn and its routing records leave through one transport, so they always land in the same place.

The posting target is configurable

The sink no longer hardcodes a destination. It selects one with IntakeSinkConfig.target (IntakeTarget { url, format, authenticated }):

  • Unset (the default): post the nested chat-completions document to the NeMo Platform ingest, with bearer auth. Unchanged from before.
  • Set: --intake-target-url <full URL> (env SWITCHYARD_INTAKE_TARGET_URL) posts a flat, top-level document straight to that URL — any data-lake posting endpoint — unauthenticated. The caller passes the full URL; no host or path is baked into the sink.

This replaces the old --intake-nvdataflow-project flag, which built one fixed URL against one fixed host. A data-lake store is now just a URL you point at.

Proof: real token usage lands in the records

A live run against Claude Haiku 4.5 through our inference gateway, with the target pointed at a local capture server. Two real calls — a routing classifier call and the routed turn — driven through the real request and response processors with store: true as the only opt-in:

LIVE gateway token usage:
  classifier sub-model: prompt=24 completion=5
  routed turn:          prompt=18 completion=11

Posted 2 flat documents to http://127.0.0.1:PORT/dataflow/sandbox-switchyard/posting
  path=/dataflow/sandbox-switchyard/posting  authorization=None
  path=/dataflow/sandbox-switchyard/posting  authorization=None

The routing-call record (real classifier tokens, its own route label, no latency because a routing call's latency is not the turn's):

{
  "_id": "live-proof-1-1784589046905-classifier-0",
  "b_switchyard_routed": true,
  "l_switchyard_input_tokens": 24,
  "l_switchyard_output_tokens": 5,
  "l_switchyard_total_tokens": 29,
  "s_switchyard_router_type": "deterministic",
  "s_switchyard_routed_to": "classifier",
  "s_switchyard_served_model": "claude-haiku-4-5",
  "s_switchyard_session_id": "live-proof-1"
}

The routed-turn record (real turn tokens, distinct _id, its own latency):

{
  "_id": "live-proof-1-1784589046905",
  "b_switchyard_routed": false,
  "l_switchyard_input_tokens": 18,
  "l_switchyard_output_tokens": 11,
  "l_switchyard_total_tokens": 29,
  "l_switchyard_latency_ms": 1,
  "s_switchyard_served_model": "claude-haiku-4-5",
  "s_switchyard_session_id": "live-proof-1"
}

Both posted to the configurable target URL, unauthenticated, with distinct _ids and real token counts. Records are metadata-only by default — no message text.

What stays the same

  • Default intake still posts to the NeMo Platform chat-completions ingest with bearer auth.
  • Records stay metadata-only unless SWITCHYARD_INTAKE_CAPTURE_CONTENT is set.
  • One routed-turn record per turn, as before; the routing-call records are additive.

@elyasmnvidian
elyasmnvidian force-pushed the emehtabuddin/switch-707-capture-routing-strategy-sub-model-requestsresponses-in branch from 131988a to f6e898c Compare July 16, 2026 05:16
@elyasmnvidian elyasmnvidian changed the title feat(intake): capture routing-strategy sub-model calls in intake feat(intake): log each router's own model call to NVDataflow Jul 16, 2026
@elyasmnvidian
elyasmnvidian marked this pull request as ready for review July 16, 2026 06:35
@elyasmnvidian
elyasmnvidian requested a review from a team as a code owner July 16, 2026 06:35
@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Changes

Submodel Intake Capture

Layer / File(s) Summary
Public contract and processor wiring
switchyard/__init__.py, switchyard/cli/..., switchyard/lib/processors/__init__.py, switchyard/lib/processors/intake_payload_builder.py, switchyard/lib/processors/submodel_intake_response_processor.py
Exports the new processor, defines CTX_SUBMODEL_CALLS, and adds submodel response processing to Intake chains.
Routing submodel metadata capture
switchyard/lib/processors/llm_classifier/..., switchyard/lib/processors/plan_execute/..., switchyard/lib/processors/stage_router/...
Classifier and planner processors record token usage and routing metadata in the shared context.
Per-call intake record emission
switchyard/lib/processors/submodel_intake_response_processor.py
Builds and enqueues separate Intake payloads for valid submodel calls, with fail-open handling and client lifecycle management.
Metadata and intake emission tests
tests/test_*processor.py
Tests metadata capture, payload fields, opt-in behavior, malformed entries, costs, multiple calls, and shutdown behavior.

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

Poem

A rabbit hops through tokens bright,
And routes each model call just right.
Intake baskets fill one by one,
With cached counts beneath the sun.
“No messages leaked!” the bunny sings,
While clean shutdown flaps its wings.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 54.76% 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
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: logging each router’s own model call to intake.

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: 3

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

387-402: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use pytest-mock for the new general test doubles.

  • tests/test_planning_request_processor.py#L387-L402: replace _UsagePlannerClient with a mocker.AsyncMock configured to return PlannerCompletion.
  • tests/test_submodel_intake_response_processor.py#L26-L34: replace _FakeIntakeClient with a mocker.Mock and an enqueue_background side effect that captures the produced payload.

As per coding guidelines, tests 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_planning_request_processor.py` around lines 387 - 402, Replace
_UsagePlannerClient in tests/test_planning_request_processor.py:387-402 with a
mocker.AsyncMock configured to return PlannerCompletion with the required
content and usage. Replace _FakeIntakeClient in
tests/test_submodel_intake_response_processor.py:26-34 with a mocker.Mock,
configuring enqueue_background with a side effect that captures the produced
payload.

Source: Coding guidelines

tests/test_submodel_intake_response_processor.py (1)

173-193: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover the actual fail-open exception paths.

The malformed non-dict case is skipped before emission; add cases where client initialization and one dict record’s payload build/enqueue fail, verifying the response remains successful and later valid calls still emit.

🤖 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_submodel_intake_response_processor.py` around lines 173 - 193,
Extend the response-processor tests around
SubModelIntakeResponseProcessor.process to cover fail-open errors during client
initialization and while building or enqueueing a dict record’s payload. Assert
each failure leaves response processing successful and does not prevent a later
valid call from emitting its background payload, while preserving the existing
malformed-entry coverage.
switchyard/lib/processors/llm_classifier/request_processor.py (1)

461-469: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Keep new token extraction within the async execution path.

These three new synchronous helpers violate the async-only requirement. Inline extraction in the existing async methods, or replace them with an awaitable shared helper.

  • switchyard/lib/processors/llm_classifier/request_processor.py#L461-L469: remove or make _classifier_token_counts async.
  • switchyard/lib/processors/plan_execute/request_processor.py#L719-L727: remove or make _planner_token_counts async.
  • switchyard/lib/processors/stage_router/classifier.py#L264-L272: remove or make _tier_token_counts async.

As per coding guidelines, switchyard/**/*.py requires “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/llm_classifier/request_processor.py` around lines
461 - 469, Keep token extraction within async execution paths by removing or
converting the synchronous helpers _classifier_token_counts in
switchyard/lib/processors/llm_classifier/request_processor.py:461-469,
_planner_token_counts in
switchyard/lib/processors/plan_execute/request_processor.py:719-727, and
_tier_token_counts in
switchyard/lib/processors/stage_router/classifier.py:264-272 to awaitable
helpers; update their call sites accordingly while preserving the existing
token-count results.

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/processors/submodel_intake_response_processor.py`:
- Around line 97-105: Validate each mapping in the calls loop before invoking
_build_submodel_payload, requiring valid model, token, and routing fields with
non-negative token counts; skip invalid entries rather than coercing them into
zero-token or blank-field payloads. Keep the existing fail-open logging behavior
for rejected records and only call client.enqueue_background for validated
entries.
- Around line 85-90: Update the submodel intake gating around
RequestMetadata.intake.enabled so requests with body-level store=true also enter
the intake path. Propagate the body opt-in into RequestMetadata or reuse the
primary intake gate, while preserving the existing behavior for requests without
either opt-in.

In `@tests/test_stage_router_classifier.py`:
- Around line 231-232: Remove the explicit `@pytest.mark.asyncio` decorator from
test_classify_stashes_submodel_call_for_intake, relying on the configured
asyncio_mode = "auto" to execute the async test.

---

Nitpick comments:
In `@switchyard/lib/processors/llm_classifier/request_processor.py`:
- Around line 461-469: Keep token extraction within async execution paths by
removing or converting the synchronous helpers _classifier_token_counts in
switchyard/lib/processors/llm_classifier/request_processor.py:461-469,
_planner_token_counts in
switchyard/lib/processors/plan_execute/request_processor.py:719-727, and
_tier_token_counts in
switchyard/lib/processors/stage_router/classifier.py:264-272 to awaitable
helpers; update their call sites accordingly while preserving the existing
token-count results.

In `@tests/test_planning_request_processor.py`:
- Around line 387-402: Replace _UsagePlannerClient in
tests/test_planning_request_processor.py:387-402 with a mocker.AsyncMock
configured to return PlannerCompletion with the required content and usage.
Replace _FakeIntakeClient in
tests/test_submodel_intake_response_processor.py:26-34 with a mocker.Mock,
configuring enqueue_background with a side effect that captures the produced
payload.

In `@tests/test_submodel_intake_response_processor.py`:
- Around line 173-193: Extend the response-processor tests around
SubModelIntakeResponseProcessor.process to cover fail-open errors during client
initialization and while building or enqueueing a dict record’s payload. Assert
each failure leaves response processing successful and does not prevent a later
valid call from emitting its background payload, while preserving the existing
malformed-entry coverage.
🪄 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: 5b2ccee4-f5b4-489b-81b2-c155963863a2

📥 Commits

Reviewing files that changed from the base of the PR and between b03b6a6 and f6e898c.

📒 Files selected for processing (13)
  • switchyard/__init__.py
  • switchyard/cli/launchers/launch_intake_config.py
  • switchyard/cli/switchyard_cli.py
  • switchyard/lib/processors/__init__.py
  • switchyard/lib/processors/intake_payload_builder.py
  • switchyard/lib/processors/llm_classifier/request_processor.py
  • switchyard/lib/processors/plan_execute/request_processor.py
  • switchyard/lib/processors/stage_router/classifier.py
  • switchyard/lib/processors/submodel_intake_response_processor.py
  • tests/test_llm_classifier_request_processor.py
  • tests/test_planning_request_processor.py
  • tests/test_stage_router_classifier.py
  • tests/test_submodel_intake_response_processor.py

Comment on lines +85 to +90
# ponytail: mirrors the header opt-in only; the OpenAI `store: true`
# body opt-in isn't mirrored, so a raw client using only `store`
# under-emits its routing-call record — the safe direction (a missing
# record, never a leaked one).
if metadata.intake.enabled is not True:
return response

@coderabbitai coderabbitai Bot Jul 16, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
ast-grep outline switchyard/lib/processors/intake_payload_builder.py --items all
rg -n -C3 'store|intake\.enabled|_request_metadata' \
  switchyard/lib/processors/intake_payload_builder.py \
  switchyard/lib/processors/submodel_intake_response_processor.py \
  switchyard/cli/switchyard_cli.py \
  tests/test_submodel_intake_response_processor.py

Repository: NVIDIA-NeMo/Switchyard

Length of output: 20934


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,220p' switchyard/lib/processors/submodel_intake_response_processor.py
printf '\n---\n'
sed -n '1,260p' switchyard/lib/request_metadata.py
printf '\n---\n'
sed -n '1,220p' tests/test_submodel_intake_response_processor.py

Repository: NVIDIA-NeMo/Switchyard

Length of output: 20754


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C4 'store|intake\.enabled|RequestMetadata|attach_request_metadata|x-switchyard-intake-enabled' \
  switchyard/lib/request_metadata.py \
  switchyard/lib/processors/submodel_intake_response_processor.py \
  switchyard/lib/processors/intake_payload_builder.py \
  tests/test_submodel_intake_response_processor.py \
  tests/test_request_metadata.py \
  switchyard/cli/switchyard_cli.py

Repository: NVIDIA-NeMo/Switchyard

Length of output: 29966


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C3 '"store"|\'store\'|store:' switchyard | sed -n '1,220p'

Repository: NVIDIA-NeMo/Switchyard

Length of output: 220


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C3 'intake\.enabled|x-switchyard-intake-enabled|store' switchyard/lib switchyard/cli tests | sed -n '1,260p'

Repository: NVIDIA-NeMo/Switchyard

Length of output: 17182


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C4 'attach_request_metadata|RequestMetadata\(|intake\.enabled|store=true|x-switchyard-intake-enabled|store' switchyard/lib switchyard/cli tests | sed -n '1,300p'

Repository: NVIDIA-NeMo/Switchyard

Length of output: 20479


🏁 Script executed:

#!/bin/bash
set -euo pipefail

wc -l switchyard/lib/request_metadata.py switchyard/lib/processors/submodel_intake_response_processor.py

Repository: NVIDIA-NeMo/Switchyard

Length of output: 283


Honor store=true on the submodel intake path. RequestMetadata.intake.enabled is the only gate here, so store=true-only requests can still drop routing-call records even though the Intake sink advertises store=true as an opt-in. Either propagate that body opt-in into RequestMetadata or share the primary intake gate.

🤖 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/submodel_intake_response_processor.py` around lines
85 - 90, Update the submodel intake gating around RequestMetadata.intake.enabled
so requests with body-level store=true also enter the intake path. Propagate the
body opt-in into RequestMetadata or reuse the primary intake gate, while
preserving the existing behavior for requests without either opt-in.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch — this is the known gap, and you've named the right fix ("share the primary intake gate").

The primary gate is the Rust IntakeRequestState.skip flag, which already ORs in the store: true body toggle. That flag lives in the Rust typed context slot, not in Python ctx.metadata, and this is a response processor with no request in hand to re-read store from — so honoring it means either a new PyO3 getter on the context (plus a rebuild) or moving emission into the Rust sink.

I've folded it into the already-noted follow-up that moves sub-model emission into the Rust sink: that inherits the sink's exact opt-in gate (header or store) for free, and also fixes the separate NVDataflow-direct transport split. Until then the behavior is safe — a store-only opt-in under-emits (a missing record); it never leaks a record for an opted-out caller. Sharpened the code comment to say exactly this.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@elyasmnvidian, that's a clear explanation — makes sense that this is a Rust/Python boundary constraint rather than something fixable in this processor alone, and folding it into the already-planned move to the Rust sink is the right call since that fix subsumes this one anyway. The fail-open direction (under-emit, never leak) is the correct tradeoff in the meantime, and the sharpened comment should help future readers understand why the gate is intentionally partial here.

Would you like me to open a follow-up issue to track moving sub-model emission into the Rust sink (covering this store-only gap, the NVDataflow-direct transport consistency, and the OpenAI store: true opt-in handling mentioned in the PR notes)? Happy to file it if useful.


✏️ Learnings added
Learnt from: elyasmnvidian
Repo: NVIDIA-NeMo/Switchyard PR: 74
File: switchyard/lib/processors/submodel_intake_response_processor.py:0-0
Timestamp: 2026-07-16T07:49:35.850Z
Learning: In the Switchyard repo (switchyard/lib/processors/submodel_intake_response_processor.py), the primary intake capture gate is the Rust `IntakeRequestState.skip` flag, which already ORs in the request body's `store: true` toggle. That flag lives in a Rust typed context slot, not in Python `ctx.metadata`, so Python response processors (like `SubModelIntakeResponseProcessor`) cannot read the body-level `store` opt-in directly — only `RequestMetadata.intake.enabled` (the header-based opt-in) is visible from Python. Honoring `store: true` from a Python response processor requires either a new PyO3 getter on the context or moving emission into the Rust intake sink. The known tradeoff is considered safe: a `store`-only opt-in under-emits (missing record) but never leaks a record for an opted-out caller.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks — no need to open a public issue for it. This follow-up is already tracked on our side and written up in the PR's "Open questions / follow-ups" section, so I'll leave it there rather than adding a third place to track the same thing. Appreciate the review.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@elyasmnvidian, understood — keeping the follow-up in the PR avoids duplicating tracking. Thanks for the clarification and for documenting the scope and safe interim behavior so clearly.

🐇👍


🧠 Learnings used
Learnt from: elyasmnvidian
Repo: NVIDIA-NeMo/Switchyard PR: 74
File: switchyard/lib/processors/submodel_intake_response_processor.py:0-0
Timestamp: 2026-07-16T07:49:35.850Z
Learning: In Switchyard Python response processors (e.g., submodel/intake processors), the body-level `store: true` toggle is not exposed via Python `ctx.metadata` because it is represented by the Rust `IntakeRequestState.skip` flag in a Rust typed context slot. Python processors should only rely on the header-based opt-in `RequestMetadata.intake.enabled` for whether intake emission is enabled. If a Python processor must honor body-level `store: true`, add an explicit PyO3 getter to expose the needed value on the context (or move the emission decision into the Rust intake sink). This design is intentionally safe: a `store`-only opt-in may under-emit (missing record) but must not leak a record for callers who opted out.

Comment thread switchyard/lib/processors/submodel_intake_response_processor.py Outdated
Comment thread tests/test_stage_router_classifier.py Outdated
@elyasmnvidian

Copy link
Copy Markdown
Contributor Author

Live check with real tokens ✅

Ran the real classifier through the pipeline against a live endpoint (nvidia/deepseek-ai/deepseek-v4-pro) and captured the record the sink builds:

router made a real call:   1190 prompt + 107 completion = 1297 tokens
→ emitted one record:
   model         nvidia/deepseek-ai/deepseek-v4-pro
   routing       router_type=deterministic, routed_to=classifier
   usage         1190 / 107 / 1297
   cost_usd      0.002443
   session_id    live-proof-001        (from RequestMetadata — the dead-key fix, live)
   request keys  [model, switchyard]   (no messages — anonymous)

same call, but the turn did NOT opt in → 0 records emitted   (capture opt-out honored)

A real router call produces one anonymous record with real token counts, real cost, and the session id; a turn that didn't opt in emits nothing.

(POSTing to a live intake endpoint needs the NeMo Platform SDK + auth, which isn't in this sandbox — the model call above is real, and the record is captured from the real sink code instead of being posted.)

NVDataflow-direct open question — resolved (it's a real gap)

NeMo Platform intake and NVDataflow are two separate stores: the Rust sink posts to NVDataflow instead of the NeMo Platform chat-completions ingest (unauthenticated vs bearer-auth). That direct mode (--intake-nvdataflow-project) exists only in the Rust sink; the Python IntakeClient these records ride only knows the NeMo Platform route.

So in the default mode both the routed turn and these records go to the same place (NeMo Platform intake). But with --intake-nvdataflow-project set, the routed turn goes to NVDataflow while these records go to NeMo Platform — they diverge. Fix: move the sub-model emission into the Rust sink so one transport honors the flag. Leaving it as the follow-up before this is used in NVDataflow-direct mode.

@elyasmnvidian
elyasmnvidian force-pushed the emehtabuddin/switch-707-capture-routing-strategy-sub-model-requestsresponses-in branch from f6e898c to d47ff00 Compare July 16, 2026 07:47
@elyasmnvidian

Copy link
Copy Markdown
Contributor Author

Pushed d47ff00d addressing the review — inline replies on the three threads, and the rest here.

Fixed

  • Skip malformed sub-model entries instead of emitting synthetic records (+ test).
  • Removed the redundant asyncio marker on the new stage-router test.
  • Added docstrings across the new processor so the source file is fully documented (helps the docstring-coverage check).
  • Documented the store: true opt-in gap and folded the real fix into the "move emission into the Rust sink" follow-up (which inherits the sink's exact gate) — details in the inline reply.

Kept as-is, with reasons

  • pytest-mock for _UsagePlannerClient / _FakeIntakeClient: these are a plain protocol stub and a capture double, matching the existing style in those files (_FakePlannerClient right above is a plain class). They aren't mock objects — swapping in mocker.Mock/AsyncMock would make them the odd ones out and re-add a mock dependency. Kept the plain doubles.
  • "keep token extraction in async paths" (the sync _*_token_counts helpers): these are pure functions that read integer fields off a usage object — no I/O, nothing to await. A sync helper called from the async path is correct and matches the existing _classifier_token_counts pattern; making them async would add ceremony for no benefit.

ruff + mypy (strict, 164 files) clean; 58 targeted tests pass.

@elyasmnvidian
elyasmnvidian force-pushed the emehtabuddin/switch-707-capture-routing-strategy-sub-model-requestsresponses-in branch from d47ff00 to 857f20f Compare July 17, 2026 16:06
@nv-msvoboda

Copy link
Copy Markdown
Contributor

@sir-merge-a-lot groom

@elyasmnvidian
elyasmnvidian force-pushed the emehtabuddin/switch-707-capture-routing-strategy-sub-model-requestsresponses-in branch 2 times, most recently from e7c259e to 0c1e1a7 Compare July 20, 2026 17:37
Signed-off-by: Elyas Mehtabuddin <emehtabuddin@nvidia.com>
@elyasmnvidian
elyasmnvidian force-pushed the emehtabuddin/switch-707-capture-routing-strategy-sub-model-requestsresponses-in branch from 0c1e1a7 to 9e8ff05 Compare July 20, 2026 21:20
Signed-off-by: Elyas Mehtabuddin <emehtabuddin@nvidia.com>
@elyasmnvidian elyasmnvidian changed the title feat(intake): log each router's own model call to NVDataflow feat(intake): log each router's own model call Jul 20, 2026
@elyasmnvidian

Copy link
Copy Markdown
Contributor Author

Two changes since the earlier version of this PR — I rewrote the body to match.

The router-call logging moved into the Rust intake sink. Earlier it was a separate Python processor with its own IntakeClient, which posts only on the NeMo Platform route. That left two gaps: a client using only store: true (no header) got no routing records, and in direct-to-store mode the routed turn and the routing records could leave through different transports. Both records now go out through the one Rust sink, gated by IntakeRequestState.skip (header opt-in or store: true), so they share one opt-in and one destination. The Python SubModelIntakeResponseProcessor and its CTX_SUBMODEL_CALLS list are deleted.

The posting target is now a configurable URL. --intake-nvdataflow-project is replaced by --intake-target-url <full URL> (env SWITCHYARD_INTAKE_TARGET_URL). The sink no longer bakes in a host or path — you pass the full posting URL, and a data-lake store is just one URL you can point at. Unset still means the default NeMo Platform ingest with bearer auth.

Proof is in the body: a live Claude Haiku 4.5 run through our inference gateway, real classifier + routed-turn token counts landing in two flat documents on the configurable target, with distinct _ids and no auth header. Full test suite and workspace clippy are green.

@github-actions

github-actions Bot commented Jul 20, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1

🚀 View preview at
https://NVIDIA-NeMo.github.io/Switchyard/pr-preview/pr-74/

Built to branch gh-pages at 2026-07-20 23:41 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

Signed-off-by: Elyas Mehtabuddin <emehtabuddin@nvidia.com>
Signed-off-by: Elyas Mehtabuddin <emehtabuddin@nvidia.com>
@elyasmnvidian

Copy link
Copy Markdown
Contributor Author

Validated end to end against a live NVDataflow sandbox — HTTP 201.

To confirm the old behavior still works, I ran a real model call through the new sink with --intake-target-url pointed at the NVDataflow sandbox, and the endpoint accepted the flat document our code produces:

live gateway usage: prompt=18  completion=13
flat doc (new sink): _id=live-nvdf-validation-…   s_source=switchyard   s_record_type=switchyard_request
                     l_switchyard_input_tokens=18   l_switchyard_output_tokens=13
POST  <nvdataflow-host>/dataflow/sandbox-switchyard/posting  ->  HTTP 201 {"status":"Created"}

Same flat, type-prefixed document and same unauthenticated POST as before — the wire behavior is unchanged. The one migration step is that you pass the full posting URL instead of a project name:

--intake-nvdataflow-project sandbox-switchyard
  ->  --intake-target-url https://<nvdataflow-host>/dataflow/sandbox-switchyard/posting

Use whichever path your project is registered under (the sandbox and registered projects differ). Because the URL is fully configurable now, moving between the sandbox, a registered project, or a different store is config, not a code change — which is the point of the refactor.

Also pushed since the last update:

  • Docs now describe --intake-target-url and the flat-document posting path.
  • Example URLs in tests were made vendor-neutral, so a grep for the old name across the tree comes back empty.

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.

2 participants