Skip to content

Commit e316512

Browse files
fix(reasoning): add gemini-3 model support with extended reasoning fields (#2069)
Co-authored-by: Tushar Mathur <tusharmath@gmail.com>
1 parent 500925f commit e316512

21 files changed

Lines changed: 570 additions & 136 deletions

crates/forge_app/src/compact.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,11 +172,13 @@ mod tests {
172172
let first_reasoning = vec![ReasoningFull {
173173
text: Some("First thought".to_string()),
174174
signature: Some("sig1".to_string()),
175+
..Default::default()
175176
}];
176177

177178
let last_reasoning = vec![ReasoningFull {
178179
text: Some("Last thought".to_string()),
179180
signature: Some("sig2".to_string()),
181+
..Default::default()
180182
}];
181183

182184
let context = Context::default()
@@ -225,6 +227,7 @@ mod tests {
225227
let reasoning = vec![ReasoningFull {
226228
text: Some("Original thought".to_string()),
227229
signature: Some("sig1".to_string()),
230+
..Default::default()
228231
}];
229232

230233
// First compaction
@@ -284,6 +287,7 @@ mod tests {
284287
let non_empty_reasoning = vec![ReasoningFull {
285288
text: Some("Valid thought".to_string()),
286289
signature: Some("sig1".to_string()),
290+
..Default::default()
287291
}];
288292

289293
// Most recent message in range has empty reasoning, earlier has non-empty

crates/forge_app/src/dto/anthropic/response.rs

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -306,10 +306,11 @@ impl TryFrom<ContentBlock> for ChatCompletionMessage {
306306
if let Some(thinking) = thinking {
307307
ChatCompletionMessage::assistant(Content::part(""))
308308
.reasoning(Content::part(thinking.clone()))
309-
.add_reasoning_detail(Reasoning::Part(vec![ReasoningPart {
310-
signature,
311-
text: Some(thinking),
312-
}]))
309+
.add_reasoning_detail(Reasoning::Part(vec![
310+
ReasoningPart::default()
311+
.text(Some(thinking))
312+
.signature(signature),
313+
]))
313314
} else {
314315
ChatCompletionMessage::assistant(Content::part(""))
315316
}
@@ -318,10 +319,9 @@ impl TryFrom<ContentBlock> for ChatCompletionMessage {
318319
if let Some(data) = data {
319320
ChatCompletionMessage::assistant(Content::part(""))
320321
.reasoning(Content::part(data.clone()))
321-
.add_reasoning_detail(Reasoning::Part(vec![ReasoningPart {
322-
signature: None,
323-
text: Some(data),
324-
}]))
322+
.add_reasoning_detail(Reasoning::Part(vec![
323+
ReasoningPart::default().text(Some(data)),
324+
]))
325325
} else {
326326
ChatCompletionMessage::assistant(Content::part(""))
327327
}
@@ -330,17 +330,16 @@ impl TryFrom<ContentBlock> for ChatCompletionMessage {
330330
if let Some(thinking) = thinking {
331331
ChatCompletionMessage::assistant(Content::part(""))
332332
.reasoning(Content::part(thinking.clone()))
333-
.add_reasoning_detail(Reasoning::Part(vec![ReasoningPart {
334-
signature: None,
335-
text: Some(thinking),
336-
}]))
333+
.add_reasoning_detail(Reasoning::Part(vec![
334+
ReasoningPart::default().text(Some(thinking)),
335+
]))
337336
} else {
338337
ChatCompletionMessage::assistant(Content::part(""))
339338
}
340339
}
341340
ContentBlock::SignatureDelta { signature } => {
342341
ChatCompletionMessage::assistant(Content::part("")).add_reasoning_detail(
343-
Reasoning::Part(vec![ReasoningPart { signature, text: None }]),
342+
Reasoning::Part(vec![ReasoningPart::default().signature(signature)]),
344343
)
345344
}
346345
ContentBlock::ToolUse { id, name, input } => {

crates/forge_app/src/dto/openai/reasoning.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,16 @@ use serde::{Deserialize, Serialize};
33
#[derive(Debug, Deserialize, Serialize, Clone)]
44
pub struct ReasoningDetail {
55
pub r#type: String,
6+
#[serde(skip_serializing_if = "Option::is_none")]
67
pub text: Option<String>,
8+
#[serde(skip_serializing_if = "Option::is_none")]
79
pub signature: Option<String>,
10+
#[serde(skip_serializing_if = "Option::is_none")]
11+
pub data: Option<String>,
12+
#[serde(skip_serializing_if = "Option::is_none")]
13+
pub id: Option<String>,
14+
#[serde(skip_serializing_if = "Option::is_none")]
15+
pub format: Option<String>,
16+
#[serde(skip_serializing_if = "Option::is_none")]
17+
pub index: Option<i32>,
818
}

crates/forge_app/src/dto/openai/request.rs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ pub struct Message {
3232
pub tool_calls: Option<Vec<ToolCall>>,
3333
#[serde(skip_serializing_if = "Option::is_none")]
3434
pub reasoning_details: Option<Vec<ReasoningDetail>>,
35+
// GitHub Copilot format (flat fields instead of array)
36+
#[serde(skip_serializing_if = "Option::is_none")]
37+
pub reasoning_text: Option<String>,
38+
#[serde(skip_serializing_if = "Option::is_none")]
39+
pub reasoning_opaque: Option<String>,
3540
}
3641

3742
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
@@ -398,12 +403,20 @@ impl From<ContextMessage> for Message {
398403
details
399404
.into_iter()
400405
.map(|detail| ReasoningDetail {
401-
r#type: "reasoning.text".to_string(),
406+
r#type: detail
407+
.type_of
408+
.unwrap_or_else(|| "reasoning.text".to_string()),
402409
text: detail.text,
403410
signature: detail.signature,
411+
data: detail.data,
412+
id: detail.id,
413+
format: detail.format,
414+
index: detail.index,
404415
})
405416
.collect::<Vec<ReasoningDetail>>()
406417
}),
418+
reasoning_text: None,
419+
reasoning_opaque: None,
407420
},
408421
ContextMessage::Tool(tool_result) => Message {
409422
role: Role::Tool,
@@ -412,6 +425,8 @@ impl From<ContextMessage> for Message {
412425
content: Some(tool_result.into()),
413426
tool_calls: None,
414427
reasoning_details: None,
428+
reasoning_text: None,
429+
reasoning_opaque: None,
415430
},
416431
ContextMessage::Image(img) => {
417432
let content = vec![ContentPart::ImageUrl {
@@ -425,6 +440,8 @@ impl From<ContextMessage> for Message {
425440
tool_call_id: None,
426441
tool_calls: None,
427442
reasoning_details: None,
443+
reasoning_text: None,
444+
reasoning_opaque: None,
428445
}
429446
}
430447
}

crates/forge_app/src/dto/openai/response.rs

Lines changed: 85 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -109,17 +109,22 @@ pub struct ResponseMessage {
109109
pub tool_calls: Option<Vec<ToolCall>>,
110110
pub refusal: Option<String>,
111111
pub reasoning_details: Option<Vec<ReasoningDetail>>,
112+
// GitHub Copilot format (flat fields instead of array)
113+
pub reasoning_text: Option<String>,
114+
pub reasoning_opaque: Option<String>,
112115
}
113116

114-
impl From<ReasoningDetail> for forge_domain::ReasoningFull {
117+
impl From<ReasoningDetail> for forge_domain::ReasoningDetail {
115118
fn from(detail: ReasoningDetail) -> Self {
116-
forge_domain::ReasoningFull { text: detail.text, signature: detail.signature }
117-
}
118-
}
119-
120-
impl From<ReasoningDetail> for forge_domain::ReasoningPart {
121-
fn from(detail: ReasoningDetail) -> Self {
122-
forge_domain::ReasoningPart { text: detail.text, signature: detail.signature }
119+
forge_domain::ReasoningDetail {
120+
text: detail.text,
121+
signature: detail.signature,
122+
data: detail.data,
123+
id: detail.id,
124+
format: detail.format,
125+
index: detail.index,
126+
type_of: Some(detail.r#type),
127+
}
123128
}
124129
}
125130

@@ -164,6 +169,52 @@ impl From<ResponseUsage> for Usage {
164169
}
165170
}
166171

172+
/// Intermediate representation of GitHub Copilot reasoning fields
173+
struct GitHubCopilotReasoning {
174+
text: Option<String>,
175+
data: Option<String>,
176+
r#type: Option<String>,
177+
}
178+
179+
impl GitHubCopilotReasoning {
180+
fn into_reasoning_detail(self) -> forge_domain::ReasoningDetail {
181+
forge_domain::ReasoningDetail {
182+
text: self.text,
183+
data: self.data,
184+
type_of: self.r#type,
185+
..Default::default()
186+
}
187+
}
188+
}
189+
190+
/// Converts GitHub Copilot flat reasoning fields to structured reasoning
191+
/// details
192+
fn convert_github_copilot_reasoning(
193+
reasoning_text: &Option<String>,
194+
reasoning_opaque: &Option<String>,
195+
) -> Option<Vec<GitHubCopilotReasoning>> {
196+
if reasoning_text.is_some() || reasoning_opaque.is_some() {
197+
let mut details = Vec::new();
198+
if let Some(text) = reasoning_text {
199+
details.push(GitHubCopilotReasoning {
200+
text: Some(text.clone()),
201+
data: None,
202+
r#type: Some("reasoning.text".to_string()),
203+
});
204+
}
205+
if let Some(opaque) = reasoning_opaque {
206+
details.push(GitHubCopilotReasoning {
207+
text: None,
208+
data: Some(opaque.clone()),
209+
r#type: Some("reasoning.encrypted".to_string()),
210+
});
211+
}
212+
Some(details)
213+
} else {
214+
None
215+
}
216+
}
217+
167218
impl TryFrom<Response> for ChatCompletionMessage {
168219
type Error = anyhow::Error;
169220

@@ -214,6 +265,16 @@ impl TryFrom<Response> for ChatCompletionMessage {
214265
resp = resp.add_reasoning_detail(forge_domain::Reasoning::Full(
215266
converted_details,
216267
));
268+
} else if let Some(details) = convert_github_copilot_reasoning(
269+
&message.reasoning_text,
270+
&message.reasoning_opaque,
271+
) {
272+
resp = resp.add_reasoning_detail(forge_domain::Reasoning::Full(
273+
details
274+
.into_iter()
275+
.map(GitHubCopilotReasoning::into_reasoning_detail)
276+
.collect(),
277+
));
217278
}
218279

219280
if let Some(tool_calls) = &message.tool_calls {
@@ -257,6 +318,16 @@ impl TryFrom<Response> for ChatCompletionMessage {
257318
resp = resp.add_reasoning_detail(forge_domain::Reasoning::Part(
258319
converted_details,
259320
));
321+
} else if let Some(details) = convert_github_copilot_reasoning(
322+
&delta.reasoning_text,
323+
&delta.reasoning_opaque,
324+
) {
325+
resp = resp.add_reasoning_detail(forge_domain::Reasoning::Part(
326+
details
327+
.into_iter()
328+
.map(GitHubCopilotReasoning::into_reasoning_detail)
329+
.collect(),
330+
));
260331
}
261332

262333
if let Some(tool_calls) = &delta.tool_calls {
@@ -404,6 +475,8 @@ mod tests {
404475
tool_calls: None,
405476
refusal: None,
406477
reasoning_details: None,
478+
reasoning_text: None,
479+
reasoning_opaque: None,
407480
},
408481
error: Some(error_response.clone()),
409482
}],
@@ -437,6 +510,8 @@ mod tests {
437510
tool_calls: None,
438511
refusal: None,
439512
reasoning_details: None,
513+
reasoning_text: None,
514+
reasoning_opaque: None,
440515
},
441516
error: Some(error_response.clone()),
442517
}],
@@ -470,6 +545,8 @@ mod tests {
470545
tool_calls: None,
471546
refusal: None,
472547
reasoning_details: None,
548+
reasoning_text: None,
549+
reasoning_opaque: None,
473550
},
474551
error: None,
475552
}],

0 commit comments

Comments
 (0)