This commit is contained in:
devin-ai-integration[bot] 2026-08-31 14:18:50 -07:00 committed by GitHub
commit 6103ea0eb5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 228 additions and 6 deletions

View file

@ -81,7 +81,23 @@ pub struct ChatCompletionsUsage {
pub prompt_tokens_details: PromptTokensDetails,
}
/// A reasoning block normalized to the shape Python reports on the message,
/// where a missing `thinking` or `signature` is omitted rather than nulled.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ThinkingBlock {
#[serde(rename = "thinking")]
Thinking {
#[serde(default, skip_serializing_if = "Option::is_none")]
thinking: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
signature: Option<String>,
},
#[serde(rename = "redacted_thinking")]
Redacted { data: String },
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct ChatCompletionsChoiceMessage {
pub role: String,
// Whether an empty turn is `None` or `""` is the provider's choice, not a
@ -89,6 +105,12 @@ pub struct ChatCompletionsChoiceMessage {
// while Converse assigns the joined string unconditionally. Each config
// mirrors its own, so keep this optional and serialize it even when None.
pub content: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_content: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thinking_blocks: Option<Vec<ThinkingBlock>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_specific_fields: Option<Map<String, Value>>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]

View file

@ -188,6 +188,7 @@ impl ChatCompletionsProviderConfig for AnthropicChatCompletionsConfig {
message: ChatCompletionsChoiceMessage {
role: "assistant".to_string(),
content: (!text.is_empty()).then_some(text),
..Default::default()
},
finish_reason: finish_reason_for(
body.get("stop_reason")

View file

@ -578,3 +578,83 @@ fn host_supplied_credentials_outrank_ambient_profile_and_role_state() {
);
assert!(host_supplied_credentials(&Map::new()).is_none());
}
#[test]
fn normalizes_a_reasoning_response_the_way_python_does() {
let response = transform_response(json!({
"output": {"message": {"role": "assistant", "content": [
{"reasoningContent": {"reasoningText": {"text": "counting", "signature": "sig"}}},
{"text": "Hi there, friend!"}
]}},
"stopReason": "end_turn",
"usage": {"inputTokens": 9, "outputTokens": 5, "totalTokens": 14}
}))
.expect("reasoning response transforms");
let message = &response.choices[0].message;
assert_eq!(message.content.as_deref(), Some("Hi there, friend!"));
assert_eq!(message.reasoning_content.as_deref(), Some("counting"));
assert_eq!(
message.thinking_blocks.as_deref(),
Some(
[ThinkingBlock::Thinking {
thinking: Some("counting".to_string()),
signature: Some("sig".to_string()),
}]
.as_slice()
)
);
assert_eq!(
serde_json::to_value(message).expect("message serializes"),
json!({
"role": "assistant",
"content": "Hi there, friend!",
"reasoning_content": "counting",
"thinking_blocks": [
{"type": "thinking", "thinking": "counting", "signature": "sig"}
],
"provider_specific_fields": {"reasoningContentBlocks": [
{"reasoningText": {"text": "counting", "signature": "sig"}}
]}
}),
"the rust message must carry the keys Python's converse transform sets"
);
}
#[test]
fn reports_redacted_reasoning_without_reasoning_text() {
let response = transform_response(json!({
"output": {"message": {"content": [
{"reasoningContent": {"redactedContent": "encrypted"}},
{"text": "done"}
]}},
"stopReason": "end_turn",
"usage": {"inputTokens": 1, "outputTokens": 1, "totalTokens": 2}
}))
.expect("redacted reasoning response transforms");
let message = &response.choices[0].message;
assert_eq!(message.reasoning_content.as_deref(), Some(""));
assert_eq!(
message.thinking_blocks.as_deref(),
Some(
[ThinkingBlock::Redacted {
data: "encrypted".to_string(),
}]
.as_slice()
)
);
}
#[test]
fn omits_the_reasoning_keys_on_a_plain_text_response() {
let response = transform_response(json!({
"output": {"message": {"content": [{"text": "hi"}]}},
"stopReason": "end_turn",
"usage": {"inputTokens": 1, "outputTokens": 1, "totalTokens": 2}
}))
.expect("response transforms");
assert_eq!(
serde_json::to_value(&response.choices[0].message).expect("message serializes"),
json!({"role": "assistant", "content": "hi"}),
"a response with no reasoning must serialize the shape Python builds"
);
}

View file

@ -9,7 +9,7 @@ use crate::chat_completions::transformation::{
use crate::chat_completions::types::{
ChatCompletionsChoice, ChatCompletionsChoiceMessage, ChatCompletionsResponse,
ChatCompletionsUsage, ChatMessage, ChatMessageContent, ProviderChatRequestData,
ProviderChatResponseData,
ProviderChatResponseData, ThinkingBlock,
};
use crate::error::{CoreError, CoreResult};
@ -49,6 +49,16 @@ const CONFIG_PARAMS: &[&str] = &[
const CONVERSE_PATH_SUFFIX: &str = "/converse";
const REASONING_CONTENT_KEY: &str = "reasoningContent";
const REASONING_BLOCKS_FIELD: &str = "reasoningContentBlocks";
struct Reasoning {
text: String,
blocks: Vec<ThinkingBlock>,
raw: Vec<Value>,
}
pub struct BedrockChatCompletionsConfig;
pub const BEDROCK_CHAT_COMPLETIONS_CONFIG: BedrockChatCompletionsConfig =
@ -91,6 +101,58 @@ fn converse_body(conversation: &Conversation, params: &Map<String, Value>) -> Va
))
}
fn thinking_block(reasoning: &Map<String, Value>) -> Option<ThinkingBlock> {
if let Some(reasoning_text) = reasoning.get("reasoningText").and_then(Value::as_object) {
return Some(ThinkingBlock::Thinking {
thinking: reasoning_text
.get("text")
.and_then(Value::as_str)
.map(str::to_string),
signature: reasoning_text
.get("signature")
.and_then(Value::as_str)
.map(str::to_string),
});
}
reasoning
.get("redactedContent")
.and_then(Value::as_str)
.map(|data| ThinkingBlock::Redacted {
data: data.to_string(),
})
}
/// Converse returns `reasoningContent` blocks unprompted on a reasoning model,
/// so this path sees them without asking for thinking. Python reports them as
/// `reasoning_content` plus `thinking_blocks` and echoes the raw blocks under
/// `provider_specific_fields`; mirror that rather than declining a response the
/// provider has already billed.
fn reasoning(content: &[Value]) -> Option<Reasoning> {
let raw: Vec<Value> = content
.iter()
.filter_map(|block| block.get(REASONING_CONTENT_KEY).cloned())
.collect();
if raw.is_empty() {
return None;
}
Some(Reasoning {
text: raw
.iter()
.filter_map(|block| {
block
.get("reasoningText")
.and_then(|reasoning_text| reasoning_text.get("text"))
.and_then(Value::as_str)
})
.collect(),
blocks: raw
.iter()
.filter_map(|block| block.as_object().and_then(thinking_block))
.collect(),
raw,
})
}
fn has_blank_text(message: &ChatMessage) -> bool {
match &message.content {
None => false,
@ -229,12 +291,14 @@ impl ChatCompletionsProviderConfig for BedrockChatCompletionsConfig {
.and_then(|message| message.get("content"))
.and_then(Value::as_array)
.ok_or(CoreError::MissingField("output.message.content"))?;
// The route declines tool requests, so anything other than a text block
// is something this path never asked for. Decline; the host falls back.
// The route declines tool requests, so anything other than a text or
// reasoning block is something this path never asked for. Decline; the
// host falls back.
if content.iter().any(|block| {
block
.as_object()
.is_none_or(|block| block.len() != 1 || !block.contains_key("text"))
block.as_object().is_none_or(|block| {
block.len() != 1
|| !(block.contains_key("text") || block.contains_key(REASONING_CONTENT_KEY))
})
}) {
return Err(CoreError::Unsupported("non-text response content block"));
}
@ -242,6 +306,18 @@ impl ChatCompletionsProviderConfig for BedrockChatCompletionsConfig {
.iter()
.filter_map(|block| block.get("text").and_then(Value::as_str))
.collect();
let (reasoning_content, thinking_blocks, provider_specific_fields) =
match reasoning(content) {
Some(reasoning) => (
Some(reasoning.text),
Some(reasoning.blocks),
Some(Map::from_iter([(
REASONING_BLOCKS_FIELD.to_string(),
Value::Array(reasoning.raw),
)])),
),
None => (None, None, None),
};
let usage = body
.get("usage")
@ -281,6 +357,9 @@ impl ChatCompletionsProviderConfig for BedrockChatCompletionsConfig {
// Anthropic. A caller calling `.strip()` on it would break
// on this path alone.
content: Some(text),
reasoning_content,
thinking_blocks,
provider_specific_fields,
},
finish_reason: finish_reason_for(
body.get("stopReason").and_then(Value::as_str).unwrap_or(""),

View file

@ -7,6 +7,8 @@ compiled extension present.
from __future__ import annotations
from typing import Final
import pytest
import litellm
@ -257,6 +259,44 @@ class TestSyncCall:
"the rust path must keep the chatcmpl id litellm already minted"
)
def test_carries_reasoning_content_from_a_converse_reasoning_response(self):
reasoning_response: Final = {
**RUST_RESPONSE,
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "hello from rust",
"reasoning_content": "counting",
"thinking_blocks": [
{"type": "thinking", "thinking": "counting", "signature": "sig"}
],
"provider_specific_fields": {
"reasoningContentBlocks": [
{"reasoningText": {"text": "counting", "signature": "sig"}}
]
},
},
"finish_reason": "stop",
}
],
}
bridge.set_rust_chat_completions(chat_completions=_RecordingCall(result=reasoning_response))
result: Final = bridge.chat_completions(**_call_kwargs(ModelResponse()))
assert result is not None
message: Final = result.choices[0].message
assert message.content == "hello from rust"
assert message.reasoning_content == "counting"
assert message.thinking_blocks == [
{"type": "thinking", "thinking": "counting", "signature": "sig"}
]
assert message.provider_specific_fields["reasoningContentBlocks"] == [
{"reasoningText": {"text": "counting", "signature": "sig"}}
]
def test_passes_the_timeout_through_as_seconds(self):
native = _RecordingCall()
bridge.set_rust_chat_completions(chat_completions=native)