Skip to content

fix(openai): handle providers emitting both reasoning and reasoning_content fields#2911

Merged
tusharmath merged 4 commits intomainfrom
fix-chutes-reasoning
Apr 9, 2026
Merged

fix(openai): handle providers emitting both reasoning and reasoning_content fields#2911
tusharmath merged 4 commits intomainfrom
fix-chutes-reasoning

Conversation

@tusharmath
Copy link
Copy Markdown
Collaborator

@tusharmath tusharmath commented Apr 9, 2026

Summary

Fix a deserialization panic when providers (e.g. moonshotai/Kimi-K2.5-TEE via Chutes) emit both reasoning and reasoning_content fields in the same delta object.

Context

Some OpenAI-compatible providers — notably moonshotai/Kimi-K2.5-TEE served through Chutes — include both reasoning and reasoning_content keys inside a single streaming delta. The previous implementation used #[serde(alias = "reasoning_content")] on the reasoning field, which causes serde to emit a duplicate_field error when both keys are present, crashing the stream parser.

Changes

  • Replaced the #[serde(alias)] approach with two separate private fields (reasoning and reasoning_content) on ResponseMessage
  • Added a public ResponseMessage::reasoning() method that merges the two fields by returning the longer non-empty value (favours content over an empty/whitespace-only entry)
  • Updated all call-sites that previously read message.reasoning directly to use the new message.reasoning() accessor
  • Added a fixture file (chutes_completion_response.json) capturing a real Chutes/Kimi-K2.5-TEE delta with both keys present
  • Added a regression test (test_kimi_k2_both_reasoning_keys_event) that asserts the response parses successfully end-to-end

Key Implementation Details

The merge strategy in reasoning(): when both fields are non-empty, the longer string wins. This handles the observed behaviour where both values are identical (Kimi-K2.5-TEE) while staying correct if a future provider sends subtly different content in each key.

Testing

# Run the targeted test
cargo test -p forge_app test_kimi_k2_both_reasoning_keys_event

# Run the full openai response test suite
cargo test -p forge_app dto::openai::response

Links

@github-actions github-actions bot added the type: fix Iterations on existing features or infrastructure. label Apr 9, 2026
@tusharmath tusharmath enabled auto-merge (squash) April 9, 2026 15:24
@tusharmath tusharmath merged commit f4ef0b0 into main Apr 9, 2026
9 checks passed
@tusharmath tusharmath deleted the fix-chutes-reasoning branch April 9, 2026 15:28
Comment on lines +171 to +186
pub fn reasoning(&self) -> Option<&str> {
match (self.reasoning.as_deref(), self.reasoning_content.as_deref()) {
(Some(a), Some(b)) => {
let a = a.trim();
let b = b.trim();
match (a.is_empty(), b.is_empty()) {
(true, _) => Some(b).filter(|s| !s.is_empty()),
(_, true) => Some(a).filter(|s| !s.is_empty()),
_ => Some(if b.len() > a.len() { b } else { a }),
}
}
(Some(a), None) => Some(a).filter(|s| !s.trim().is_empty()),
(None, Some(b)) => Some(b).filter(|s| !s.trim().is_empty()),
(None, None) => None,
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The reasoning() method returns inconsistent data: trimmed strings when both fields are present, but untrimmed strings when only one field is present.

When both reasoning and reasoning_content exist (line 173-180), the returned value is the trimmed version (a.trim() or b.trim()). However, when only one field exists (lines 182-183), the original untrimmed string is returned.

For example:

  • reasoning(Some(" hello "), None) returns " hello " (untrimmed)
  • reasoning(Some(" hello "), Some("")) returns "hello" (trimmed)

This inconsistency could cause production issues if downstream code expects consistent whitespace handling.

Fix: Either always return trimmed strings or always return untrimmed strings. For consistency with the filtering logic that checks !s.trim().is_empty(), the method should return trimmed strings in all cases:

pub fn reasoning(&self) -> Option<&str> {
    match (self.reasoning.as_deref(), self.reasoning_content.as_deref()) {
        (Some(a), Some(b)) => {
            let a = a.trim();
            let b = b.trim();
            match (a.is_empty(), b.is_empty()) {
                (true, _) => Some(b).filter(|s| !s.is_empty()),
                (_, true) => Some(a).filter(|s| !s.is_empty()),
                _ => Some(if b.len() > a.len() { b } else { a }),
            }
        }
        (Some(a), None) => {
            let trimmed = a.trim();
            Some(trimmed).filter(|s| !s.is_empty())
        }
        (None, Some(b)) => {
            let trimmed = b.trim();
            Some(trimmed).filter(|s| !s.is_empty())
        }
        (None, None) => None,
    }
}
Suggested change
pub fn reasoning(&self) -> Option<&str> {
match (self.reasoning.as_deref(), self.reasoning_content.as_deref()) {
(Some(a), Some(b)) => {
let a = a.trim();
let b = b.trim();
match (a.is_empty(), b.is_empty()) {
(true, _) => Some(b).filter(|s| !s.is_empty()),
(_, true) => Some(a).filter(|s| !s.is_empty()),
_ => Some(if b.len() > a.len() { b } else { a }),
}
}
(Some(a), None) => Some(a).filter(|s| !s.trim().is_empty()),
(None, Some(b)) => Some(b).filter(|s| !s.trim().is_empty()),
(None, None) => None,
}
}
pub fn reasoning(&self) -> Option<&str> {
match (self.reasoning.as_deref(), self.reasoning_content.as_deref()) {
(Some(a), Some(b)) => {
let a = a.trim();
let b = b.trim();
match (a.is_empty(), b.is_empty()) {
(true, _) => Some(b).filter(|s| !s.is_empty()),
(_, true) => Some(a).filter(|s| !s.is_empty()),
_ => Some(if b.len() > a.len() { b } else { a }),
}
}
(Some(a), None) => {
let trimmed = a.trim();
Some(trimmed).filter(|s| !s.is_empty())
}
(None, Some(b)) => {
let trimmed = b.trim();
Some(trimmed).filter(|s| !s.is_empty())
}
(None, None) => None,
}
}

Spotted by Graphite

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: fix Iterations on existing features or infrastructure.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant