Skip to content

feat: add neutral native provider IR - #2

Merged
jerenkrantz merged 42 commits into
mainfrom
paul.querna/codex-neutral-ir
Sep 14, 2026
Merged

jerenkrantz merged 42 commits into
mainfrom
paul.querna/codex-neutral-ir

Conversation

@c1-squire-dev

@c1-squire-dev c1-squire-dev Bot commented Sep 13, 2026

Copy link
Copy Markdown

Summary

  • add typed neutral representations for custom, computer, and hosted tools plus calls and results
  • model bounded opaque state, compaction, generated images, pause-turn, response metadata, and structured provider failures
  • add semantic stream classification and finalized typed events without provider JSON escape hatches
  • preserve existing default serialization and make every built-in adapter match the expanded enums explicitly

Verification

  • cargo fmt --all --check
  • cargo clippy --workspace --all-targets -- -D warnings
  • cargo check --workspace
  • cargo test -p switchyard-protocol -p switchyard-translation -p switchyard-llm-client (256 passed)

cargo test --workspace reaches the final link step but cannot link the Nix Python 3.13 library in this environment because it requires GLIBC 2.38 symbols; the repository CI matrix provides its own compatible Python runner.

itayzit and others added 30 commits September 4, 2026 18:58
)

* docs(readme): make Relay and libsy quickstarts runnable

Signed-off-by: Itay Zitvar <izitvar@nvidia.com>

* docs(relay): configure deployment before enabling plugin and add macOS packaging path

Signed-off-by: Itay Zitvar <izitvar@nvidia.com>

* docs(relay): require integrity_only policy override for the unsigned plugin bundle

Signed-off-by: Itay Zitvar <izitvar@nvidia.com>

---------

Signed-off-by: Itay Zitvar <izitvar@nvidia.com>
* feat(libsy): add optional decision metadata

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

* feat(libsy): assign routing decision IDs

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

* refactor(libsy): rename decision metadata

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

* test: remove dumb test

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

---------

Signed-off-by: nachiketb <nachiketb@nvidia.com>
Adds system_prompt to native Switchyard targets so the prompt follows the model that actually answers the request.

Signed-off-by: Alex Fournier <afournier@nvidia.com>
…-NeMo#645)

* fix(translation): replay responses reasoning as input history

Signed-off-by: Sabhatina Selvam <sabhatinas@nvidia.com>

* fix(client): flatten responses namespace tools upstream

Signed-off-by: Sabhatina Selvam <sabhatinas@nvidia.com>

* fix(translation): omit empty responses reasoning

Signed-off-by: Sabhatina Selvam <sabhatinas@nvidia.com>

---------

