mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-06 08:16:43 +00:00
Merge c4f595ce54 into 40edeaaecb
This commit is contained in:
commit
6103ea0eb5
5 changed files with 228 additions and 6 deletions
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(""),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue