Skip to content

Commit be0b33b

Browse files
think-in-universeserrrfiratclaude
authored
fix: duplicate reasoning_content fields in chat completions response (#2493)
* fix: duplicate reasoning_content fields in chat completions response * fix: resolve comments * style: fix rustfmt and ignore RUSTSEC-2026-{0098,0099} in cargo-deny Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: resolve comments --------- Co-authored-by: serrrfirat <f@nuff.tech> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4353493 commit be0b33b

2 files changed

Lines changed: 133 additions & 31 deletions

File tree

deny.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ ignore = [
1717
# rand unsoundness with custom logger calling rand::rng() during reseed — we don't use this pattern;
1818
# revisit/remove by 2026-06-30, or when transitive deps (tower, nanoid, phf_generator) release rand ≥0.9.3 compat
1919
"RUSTSEC-2026-0097",
20+
# rustls-webpki URI/wildcard name constraint bypass — 0.102.x/0.103.x pinned by libsql transitive dep
21+
"RUSTSEC-2026-0098",
22+
"RUSTSEC-2026-0099",
2023
]
2124

2225
[licenses]

src/llm/nearai_chat.rs

Lines changed: 130 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -523,10 +523,14 @@ impl LlmProvider for NearAiChatProvider {
523523

524524
// Fall back to reasoning_content when content is null (same as
525525
// complete_with_tools — reasoning models may put the answer there).
526-
let content = choice
527-
.message
528-
.content
529-
.or(choice.message.reasoning_content)
526+
let ChatCompletionResponseMessage {
527+
content,
528+
reasoning_content,
529+
reasoning,
530+
..
531+
} = choice.message;
532+
let content = content
533+
.or(reasoning_content.or(reasoning))
530534
.unwrap_or_default();
531535
let finish_reason = match choice.finish_reason.as_deref() {
532536
Some("stop") => FinishReason::Stop,
@@ -602,9 +606,16 @@ impl LlmProvider for NearAiChatProvider {
602606
provider: "nearai_chat".to_string(),
603607
})?;
604608

605-
let tool_calls: Vec<ToolCall> = choice
606-
.message
607-
.tool_calls
609+
let ChatCompletionResponseMessage {
610+
content: message_content,
611+
reasoning_content,
612+
reasoning,
613+
tool_calls: message_tool_calls,
614+
..
615+
} = choice.message;
616+
let reasoning_fallback = reasoning_content.or(reasoning);
617+
618+
let tool_calls: Vec<ToolCall> = message_tool_calls
608619
.unwrap_or_default()
609620
.into_iter()
610621
.map(|tc| {
@@ -626,9 +637,9 @@ impl LlmProvider for NearAiChatProvider {
626637
// leaking that into conversation history inflates context and
627638
// confuses the model.
628639
let content = if tool_calls.is_empty() {
629-
choice.message.content.or(choice.message.reasoning_content)
640+
message_content.or(reasoning_fallback)
630641
} else {
631-
choice.message.content
642+
message_content
632643
};
633644

634645
let finish_reason = match choice.finish_reason.as_deref() {
@@ -1072,8 +1083,10 @@ struct ChatCompletionResponseMessage {
10721083
/// Some models return chain-of-thought reasoning here instead of in
10731084
/// `content`. vLLM/SGLang backends (used by NEAR AI) return the field
10741085
/// as `reasoning`; other APIs (GLM-5, DeepSeek) use `reasoning_content`.
1075-
#[serde(default, alias = "reasoning")]
1086+
#[serde(default)]
10761087
reasoning_content: Option<String>,
1088+
#[serde(default)]
1089+
reasoning: Option<String>,
10771090
tool_calls: Option<Vec<ChatCompletionToolCall>>,
10781091
}
10791092

@@ -1454,7 +1467,7 @@ mod tests {
14541467
assert_eq!(output, default_out);
14551468
}
14561469

1457-
/// Regression: reasoning_content must NOT leak into tool-call responses.
1470+
/// Regression: reasoning fallbacks must NOT leak into tool-call responses.
14581471
#[test]
14591472
fn test_reasoning_content_not_leaked_into_tool_call_response() {
14601473
let response: ChatCompletionResponse = serde_json::from_value(serde_json::json!({
@@ -1464,6 +1477,7 @@ mod tests {
14641477
"role": "assistant",
14651478
"content": null,
14661479
"reasoning_content": "Let me think about which tool to call...",
1480+
"reasoning": "Secondary reasoning fallback text",
14671481
"tool_calls": [{
14681482
"id": "call_abc123",
14691483
"type": "function",
@@ -1480,9 +1494,15 @@ mod tests {
14801494
.unwrap();
14811495

14821496
let choice = response.choices.into_iter().next().unwrap();
1483-
let tool_calls: Vec<ToolCall> = choice
1484-
.message
1485-
.tool_calls
1497+
let ChatCompletionResponseMessage {
1498+
content: message_content,
1499+
reasoning_content,
1500+
reasoning,
1501+
tool_calls: message_tool_calls,
1502+
..
1503+
} = choice.message;
1504+
let reasoning_fallback = reasoning_content.or(reasoning);
1505+
let tool_calls: Vec<ToolCall> = message_tool_calls
14861506
.unwrap_or_default()
14871507
.into_iter()
14881508
.map(|tc| {
@@ -1498,14 +1518,14 @@ mod tests {
14981518
.collect();
14991519

15001520
let content = if tool_calls.is_empty() {
1501-
choice.message.content.or(choice.message.reasoning_content)
1521+
message_content.or(reasoning_fallback)
15021522
} else {
1503-
choice.message.content
1523+
message_content
15041524
};
15051525

15061526
assert!(
15071527
content.is_none(),
1508-
"reasoning_content should NOT leak into tool-call responses, got: {:?}",
1528+
"reasoning fallbacks should NOT leak into tool-call responses, got: {:?}",
15091529
content
15101530
);
15111531
assert_eq!(tool_calls.len(), 1);
@@ -1521,7 +1541,8 @@ mod tests {
15211541
"message": {
15221542
"role": "assistant",
15231543
"content": null,
1524-
"reasoning_content": "The answer is 42."
1544+
"reasoning_content": "The answer is 42.",
1545+
"reasoning": "Backup reasoning text"
15251546
},
15261547
"finish_reason": "stop"
15271548
}],
@@ -1530,9 +1551,15 @@ mod tests {
15301551
.unwrap();
15311552

15321553
let choice = response.choices.into_iter().next().unwrap();
1533-
let tool_calls: Vec<ToolCall> = choice
1534-
.message
1535-
.tool_calls
1554+
let ChatCompletionResponseMessage {
1555+
content: message_content,
1556+
reasoning_content,
1557+
reasoning,
1558+
tool_calls: message_tool_calls,
1559+
..
1560+
} = choice.message;
1561+
let reasoning_fallback = reasoning_content.or(reasoning);
1562+
let tool_calls: Vec<ToolCall> = message_tool_calls
15361563
.unwrap_or_default()
15371564
.into_iter()
15381565
.map(|tc| {
@@ -1548,9 +1575,9 @@ mod tests {
15481575
.collect();
15491576

15501577
let content = if tool_calls.is_empty() {
1551-
choice.message.content.or(choice.message.reasoning_content)
1578+
message_content.or(reasoning_fallback)
15521579
} else {
1553-
choice.message.content
1580+
message_content
15541581
};
15551582

15561583
assert_eq!(
@@ -1562,7 +1589,7 @@ mod tests {
15621589
}
15631590

15641591
/// The vLLM/SGLang API returns `reasoning` (not `reasoning_content`).
1565-
/// Verify that the serde alias deserializes it correctly.
1592+
/// Verify that this dedicated field is consumed as fallback content.
15661593
#[test]
15671594
fn test_reasoning_field_alias_accepted() {
15681595
let response: ChatCompletionResponse = serde_json::from_value(serde_json::json!({
@@ -1581,12 +1608,18 @@ mod tests {
15811608
.unwrap();
15821609

15831610
let choice = response.choices.into_iter().next().unwrap();
1584-
let content = choice.message.content.or(choice.message.reasoning_content);
1611+
let ChatCompletionResponseMessage {
1612+
content,
1613+
reasoning_content,
1614+
reasoning,
1615+
..
1616+
} = choice.message;
1617+
let content = content.or(reasoning_content.or(reasoning));
15851618

15861619
assert_eq!(
15871620
content,
15881621
Some("The answer is 42.".to_string()),
1589-
"reasoning field (vLLM alias) should deserialize into reasoning_content"
1622+
"reasoning should be used as fallback content"
15901623
);
15911624
}
15921625

@@ -1618,9 +1651,15 @@ mod tests {
16181651
.unwrap();
16191652

16201653
let choice = response.choices.into_iter().next().unwrap();
1621-
let tool_calls: Vec<ToolCall> = choice
1622-
.message
1623-
.tool_calls
1654+
let ChatCompletionResponseMessage {
1655+
content: message_content,
1656+
reasoning_content,
1657+
reasoning,
1658+
tool_calls: message_tool_calls,
1659+
..
1660+
} = choice.message;
1661+
let reasoning_fallback = reasoning_content.or(reasoning);
1662+
let tool_calls: Vec<ToolCall> = message_tool_calls
16241663
.unwrap_or_default()
16251664
.into_iter()
16261665
.map(|tc| {
@@ -1636,9 +1675,9 @@ mod tests {
16361675
.collect();
16371676

16381677
let content = if tool_calls.is_empty() {
1639-
choice.message.content.or(choice.message.reasoning_content)
1678+
message_content.or(reasoning_fallback)
16401679
} else {
1641-
choice.message.content
1680+
message_content
16421681
};
16431682

16441683
assert!(
@@ -1648,6 +1687,66 @@ mod tests {
16481687
assert_eq!(tool_calls.len(), 1);
16491688
}
16501689

1690+
/// Regression: payloads that include BOTH reasoning fields must parse
1691+
/// successfully and honor fallback precedence:
1692+
/// content -> reasoning_content -> reasoning.
1693+
#[test]
1694+
fn test_both_reasoning_fields_parse_with_defined_precedence() {
1695+
// Case 1: content is present, so it wins over both reasoning fields.
1696+
let response_with_content: ChatCompletionResponse =
1697+
serde_json::from_value(serde_json::json!({
1698+
"id": "chatcmpl-test-content",
1699+
"choices": [{
1700+
"message": {
1701+
"role": "assistant",
1702+
"content": "Final answer in content.",
1703+
"reasoning_content": "Reasoning content fallback",
1704+
"reasoning": "Reasoning alias fallback"
1705+
},
1706+
"finish_reason": "stop"
1707+
}]
1708+
}))
1709+
.expect("payload with both reasoning fields should deserialize");
1710+
let choice = response_with_content.choices.into_iter().next().unwrap();
1711+
let ChatCompletionResponseMessage {
1712+
content,
1713+
reasoning_content,
1714+
reasoning,
1715+
..
1716+
} = choice.message;
1717+
let selected = content
1718+
.or(reasoning_content.or(reasoning))
1719+
.expect("content should be selected");
1720+
assert_eq!(selected, "Final answer in content.");
1721+
1722+
// Case 2: content is null; reasoning_content should win over reasoning.
1723+
let response_without_content: ChatCompletionResponse =
1724+
serde_json::from_value(serde_json::json!({
1725+
"id": "chatcmpl-test-reasoning",
1726+
"choices": [{
1727+
"message": {
1728+
"role": "assistant",
1729+
"content": null,
1730+
"reasoning_content": "Preferred reasoning_content",
1731+
"reasoning": "Secondary reasoning"
1732+
},
1733+
"finish_reason": "stop"
1734+
}]
1735+
}))
1736+
.expect("payload with both reasoning fields should deserialize");
1737+
let choice = response_without_content.choices.into_iter().next().unwrap();
1738+
let ChatCompletionResponseMessage {
1739+
content,
1740+
reasoning_content,
1741+
reasoning,
1742+
..
1743+
} = choice.message;
1744+
let selected = content
1745+
.or(reasoning_content.or(reasoning))
1746+
.expect("reasoning fallback should be selected");
1747+
assert_eq!(selected, "Preferred reasoning_content");
1748+
}
1749+
16511750
#[tokio::test]
16521751
async fn test_resolve_bearer_token_config_api_key() {
16531752
// When config.api_key is set, it takes top priority.

0 commit comments

Comments
 (0)