Signed-off-by: Sabhatina Selvam <sabhatinas@nvidia.com>
Signed-off-by: Alex Fournier <afournier@nvidia.com>
Signed-off-by: Graham King <grahamk@nvidia.com>
Signed-off-by: Alex Fournier <afournier@nvidia.com>
…ransforming routes (NVIDIA-NeMo#646)

* fix(translation): carry encrypted reasoning through the Responses codec

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

* fix(translation): decode text-only Responses reasoning items on output_item.done

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

* fix(translation): decode Responses reasoning from added, done, text.done, and completed carriers

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

* feat(translation): opt-in trace of raw Responses stream events

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

* fix(translation): emit Responses reasoning in the standard summary_text shape

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

* fix(translation): make synthesized Responses item ids unique across responses

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

* fix(translation): keep synthesized Responses item ids within 64 characters

Embedding the upstream response id made synthesized item ids unique across
turns, but some upstreams issue response ids several hundred characters
long, and OpenAI rejects replayed item ids over 64 characters. A session
that started on such an upstream and later moved to an OpenAI model failed
every request with a 400 on the replayed history. Long response ids are now
replaced by a 64-bit FNV-1a digest, which keeps ids distinct per response
while bounding their length.

Signed-off-by: Lin Jia <linj@nvidia.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(translation): replay encrypted reasoning under the item id it was issued with

Encrypted reasoning returned by OpenAI-compatible providers is bound to the
output item id it was issued under. The buffered path re-emitted such items
with a synthesized id while keeping the payload, so a client that replayed
the conversation got a 400 (invalid_encrypted_content: item_id did not match
the target item id) on its next request and the session died. This affected
any escalation route whose efficient tier returns encrypted reasoning.

Both decoders now record the provider item id on the reasoning.encrypted
detail, and both encoders reuse that id for the emitted reasoning item. If
the id only becomes known after the item has already opened under another
id, the payload is dropped with a warning instead of poisoning the replay.

Signed-off-by: Lin Jia <linj@nvidia.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(translation): accept a verbatim reasoning item as an encrypted reasoning detail

The buffered request decoder in NVIDIA-NeMo#645 keeps a provider's reasoning item whole
as the reasoning detail when it carries encrypted_content. The stream and
buffered response encoders here read the payload and item id through the
shared helpers, so those helpers now recognise that shape alongside the
documented reasoning.encrypted object. This keeps the buffered-decode,
re-stream path (used by any route that buffers a reply) carrying the
encrypted payload under its original id regardless of which PR lands first.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Lin Jia <linj@nvidia.com>

* fix(translation): keep every reasoning item of a streamed Responses response

GPT-5 models emit a reasoning item ahead of each tool call, so one response
can carry several reasoning items. The Responses stream encoder kept a single
reasoning slot: the second item never opened, and when its encrypted payload
arrived under a different provider id the encoder dropped it with a warning
(observed on 1.5 percent of GPT-5.6 turns behind Switchyard). The same slot
also opened under a synthesized id whenever summary text streamed before the
payload, which made the payload unverifiable and dropped it too.

The encoder now tracks reasoning items per source content index and emits
each as its own output item, closing them in provider order. The Responses
stream decoder announces a reasoning item's provider id as soon as the
added event names it, so the encoder opens the item under that id before any
text or payload arrives. The response accumulator folds the announcement into
the payload detail that follows it and drops announcements whose payload
never came, so replayed history keeps one detail per item; the chat encoder
skips announcements since a chat client cannot use them.

Tests cover a two-reasoning-item response, summary text arriving before the
payload, and the decoder announcing an id exactly once across added, done and
completed events.

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

* chore(translation): changelog entries and review tidy-ups for the Responses codec fixes

Adds the changelog entries for the Responses reasoning, item-id, and raw
trace changes, removes a comment left behind when a helper moved to the
shared codec module, and lists the new tracing dependency with the rest of
the crate's dependencies.

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

---------

Signed-off-by: Lin Jia <linj@nvidia.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* feat(libsy): populate bounded outcome evidence

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

* docs(libsy): clarify evidence helper contracts

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

* fix(libsy): preserve deciding outcome evidence

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

---------

Signed-off-by: nachiketb <nachiketb@nvidia.com>
…DIA-NeMo#658)

* feat(libsy): expose outcome metadata in OpenTelemetry and Python

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

* fix(libsy): preserve span identity and verify Python evidence

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

---------

Signed-off-by: nachiketb <nachiketb@nvidia.com>
* docs: add OpenTelemetry reference

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

* docs: focus OpenTelemetry reference on libraries

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

* docs: simplify OpenTelemetry reference

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

---------

Signed-off-by: nachiketb <nachiketb@nvidia.com>
* feat(routing): add auto algorithm type

Signed-off-by: Ryan Lempka <rlempka@nvidia.com>

* fix(routing): report correct route type in auto route errors

Signed-off-by: Ryan Lempka <rlempka@nvidia.com>

* docs(routing): describe auto routing without naming its current default

Signed-off-by: Ryan Lempka <rlempka@nvidia.com>

* feat(routing): give auto its own preset knobs instead of stage_router's fields

Signed-off-by: Ryan Lempka <rlempka@nvidia.com>

* docs(getting-started): use auto for the quick start route

Signed-off-by: Ryan Lempka <rlempka@nvidia.com>

---------

Signed-off-by: Ryan Lempka <rlempka@nvidia.com>
* refactor(stage-router): generalize tool activity categories

Signed-off-by: Sean Lopp <slopp@nvidia.com>

* feat(stage-router): configure custom tool semantics

Signed-off-by: Sean Lopp <slopp@nvidia.com>

* docs(stage-router): document custom tool semantics

Signed-off-by: Sean Lopp <slopp@nvidia.com>

* fix(stage-router): address tool semantics review

Signed-off-by: Sean Lopp <slopp@nvidia.com>

* fix(stage-router): preserve built-in tool semantics

Signed-off-by: Sabhatina Selvam <sabhatinas@nvidia.com>

* test(stage-router): cover custom semantics scoring

---------

Signed-off-by: Sean Lopp <slopp@nvidia.com>
Signed-off-by: Sabhatina Selvam <sabhatinas@nvidia.com>
Co-authored-by: Sabhatina Selvam <sabhatinas@nvidia.com>
…o#649)

Signed-off-by: Alex Fournier <afournier@nvidia.com>
)

