Skip to content

fix(translation): keep foreign reasoning controls out of Responses requests - #792

Open
harshitwandhare wants to merge 1 commit into
NVIDIA-NeMo:mainfrom
harshitwandhare:fix/responses-reasoning-raw-keys
Open

harshitwandhare wants to merge 1 commit into
NVIDIA-NeMo:mainfrom
harshitwandhare:fix/responses-reasoning-raw-keys

Conversation

@harshitwandhare

@harshitwandhare harshitwandhare commented Sep 19, 2026

Copy link
Copy Markdown

What is broken

Translating an Anthropic Messages request into OpenAI Responses copies the inbound thinking object straight into the Responses reasoning field.

Input:

{"model": "claude-opus-5", "max_tokens": 32000,
 "messages": [{"role": "user", "content": "hi"}],
 "thinking": {"type": "enabled", "budget_tokens": 10000}}

Output on main at bfcd023c, reproduced before writing anything:

{"model": "claude-opus-5", "input": "hi", "max_output_tokens": 32000,
 "reasoning": {"type": "enabled", "budget_tokens": 10000}}

diagnostics: [].

type and budget_tokens are Anthropic's spelling. The Responses reasoning object defines effort, summary and generate_summary. So the request names a Responses field and fills it with another provider's controls, and nothing is reported.

I have not sent this to a live Responses endpoint, so I am not claiming what any particular server does with it. The emitted shape is not the one Responses defines, and this repo already holds the position that Anthropic thinking has no Responses representation: anthropic_thinking_is_dropped_from_responses_input asserts that for thinking content blocks. The request-level control was skipping the same rule.

Why

ReasoningParams.raw holds the source format's reasoning object verbatim. The Anthropic decoder fills it from thinking (codecs/anthropic/buffered.rs:80) and the Responses decoder from reasoning (codecs/responses/buffered.rs:69). The Responses encoder then merged raw into its output with no check on where it came from:

let mut reasoning = request.reasoning.raw.as_ref()
    .and_then(Value::as_object).cloned().unwrap_or_default();

That is correct when the request arrived as Responses and wrong for every other source, and nothing on ReasoningParams records which format filled it.

That merge arrived in #774 two days ago, and the carry-forward is where the leak starts. Checked by running the same input at both commits:

328591dd (cb8ad962^)  reasoning key absent
cb8ad962  (#774)       "reasoning": {"type": "enabled", "budget_tokens": 10000}

The Responses-to-Responses behaviour #774 was after is the part worth keeping, and this change keeps it.

The fix

The Responses encoder copies only the keys Responses defines and sends the rest through push_lossy. Under the default AllowWithDiagnostics the request goes out without the foreign keys and carries a lossy_conversion warning naming them; under Reject it errors, which is what a caller sets that policy to get. A Responses source is unaffected: effort and summary still carry through.

The key set allowed through is effort, summary and generate_summary. If Responses takes others that I have missed, say so and I will widen it.

Scope I did not take

  • budget_tokens could arguably map onto effort instead of being dropped. How a token budget corresponds to an effort level is a semantic call, so I left it alone.
  • The Anthropic and Chat encoders also drop reasoning.raw with no diagnostic, for example Anthropic thinking into Chat, or Responses summary into Chat. The same gap in different encoders. I can send that separately if you want it.

How it was verified

Three tests added to crates/switchyard-translation/tests/request_translation.rs. Run against pristine main with only the tests applied:

anthropic_thinking_controls_are_not_emitted_as_responses_reasoning ... FAILED
  left:  Some(Object {"type": String("enabled"), "budget_tokens": Number(10000)})
  right: None
rejecting_lossy_conversion_rejects_untranslatable_reasoning_controls ... FAILED
  assertion failed: result.is_err()
responses_reasoning_summary_still_carries_through_a_rebuild ... ok

The third is the no-regression guard and passes on both trees. All three pass with the fix.

Gates run locally on the branch, in WSL Ubuntu 24.04 on the pinned 1.96.1 toolchain:

gate result
cargo fmt --all --check exit 0
cargo clippy --workspace --all-targets --locked -- -D warnings exit 0
cargo clippy -p switchyard-server --all-targets --features prefill-router --locked -- -D warnings exit 0
cargo test --workspace --locked 855 passed, 0 failed, across 36 binaries. main is 852 / 0, so the delta is exactly the 3 new tests
cargo test -p switchyard-runner --features prefill-router --locked 65 passed, 0 failed
uv run ruff check . All checks passed!
uv run mypy switchyard Success: no issues found in 3 source files
spdx headers scanned=225 missing=0
uv run pytest tests/ -q -m "not integration" 1 failed, 134 passed

The one pytest failure is test_escalation_falls_back_on_first_stream_context_overflow, and it fails the same way on main in the same checkout and environment, so it is not from this change. I have not chased it further.

Summary by CodeRabbit

  • Bug Fixes

    • Improved translation of reasoning settings for OpenAI Responses requests.
    • Supported reasoning fields are preserved consistently during request rebuilding.
    • Unsupported reasoning controls are now omitted and reported through lossiness diagnostics.
    • Strict translation policies now reject requests containing unsupported reasoning controls instead of silently proceeding.
  • Tests

    • Added coverage for supported-field preservation, unsupported-control handling, diagnostics, and strict-policy rejection.

…quests

Signed-off-by: Harshit Wandhare <harshitwandhare45@gmail.com>
@coderabbitai

coderabbitai Bot commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: NVIDIA-NeMo/Switchyard/.coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 05a49a5c-2198-491f-b4f2-8d46fd26affd

📥 Commits

Reviewing files that changed from the base of the PR and between bfcd023 and a367c6d.

📒 Files selected for processing (2)
  • crates/switchyard-translation/src/codecs/responses/buffered.rs
  • crates/switchyard-translation/tests/request_translation.rs

Included review availability: Your plan provides up to 12 included reviews per hour; 11 remain after this review.


Walkthrough

The Responses encoder now emits only supported reasoning fields, reports omitted fields through diagnostics, and applies strict-policy failures when required. Tests cover Anthropic control loss and preservation of supported Responses reasoning fields.

Changes

Responses reasoning encoding

Layer / File(s) Summary
Reasoning field filtering and diagnostics
crates/switchyard-translation/src/codecs/responses/buffered.rs
The encoder allowlists effort, generate_summary, and summary. It omits other reasoning fields, reports them in sorted diagnostics, and preserves normalized effort.
Translation behavior tests
crates/switchyard-translation/tests/request_translation.rs
Tests verify unsupported Anthropic controls are omitted, diagnosed, and rejected under strict policy. Another test verifies supported Responses fields survive re-encoding without diagnostics.

Priority: ⬇️ Low

Estimated code review effort: 2 (Simple) | ~12 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: preventing foreign reasoning controls from being included in OpenAI Responses requests.
Docstring Coverage ✅ Passed Docstring coverage is 80.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 5 functions across 2 files.
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.

A rabbit guards the reasoning gate
Three bright keys pass through in state
Lost fields leave a tidy trace
Strict rules stop the altered case
Supported thoughts return in place

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

@harshitwandhare

Copy link
Copy Markdown
Author

Heads up for whoever picks this up: #799, opened today, fixes the same leak by a more general route. It replaces ReasoningParams.raw with a per-format raw_by_format map, so the Anthropic decoder's object is never visible to the Responses encoder in the first place.

I measured both heads on the same input rather than reading the diff. At 4af42218 the leak is gone, but the drop is silent: diagnostics is empty and LossyConversionPolicy::Reject still returns Ok. Full table and reproduction are on #799 (comment).

So the useful part of this PR is now the reporting, not the allowlist. If #799 goes in, I will either fold the two diagnostics tests into it or reduce this PR to the push_lossy call on top of it, whichever the reviewers prefer. No need to review the allowlist here on its own merits until that is settled.

@ajcasagrande

Copy link
Copy Markdown

@harshitwandhare please feel free to copy any useful bits from #799 otherwise, I have closed for now.

@harshitwandhare

Copy link
Copy Markdown
Author

Thanks, and understood on the alignment.

I am leaving this PR as it stands rather than copying raw_by_format across. That map is the part waiting on alignment, so pulling it into a smaller PR would carry the same open question with it. This one stays inside the Responses encoder: allowlist effort, summary and generate_summary, push_lossy for the rest. It touches no decoder and does not change ReasoningParams, so whichever way the alignment lands, this either stays as the encoder's last check or drops in one commit.

Withdrawing the note I left above: with #799 closed there is nothing to settle first, and this is ready to review as it stands. Head is a367c6da, ahead=1 behind=0 against main, checks green.

If the per-format direction comes back later, ping me and I will rebase onto it or close this, whichever is less work for you.

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