From 1ff9b7e2a44a120c3912081b5f3a49ecc60a1e78 Mon Sep 17 00:00:00 2001 From: Tushar Date: Thu, 9 Apr 2026 20:50:28 +0530 Subject: [PATCH 1/4] fix(openai): handle providers emitting both reasoning and reasoning_content fields --- .../fixtures/chutes_completion_response.json | 23 ++++++++++ crates/forge_app/src/dto/openai/response.rs | 44 ++++++++++++++++--- 2 files changed, 61 insertions(+), 6 deletions(-) create mode 100644 crates/forge_app/src/dto/openai/fixtures/chutes_completion_response.json diff --git a/crates/forge_app/src/dto/openai/fixtures/chutes_completion_response.json b/crates/forge_app/src/dto/openai/fixtures/chutes_completion_response.json new file mode 100644 index 0000000000..692902e9b7 --- /dev/null +++ b/crates/forge_app/src/dto/openai/fixtures/chutes_completion_response.json @@ -0,0 +1,23 @@ +{ + "id": "chatcmpl-a9db7cc9005c6568", + "object": "chat.completion.chunk", + "created": 1775233185, + "model": "moonshotai/Kimi-K2.5-TEE", + "choices": [ + { + "index": 0, + "delta": { "reasoning": " The", "reasoning_content": " The" }, + "logprobs": null, + "finish_reason": null, + "token_ids": null, + "hidden_states": null + } + ], + "usage": { + "prompt_tokens": 8764, + "total_tokens": 8765, + "completion_tokens": 1, + "reasoning_tokens": 1 + }, + "chutes_verification": "6bbeaf04d5800d774cc497b0a619acee" +} diff --git a/crates/forge_app/src/dto/openai/response.rs b/crates/forge_app/src/dto/openai/response.rs index 90829302f4..5720b82a4d 100644 --- a/crates/forge_app/src/dto/openai/response.rs +++ b/crates/forge_app/src/dto/openai/response.rs @@ -136,11 +136,19 @@ pub enum Choice { }, } +/// A message returned by a provider, used for both streaming deltas and +/// non-streaming responses. +/// +/// `reasoning` and `reasoning_content` are kept as separate private fields +/// because some providers (e.g. `moonshotai/Kimi-K2.5-TEE`) emit **both** +/// keys in the same delta object. Using `#[serde(alias)]` would cause a +/// `duplicate_field` error in that case. Use [`ResponseMessage::reasoning`] +/// to read the value in preference order. #[derive(Debug, Deserialize, Serialize, Clone)] pub struct ResponseMessage { pub content: Option, - #[serde(alias = "reasoning_content")] - pub reasoning: Option, + reasoning: Option, + reasoning_content: Option, pub role: Option, pub tool_calls: Option>, pub refusal: Option, @@ -152,6 +160,16 @@ pub struct ResponseMessage { pub extra_content: Option, } +impl ResponseMessage { + /// Returns the reasoning text, preferring `reasoning` over + /// `reasoning_content` when both are present. + pub fn reasoning(&self) -> Option<&str> { + self.reasoning + .as_deref() + .or(self.reasoning_content.as_deref()) + } +} + impl From for forge_domain::ReasoningDetail { fn from(detail: ReasoningDetail) -> Self { forge_domain::ReasoningDetail { @@ -319,8 +337,8 @@ impl TryFrom for ChatCompletionMessage { .clone() .and_then(|s| FinishReason::from_str(&s).ok()), ); - if let Some(reasoning) = &message.reasoning { - resp = resp.reasoning(Content::full(reasoning.clone())); + if let Some(reasoning) = message.reasoning() { + resp = resp.reasoning(Content::full(reasoning.to_owned())); } if let Some(thought_signature) = message @@ -387,8 +405,8 @@ impl TryFrom for ChatCompletionMessage { .and_then(|s| FinishReason::from_str(&s).ok()), ); - if let Some(reasoning) = &delta.reasoning { - resp = resp.reasoning(Content::part(reasoning.clone())); + if let Some(reasoning) = delta.reasoning() { + resp = resp.reasoning(Content::part(reasoning.to_owned())); } if let Some(thought_signature) = delta @@ -571,6 +589,17 @@ mod tests { assert!(Fixture::test_response_compatibility(event)); } + #[tokio::test] + async fn test_kimi_k2_both_reasoning_keys_event() { + // moonshotai/Kimi-K2.5-TEE emits both "reasoning" and "reasoning_content" + // in the same delta object. This must parse without a duplicate_field error. + let fixture = load_fixture("chutes_completion_response.json").await; + let actual = serde_json::from_value::(fixture); + assert!(actual.is_ok(), "Failed to parse: {:?}", actual.err()); + let completion_result = ChatCompletionMessage::try_from(actual.unwrap()); + assert!(completion_result.is_ok()); + } + #[test] fn test_fireworks_response_event_missing_arguments() { let event = "{\"id\":\"gen-1749331907-SttL6PXleVHnrdLMABfU\",\"provider\":\"Fireworks\",\"model\":\"qwen/qwen3-235b-a22b\",\"object\":\"chat.completion.chunk\",\"created\":1749331907,\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"index\":0,\"id\":\"call_Wl2L8rrzHwrXSeiciIvU65IS\",\"type\":\"function\",\"function\":{\"name\":\"attempt_completion\"}}]},\"finish_reason\":null,\"native_finish_reason\":null,\"logprobs\":null}]}"; @@ -632,6 +661,7 @@ mod tests { message: ResponseMessage { content: Some("test content".to_string()), reasoning: None, + reasoning_content: None, role: Some("assistant".to_string()), tool_calls: None, refusal: None, @@ -669,6 +699,7 @@ mod tests { delta: ResponseMessage { content: Some("test content".to_string()), reasoning: None, + reasoning_content: None, role: Some("assistant".to_string()), tool_calls: None, refusal: None, @@ -706,6 +737,7 @@ mod tests { message: ResponseMessage { content: Some("Hello, world!".to_string()), reasoning: None, + reasoning_content: None, role: Some("assistant".to_string()), tool_calls: None, refusal: None, From 80f12526ebb141eb556c52bb525eeccec2da8b8c Mon Sep 17 00:00:00 2001 From: Tushar Date: Thu, 9 Apr 2026 20:52:50 +0530 Subject: [PATCH 2/4] fix(openai): merge reasoning fields by longest non-empty value --- crates/forge_app/src/dto/openai/response.rs | 26 +++++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/crates/forge_app/src/dto/openai/response.rs b/crates/forge_app/src/dto/openai/response.rs index 5720b82a4d..b9c50b4cbd 100644 --- a/crates/forge_app/src/dto/openai/response.rs +++ b/crates/forge_app/src/dto/openai/response.rs @@ -147,6 +147,10 @@ pub enum Choice { #[derive(Debug, Deserialize, Serialize, Clone)] pub struct ResponseMessage { pub content: Option, + // Private: some providers (e.g. moonshotai/Kimi-K2.5-TEE) emit both keys + // in the same delta object. Exposing them directly would let callers + // accidentally read only one and miss the other. Use `reasoning()` instead, + // which merges them in preference order. reasoning: Option, reasoning_content: Option, pub role: Option, @@ -161,12 +165,24 @@ pub struct ResponseMessage { } impl ResponseMessage { - /// Returns the reasoning text, preferring `reasoning` over - /// `reasoning_content` when both are present. + /// Returns the reasoning text. When both `reasoning` and + /// `reasoning_content` are present, the longer non-empty value is + /// returned; otherwise whichever is non-empty is used. pub fn reasoning(&self) -> Option<&str> { - self.reasoning - .as_deref() - .or(self.reasoning_content.as_deref()) + 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, + } } } From 15c5a3b0f0c35cb5f7bb5c0cebdd075727d3c187 Mon Sep 17 00:00:00 2001 From: Tushar Date: Thu, 9 Apr 2026 20:53:41 +0530 Subject: [PATCH 3/4] test(openai): add unit tests for reasoning field selection logic --- crates/forge_app/src/dto/openai/response.rs | 57 +++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/crates/forge_app/src/dto/openai/response.rs b/crates/forge_app/src/dto/openai/response.rs index b9c50b4cbd..18f07889fb 100644 --- a/crates/forge_app/src/dto/openai/response.rs +++ b/crates/forge_app/src/dto/openai/response.rs @@ -565,6 +565,63 @@ mod tests { struct Fixture; + fn response_message(reasoning: Option<&str>, reasoning_content: Option<&str>) -> ResponseMessage { + ResponseMessage { + content: None, + reasoning: reasoning.map(str::to_owned), + reasoning_content: reasoning_content.map(str::to_owned), + role: None, + tool_calls: None, + refusal: None, + reasoning_details: None, + reasoning_text: None, + reasoning_opaque: None, + extra_content: None, + } + } + + #[test] + fn test_reasoning_only_reasoning_field() { + let fixture = response_message(Some("hello"), None); + assert_eq!(fixture.reasoning(), Some("hello")); + } + + #[test] + fn test_reasoning_only_reasoning_content_field() { + let fixture = response_message(None, Some("hello")); + assert_eq!(fixture.reasoning(), Some("hello")); + } + + #[test] + fn test_reasoning_both_returns_longer() { + let fixture = response_message(Some("short"), Some("much longer text")); + assert_eq!(fixture.reasoning(), Some("much longer text")); + } + + #[test] + fn test_reasoning_both_equal_length_returns_reasoning() { + let fixture = response_message(Some("aaa"), Some("bbb")); + assert_eq!(fixture.reasoning(), Some("aaa")); + } + + #[test] + fn test_reasoning_both_present_one_empty_returns_non_empty() { + let fixture = response_message(Some(""), Some("content")); + assert_eq!(fixture.reasoning(), Some("content")); + } + + #[test] + fn test_reasoning_both_empty_returns_none() { + let fixture = response_message(Some(""), Some("")); + assert_eq!(fixture.reasoning(), None); + } + + #[test] + fn test_reasoning_neither_present_returns_none() { + let fixture = response_message(None, None); + assert_eq!(fixture.reasoning(), None); + } + async fn load_fixture(filename: &str) -> serde_json::Value { let fixture_path = format!("src/dto/openai/fixtures/{}", filename); let fixture_content = tokio::fs::read_to_string(&fixture_path) From 4da35c526d35495b351fb31ffe73d9ed216762c3 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 15:26:20 +0000 Subject: [PATCH 4/4] [autofix.ci] apply automated fixes --- crates/forge_app/src/dto/openai/response.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/forge_app/src/dto/openai/response.rs b/crates/forge_app/src/dto/openai/response.rs index 18f07889fb..63b0b152fb 100644 --- a/crates/forge_app/src/dto/openai/response.rs +++ b/crates/forge_app/src/dto/openai/response.rs @@ -565,7 +565,10 @@ mod tests { struct Fixture; - fn response_message(reasoning: Option<&str>, reasoning_content: Option<&str>) -> ResponseMessage { + fn response_message( + reasoning: Option<&str>, + reasoning_content: Option<&str>, + ) -> ResponseMessage { ResponseMessage { content: None, reasoning: reasoning.map(str::to_owned),