Signed-off-by: Eugen Nekhai <eugen.nekhai@gmail.com>
…NVIDIA-NeMo#664)

* fix(translation): carry the paired call name on Responses tool outputs

Signed-off-by: Zengyuan Liu <zengyuanl@nvidia.com>

* fix(translation): pair parallel tool calls with their outputs in Responses input

Signed-off-by: Zengyuan Liu <zengyuanl@nvidia.com>

* fix(translation): strip Codex compaction markers before Responses decode

Signed-off-by: Zengyuan Liu <zengyuanl@nvidia.com>

---------

Signed-off-by: Zengyuan Liu <zengyuanl@nvidia.com>
…e request shape (NVIDIA-NeMo#648)

* feat(translation): round-trip Responses freeform custom tools

Codex drives GPT-5 models with freeform tools: the definition is
{"type": "custom", ...} and the model answers with custom_tool_call items
whose input is a raw string. The Responses codec only modelled function
tools, so a Codex session against a GPT-5 model through Switchyard lost its
tool definitions and its tool calls and ended after one turn.

Custom tools now pass through the IR as a function with a single input
argument, with the verbatim definitions kept on the request extensions.
History items custom_tool_call and custom_tool_call_output decode and
re-encode with their types intact, upstream custom_tool_call output items
decode on both the buffered and stream paths, and when a response is
encoded with the request's extensions, calls to a custom tool are rewritten
back into custom_tool_call items. Argument delta events for such calls are
dropped on the stream because a partial JSON delta has no freeform
equivalent; clients read the completed item.

Signed-off-by: Lin Jia <linj@nvidia.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(translation): understand Responses-lite additional_tools input items

Codex sends GPT-5 requests in a lite shape: no top-level tools, empty
instructions, the tool definitions inside input[0] as an additional_tools
developer item, and the base instructions as a developer message. The
Responses codec did not know the item, so a routed GPT-5 session had no
tools in the IR and the item was turned into a user message carrying the
tool JSON.

The request decoder now reads the item's tools as the request's tool
definitions (including freeform tools) and keeps the array verbatim on
the request extensions; the input decoder skips the item; the request
encoder re-emits it in place and leaves top-level tools absent, so a
Responses upstream receives the request in the shape the client used,
while a chat upstream receives ordinary function tools.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Lin Jia <linj@nvidia.com>

* fix(translation): give rewritten custom tool calls a ctc item id prefix

OpenAI validates replayed item ids by prefix and rejects a custom_tool_call
whose id starts with fc_ ("Expected an ID that begins with 'ctc'"). When a
function_call item is rewritten into a custom_tool_call for the client, its
synthesized id now takes the ctc_ prefix.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Lin Jia <linj@nvidia.com>

* docs(translation): explain the custom-tool bookkeeping in the Responses request codec

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

* fix(translation): keep the additional_tools item when a lite request encodes input as a string

A request that reduces to one user text encodes its Responses input as a
plain string. The re-emitted additional_tools item was inserted only into an
array input, so a Responses-lite request with a single user message lost its
tool definitions on the way upstream. The encoder now widens the string to
the equivalent one-message array before inserting the item. Found by review
on NVIDIA-NeMo#648; covered by a request round-trip test.

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

* fix(translation): pair freeform tool calls with their outputs

NVIDIA-NeMo#664 reorders replayed history so each function_call_output follows its
call. Freeform (custom) calls and their custom_tool_call_output items are
the same shape for an upstream that pairs by adjacency, so the pass now
treats them alike. A tool result answering a freeform call keeps the
custom_tool_call_output type from this branch and the paired call name from

Signed-off-by: Lin Jia <linj@nvidia.com>
NVIDIA-NeMo#664. Covered by a parallel custom-call round-trip test.

---------

Signed-off-by: Lin Jia <linj@nvidia.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…dge and log verdicts (NVIDIA-NeMo#639)

* Log escalation judge verdicts at debug

The escalation judge already returns a reason with every verdict, but the
router discarded it after reading the boolean, so an operator tuning the
judge prompt could not see why sessions were held on the efficient tier.
Keep the reason on the verdict and emit one debug event per verdict under
the util::escalation target; it is silent unless that target is enabled.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 5e6cf24ed4a86b223c2aba4940f43c655049a58e)
Signed-off-by: Lin Jia <linj@nvidia.com>

* Anchor every pre-reply user message as task framing for the escalation judge

The trajectory summary handed to the escalation judge pinned only the
first user message as the task statement. Codex sends an environment
context block as its first user message and the task as the second, so
the judge saw shell and cwd boilerplate as the task while the real task
sat in the rolling window, truncated to the per-message cap, and
scrolled out after about thirty messages. From then on every task-aware
pattern in the rubric (drift, unverified completion, violated
constraints) had nothing to compare against, and only friction patterns
could fire.

Treat every user message that precedes the first assistant reply as
task framing and anchor them all, with a wider per-message budget so a
multi-thousand-character feature specification survives intact.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit fc61f715ed165e73e0664e4a866ec3b99aa38742)
Signed-off-by: Lin Jia <linj@nvidia.com>

