fix(openai): normalise OpenRouter non-stream message.reasoning → reasoning_content (#648) - #501

Merged
moonming merged 2 commits into
mainfrom
fix/openrouter-nonstream-reasoning-648
Jun 3, 2026
Merged

fix(openai): normalise OpenRouter non-stream message.reasoning → reasoning_content (#648)#501
moonming merged 2 commits into
mainfrom
fix/openrouter-nonstream-reasoning-648

Conversation

@moonming

@moonmingmoonming commented Jun 3, 2026

Copy link
Copy Markdown
Member

Summary

Fixes the non-stream half of the OpenRouter reasoning gap in AISIX-Cloud #648. OpenRouter (and some OpenAI-compatible aggregators) return a reasoning model's chain-of-thought at message.reasoning (string) — not the DeepSeek-canonical message.reasoning_content. OpenAiResponseMessage was a closed struct deserializing only reasoning_content, so message.reasoning was dropped at decode. A reasoning model that emits its whole answer as reasoning (e.g. z-ai/glm-4.6 via OpenRouter) then reached the customer with bothcontent AND reasoning_content empty.

Evidence (AISIX-Cloud real-chain e2e): the z-ai non-stream call returns status=200, completion_tokens=230 — the upstream generated output — yet the customer saw empty content+reasoning_content. Consistent across runs; not model nondeterminism.

The streaming path already handles OpenRouter's delta.reasoning via the per-key reasoning_field override — but that lift is streaming/delta-only (extract_reasoning_field hard-rejects non-delta paths), so the non-stream response had no path to surface it.

Change (auto-normalize — no per-key config required)

crates/aisix-provider-openai/src/wire.rs:

  • Capture message.reasoning on OpenAiResponseMessage (was dropped at deserialize).
  • In response_into_chat_response, lift it into the canonical reasoning_contentextra slot when the canonical reasoning_content is absent/empty. DeepSeek-canonical reasoning_content takes precedence when both are present; an empty reasoning adds no noise field (same skip-empty rule as guardrails: streaming output moderation forwards content live before check_output (pre-P2 leak) #466). Symmetric with the existing non-stream reasoning_content capture.

Scope / blast radius (this is a global OpenAI-wire change, not OpenRouter-gated)

OpenAiResponseMessage is the shared OpenAI chat-completions response type for the whole workspace — it's also what aisix-provider-azure-openai and any future OpenAI-compatible bridge parse responses through (see the module doc on pub visibility). So this normalization is not keyed to OpenRouter: any upstream routed through the OpenAI wire that returns a non-empty message.reasoning (and no/empty message.reasoning_content) will now have it surfaced as canonical reasoning_content.

This is intended — reasoning is the de-facto field for OpenAI-compatible aggregators — and is safe because:

  • it only fills the canonical slot when the canonical field is absent/empty (never overrides a real reasoning_content);
  • it adds no field when reasoning is empty/absent (serde default), so OpenAI / Azure-OpenAI / DeepSeek-compat upstreams that never emit reasoning are byte-for-byte unaffected.

The blast radius is "OpenAI-compatible upstreams that emit message.reasoning", which today is OpenRouter-class aggregators; calling it out so reviewers don't read it as an OpenRouter-only branch.

Test plan

  • 4 unit tests in wire.rs: (1) OpenRouter reasoning→canonical; (2) canonical-reasoning_content-wins precedence; (3) empty-reasoning-adds-no-field; (4) empty-canonical-reasoning_content-falls-through-to-reasoning — the discriminating guard exercising the real precedence logic (reasoning_content.filter(!empty).or(reasoning)). Verified tests (1) and (4) FAIL against the pre-fix canonical-only code and pass with the fix (genuine regression guards, not non-discriminating).
  • Existing 8 reasoning tests pass (no regression) — 12 reasoning tests green total.
  • cargo clippy -p aisix-provider-openai + cargo fmt --check clean.
  • Sibling crates that reuse these wire types (aisix-provider-azure-openai, aisix-proxy) build.
  • CI green.

Streaming parity (follow-up, out of scope here)

This PR makes the non-stream path auto-normalize with no operator config. Bringing the streaming path to the same no-config parity (default the delta.reasoning lift for the OpenRouter adapter so it works without a per-key reasoning_field override) is tracked in #502.

Closes the non-stream half of api7/AISIX-Cloud#648. Once this lands in the :dev image, the AISIX-Cloud openrouter-longtail real-chain z-ai non-stream leg goes green without the held-back workaround.

Summary by CodeRabbit

Release Notes

  • New Features
    • Added support for handling reasoning content in OpenAI API responses. Reasoning information is now properly captured and included in response data when available.

…oning_content (#648)
OpenRouter (and some OpenAI-compatible aggregators) return a reasoning
model's chain-of-thought at `message.reasoning` on the non-stream path,
NOT the DeepSeek-canonical `message.reasoning_content`. `OpenAiResponseMessage`
was a closed struct that only deserialized `reasoning_content`, so
`message.reasoning` was dropped at decode. A reasoning model (e.g.
z-ai/glm-4.6 via OpenRouter) that emits its whole answer as reasoning then
reached the customer with BOTH `content` AND `reasoning_content` empty —
observed on the real-chain e2e (status 200, completion_tokens=230, empty
customer fields).
The streaming path already handles this via the per-key `reasoning_field`
override, but that lift is streaming/`delta`-only (extract_reasoning_field
hard-rejects non-`delta` paths), so non-stream had no path to surface it.
Fix (auto-normalize, no per-key config needed):
- Capture `message.reasoning` on OpenAiResponseMessage (was dropped).
- In response_into_chat_response, lift it into the canonical
`reasoning_content` extra slot when canonical reasoning_content is
absent/empty. The DeepSeek-canonical field takes precedence when both are
present; empty `reasoning` adds no noise field (same skip-empty rule as
#466). Symmetric with the existing non-stream reasoning_content capture.
Non-OpenRouter upstreams omit `reasoning` (serde default) → unaffected.
Tests: 3 new unit tests (OpenRouter reasoning→canonical; canonical-wins
precedence; empty-reasoning adds no field) + the existing 8 reasoning tests
pass. clippy + fmt clean; sibling crates (azure-openai, proxy) build.
@coderabbitai

coderabbitaiBot commented Jun 3, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@moonming, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 5 minutes and 21 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: 5ea4ec7f-9e9b-4242-8721-d70550f11590

📥 Commits

Reviewing files that changed from the base of the PR and between e127181 and bf3f008.

📒 Files selected for processing (1)
  • crates/aisix-provider-openai/src/wire.rs
📝 Walkthrough

Walkthrough

This PR extends the OpenAI provider's wire format to handle OpenRouter-style reasoning. The struct gains an optional reasoning field, normalization logic implements fallback-based extraction preferring reasoning_content, and test coverage validates the precedence and empty-value handling.

Changes

OpenRouter Reasoning Normalization

Layer / File(s)Summary
OpenRouter reasoning field support
crates/aisix-provider-openai/src/wire.rs
OpenAiResponseMessage struct adds an optional reasoning field (serde-defaulted) to capture OpenRouter-style reasoning content, with documentation clarifying its relationship to the canonical reasoning_content field.
Reasoning extraction and precedence
crates/aisix-provider-openai/src/wire.rs
response_into_chat_response function implements fallback logic: extracts a single non-empty reasoning value by preferring message.reasoning_content over message.reasoning, then inserts it into extra.reasoning_content only when non-empty.
Reasoning normalization tests
crates/aisix-provider-openai/src/wire.rs
Three new test cases validate: OpenRouter message.reasoning is normalized into extra.reasoning_content, canonical reasoning_content takes precedence when both fields are present, and empty reasoning does not create a field.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes


Note

🎁 Summarized by CodeRabbit Free

Your organization has reached its limit of developer seats under the Pro Plan. For new users, CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please add seats to your subscription by visiting https://app.coderabbit.ai/login.If you believe this is a mistake and have available seats, please assign one to the pull request author through the subscription management page using the link above.

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

…normalisation
The empty-canonical-falls-through case is the one branch exercising the
real precedence logic (`reasoning_content.filter(!empty).or(reasoning)`):
an empty `reasoning_content` ("") alongside a real OpenRouter `reasoning`
must surface `reasoning`. This test FAILS against the pre-fix
canonical-only code, so it's a genuine regression guard (#648).
@moonming

Copy link
Copy Markdown
MemberAuthor

Audit remediation (REQUEST-CHANGES → all 3 MEDIUM addressed)

MEDIUM-1 — non-discriminating tests. Added a 4th test non_streaming_empty_canonical_falls_through_to_openrouter_reasoning (commit bf3f008) covering the one branch with real precedence logic: empty canonical reasoning_content:"" alongside a real reasoning must fall through to reasoning. Verified it (and …normalises_to_reasoning_content) FAIL against the pre-fix canonical-only code and pass with the fix — i.e. genuine regression guards. The two original "skip-empty"/"precedence" tests pass either way by design; they're kept as documentation of those rules.

MEDIUM-2 — scope read as OpenRouter-only. Added a "Scope / blast radius" section to the PR body: OpenAiResponseMessage is the shared OpenAI-wire response type (also used by aisix-provider-azure-openai and any OpenAI-compatible bridge), so the normalization applies to any upstream emitting message.reasoning, not just OpenRouter. Called out that it only fills the canonical slot when absent/empty (never overrides) and adds no field when reasoning is empty/absent → upstreams that never emit reasoning are byte-for-byte unaffected.

MEDIUM-3 — streaming-parity not tracked. Filed #502 (auto-normalize delta.reasoning on the streaming path so neither path needs a per-key reasoning_field override) and linked it from the PR body under "Streaming parity (follow-up)".

Local gate: cargo test -p aisix-provider-openai94 passed / 0 failed; cargo clippy --all-targets + cargo fmt --check clean.

@moonming
moonming merged commit e052be0 into mainJun 3, 2026
8 checks passed
@moonming
moonming deleted the fix/openrouter-nonstream-reasoning-648 branch June 3, 2026 01:07
moonming added a commit that referenced this pull request Jun 5, 2026
…ponse (#684)
gpt-4o-audio chat models return generated audio at message.audio when the
request asks for the audio output modality. The request-side modalities/audio
params already forward via the OpenAI bridge's flatten extra, so the upstream
generates audio — but OpenAiResponseMessage didn't model audio, so it was
dropped at deserialize (customer got 200 + tokens, no audio). Adds an audio
field + surfaces it via ChatMessage.extra (same mechanism #501 used for
reasoning). Null/absent audio adds no field; non-audio upstreams unaffected.
Fixesapi7/AISIX-Cloud#684. Streaming-audio parity tracked in #518.
Sign up for freeto 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.

1 participant

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(openai): normalise OpenRouter non-stream message.reasoning → reasoning_content (#648) - #501

Merged
moonming merged 2 commits into
mainfrom
fix/openrouter-nonstream-reasoning-648
Jun 3, 2026
Merged

fix(openai): normalise OpenRouter non-stream message.reasoning → reasoning_content (#648)#501
moonming merged 2 commits into
mainfrom
fix/openrouter-nonstream-reasoning-648

Conversation

@moonming

@moonmingmoonming commented Jun 3, 2026

Copy link
Copy Markdown
Member

Summary

Fixes the non-stream half of the OpenRouter reasoning gap in AISIX-Cloud #648. OpenRouter (and some OpenAI-compatible aggregators) return a reasoning model's chain-of-thought at message.reasoning (string) — not the DeepSeek-canonical message.reasoning_content. OpenAiResponseMessage was a closed struct deserializing only reasoning_content, so message.reasoning was dropped at decode. A reasoning model that emits its whole answer as reasoning (e.g. z-ai/glm-4.6 via OpenRouter) then reached the customer with bothcontent AND reasoning_content empty.

Evidence (AISIX-Cloud real-chain e2e): the z-ai non-stream call returns status=200, completion_tokens=230 — the upstream generated output — yet the customer saw empty content+reasoning_content. Consistent across runs; not model nondeterminism.

The streaming path already handles OpenRouter's delta.reasoning via the per-key reasoning_field override — but that lift is streaming/delta-only (extract_reasoning_field hard-rejects non-delta paths), so the non-stream response had no path to surface it.

Change (auto-normalize — no per-key config required)

crates/aisix-provider-openai/src/wire.rs:

  • Capture message.reasoning on OpenAiResponseMessage (was dropped at deserialize).
  • In response_into_chat_response, lift it into the canonical reasoning_contentextra slot when the canonical reasoning_content is absent/empty. DeepSeek-canonical reasoning_content takes precedence when both are present; an empty reasoning adds no noise field (same skip-empty rule as guardrails: streaming output moderation forwards content live before check_output (pre-P2 leak) #466). Symmetric with the existing non-stream reasoning_content capture.

Scope / blast radius (this is a global OpenAI-wire change, not OpenRouter-gated)

OpenAiResponseMessage is the shared OpenAI chat-completions response type for the whole workspace — it's also what aisix-provider-azure-openai and any future OpenAI-compatible bridge parse responses through (see the module doc on pub visibility). So this normalization is not keyed to OpenRouter: any upstream routed through the OpenAI wire that returns a non-empty message.reasoning (and no/empty message.reasoning_content) will now have it surfaced as canonical reasoning_content.

This is intended — reasoning is the de-facto field for OpenAI-compatible aggregators — and is safe because:

  • it only fills the canonical slot when the canonical field is absent/empty (never overrides a real reasoning_content);
  • it adds no field when reasoning is empty/absent (serde default), so OpenAI / Azure-OpenAI / DeepSeek-compat upstreams that never emit reasoning are byte-for-byte unaffected.

The blast radius is "OpenAI-compatible upstreams that emit message.reasoning", which today is OpenRouter-class aggregators; calling it out so reviewers don't read it as an OpenRouter-only branch.

Test plan

  • 4 unit tests in wire.rs: (1) OpenRouter reasoning→canonical; (2) canonical-reasoning_content-wins precedence; (3) empty-reasoning-adds-no-field; (4) empty-canonical-reasoning_content-falls-through-to-reasoning — the discriminating guard exercising the real precedence logic (reasoning_content.filter(!empty).or(reasoning)). Verified tests (1) and (4) FAIL against the pre-fix canonical-only code and pass with the fix (genuine regression guards, not non-discriminating).
  • Existing 8 reasoning tests pass (no regression) — 12 reasoning tests green total.
  • cargo clippy -p aisix-provider-openai + cargo fmt --check clean.
  • Sibling crates that reuse these wire types (aisix-provider-azure-openai, aisix-proxy) build.
  • CI green.

Streaming parity (follow-up, out of scope here)

This PR makes the non-stream path auto-normalize with no operator config. Bringing the streaming path to the same no-config parity (default the delta.reasoning lift for the OpenRouter adapter so it works without a per-key reasoning_field override) is tracked in #502.

Closes the non-stream half of api7/AISIX-Cloud#648. Once this lands in the :dev image, the AISIX-Cloud openrouter-longtail real-chain z-ai non-stream leg goes green without the held-back workaround.

Summary by CodeRabbit

Release Notes

  • New Features
    • Added support for handling reasoning content in OpenAI API responses. Reasoning information is now properly captured and included in response data when available.

…oning_content (#648)
OpenRouter (and some OpenAI-compatible aggregators) return a reasoning
model's chain-of-thought at `message.reasoning` on the non-stream path,
NOT the DeepSeek-canonical `message.reasoning_content`. `OpenAiResponseMessage`
was a closed struct that only deserialized `reasoning_content`, so
`message.reasoning` was dropped at decode. A reasoning model (e.g.
z-ai/glm-4.6 via OpenRouter) that emits its whole answer as reasoning then
reached the customer with BOTH `content` AND `reasoning_content` empty —
observed on the real-chain e2e (status 200, completion_tokens=230, empty
customer fields).
The streaming path already handles this via the per-key `reasoning_field`
override, but that lift is streaming/`delta`-only (extract_reasoning_field
hard-rejects non-`delta` paths), so non-stream had no path to surface it.
Fix (auto-normalize, no per-key config needed):
- Capture `message.reasoning` on OpenAiResponseMessage (was dropped).
- In response_into_chat_response, lift it into the canonical
`reasoning_content` extra slot when canonical reasoning_content is
absent/empty. The DeepSeek-canonical field takes precedence when both are
present; empty `reasoning` adds no noise field (same skip-empty rule as
#466). Symmetric with the existing non-stream reasoning_content capture.
Non-OpenRouter upstreams omit `reasoning` (serde default) → unaffected.
Tests: 3 new unit tests (OpenRouter reasoning→canonical; canonical-wins
precedence; empty-reasoning adds no field) + the existing 8 reasoning tests
pass. clippy + fmt clean; sibling crates (azure-openai, proxy) build.
@coderabbitai

coderabbitaiBot commented Jun 3, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@moonming, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 5 minutes and 21 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: 5ea4ec7f-9e9b-4242-8721-d70550f11590

📥 Commits

Reviewing files that changed from the base of the PR and between e127181 and bf3f008.

📒 Files selected for processing (1)
  • crates/aisix-provider-openai/src/wire.rs
📝 Walkthrough

Walkthrough

This PR extends the OpenAI provider's wire format to handle OpenRouter-style reasoning. The struct gains an optional reasoning field, normalization logic implements fallback-based extraction preferring reasoning_content, and test coverage validates the precedence and empty-value handling.

Changes

OpenRouter Reasoning Normalization

Layer / File(s)Summary
OpenRouter reasoning field support
crates/aisix-provider-openai/src/wire.rs
OpenAiResponseMessage struct adds an optional reasoning field (serde-defaulted) to capture OpenRouter-style reasoning content, with documentation clarifying its relationship to the canonical reasoning_content field.
Reasoning extraction and precedence
crates/aisix-provider-openai/src/wire.rs
response_into_chat_response function implements fallback logic: extracts a single non-empty reasoning value by preferring message.reasoning_content over message.reasoning, then inserts it into extra.reasoning_content only when non-empty.
Reasoning normalization tests
crates/aisix-provider-openai/src/wire.rs
Three new test cases validate: OpenRouter message.reasoning is normalized into extra.reasoning_content, canonical reasoning_content takes precedence when both fields are present, and empty reasoning does not create a field.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes


Note

🎁 Summarized by CodeRabbit Free

Your organization has reached its limit of developer seats under the Pro Plan. For new users, CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please add seats to your subscription by visiting https://app.coderabbit.ai/login.If you believe this is a mistake and have available seats, please assign one to the pull request author through the subscription management page using the link above.

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

…normalisation
The empty-canonical-falls-through case is the one branch exercising the
real precedence logic (`reasoning_content.filter(!empty).or(reasoning)`):
an empty `reasoning_content` ("") alongside a real OpenRouter `reasoning`
must surface `reasoning`. This test FAILS against the pre-fix
canonical-only code, so it's a genuine regression guard (#648).
@moonming

Copy link
Copy Markdown
MemberAuthor

Audit remediation (REQUEST-CHANGES → all 3 MEDIUM addressed)

MEDIUM-1 — non-discriminating tests. Added a 4th test non_streaming_empty_canonical_falls_through_to_openrouter_reasoning (commit bf3f008) covering the one branch with real precedence logic: empty canonical reasoning_content:"" alongside a real reasoning must fall through to reasoning. Verified it (and …normalises_to_reasoning_content) FAIL against the pre-fix canonical-only code and pass with the fix — i.e. genuine regression guards. The two original "skip-empty"/"precedence" tests pass either way by design; they're kept as documentation of those rules.

MEDIUM-2 — scope read as OpenRouter-only. Added a "Scope / blast radius" section to the PR body: OpenAiResponseMessage is the shared OpenAI-wire response type (also used by aisix-provider-azure-openai and any OpenAI-compatible bridge), so the normalization applies to any upstream emitting message.reasoning, not just OpenRouter. Called out that it only fills the canonical slot when absent/empty (never overrides) and adds no field when reasoning is empty/absent → upstreams that never emit reasoning are byte-for-byte unaffected.

MEDIUM-3 — streaming-parity not tracked. Filed #502 (auto-normalize delta.reasoning on the streaming path so neither path needs a per-key reasoning_field override) and linked it from the PR body under "Streaming parity (follow-up)".

Local gate: cargo test -p aisix-provider-openai94 passed / 0 failed; cargo clippy --all-targets + cargo fmt --check clean.

@moonming
moonming merged commit e052be0 into mainJun 3, 2026
8 checks passed
@moonming
moonming deleted the fix/openrouter-nonstream-reasoning-648 branch June 3, 2026 01:07
moonming added a commit that referenced this pull request Jun 5, 2026
…ponse (#684)
gpt-4o-audio chat models return generated audio at message.audio when the
request asks for the audio output modality. The request-side modalities/audio
params already forward via the OpenAI bridge's flatten extra, so the upstream
generates audio — but OpenAiResponseMessage didn't model audio, so it was
dropped at deserialize (customer got 200 + tokens, no audio). Adds an audio
field + surfaces it via ChatMessage.extra (same mechanism #501 used for
reasoning). Null/absent audio adds no field; non-audio upstreams unaffected.
Fixesapi7/AISIX-Cloud#684. Streaming-audio parity tracked in #518.
Sign up for freeto 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.

1 participant

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(openai): normalise OpenRouter non-stream message.reasoning → reasoning_content (#648) - #501

Merged
moonming merged 2 commits into
mainfrom
fix/openrouter-nonstream-reasoning-648
Jun 3, 2026
Merged

fix(openai): normalise OpenRouter non-stream message.reasoning → reasoning_content (#648)#501
moonming merged 2 commits into
mainfrom
fix/openrouter-nonstream-reasoning-648

Conversation

@moonming

@moonmingmoonming commented Jun 3, 2026

Copy link
Copy Markdown
Member

Summary

Fixes the non-stream half of the OpenRouter reasoning gap in AISIX-Cloud #648. OpenRouter (and some OpenAI-compatible aggregators) return a reasoning model's chain-of-thought at message.reasoning (string) — not the DeepSeek-canonical message.reasoning_content. OpenAiResponseMessage was a closed struct deserializing only reasoning_content, so message.reasoning was dropped at decode. A reasoning model that emits its whole answer as reasoning (e.g. z-ai/glm-4.6 via OpenRouter) then reached the customer with bothcontent AND reasoning_content empty.

Evidence (AISIX-Cloud real-chain e2e): the z-ai non-stream call returns status=200, completion_tokens=230 — the upstream generated output — yet the customer saw empty content+reasoning_content. Consistent across runs; not model nondeterminism.

The streaming path already handles OpenRouter's delta.reasoning via the per-key reasoning_field override — but that lift is streaming/delta-only (extract_reasoning_field hard-rejects non-delta paths), so the non-stream response had no path to surface it.

Change (auto-normalize — no per-key config required)

crates/aisix-provider-openai/src/wire.rs:

  • Capture message.reasoning on OpenAiResponseMessage (was dropped at deserialize).
  • In response_into_chat_response, lift it into the canonical reasoning_contentextra slot when the canonical reasoning_content is absent/empty. DeepSeek-canonical reasoning_content takes precedence when both are present; an empty reasoning adds no noise field (same skip-empty rule as guardrails: streaming output moderation forwards content live before check_output (pre-P2 leak) #466). Symmetric with the existing non-stream reasoning_content capture.

Scope / blast radius (this is a global OpenAI-wire change, not OpenRouter-gated)

OpenAiResponseMessage is the shared OpenAI chat-completions response type for the whole workspace — it's also what aisix-provider-azure-openai and any future OpenAI-compatible bridge parse responses through (see the module doc on pub visibility). So this normalization is not keyed to OpenRouter: any upstream routed through the OpenAI wire that returns a non-empty message.reasoning (and no/empty message.reasoning_content) will now have it surfaced as canonical reasoning_content.

This is intended — reasoning is the de-facto field for OpenAI-compatible aggregators — and is safe because:

  • it only fills the canonical slot when the canonical field is absent/empty (never overrides a real reasoning_content);
  • it adds no field when reasoning is empty/absent (serde default), so OpenAI / Azure-OpenAI / DeepSeek-compat upstreams that never emit reasoning are byte-for-byte unaffected.

The blast radius is "OpenAI-compatible upstreams that emit message.reasoning", which today is OpenRouter-class aggregators; calling it out so reviewers don't read it as an OpenRouter-only branch.

Test plan

  • 4 unit tests in wire.rs: (1) OpenRouter reasoning→canonical; (2) canonical-reasoning_content-wins precedence; (3) empty-reasoning-adds-no-field; (4) empty-canonical-reasoning_content-falls-through-to-reasoning — the discriminating guard exercising the real precedence logic (reasoning_content.filter(!empty).or(reasoning)). Verified tests (1) and (4) FAIL against the pre-fix canonical-only code and pass with the fix (genuine regression guards, not non-discriminating).
  • Existing 8 reasoning tests pass (no regression) — 12 reasoning tests green total.
  • cargo clippy -p aisix-provider-openai + cargo fmt --check clean.
  • Sibling crates that reuse these wire types (aisix-provider-azure-openai, aisix-proxy) build.
  • CI green.

Streaming parity (follow-up, out of scope here)

This PR makes the non-stream path auto-normalize with no operator config. Bringing the streaming path to the same no-config parity (default the delta.reasoning lift for the OpenRouter adapter so it works without a per-key reasoning_field override) is tracked in #502.

Closes the non-stream half of api7/AISIX-Cloud#648. Once this lands in the :dev image, the AISIX-Cloud openrouter-longtail real-chain z-ai non-stream leg goes green without the held-back workaround.

Summary by CodeRabbit

Release Notes

  • New Features
    • Added support for handling reasoning content in OpenAI API responses. Reasoning information is now properly captured and included in response data when available.

…oning_content (#648)
OpenRouter (and some OpenAI-compatible aggregators) return a reasoning
model's chain-of-thought at `message.reasoning` on the non-stream path,
NOT the DeepSeek-canonical `message.reasoning_content`. `OpenAiResponseMessage`
was a closed struct that only deserialized `reasoning_content`, so
`message.reasoning` was dropped at decode. A reasoning model (e.g.
z-ai/glm-4.6 via OpenRouter) that emits its whole answer as reasoning then
reached the customer with BOTH `content` AND `reasoning_content` empty —
observed on the real-chain e2e (status 200, completion_tokens=230, empty
customer fields).
The streaming path already handles this via the per-key `reasoning_field`
override, but that lift is streaming/`delta`-only (extract_reasoning_field
hard-rejects non-`delta` paths), so non-stream had no path to surface it.
Fix (auto-normalize, no per-key config needed):
- Capture `message.reasoning` on OpenAiResponseMessage (was dropped).
- In response_into_chat_response, lift it into the canonical
`reasoning_content` extra slot when canonical reasoning_content is
absent/empty. The DeepSeek-canonical field takes precedence when both are
present; empty `reasoning` adds no noise field (same skip-empty rule as
#466). Symmetric with the existing non-stream reasoning_content capture.
Non-OpenRouter upstreams omit `reasoning` (serde default) → unaffected.
Tests: 3 new unit tests (OpenRouter reasoning→canonical; canonical-wins
precedence; empty-reasoning adds no field) + the existing 8 reasoning tests
pass. clippy + fmt clean; sibling crates (azure-openai, proxy) build.
@coderabbitai

coderabbitaiBot commented Jun 3, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@moonming, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 5 minutes and 21 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: 5ea4ec7f-9e9b-4242-8721-d70550f11590

📥 Commits

Reviewing files that changed from the base of the PR and between e127181 and bf3f008.

📒 Files selected for processing (1)
  • crates/aisix-provider-openai/src/wire.rs
📝 Walkthrough

Walkthrough

This PR extends the OpenAI provider's wire format to handle OpenRouter-style reasoning. The struct gains an optional reasoning field, normalization logic implements fallback-based extraction preferring reasoning_content, and test coverage validates the precedence and empty-value handling.

Changes

OpenRouter Reasoning Normalization

Layer / File(s)Summary
OpenRouter reasoning field support
crates/aisix-provider-openai/src/wire.rs
OpenAiResponseMessage struct adds an optional reasoning field (serde-defaulted) to capture OpenRouter-style reasoning content, with documentation clarifying its relationship to the canonical reasoning_content field.
Reasoning extraction and precedence
crates/aisix-provider-openai/src/wire.rs
response_into_chat_response function implements fallback logic: extracts a single non-empty reasoning value by preferring message.reasoning_content over message.reasoning, then inserts it into extra.reasoning_content only when non-empty.
Reasoning normalization tests
crates/aisix-provider-openai/src/wire.rs
Three new test cases validate: OpenRouter message.reasoning is normalized into extra.reasoning_content, canonical reasoning_content takes precedence when both fields are present, and empty reasoning does not create a field.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes


Note

🎁 Summarized by CodeRabbit Free

Your organization has reached its limit of developer seats under the Pro Plan. For new users, CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please add seats to your subscription by visiting https://app.coderabbit.ai/login.If you believe this is a mistake and have available seats, please assign one to the pull request author through the subscription management page using the link above.

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

…normalisation
The empty-canonical-falls-through case is the one branch exercising the
real precedence logic (`reasoning_content.filter(!empty).or(reasoning)`):
an empty `reasoning_content` ("") alongside a real OpenRouter `reasoning`
must surface `reasoning`. This test FAILS against the pre-fix
canonical-only code, so it's a genuine regression guard (#648).
@moonming

Copy link
Copy Markdown
MemberAuthor

Audit remediation (REQUEST-CHANGES → all 3 MEDIUM addressed)

MEDIUM-1 — non-discriminating tests. Added a 4th test non_streaming_empty_canonical_falls_through_to_openrouter_reasoning (commit bf3f008) covering the one branch with real precedence logic: empty canonical reasoning_content:"" alongside a real reasoning must fall through to reasoning. Verified it (and …normalises_to_reasoning_content) FAIL against the pre-fix canonical-only code and pass with the fix — i.e. genuine regression guards. The two original "skip-empty"/"precedence" tests pass either way by design; they're kept as documentation of those rules.

MEDIUM-2 — scope read as OpenRouter-only. Added a "Scope / blast radius" section to the PR body: OpenAiResponseMessage is the shared OpenAI-wire response type (also used by aisix-provider-azure-openai and any OpenAI-compatible bridge), so the normalization applies to any upstream emitting message.reasoning, not just OpenRouter. Called out that it only fills the canonical slot when absent/empty (never overrides) and adds no field when reasoning is empty/absent → upstreams that never emit reasoning are byte-for-byte unaffected.

MEDIUM-3 — streaming-parity not tracked. Filed #502 (auto-normalize delta.reasoning on the streaming path so neither path needs a per-key reasoning_field override) and linked it from the PR body under "Streaming parity (follow-up)".

Local gate: cargo test -p aisix-provider-openai94 passed / 0 failed; cargo clippy --all-targets + cargo fmt --check clean.

@moonming
moonming merged commit e052be0 into mainJun 3, 2026
8 checks passed
@moonming
moonming deleted the fix/openrouter-nonstream-reasoning-648 branch June 3, 2026 01:07
moonming added a commit that referenced this pull request Jun 5, 2026
…ponse (#684)
gpt-4o-audio chat models return generated audio at message.audio when the
request asks for the audio output modality. The request-side modalities/audio
params already forward via the OpenAI bridge's flatten extra, so the upstream
generates audio — but OpenAiResponseMessage didn't model audio, so it was
dropped at deserialize (customer got 200 + tokens, no audio). Adds an audio
field + surfaces it via ChatMessage.extra (same mechanism #501 used for
reasoning). Null/absent audio adds no field; non-audio upstreams unaffected.
Fixesapi7/AISIX-Cloud#684. Streaming-audio parity tracked in #518.
Sign up for freeto 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.

1 participant

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(openai): normalise OpenRouter non-stream message.reasoning → reasoning_content (#648) - #501

Merged
moonming merged 2 commits into
mainfrom
fix/openrouter-nonstream-reasoning-648
Jun 3, 2026
Merged

fix(openai): normalise OpenRouter non-stream message.reasoning → reasoning_content (#648)#501
moonming merged 2 commits into
mainfrom
fix/openrouter-nonstream-reasoning-648

Conversation

@moonming

@moonmingmoonming commented Jun 3, 2026

Copy link
Copy Markdown
Member

Summary

Fixes the non-stream half of the OpenRouter reasoning gap in AISIX-Cloud #648. OpenRouter (and some OpenAI-compatible aggregators) return a reasoning model's chain-of-thought at message.reasoning (string) — not the DeepSeek-canonical message.reasoning_content. OpenAiResponseMessage was a closed struct deserializing only reasoning_content, so message.reasoning was dropped at decode. A reasoning model that emits its whole answer as reasoning (e.g. z-ai/glm-4.6 via OpenRouter) then reached the customer with bothcontent AND reasoning_content empty.

Evidence (AISIX-Cloud real-chain e2e): the z-ai non-stream call returns status=200, completion_tokens=230 — the upstream generated output — yet the customer saw empty content+reasoning_content. Consistent across runs; not model nondeterminism.

The streaming path already handles OpenRouter's delta.reasoning via the per-key reasoning_field override — but that lift is streaming/delta-only (extract_reasoning_field hard-rejects non-delta paths), so the non-stream response had no path to surface it.

Change (auto-normalize — no per-key config required)

crates/aisix-provider-openai/src/wire.rs:

  • Capture message.reasoning on OpenAiResponseMessage (was dropped at deserialize).
  • In response_into_chat_response, lift it into the canonical reasoning_contentextra slot when the canonical reasoning_content is absent/empty. DeepSeek-canonical reasoning_content takes precedence when both are present; an empty reasoning adds no noise field (same skip-empty rule as guardrails: streaming output moderation forwards content live before check_output (pre-P2 leak) #466). Symmetric with the existing non-stream reasoning_content capture.

Scope / blast radius (this is a global OpenAI-wire change, not OpenRouter-gated)

OpenAiResponseMessage is the shared OpenAI chat-completions response type for the whole workspace — it's also what aisix-provider-azure-openai and any future OpenAI-compatible bridge parse responses through (see the module doc on pub visibility). So this normalization is not keyed to OpenRouter: any upstream routed through the OpenAI wire that returns a non-empty message.reasoning (and no/empty message.reasoning_content) will now have it surfaced as canonical reasoning_content.

This is intended — reasoning is the de-facto field for OpenAI-compatible aggregators — and is safe because:

  • it only fills the canonical slot when the canonical field is absent/empty (never overrides a real reasoning_content);
  • it adds no field when reasoning is empty/absent (serde default), so OpenAI / Azure-OpenAI / DeepSeek-compat upstreams that never emit reasoning are byte-for-byte unaffected.

The blast radius is "OpenAI-compatible upstreams that emit message.reasoning", which today is OpenRouter-class aggregators; calling it out so reviewers don't read it as an OpenRouter-only branch.

Test plan

  • 4 unit tests in wire.rs: (1) OpenRouter reasoning→canonical; (2) canonical-reasoning_content-wins precedence; (3) empty-reasoning-adds-no-field; (4) empty-canonical-reasoning_content-falls-through-to-reasoning — the discriminating guard exercising the real precedence logic (reasoning_content.filter(!empty).or(reasoning)). Verified tests (1) and (4) FAIL against the pre-fix canonical-only code and pass with the fix (genuine regression guards, not non-discriminating).
  • Existing 8 reasoning tests pass (no regression) — 12 reasoning tests green total.
  • cargo clippy -p aisix-provider-openai + cargo fmt --check clean.
  • Sibling crates that reuse these wire types (aisix-provider-azure-openai, aisix-proxy) build.
  • CI green.

Streaming parity (follow-up, out of scope here)

This PR makes the non-stream path auto-normalize with no operator config. Bringing the streaming path to the same no-config parity (default the delta.reasoning lift for the OpenRouter adapter so it works without a per-key reasoning_field override) is tracked in #502.

Closes the non-stream half of api7/AISIX-Cloud#648. Once this lands in the :dev image, the AISIX-Cloud openrouter-longtail real-chain z-ai non-stream leg goes green without the held-back workaround.

Summary by CodeRabbit

Release Notes

  • New Features
    • Added support for handling reasoning content in OpenAI API responses. Reasoning information is now properly captured and included in response data when available.

…oning_content (#648)
OpenRouter (and some OpenAI-compatible aggregators) return a reasoning
model's chain-of-thought at `message.reasoning` on the non-stream path,
NOT the DeepSeek-canonical `message.reasoning_content`. `OpenAiResponseMessage`
was a closed struct that only deserialized `reasoning_content`, so
`message.reasoning` was dropped at decode. A reasoning model (e.g.
z-ai/glm-4.6 via OpenRouter) that emits its whole answer as reasoning then
reached the customer with BOTH `content` AND `reasoning_content` empty —
observed on the real-chain e2e (status 200, completion_tokens=230, empty
customer fields).
The streaming path already handles this via the per-key `reasoning_field`
override, but that lift is streaming/`delta`-only (extract_reasoning_field
hard-rejects non-`delta` paths), so non-stream had no path to surface it.
Fix (auto-normalize, no per-key config needed):
- Capture `message.reasoning` on OpenAiResponseMessage (was dropped).
- In response_into_chat_response, lift it into the canonical
`reasoning_content` extra slot when canonical reasoning_content is
absent/empty. The DeepSeek-canonical field takes precedence when both are
present; empty `reasoning` adds no noise field (same skip-empty rule as
#466). Symmetric with the existing non-stream reasoning_content capture.
Non-OpenRouter upstreams omit `reasoning` (serde default) → unaffected.
Tests: 3 new unit tests (OpenRouter reasoning→canonical; canonical-wins
precedence; empty-reasoning adds no field) + the existing 8 reasoning tests
pass. clippy + fmt clean; sibling crates (azure-openai, proxy) build.
@coderabbitai

coderabbitaiBot commented Jun 3, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@moonming, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 5 minutes and 21 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: 5ea4ec7f-9e9b-4242-8721-d70550f11590

📥 Commits

Reviewing files that changed from the base of the PR and between e127181 and bf3f008.

📒 Files selected for processing (1)
  • crates/aisix-provider-openai/src/wire.rs
📝 Walkthrough

Walkthrough

This PR extends the OpenAI provider's wire format to handle OpenRouter-style reasoning. The struct gains an optional reasoning field, normalization logic implements fallback-based extraction preferring reasoning_content, and test coverage validates the precedence and empty-value handling.

Changes

OpenRouter Reasoning Normalization

Layer / File(s)Summary
OpenRouter reasoning field support
crates/aisix-provider-openai/src/wire.rs
OpenAiResponseMessage struct adds an optional reasoning field (serde-defaulted) to capture OpenRouter-style reasoning content, with documentation clarifying its relationship to the canonical reasoning_content field.
Reasoning extraction and precedence
crates/aisix-provider-openai/src/wire.rs
response_into_chat_response function implements fallback logic: extracts a single non-empty reasoning value by preferring message.reasoning_content over message.reasoning, then inserts it into extra.reasoning_content only when non-empty.
Reasoning normalization tests
crates/aisix-provider-openai/src/wire.rs
Three new test cases validate: OpenRouter message.reasoning is normalized into extra.reasoning_content, canonical reasoning_content takes precedence when both fields are present, and empty reasoning does not create a field.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes


Note

🎁 Summarized by CodeRabbit Free

Your organization has reached its limit of developer seats under the Pro Plan. For new users, CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please add seats to your subscription by visiting https://app.coderabbit.ai/login.If you believe this is a mistake and have available seats, please assign one to the pull request author through the subscription management page using the link above.

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

…normalisation
The empty-canonical-falls-through case is the one branch exercising the
real precedence logic (`reasoning_content.filter(!empty).or(reasoning)`):
an empty `reasoning_content` ("") alongside a real OpenRouter `reasoning`
must surface `reasoning`. This test FAILS against the pre-fix
canonical-only code, so it's a genuine regression guard (#648).
@moonming

Copy link
Copy Markdown
MemberAuthor

Audit remediation (REQUEST-CHANGES → all 3 MEDIUM addressed)

MEDIUM-1 — non-discriminating tests. Added a 4th test non_streaming_empty_canonical_falls_through_to_openrouter_reasoning (commit bf3f008) covering the one branch with real precedence logic: empty canonical reasoning_content:"" alongside a real reasoning must fall through to reasoning. Verified it (and …normalises_to_reasoning_content) FAIL against the pre-fix canonical-only code and pass with the fix — i.e. genuine regression guards. The two original "skip-empty"/"precedence" tests pass either way by design; they're kept as documentation of those rules.

MEDIUM-2 — scope read as OpenRouter-only. Added a "Scope / blast radius" section to the PR body: OpenAiResponseMessage is the shared OpenAI-wire response type (also used by aisix-provider-azure-openai and any OpenAI-compatible bridge), so the normalization applies to any upstream emitting message.reasoning, not just OpenRouter. Called out that it only fills the canonical slot when absent/empty (never overrides) and adds no field when reasoning is empty/absent → upstreams that never emit reasoning are byte-for-byte unaffected.

MEDIUM-3 — streaming-parity not tracked. Filed #502 (auto-normalize delta.reasoning on the streaming path so neither path needs a per-key reasoning_field override) and linked it from the PR body under "Streaming parity (follow-up)".

Local gate: cargo test -p aisix-provider-openai94 passed / 0 failed; cargo clippy --all-targets + cargo fmt --check clean.

@moonming
moonming merged commit e052be0 into mainJun 3, 2026
8 checks passed
@moonming
moonming deleted the fix/openrouter-nonstream-reasoning-648 branch June 3, 2026 01:07
moonming added a commit that referenced this pull request Jun 5, 2026
…ponse (#684)
gpt-4o-audio chat models return generated audio at message.audio when the
request asks for the audio output modality. The request-side modalities/audio
params already forward via the OpenAI bridge's flatten extra, so the upstream
generates audio — but OpenAiResponseMessage didn't model audio, so it was
dropped at deserialize (customer got 200 + tokens, no audio). Adds an audio
field + surfaces it via ChatMessage.extra (same mechanism #501 used for
reasoning). Null/absent audio adds no field; non-audio upstreams unaffected.
Fixesapi7/AISIX-Cloud#684. Streaming-audio parity tracked in #518.
Sign up for freeto 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.

1 participant

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(openai): normalise OpenRouter non-stream message.reasoning → reasoning_content (#648) - #501

Merged
moonming merged 2 commits into
mainfrom
fix/openrouter-nonstream-reasoning-648
Jun 3, 2026
Merged

fix(openai): normalise OpenRouter non-stream message.reasoning → reasoning_content (#648)#501
moonming merged 2 commits into
mainfrom
fix/openrouter-nonstream-reasoning-648

Conversation

@moonming

@moonmingmoonming commented Jun 3, 2026

Copy link
Copy Markdown
Member

Summary

Fixes the non-stream half of the OpenRouter reasoning gap in AISIX-Cloud #648. OpenRouter (and some OpenAI-compatible aggregators) return a reasoning model's chain-of-thought at message.reasoning (string) — not the DeepSeek-canonical message.reasoning_content. OpenAiResponseMessage was a closed struct deserializing only reasoning_content, so message.reasoning was dropped at decode. A reasoning model that emits its whole answer as reasoning (e.g. z-ai/glm-4.6 via OpenRouter) then reached the customer with bothcontent AND reasoning_content empty.

Evidence (AISIX-Cloud real-chain e2e): the z-ai non-stream call returns status=200, completion_tokens=230 — the upstream generated output — yet the customer saw empty content+reasoning_content. Consistent across runs; not model nondeterminism.

The streaming path already handles OpenRouter's delta.reasoning via the per-key reasoning_field override — but that lift is streaming/delta-only (extract_reasoning_field hard-rejects non-delta paths), so the non-stream response had no path to surface it.

Change (auto-normalize — no per-key config required)

crates/aisix-provider-openai/src/wire.rs:

  • Capture message.reasoning on OpenAiResponseMessage (was dropped at deserialize).
  • In response_into_chat_response, lift it into the canonical reasoning_contentextra slot when the canonical reasoning_content is absent/empty. DeepSeek-canonical reasoning_content takes precedence when both are present; an empty reasoning adds no noise field (same skip-empty rule as guardrails: streaming output moderation forwards content live before check_output (pre-P2 leak) #466). Symmetric with the existing non-stream reasoning_content capture.

Scope / blast radius (this is a global OpenAI-wire change, not OpenRouter-gated)

OpenAiResponseMessage is the shared OpenAI chat-completions response type for the whole workspace — it's also what aisix-provider-azure-openai and any future OpenAI-compatible bridge parse responses through (see the module doc on pub visibility). So this normalization is not keyed to OpenRouter: any upstream routed through the OpenAI wire that returns a non-empty message.reasoning (and no/empty message.reasoning_content) will now have it surfaced as canonical reasoning_content.

This is intended — reasoning is the de-facto field for OpenAI-compatible aggregators — and is safe because:

  • it only fills the canonical slot when the canonical field is absent/empty (never overrides a real reasoning_content);
  • it adds no field when reasoning is empty/absent (serde default), so OpenAI / Azure-OpenAI / DeepSeek-compat upstreams that never emit reasoning are byte-for-byte unaffected.

The blast radius is "OpenAI-compatible upstreams that emit message.reasoning", which today is OpenRouter-class aggregators; calling it out so reviewers don't read it as an OpenRouter-only branch.

Test plan

  • 4 unit tests in wire.rs: (1) OpenRouter reasoning→canonical; (2) canonical-reasoning_content-wins precedence; (3) empty-reasoning-adds-no-field; (4) empty-canonical-reasoning_content-falls-through-to-reasoning — the discriminating guard exercising the real precedence logic (reasoning_content.filter(!empty).or(reasoning)). Verified tests (1) and (4) FAIL against the pre-fix canonical-only code and pass with the fix (genuine regression guards, not non-discriminating).
  • Existing 8 reasoning tests pass (no regression) — 12 reasoning tests green total.
  • cargo clippy -p aisix-provider-openai + cargo fmt --check clean.
  • Sibling crates that reuse these wire types (aisix-provider-azure-openai, aisix-proxy) build.
  • CI green.

Streaming parity (follow-up, out of scope here)

This PR makes the non-stream path auto-normalize with no operator config. Bringing the streaming path to the same no-config parity (default the delta.reasoning lift for the OpenRouter adapter so it works without a per-key reasoning_field override) is tracked in #502.

Closes the non-stream half of api7/AISIX-Cloud#648. Once this lands in the :dev image, the AISIX-Cloud openrouter-longtail real-chain z-ai non-stream leg goes green without the held-back workaround.

Summary by CodeRabbit

Release Notes

  • New Features
    • Added support for handling reasoning content in OpenAI API responses. Reasoning information is now properly captured and included in response data when available.

…oning_content (#648)
OpenRouter (and some OpenAI-compatible aggregators) return a reasoning
model's chain-of-thought at `message.reasoning` on the non-stream path,
NOT the DeepSeek-canonical `message.reasoning_content`. `OpenAiResponseMessage`
was a closed struct that only deserialized `reasoning_content`, so
`message.reasoning` was dropped at decode. A reasoning model (e.g.
z-ai/glm-4.6 via OpenRouter) that emits its whole answer as reasoning then
reached the customer with BOTH `content` AND `reasoning_content` empty —
observed on the real-chain e2e (status 200, completion_tokens=230, empty
customer fields).
The streaming path already handles this via the per-key `reasoning_field`
override, but that lift is streaming/`delta`-only (extract_reasoning_field
hard-rejects non-`delta` paths), so non-stream had no path to surface it.
Fix (auto-normalize, no per-key config needed):
- Capture `message.reasoning` on OpenAiResponseMessage (was dropped).
- In response_into_chat_response, lift it into the canonical
`reasoning_content` extra slot when canonical reasoning_content is
absent/empty. The DeepSeek-canonical field takes precedence when both are
present; empty `reasoning` adds no noise field (same skip-empty rule as
#466). Symmetric with the existing non-stream reasoning_content capture.
Non-OpenRouter upstreams omit `reasoning` (serde default) → unaffected.
Tests: 3 new unit tests (OpenRouter reasoning→canonical; canonical-wins
precedence; empty-reasoning adds no field) + the existing 8 reasoning tests
pass. clippy + fmt clean; sibling crates (azure-openai, proxy) build.
@coderabbitai

coderabbitaiBot commented Jun 3, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@moonming, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 5 minutes and 21 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: 5ea4ec7f-9e9b-4242-8721-d70550f11590

📥 Commits

Reviewing files that changed from the base of the PR and between e127181 and bf3f008.

📒 Files selected for processing (1)
  • crates/aisix-provider-openai/src/wire.rs
📝 Walkthrough

Walkthrough

This PR extends the OpenAI provider's wire format to handle OpenRouter-style reasoning. The struct gains an optional reasoning field, normalization logic implements fallback-based extraction preferring reasoning_content, and test coverage validates the precedence and empty-value handling.

Changes

OpenRouter Reasoning Normalization

Layer / File(s)Summary
OpenRouter reasoning field support
crates/aisix-provider-openai/src/wire.rs
OpenAiResponseMessage struct adds an optional reasoning field (serde-defaulted) to capture OpenRouter-style reasoning content, with documentation clarifying its relationship to the canonical reasoning_content field.
Reasoning extraction and precedence
crates/aisix-provider-openai/src/wire.rs
response_into_chat_response function implements fallback logic: extracts a single non-empty reasoning value by preferring message.reasoning_content over message.reasoning, then inserts it into extra.reasoning_content only when non-empty.
Reasoning normalization tests
crates/aisix-provider-openai/src/wire.rs
Three new test cases validate: OpenRouter message.reasoning is normalized into extra.reasoning_content, canonical reasoning_content takes precedence when both fields are present, and empty reasoning does not create a field.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes


Note

🎁 Summarized by CodeRabbit Free

Your organization has reached its limit of developer seats under the Pro Plan. For new users, CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please add seats to your subscription by visiting https://app.coderabbit.ai/login.If you believe this is a mistake and have available seats, please assign one to the pull request author through the subscription management page using the link above.

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

…normalisation
The empty-canonical-falls-through case is the one branch exercising the
real precedence logic (`reasoning_content.filter(!empty).or(reasoning)`):
an empty `reasoning_content` ("") alongside a real OpenRouter `reasoning`
must surface `reasoning`. This test FAILS against the pre-fix
canonical-only code, so it's a genuine regression guard (#648).
@moonming

Copy link
Copy Markdown
MemberAuthor

Audit remediation (REQUEST-CHANGES → all 3 MEDIUM addressed)

MEDIUM-1 — non-discriminating tests. Added a 4th test non_streaming_empty_canonical_falls_through_to_openrouter_reasoning (commit bf3f008) covering the one branch with real precedence logic: empty canonical reasoning_content:"" alongside a real reasoning must fall through to reasoning. Verified it (and …normalises_to_reasoning_content) FAIL against the pre-fix canonical-only code and pass with the fix — i.e. genuine regression guards. The two original "skip-empty"/"precedence" tests pass either way by design; they're kept as documentation of those rules.

MEDIUM-2 — scope read as OpenRouter-only. Added a "Scope / blast radius" section to the PR body: OpenAiResponseMessage is the shared OpenAI-wire response type (also used by aisix-provider-azure-openai and any OpenAI-compatible bridge), so the normalization applies to any upstream emitting message.reasoning, not just OpenRouter. Called out that it only fills the canonical slot when absent/empty (never overrides) and adds no field when reasoning is empty/absent → upstreams that never emit reasoning are byte-for-byte unaffected.

MEDIUM-3 — streaming-parity not tracked. Filed #502 (auto-normalize delta.reasoning on the streaming path so neither path needs a per-key reasoning_field override) and linked it from the PR body under "Streaming parity (follow-up)".

Local gate: cargo test -p aisix-provider-openai94 passed / 0 failed; cargo clippy --all-targets + cargo fmt --check clean.

@moonming
moonming merged commit e052be0 into mainJun 3, 2026
8 checks passed
@moonming
moonming deleted the fix/openrouter-nonstream-reasoning-648 branch June 3, 2026 01:07
moonming added a commit that referenced this pull request Jun 5, 2026
…ponse (#684)
gpt-4o-audio chat models return generated audio at message.audio when the
request asks for the audio output modality. The request-side modalities/audio
params already forward via the OpenAI bridge's flatten extra, so the upstream
generates audio — but OpenAiResponseMessage didn't model audio, so it was
dropped at deserialize (customer got 200 + tokens, no audio). Adds an audio
field + surfaces it via ChatMessage.extra (same mechanism #501 used for
reasoning). Null/absent audio adds no field; non-audio upstreams unaffected.
Fixesapi7/AISIX-Cloud#684. Streaming-audio parity tracked in #518.
Sign up for freeto 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.

1 participant

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(openai): normalise OpenRouter non-stream message.reasoning → reasoning_content (#648) - #501

Merged
moonming merged 2 commits into
mainfrom
fix/openrouter-nonstream-reasoning-648
Jun 3, 2026
Merged

fix(openai): normalise OpenRouter non-stream message.reasoning → reasoning_content (#648)#501
moonming merged 2 commits into
mainfrom
fix/openrouter-nonstream-reasoning-648

Conversation

@moonming

@moonmingmoonming commented Jun 3, 2026

Copy link
Copy Markdown
Member

Summary

Fixes the non-stream half of the OpenRouter reasoning gap in AISIX-Cloud #648. OpenRouter (and some OpenAI-compatible aggregators) return a reasoning model's chain-of-thought at message.reasoning (string) — not the DeepSeek-canonical message.reasoning_content. OpenAiResponseMessage was a closed struct deserializing only reasoning_content, so message.reasoning was dropped at decode. A reasoning model that emits its whole answer as reasoning (e.g. z-ai/glm-4.6 via OpenRouter) then reached the customer with bothcontent AND reasoning_content empty.

Evidence (AISIX-Cloud real-chain e2e): the z-ai non-stream call returns status=200, completion_tokens=230 — the upstream generated output — yet the customer saw empty content+reasoning_content. Consistent across runs; not model nondeterminism.

The streaming path already handles OpenRouter's delta.reasoning via the per-key reasoning_field override — but that lift is streaming/delta-only (extract_reasoning_field hard-rejects non-delta paths), so the non-stream response had no path to surface it.

Change (auto-normalize — no per-key config required)

crates/aisix-provider-openai/src/wire.rs:

  • Capture message.reasoning on OpenAiResponseMessage (was dropped at deserialize).
  • In response_into_chat_response, lift it into the canonical reasoning_contentextra slot when the canonical reasoning_content is absent/empty. DeepSeek-canonical reasoning_content takes precedence when both are present; an empty reasoning adds no noise field (same skip-empty rule as guardrails: streaming output moderation forwards content live before check_output (pre-P2 leak) #466). Symmetric with the existing non-stream reasoning_content capture.

Scope / blast radius (this is a global OpenAI-wire change, not OpenRouter-gated)

OpenAiResponseMessage is the shared OpenAI chat-completions response type for the whole workspace — it's also what aisix-provider-azure-openai and any future OpenAI-compatible bridge parse responses through (see the module doc on pub visibility). So this normalization is not keyed to OpenRouter: any upstream routed through the OpenAI wire that returns a non-empty message.reasoning (and no/empty message.reasoning_content) will now have it surfaced as canonical reasoning_content.

This is intended — reasoning is the de-facto field for OpenAI-compatible aggregators — and is safe because:

  • it only fills the canonical slot when the canonical field is absent/empty (never overrides a real reasoning_content);
  • it adds no field when reasoning is empty/absent (serde default), so OpenAI / Azure-OpenAI / DeepSeek-compat upstreams that never emit reasoning are byte-for-byte unaffected.

The blast radius is "OpenAI-compatible upstreams that emit message.reasoning", which today is OpenRouter-class aggregators; calling it out so reviewers don't read it as an OpenRouter-only branch.

Test plan

  • 4 unit tests in wire.rs: (1) OpenRouter reasoning→canonical; (2) canonical-reasoning_content-wins precedence; (3) empty-reasoning-adds-no-field; (4) empty-canonical-reasoning_content-falls-through-to-reasoning — the discriminating guard exercising the real precedence logic (reasoning_content.filter(!empty).or(reasoning)). Verified tests (1) and (4) FAIL against the pre-fix canonical-only code and pass with the fix (genuine regression guards, not non-discriminating).
  • Existing 8 reasoning tests pass (no regression) — 12 reasoning tests green total.
  • cargo clippy -p aisix-provider-openai + cargo fmt --check clean.
  • Sibling crates that reuse these wire types (aisix-provider-azure-openai, aisix-proxy) build.
  • CI green.

Streaming parity (follow-up, out of scope here)

This PR makes the non-stream path auto-normalize with no operator config. Bringing the streaming path to the same no-config parity (default the delta.reasoning lift for the OpenRouter adapter so it works without a per-key reasoning_field override) is tracked in #502.

Closes the non-stream half of api7/AISIX-Cloud#648. Once this lands in the :dev image, the AISIX-Cloud openrouter-longtail real-chain z-ai non-stream leg goes green without the held-back workaround.

Summary by CodeRabbit

Release Notes

  • New Features
    • Added support for handling reasoning content in OpenAI API responses. Reasoning information is now properly captured and included in response data when available.

…oning_content (#648)
OpenRouter (and some OpenAI-compatible aggregators) return a reasoning
model's chain-of-thought at `message.reasoning` on the non-stream path,
NOT the DeepSeek-canonical `message.reasoning_content`. `OpenAiResponseMessage`
was a closed struct that only deserialized `reasoning_content`, so
`message.reasoning` was dropped at decode. A reasoning model (e.g.
z-ai/glm-4.6 via OpenRouter) that emits its whole answer as reasoning then
reached the customer with BOTH `content` AND `reasoning_content` empty —
observed on the real-chain e2e (status 200, completion_tokens=230, empty
customer fields).
The streaming path already handles this via the per-key `reasoning_field`
override, but that lift is streaming/`delta`-only (extract_reasoning_field
hard-rejects non-`delta` paths), so non-stream had no path to surface it.
Fix (auto-normalize, no per-key config needed):
- Capture `message.reasoning` on OpenAiResponseMessage (was dropped).
- In response_into_chat_response, lift it into the canonical
`reasoning_content` extra slot when canonical reasoning_content is
absent/empty. The DeepSeek-canonical field takes precedence when both are
present; empty `reasoning` adds no noise field (same skip-empty rule as
#466). Symmetric with the existing non-stream reasoning_content capture.
Non-OpenRouter upstreams omit `reasoning` (serde default) → unaffected.
Tests: 3 new unit tests (OpenRouter reasoning→canonical; canonical-wins
precedence; empty-reasoning adds no field) + the existing 8 reasoning tests
pass. clippy + fmt clean; sibling crates (azure-openai, proxy) build.
@coderabbitai

coderabbitaiBot commented Jun 3, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@moonming, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 5 minutes and 21 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: 5ea4ec7f-9e9b-4242-8721-d70550f11590

📥 Commits

Reviewing files that changed from the base of the PR and between e127181 and bf3f008.

📒 Files selected for processing (1)
  • crates/aisix-provider-openai/src/wire.rs
📝 Walkthrough

Walkthrough

This PR extends the OpenAI provider's wire format to handle OpenRouter-style reasoning. The struct gains an optional reasoning field, normalization logic implements fallback-based extraction preferring reasoning_content, and test coverage validates the precedence and empty-value handling.

Changes

OpenRouter Reasoning Normalization

Layer / File(s)Summary
OpenRouter reasoning field support
crates/aisix-provider-openai/src/wire.rs
OpenAiResponseMessage struct adds an optional reasoning field (serde-defaulted) to capture OpenRouter-style reasoning content, with documentation clarifying its relationship to the canonical reasoning_content field.
Reasoning extraction and precedence
crates/aisix-provider-openai/src/wire.rs
response_into_chat_response function implements fallback logic: extracts a single non-empty reasoning value by preferring message.reasoning_content over message.reasoning, then inserts it into extra.reasoning_content only when non-empty.
Reasoning normalization tests
crates/aisix-provider-openai/src/wire.rs
Three new test cases validate: OpenRouter message.reasoning is normalized into extra.reasoning_content, canonical reasoning_content takes precedence when both fields are present, and empty reasoning does not create a field.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes


Note

🎁 Summarized by CodeRabbit Free

Your organization has reached its limit of developer seats under the Pro Plan. For new users, CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please add seats to your subscription by visiting https://app.coderabbit.ai/login.If you believe this is a mistake and have available seats, please assign one to the pull request author through the subscription management page using the link above.

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

…normalisation
The empty-canonical-falls-through case is the one branch exercising the
real precedence logic (`reasoning_content.filter(!empty).or(reasoning)`):
an empty `reasoning_content` ("") alongside a real OpenRouter `reasoning`
must surface `reasoning`. This test FAILS against the pre-fix
canonical-only code, so it's a genuine regression guard (#648).
@moonming

Copy link
Copy Markdown
MemberAuthor

Audit remediation (REQUEST-CHANGES → all 3 MEDIUM addressed)

MEDIUM-1 — non-discriminating tests. Added a 4th test non_streaming_empty_canonical_falls_through_to_openrouter_reasoning (commit bf3f008) covering the one branch with real precedence logic: empty canonical reasoning_content:"" alongside a real reasoning must fall through to reasoning. Verified it (and …normalises_to_reasoning_content) FAIL against the pre-fix canonical-only code and pass with the fix — i.e. genuine regression guards. The two original "skip-empty"/"precedence" tests pass either way by design; they're kept as documentation of those rules.

MEDIUM-2 — scope read as OpenRouter-only. Added a "Scope / blast radius" section to the PR body: OpenAiResponseMessage is the shared OpenAI-wire response type (also used by aisix-provider-azure-openai and any OpenAI-compatible bridge), so the normalization applies to any upstream emitting message.reasoning, not just OpenRouter. Called out that it only fills the canonical slot when absent/empty (never overrides) and adds no field when reasoning is empty/absent → upstreams that never emit reasoning are byte-for-byte unaffected.

MEDIUM-3 — streaming-parity not tracked. Filed #502 (auto-normalize delta.reasoning on the streaming path so neither path needs a per-key reasoning_field override) and linked it from the PR body under "Streaming parity (follow-up)".

Local gate: cargo test -p aisix-provider-openai94 passed / 0 failed; cargo clippy --all-targets + cargo fmt --check clean.

@moonming
moonming merged commit e052be0 into mainJun 3, 2026
8 checks passed
@moonming
moonming deleted the fix/openrouter-nonstream-reasoning-648 branch June 3, 2026 01:07
moonming added a commit that referenced this pull request Jun 5, 2026
…ponse (#684)
gpt-4o-audio chat models return generated audio at message.audio when the
request asks for the audio output modality. The request-side modalities/audio
params already forward via the OpenAI bridge's flatten extra, so the upstream
generates audio — but OpenAiResponseMessage didn't model audio, so it was
dropped at deserialize (customer got 200 + tokens, no audio). Adds an audio
field + surfaces it via ChatMessage.extra (same mechanism #501 used for
reasoning). Null/absent audio adds no field; non-audio upstreams unaffected.
Fixesapi7/AISIX-Cloud#684. Streaming-audio parity tracked in #518.
Sign up for freeto 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.

1 participant

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(openai): normalise OpenRouter non-stream message.reasoning → reasoning_content (#648) - #501

Merged
moonming merged 2 commits into
mainfrom
fix/openrouter-nonstream-reasoning-648
Jun 3, 2026
Merged

fix(openai): normalise OpenRouter non-stream message.reasoning → reasoning_content (#648)#501
moonming merged 2 commits into
mainfrom
fix/openrouter-nonstream-reasoning-648

Conversation

@moonming

@moonmingmoonming commented Jun 3, 2026

Copy link
Copy Markdown
Member

Summary

Fixes the non-stream half of the OpenRouter reasoning gap in AISIX-Cloud #648. OpenRouter (and some OpenAI-compatible aggregators) return a reasoning model's chain-of-thought at message.reasoning (string) — not the DeepSeek-canonical message.reasoning_content. OpenAiResponseMessage was a closed struct deserializing only reasoning_content, so message.reasoning was dropped at decode. A reasoning model that emits its whole answer as reasoning (e.g. z-ai/glm-4.6 via OpenRouter) then reached the customer with bothcontent AND reasoning_content empty.

Evidence (AISIX-Cloud real-chain e2e): the z-ai non-stream call returns status=200, completion_tokens=230 — the upstream generated output — yet the customer saw empty content+reasoning_content. Consistent across runs; not model nondeterminism.

The streaming path already handles OpenRouter's delta.reasoning via the per-key reasoning_field override — but that lift is streaming/delta-only (extract_reasoning_field hard-rejects non-delta paths), so the non-stream response had no path to surface it.

Change (auto-normalize — no per-key config required)

crates/aisix-provider-openai/src/wire.rs:

  • Capture message.reasoning on OpenAiResponseMessage (was dropped at deserialize).
  • In response_into_chat_response, lift it into the canonical reasoning_contentextra slot when the canonical reasoning_content is absent/empty. DeepSeek-canonical reasoning_content takes precedence when both are present; an empty reasoning adds no noise field (same skip-empty rule as guardrails: streaming output moderation forwards content live before check_output (pre-P2 leak) #466). Symmetric with the existing non-stream reasoning_content capture.

Scope / blast radius (this is a global OpenAI-wire change, not OpenRouter-gated)

OpenAiResponseMessage is the shared OpenAI chat-completions response type for the whole workspace — it's also what aisix-provider-azure-openai and any future OpenAI-compatible bridge parse responses through (see the module doc on pub visibility). So this normalization is not keyed to OpenRouter: any upstream routed through the OpenAI wire that returns a non-empty message.reasoning (and no/empty message.reasoning_content) will now have it surfaced as canonical reasoning_content.

This is intended — reasoning is the de-facto field for OpenAI-compatible aggregators — and is safe because:

  • it only fills the canonical slot when the canonical field is absent/empty (never overrides a real reasoning_content);
  • it adds no field when reasoning is empty/absent (serde default), so OpenAI / Azure-OpenAI / DeepSeek-compat upstreams that never emit reasoning are byte-for-byte unaffected.

The blast radius is "OpenAI-compatible upstreams that emit message.reasoning", which today is OpenRouter-class aggregators; calling it out so reviewers don't read it as an OpenRouter-only branch.

Test plan

  • 4 unit tests in wire.rs: (1) OpenRouter reasoning→canonical; (2) canonical-reasoning_content-wins precedence; (3) empty-reasoning-adds-no-field; (4) empty-canonical-reasoning_content-falls-through-to-reasoning — the discriminating guard exercising the real precedence logic (reasoning_content.filter(!empty).or(reasoning)). Verified tests (1) and (4) FAIL against the pre-fix canonical-only code and pass with the fix (genuine regression guards, not non-discriminating).
  • Existing 8 reasoning tests pass (no regression) — 12 reasoning tests green total.
  • cargo clippy -p aisix-provider-openai + cargo fmt --check clean.
  • Sibling crates that reuse these wire types (aisix-provider-azure-openai, aisix-proxy) build.
  • CI green.

Streaming parity (follow-up, out of scope here)

This PR makes the non-stream path auto-normalize with no operator config. Bringing the streaming path to the same no-config parity (default the delta.reasoning lift for the OpenRouter adapter so it works without a per-key reasoning_field override) is tracked in #502.

Closes the non-stream half of api7/AISIX-Cloud#648. Once this lands in the :dev image, the AISIX-Cloud openrouter-longtail real-chain z-ai non-stream leg goes green without the held-back workaround.

Summary by CodeRabbit

Release Notes

  • New Features
    • Added support for handling reasoning content in OpenAI API responses. Reasoning information is now properly captured and included in response data when available.

…oning_content (#648)
OpenRouter (and some OpenAI-compatible aggregators) return a reasoning
model's chain-of-thought at `message.reasoning` on the non-stream path,
NOT the DeepSeek-canonical `message.reasoning_content`. `OpenAiResponseMessage`
was a closed struct that only deserialized `reasoning_content`, so
`message.reasoning` was dropped at decode. A reasoning model (e.g.
z-ai/glm-4.6 via OpenRouter) that emits its whole answer as reasoning then
reached the customer with BOTH `content` AND `reasoning_content` empty —
observed on the real-chain e2e (status 200, completion_tokens=230, empty
customer fields).
The streaming path already handles this via the per-key `reasoning_field`
override, but that lift is streaming/`delta`-only (extract_reasoning_field
hard-rejects non-`delta` paths), so non-stream had no path to surface it.
Fix (auto-normalize, no per-key config needed):
- Capture `message.reasoning` on OpenAiResponseMessage (was dropped).
- In response_into_chat_response, lift it into the canonical
`reasoning_content` extra slot when canonical reasoning_content is
absent/empty. The DeepSeek-canonical field takes precedence when both are
present; empty `reasoning` adds no noise field (same skip-empty rule as
#466). Symmetric with the existing non-stream reasoning_content capture.
Non-OpenRouter upstreams omit `reasoning` (serde default) → unaffected.
Tests: 3 new unit tests (OpenRouter reasoning→canonical; canonical-wins
precedence; empty-reasoning adds no field) + the existing 8 reasoning tests
pass. clippy + fmt clean; sibling crates (azure-openai, proxy) build.
@coderabbitai

coderabbitaiBot commented Jun 3, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@moonming, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 5 minutes and 21 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: 5ea4ec7f-9e9b-4242-8721-d70550f11590

📥 Commits

Reviewing files that changed from the base of the PR and between e127181 and bf3f008.

📒 Files selected for processing (1)
  • crates/aisix-provider-openai/src/wire.rs
📝 Walkthrough

Walkthrough

This PR extends the OpenAI provider's wire format to handle OpenRouter-style reasoning. The struct gains an optional reasoning field, normalization logic implements fallback-based extraction preferring reasoning_content, and test coverage validates the precedence and empty-value handling.

Changes

OpenRouter Reasoning Normalization

Layer / File(s)Summary
OpenRouter reasoning field support
crates/aisix-provider-openai/src/wire.rs
OpenAiResponseMessage struct adds an optional reasoning field (serde-defaulted) to capture OpenRouter-style reasoning content, with documentation clarifying its relationship to the canonical reasoning_content field.
Reasoning extraction and precedence
crates/aisix-provider-openai/src/wire.rs
response_into_chat_response function implements fallback logic: extracts a single non-empty reasoning value by preferring message.reasoning_content over message.reasoning, then inserts it into extra.reasoning_content only when non-empty.
Reasoning normalization tests
crates/aisix-provider-openai/src/wire.rs
Three new test cases validate: OpenRouter message.reasoning is normalized into extra.reasoning_content, canonical reasoning_content takes precedence when both fields are present, and empty reasoning does not create a field.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes


Note

🎁 Summarized by CodeRabbit Free

Your organization has reached its limit of developer seats under the Pro Plan. For new users, CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please add seats to your subscription by visiting https://app.coderabbit.ai/login.If you believe this is a mistake and have available seats, please assign one to the pull request author through the subscription management page using the link above.

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

…normalisation
The empty-canonical-falls-through case is the one branch exercising the
real precedence logic (`reasoning_content.filter(!empty).or(reasoning)`):
an empty `reasoning_content` ("") alongside a real OpenRouter `reasoning`
must surface `reasoning`. This test FAILS against the pre-fix
canonical-only code, so it's a genuine regression guard (#648).
@moonming

Copy link
Copy Markdown
MemberAuthor

Audit remediation (REQUEST-CHANGES → all 3 MEDIUM addressed)

MEDIUM-1 — non-discriminating tests. Added a 4th test non_streaming_empty_canonical_falls_through_to_openrouter_reasoning (commit bf3f008) covering the one branch with real precedence logic: empty canonical reasoning_content:"" alongside a real reasoning must fall through to reasoning. Verified it (and …normalises_to_reasoning_content) FAIL against the pre-fix canonical-only code and pass with the fix — i.e. genuine regression guards. The two original "skip-empty"/"precedence" tests pass either way by design; they're kept as documentation of those rules.

MEDIUM-2 — scope read as OpenRouter-only. Added a "Scope / blast radius" section to the PR body: OpenAiResponseMessage is the shared OpenAI-wire response type (also used by aisix-provider-azure-openai and any OpenAI-compatible bridge), so the normalization applies to any upstream emitting message.reasoning, not just OpenRouter. Called out that it only fills the canonical slot when absent/empty (never overrides) and adds no field when reasoning is empty/absent → upstreams that never emit reasoning are byte-for-byte unaffected.

MEDIUM-3 — streaming-parity not tracked. Filed #502 (auto-normalize delta.reasoning on the streaming path so neither path needs a per-key reasoning_field override) and linked it from the PR body under "Streaming parity (follow-up)".

Local gate: cargo test -p aisix-provider-openai94 passed / 0 failed; cargo clippy --all-targets + cargo fmt --check clean.

@moonming
moonming merged commit e052be0 into mainJun 3, 2026
8 checks passed
@moonming
moonming deleted the fix/openrouter-nonstream-reasoning-648 branch June 3, 2026 01:07
moonming added a commit that referenced this pull request Jun 5, 2026
…ponse (#684)
gpt-4o-audio chat models return generated audio at message.audio when the
request asks for the audio output modality. The request-side modalities/audio
params already forward via the OpenAI bridge's flatten extra, so the upstream
generates audio — but OpenAiResponseMessage didn't model audio, so it was
dropped at deserialize (customer got 200 + tokens, no audio). Adds an audio
field + surfaces it via ChatMessage.extra (same mechanism #501 used for
reasoning). Null/absent audio adds no field; non-audio upstreams unaffected.
Fixesapi7/AISIX-Cloud#684. Streaming-audio parity tracked in #518.
Sign up for freeto 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.

1 participant

@moonming
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(openai): normalise OpenRouter non-stream message.reasoning → reasoning_content (#648) - #501

Merged
moonming merged 2 commits into
mainfrom
fix/openrouter-nonstream-reasoning-648
Jun 3, 2026
Merged

fix(openai): normalise OpenRouter non-stream message.reasoning → reasoning_content (#648)#501
moonming merged 2 commits into
mainfrom
fix/openrouter-nonstream-reasoning-648

Conversation

@moonming

@moonmingmoonming commented Jun 3, 2026

Copy link
Copy Markdown
Member

Summary

Fixes the non-stream half of the OpenRouter reasoning gap in AISIX-Cloud #648. OpenRouter (and some OpenAI-compatible aggregators) return a reasoning model's chain-of-thought at message.reasoning (string) — not the DeepSeek-canonical message.reasoning_content. OpenAiResponseMessage was a closed struct deserializing only reasoning_content, so message.reasoning was dropped at decode. A reasoning model that emits its whole answer as reasoning (e.g. z-ai/glm-4.6 via OpenRouter) then reached the customer with bothcontent AND reasoning_content empty.

Evidence (AISIX-Cloud real-chain e2e): the z-ai non-stream call returns status=200, completion_tokens=230 — the upstream generated output — yet the customer saw empty content+reasoning_content. Consistent across runs; not model nondeterminism.

The streaming path already handles OpenRouter's delta.reasoning via the per-key reasoning_field override — but that lift is streaming/delta-only (extract_reasoning_field hard-rejects non-delta paths), so the non-stream response had no path to surface it.

Change (auto-normalize — no per-key config required)

crates/aisix-provider-openai/src/wire.rs:

  • Capture message.reasoning on OpenAiResponseMessage (was dropped at deserialize).
  • In response_into_chat_response, lift it into the canonical reasoning_contentextra slot when the canonical reasoning_content is absent/empty. DeepSeek-canonical reasoning_content takes precedence when both are present; an empty reasoning adds no noise field (same skip-empty rule as guardrails: streaming output moderation forwards content live before check_output (pre-P2 leak) #466). Symmetric with the existing non-stream reasoning_content capture.

Scope / blast radius (this is a global OpenAI-wire change, not OpenRouter-gated)

OpenAiResponseMessage is the shared OpenAI chat-completions response type for the whole workspace — it's also what aisix-provider-azure-openai and any future OpenAI-compatible bridge parse responses through (see the module doc on pub visibility). So this normalization is not keyed to OpenRouter: any upstream routed through the OpenAI wire that returns a non-empty message.reasoning (and no/empty message.reasoning_content) will now have it surfaced as canonical reasoning_content.

This is intended — reasoning is the de-facto field for OpenAI-compatible aggregators — and is safe because:

  • it only fills the canonical slot when the canonical field is absent/empty (never overrides a real reasoning_content);
  • it adds no field when reasoning is empty/absent (serde default), so OpenAI / Azure-OpenAI / DeepSeek-compat upstreams that never emit reasoning are byte-for-byte unaffected.

The blast radius is "OpenAI-compatible upstreams that emit message.reasoning", which today is OpenRouter-class aggregators; calling it out so reviewers don't read it as an OpenRouter-only branch.

Test plan

  • 4 unit tests in wire.rs: (1) OpenRouter reasoning→canonical; (2) canonical-reasoning_content-wins precedence; (3) empty-reasoning-adds-no-field; (4) empty-canonical-reasoning_content-falls-through-to-reasoning — the discriminating guard exercising the real precedence logic (reasoning_content.filter(!empty).or(reasoning)). Verified tests (1) and (4) FAIL against the pre-fix canonical-only code and pass with the fix (genuine regression guards, not non-discriminating).
  • Existing 8 reasoning tests pass (no regression) — 12 reasoning tests green total.
  • cargo clippy -p aisix-provider-openai + cargo fmt --check clean.
  • Sibling crates that reuse these wire types (aisix-provider-azure-openai, aisix-proxy) build.
  • CI green.

Streaming parity (follow-up, out of scope here)

This PR makes the non-stream path auto-normalize with no operator config. Bringing the streaming path to the same no-config parity (default the delta.reasoning lift for the OpenRouter adapter so it works without a per-key reasoning_field override) is tracked in #502.

Closes the non-stream half of api7/AISIX-Cloud#648. Once this lands in the :dev image, the AISIX-Cloud openrouter-longtail real-chain z-ai non-stream leg goes green without the held-back workaround.

Summary by CodeRabbit

Release Notes

  • New Features
    • Added support for handling reasoning content in OpenAI API responses. Reasoning information is now properly captured and included in response data when available.

…oning_content (#648)
OpenRouter (and some OpenAI-compatible aggregators) return a reasoning
model's chain-of-thought at `message.reasoning` on the non-stream path,
NOT the DeepSeek-canonical `message.reasoning_content`. `OpenAiResponseMessage`
was a closed struct that only deserialized `reasoning_content`, so
`message.reasoning` was dropped at decode. A reasoning model (e.g.
z-ai/glm-4.6 via OpenRouter) that emits its whole answer as reasoning then
reached the customer with BOTH `content` AND `reasoning_content` empty —
observed on the real-chain e2e (status 200, completion_tokens=230, empty
customer fields).
The streaming path already handles this via the per-key `reasoning_field`
override, but that lift is streaming/`delta`-only (extract_reasoning_field
hard-rejects non-`delta` paths), so non-stream had no path to surface it.
Fix (auto-normalize, no per-key config needed):
- Capture `message.reasoning` on OpenAiResponseMessage (was dropped).
- In response_into_chat_response, lift it into the canonical
`reasoning_content` extra slot when canonical reasoning_content is
absent/empty. The DeepSeek-canonical field takes precedence when both are
present; empty `reasoning` adds no noise field (same skip-empty rule as
#466). Symmetric with the existing non-stream reasoning_content capture.
Non-OpenRouter upstreams omit `reasoning` (serde default) → unaffected.
Tests: 3 new unit tests (OpenRouter reasoning→canonical; canonical-wins
precedence; empty-reasoning adds no field) + the existing 8 reasoning tests
pass. clippy + fmt clean; sibling crates (azure-openai, proxy) build.
@coderabbitai

coderabbitaiBot commented Jun 3, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@moonming, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 5 minutes and 21 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: 5ea4ec7f-9e9b-4242-8721-d70550f11590

📥 Commits

Reviewing files that changed from the base of the PR and between e127181 and bf3f008.

📒 Files selected for processing (1)
  • crates/aisix-provider-openai/src/wire.rs
📝 Walkthrough

Walkthrough

This PR extends the OpenAI provider's wire format to handle OpenRouter-style reasoning. The struct gains an optional reasoning field, normalization logic implements fallback-based extraction preferring reasoning_content, and test coverage validates the precedence and empty-value handling.

Changes

OpenRouter Reasoning Normalization

Layer / File(s)Summary
OpenRouter reasoning field support
crates/aisix-provider-openai/src/wire.rs
OpenAiResponseMessage struct adds an optional reasoning field (serde-defaulted) to capture OpenRouter-style reasoning content, with documentation clarifying its relationship to the canonical reasoning_content field.
Reasoning extraction and precedence
crates/aisix-provider-openai/src/wire.rs
response_into_chat_response function implements fallback logic: extracts a single non-empty reasoning value by preferring message.reasoning_content over message.reasoning, then inserts it into extra.reasoning_content only when non-empty.
Reasoning normalization tests
crates/aisix-provider-openai/src/wire.rs
Three new test cases validate: OpenRouter message.reasoning is normalized into extra.reasoning_content, canonical reasoning_content takes precedence when both fields are present, and empty reasoning does not create a field.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes


Note

🎁 Summarized by CodeRabbit Free

Your organization has reached its limit of developer seats under the Pro Plan. For new users, CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please add seats to your subscription by visiting https://app.coderabbit.ai/login.If you believe this is a mistake and have available seats, please assign one to the pull request author through the subscription management page using the link above.

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

…normalisation
The empty-canonical-falls-through case is the one branch exercising the
real precedence logic (`reasoning_content.filter(!empty).or(reasoning)`):
an empty `reasoning_content` ("") alongside a real OpenRouter `reasoning`
must surface `reasoning`. This test FAILS against the pre-fix
canonical-only code, so it's a genuine regression guard (#648).
@moonming

Copy link
Copy Markdown
MemberAuthor

Audit remediation (REQUEST-CHANGES → all 3 MEDIUM addressed)

MEDIUM-1 — non-discriminating tests. Added a 4th test non_streaming_empty_canonical_falls_through_to_openrouter_reasoning (commit bf3f008) covering the one branch with real precedence logic: empty canonical reasoning_content:"" alongside a real reasoning must fall through to reasoning. Verified it (and …normalises_to_reasoning_content) FAIL against the pre-fix canonical-only code and pass with the fix — i.e. genuine regression guards. The two original "skip-empty"/"precedence" tests pass either way by design; they're kept as documentation of those rules.

MEDIUM-2 — scope read as OpenRouter-only. Added a "Scope / blast radius" section to the PR body: OpenAiResponseMessage is the shared OpenAI-wire response type (also used by aisix-provider-azure-openai and any OpenAI-compatible bridge), so the normalization applies to any upstream emitting message.reasoning, not just OpenRouter. Called out that it only fills the canonical slot when absent/empty (never overrides) and adds no field when reasoning is empty/absent → upstreams that never emit reasoning are byte-for-byte unaffected.

MEDIUM-3 — streaming-parity not tracked. Filed #502 (auto-normalize delta.reasoning on the streaming path so neither path needs a per-key reasoning_field override) and linked it from the PR body under "Streaming parity (follow-up)".

Local gate: cargo test -p aisix-provider-openai94 passed / 0 failed; cargo clippy --all-targets + cargo fmt --check clean.

@moonming
moonming merged commit e052be0 into mainJun 3, 2026
8 checks passed
@moonming
moonming deleted the fix/openrouter-nonstream-reasoning-648 branch June 3, 2026 01:07
moonming added a commit that referenced this pull request Jun 5, 2026
…ponse (#684)
gpt-4o-audio chat models return generated audio at message.audio when the
request asks for the audio output modality. The request-side modalities/audio
params already forward via the OpenAI bridge's flatten extra, so the upstream
generates audio — but OpenAiResponseMessage didn't model audio, so it was
dropped at deserialize (customer got 200 + tokens, no audio). Adds an audio
field + surfaces it via ChatMessage.extra (same mechanism #501 used for
reasoning). Null/absent audio adds no field; non-audio upstreams unaffected.
Fixesapi7/AISIX-Cloud#684. Streaming-audio parity tracked in #518.
Sign up for freeto 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.

1 participant

@moonming