From c4f595ce5465afc914a9e8e0137bfc6c1717840b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:12:08 +0000 Subject: [PATCH] fix(rust): normalize bedrock converse reasoningContent on the rust chat completions path Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../crates/core/src/chat_completions/types.rs | 22 +++++ .../chat_completions/transformation.rs | 1 + .../bedrock/chat_completions/tests.rs | 80 ++++++++++++++++ .../chat_completions/transformation.rs | 91 +++++++++++++++++-- .../rust_bridge/test_chat_completions.py | 40 ++++++++ 5 files changed, 228 insertions(+), 6 deletions(-) diff --git a/litellm-rust/crates/core/src/chat_completions/types.rs b/litellm-rust/crates/core/src/chat_completions/types.rs index 35dd543a986..7af92e7c581 100644 --- a/litellm-rust/crates/core/src/chat_completions/types.rs +++ b/litellm-rust/crates/core/src/chat_completions/types.rs @@ -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, + #[serde(default, skip_serializing_if = "Option::is_none")] + signature: Option, + }, + #[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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_content: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub thinking_blocks: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_specific_fields: Option>, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/litellm-rust/crates/core/src/providers/anthropic/chat_completions/transformation.rs b/litellm-rust/crates/core/src/providers/anthropic/chat_completions/transformation.rs index 3658642b539..7e32392915f 100644 --- a/litellm-rust/crates/core/src/providers/anthropic/chat_completions/transformation.rs +++ b/litellm-rust/crates/core/src/providers/anthropic/chat_completions/transformation.rs @@ -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") diff --git a/litellm-rust/crates/core/src/providers/bedrock/chat_completions/tests.rs b/litellm-rust/crates/core/src/providers/bedrock/chat_completions/tests.rs index 4b75dcb8e9d..54c485a44c0 100644 --- a/litellm-rust/crates/core/src/providers/bedrock/chat_completions/tests.rs +++ b/litellm-rust/crates/core/src/providers/bedrock/chat_completions/tests.rs @@ -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" + ); +} diff --git a/litellm-rust/crates/core/src/providers/bedrock/chat_completions/transformation.rs b/litellm-rust/crates/core/src/providers/bedrock/chat_completions/transformation.rs index b107950748e..eb389f2e17d 100644 --- a/litellm-rust/crates/core/src/providers/bedrock/chat_completions/transformation.rs +++ b/litellm-rust/crates/core/src/providers/bedrock/chat_completions/transformation.rs @@ -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, + raw: Vec, +} + pub struct BedrockChatCompletionsConfig; pub const BEDROCK_CHAT_COMPLETIONS_CONFIG: BedrockChatCompletionsConfig = @@ -91,6 +101,58 @@ fn converse_body(conversation: &Conversation, params: &Map) -> Va )) } +fn thinking_block(reasoning: &Map) -> Option { + 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 { + let raw: Vec = 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(""), diff --git a/tests/test_litellm/rust_bridge/test_chat_completions.py b/tests/test_litellm/rust_bridge/test_chat_completions.py index 47cb66932b7..c6a943dfa4c 100644 --- a/tests/test_litellm/rust_bridge/test_chat_completions.py +++ b/tests/test_litellm/rust_bridge/test_chat_completions.py @@ -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)