---------

Signed-off-by: Lin Jia <linj@nvidia.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
… it serves (NVIDIA-NeMo#666)

* feat(runner): let a target force the reasoning effort of the requests it serves

A target's extra_body only fills keys the caller left out, so it cannot change
the reasoning effort of a request from a client that always sets one. Codex,
for example, sends reasoning.effort on every Responses request (high by default
for gpt-5.6), which meant a route could not run its strong tier at max behind
a client configured for high, and a per-target extra_body.reasoning_effort was
silently ignored on the Responses wire. Every routed GPT-5.6 run in the DeepSWE
program ran at high for that reason.

This adds an optional reasoning_effort on [targets.<name>]. When set, the
client applies it after the extra_body merge and it replaces the caller's
value: reasoning.effort on the Responses wire, reasoning_effort on Chat
Completions. Anthropic has no equivalent, so the runner rejects the key on
anthropic_messages clients at load time, along with blank values.

Tests cover the override on both OpenAI wire formats against a mock upstream,
TOML parsing, the blank rejection, and the Anthropic rejection. The TOML
schema reference documents the key.

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

* docs(reference): note that two efforts of one model need two llm clients

The runner keeps one target per model id and llm client and drops a
duplicate with a warning, so an effort-tier pair for one model must put each
tier on its own llm_clients entry pointing at the same endpoint. Say so next
to the reasoning_effort key, where the question arises.

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

* docs(client): explain the ordering and wire shapes of the reasoning effort override

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

* fix(runner): reject duplicate targets whose request settings conflict

The LLM client keeps one backend per model id, so two targets naming the same
model on the same llm client share a backend. That was tolerated with a
warning, which is fine for an alias with identical settings but silently
wrong once targets can carry reasoning_effort or extra_body: the second
target's settings never reach the wire. Loading now fails when two such
targets differ in reasoning_effort or extra_body, and still only warns for an
identical alias. Found by review on NVIDIA-NeMo#666; covered by a runner test.

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

---------

Signed-off-by: Lin Jia <linj@nvidia.com>
NVIDIA-NeMo#671)

* fix(translation): open a reasoning summary part only when text streams

The Responses stream encoder opened a summary part on every reasoning item,
including encrypted-only items that never stream text, and closed it only
when text had accumulated. An encrypted-only item therefore left the client
with a reasoning_summary_part.added and no matching done, while the finished
item carried an empty summary. The part now opens on the first text delta,
so encrypted-only items open, carry their payload, and close with an empty
summary and no part events. A stream test covers the encrypted-only case;
the existing text case still sees its part open and close.

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

* fix(translation): do not open a summary part for an empty reasoning delta

An empty reasoning delta opened the item's summary part without adding text,
and the part then never closed. The delta now only opens the item. Found by
review; covered by a stream test.

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

---------

Signed-off-by: Lin Jia <linj@nvidia.com>
Signed-off-by: Alex Fournier <afournier@nvidia.com>
* docs(relay): document the native integration

Signed-off-by: Alex Fournier <afournier@nvidia.com>

* docs(relay): explain routing value and cost measurement

Signed-off-by: Alex Fournier <afournier@nvidia.com>

* docs(relay): simplify integration overview

Signed-off-by: Alex Fournier <afournier@nvidia.com>

* docs(relay): clarify routing cost accounting

Signed-off-by: Alex Fournier <afournier@nvidia.com>

* docs(relay): clarify combined cost wording

Signed-off-by: Alex Fournier <afournier@nvidia.com>

* docs(relay): distinguish ATIF and OpenTelemetry

Signed-off-by: Alex Fournier <afournier@nvidia.com>

* docs(relay): clarify telemetry boundaries

Signed-off-by: Alex Fournier <afournier@nvidia.com>

* docs(relay): address integration review

Signed-off-by: Alex Fournier <afournier@nvidia.com>

* docs(relay): list supported routing algorithms

Signed-off-by: Alex Fournier <afournier@nvidia.com>

---------

Signed-off-by: Alex Fournier <afournier@nvidia.com>
Signed-off-by: Alex Fournier <afournier@nvidia.com>
Signed-off-by: Alex Fournier <afournier@nvidia.com>
Signed-off-by: Alex Fournier <afournier@nvidia.com>
Signed-off-by: Alex Fournier <afournier@nvidia.com>
Signed-off-by: Alex Fournier <afournier@nvidia.com>
grahamking and others added 2 commits September 11, 2026 13:56
…specific model. (NVIDIA-NeMo#630)

Instead of giving the available models to the algorithm in new we pass them alongside the request in run_stream where they go in the Driver.

Algorithms work with Category instead of ModelId. Later libsy selects a model from the list for that category. The mapping from Category to ModelId lives in the Driver.

See NVIDIA-NeMo#588 for the originating idea.

Assisted-by: Codex:GPT 5.6 Sol high
Assisted-by: Claude:Opus 5 medium
Reviewed-by: Claude:Opus 5 medium
Signed-off-by: Graham King grahamk@nvidia.com
Signed-off-by: Ting-Hong Shieh <32212900+ting-hong-shieh@users.noreply.github.com>
@c1-squire-dev
c1-squire-dev Bot force-pushed the paul.querna/codex-neutral-ir branch from 64723b8 to 1d7e38a Compare September 13, 2026 15:05
jerenkrantz and others added 10 commits September 14, 2026 16:28
Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
ci: adapt workflows for downstream integration
…ield-provenance

fix(translation): preserve OpenAI reasoning field provenance
Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
Signed-off-by: Paul Querna <paul.querna@conductorone.com>
…tus seam

The typed routed-call contract landed with three consumers still reading the
legacy shape, so a host that classifies its own failures lost behavior the
status-shaped path had.

- staged escalation: the efficient-tier fall-through matched only
  `ContextWindowExceeded`, so a host returning `RoutedCall` terminated the run
  instead of escalating. It now also accepts an advanceable `ContextWindow` or
  `TargetIncompatible`, and still honors `Stop` as terminal.
- exhaustion status: `ProviderTargetsExhausted` collapsed to 503 with its
  bounded summary unread. It now resolves from the partition in precedence
  order -- exact availability evidence or a circuit bypass, then host or
  provider timeouts, then homogeneous rate-limit and request-shape cases, then
  a plain routing failure -- so the 429, 504, and 422 public codes are
  reachable and 503 stays truthful.
- overflow exhaustion: an exhaustion whose every real failure was an overflow
  reported target unavailability, which is the conflation this contract exists
  to remove. It is now reported as a context-window condition at both seams
  that consume the distinction: the candidate hop's reason, and the runner's
  terminal telemetry kind. Bypassed circuits neither establish nor contradict
  the overflow, and an all-bypassed exhaustion stays unavailability. Mixed
  classes remain unavailability.

`ProviderTargetsExhaustedSummary` grows `real_failures` and
`is_context_window_exhaustion` so the aggregate rules are stated once, and
rejects an over-long class-entry list before it sizes an allocation.

Behavioral tests cover each seam, including the five aggregate-precedence
combinations. No existing public type, constructor signature, or client
requirement changes.

Staged on the D-TB-103 immutable review fork ahead of upstream submission.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
Signed-off-by: Paul Querna <paul.querna@conductorone.com>
…codec

feat: Bedrock Converse codec and typed routed-call failure contract
Signed-off-by: Paul Querna <paul.querna@conductorone.com>
Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
@jerenkrantz
jerenkrantz force-pushed the paul.querna/codex-neutral-ir branch from 85c3430 to 68770e1 Compare September 14, 2026 17:59
@jerenkrantz
jerenkrantz merged commit 42e38f2 into main Sep 14, 2026
5 of 6 checks passed
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.