Skip to content

translator: preserve reasoning in both directions for Anthropic-to-OpenAI with vLLM - #2956

Open
anencore94 wants to merge 3 commits into
theagentrouter:mainfrom
anencore94:fix/issue-2937-vllm-reasoning
Open

anencore94 wants to merge 3 commits into
theagentrouter:mainfrom
anencore94:fix/issue-2937-vllm-reasoning

Conversation

@anencore94

Copy link
Copy Markdown

Description

This commit fixes reasoning loss in both directions when translating between the Anthropic Messages API and OpenAI Chat Completions with vLLM (>= v0.30.0) backends:

  1. Response path (OpenAI -> Anthropic): vLLM renamed reasoning_content to reasoning (choices[].message.reasoning in non-streaming and choices[].delta.reasoning in streaming) and no longer emits reasoning_content. Add Reasoning (json:"reasoning,omitempty") to ChatCompletionResponseChoiceMessage and ChatCompletionResponseChunkChoiceDelta, redact it in redactResponseMessage, and fall back to reasoning in both non-streaming and streaming response translation when reasoning_content is unset or empty (ThinkingBlocks > ReasoningContent > Reasoning).
  2. Request path (Anthropic -> OpenAI): appendAnthropicAssistantMessage previously encoded assistant thinking blocks as structured content parts ({"type":"thinking","text":...}), which vLLM ignores when rendering assistant reasoning into the chat template (<think>...</think>). Place assistant thinking text on the message as reasoning_content and preserve structured thinking (with signatures) and redacted_thinking blocks in thinking_blocks, matching the convention established in translator: stream reasoning_content as plain string, signatures via thinking_blocks #2319 and translator: serialize non-streaming Bedrock reasoning_content as plain string #2524.

Related Issues/PRs (if applicable)

Fixes #2937

…enAI with vLLM

Signed-off-by: Jaeyeon Kim <anencore94@gmail.com>
@anencore94
anencore94 requested a review from a team as a code owner November 1, 2026 07:35
@netlify

netlify Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for theagentrouter canceled.

Name Link
🔨 Latest commit 9501f60
🔍 Latest deploy log https://app.netlify.com/projects/theagentrouter/deploys/6aedc6aa88cbc8622a1339cd

@anencore94

anencore94 commented Nov 2, 2026 •

Copy link
Copy Markdown
Author

Hi @aabchoo @nacx @yuzisun — could you please approve the CI workflow run and take a look at this PR when you have a moment? It addresses both the response (choices[].message.reasoning / choices[].delta.reasoning) and multi-turn request (reasoning_content + thinking_blocks on assistant messages) sides of #2937. Thanks!

@kevinkswSiloAI

kevinkswSiloAI commented Nov 5, 2026 •

Copy link
Copy Markdown

We're hitting the same problem described in #2937 and would like to see this land.

Our setup: Envoy AI Gateway runs in front of vLLM, which serves reasoning models. Clients call the Anthropic Messages API (/v1/messages), Claude Code among them, and the gateway translates to vLLM's Chat Completions API.

Symptom:

  • Without streaming: the Anthropic response has only the text block, with no thinking block.
  • With streaming: no thinking content block is ever started and no thinking_delta events are sent. The client gets the text deltas only.

What we found while debugging:

This PR's response-side change addresses exactly that cause, and the precedence (thinking_blocks > reasoning_content > reasoning) is the one we'd expect. It also fixes the request-side history problem from #2937, which we hadn't looked into.

Related PR: #2945 by @hustxiayang fixes the same response-side problem in nearly the same way: it reads reasoning from the message and stream delta when reasoning_content is absent, and redacts it. It doesn't change the request side, though, so the multi-turn history problem from #2937 would remain. Since this PR fixes both halves of #2937, could it be considered as the fix for the issue?

@aabchoo @nacx @yuzisun, can we consider this one to be merged to fix the reasoning functionality for vLLM?

Thanks @anencore94!

@codecov

codecov Bot commented Nov 5, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 68.00000% with 16 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/translator/openai_helper.go 65.95% 16 Missing ⚠️

📢 Thoughts on this report? Let us know!

@aabchoo

aabchoo commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

Will take a look sometime this week or next. Could you fix the codecov for now?

@aabchoo aabchoo self-assigned this Nov 5, 2026
if len(rc.ReasoningContent.RedactedContent) > 0 {

// Fall back to Reasoning (used by vLLM >= v0.30.0, which renamed reasoning_content to reasoning).
if msg.Reasoning != nil && *msg.Reasoning != "" {

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.

fall back to reasoning in both non-streaming and streaming response translation when reasoning_content is unset or empty

This function doesn't actually do that? This only checks if reasoning is valid

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 9501f60. The nonempty string branch already returned early, but an empty structured reasoning_content object could also return early and suppress the reasoning fallback. The structured branch now returns only when it appends meaningful text/signature or redacted content. Added wire-format response regressions for absent/null/empty content and structured objects, plus precedence checks for signed and redacted content.

if text != "" {
assistantMsg.Content = openai.StringOrAssistantRoleContentUnion{Value: text}
}
text := anthropicContentToText(msg.Content)

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.

side note, anthropicContentToText joins without a separator which is very different from the above.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Changed in 9501f60 to concatenate without inserting a newline, matching anthropicContentToText. The regression preserves whitespace supplied by the original blocks exactly, while retaining the individual signed thinking_blocks unchanged.

}

// Redact reasoning from backends that name the field "reasoning" (vLLM)
if msg.Reasoning != nil {

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.

should this be treated as a fall back as well?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I kept the two redactions independent. This function sanitizes the object for logging rather than selecting the response field: both fields can coexist, so redacting only the preferred field could leave the other field’s private reasoning in the logged object. Added a regression in 9501f60 that supplies both fields, verifies both are redacted, and verifies the original object is unchanged.

if block.Thinking.Thinking != "" {
reasoningTexts = append(reasoningTexts, block.Thinking.Thinking)
}
thinkingBlocks = append(thinkingBlocks, openai.ThinkingBlock{

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.

Worth checking if thinking and signature is empty?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 9501f60: skip a thinking block when both thinking and signature are empty. Nonempty unsigned text and signature-only blocks remain preserved, as do redacted-thinking blocks. Added regression cases for each boundary.

Signed-off-by: Jaeyeon Kim <anencore94@gmail.com>
@anencore94

Copy link
Copy Markdown
Author

@aabchoo pushed 9501f60 with the review fixes and coverage additions; I replied to each inline thread.

Added 17 boundary/regression cases. Four cases fail before the fixes and pass afterward. make precommit, final lint (0 issues), and make test-coverage GO_TEST_ARGS='-race -json' pass locally: 76 packages, 8,287 test/subtest pass events, with the recording test skipped. Repository-filtered coverage is 89.7%; appendThinkingBlocks and redactResponseMessage each have 100% statement coverage. Local mapping covers all 52 changed executable lines across the PR, but this is not an uploaded Codecov result; upstream CI/Codecov for the new head remains to be verified.

No live-provider or GPU E2E pass is claimed. This follow-up used OpenAI Codex for implementation, review and testing.

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.

Anthropic-to-OpenAI translation loses reasoning with vLLM backends in both directions

3 participants