diff --git a/litellm-rust/Cargo.lock b/litellm-rust/Cargo.lock index 1cc200a7bec..9654113e2b9 100644 --- a/litellm-rust/Cargo.lock +++ b/litellm-rust/Cargo.lock @@ -1953,6 +1953,8 @@ dependencies = [ name = "litellm-core" version = "0.1.0" dependencies = [ + "aws-smithy-eventstream", + "aws-smithy-types", "base64 0.22.1", "bytes", "data-url", @@ -1961,6 +1963,7 @@ dependencies = [ "litellm-auth-aws", "litellm-auth-azure", "litellm-auth-gcp", + "litellm-framing", "mime_guess", "moka", "rand 0.8.7", diff --git a/litellm-rust/Cargo.toml b/litellm-rust/Cargo.toml index 879090870d8..6033e6957f3 100644 --- a/litellm-rust/Cargo.toml +++ b/litellm-rust/Cargo.toml @@ -11,6 +11,7 @@ repository = "https://github.com/BerriAI/litellm" [workspace.dependencies] bytes = "1" litellm-core = { path = "crates/core" } +litellm-framing = { path = "crates/framer" } litellm-auth = { path = "crates/auth" } litellm-auth-aws = { path = "crates/auth-aws" } litellm-auth-azure = { path = "crates/auth-azure" } diff --git a/litellm-rust/crates/core/Cargo.toml b/litellm-rust/crates/core/Cargo.toml index ededfeef8af..eccd753b3a0 100644 --- a/litellm-rust/crates/core/Cargo.toml +++ b/litellm-rust/crates/core/Cargo.toml @@ -15,6 +15,7 @@ litellm-auth.workspace = true litellm-auth-aws.workspace = true litellm-auth-azure.workspace = true litellm-auth-gcp.workspace = true +litellm-framing.workspace = true moka.workspace = true mime_guess = "2.0.5" rand.workspace = true @@ -34,4 +35,6 @@ url.workspace = true veil.workspace = true [dev-dependencies] +aws-smithy-eventstream = "=0.61.1" +aws-smithy-types = "1.6.1" rstest.workspace = true diff --git a/litellm-rust/crates/core/src/chat_completions/mod.rs b/litellm-rust/crates/core/src/chat_completions/mod.rs index 401eef609f2..2a391f942aa 100644 --- a/litellm-rust/crates/core/src/chat_completions/mod.rs +++ b/litellm-rust/crates/core/src/chat_completions/mod.rs @@ -14,6 +14,7 @@ pub mod conversation; pub(crate) mod handler; mod prepare; pub mod response_utils; +pub mod streaming; pub mod transformation; pub mod types; diff --git a/litellm-rust/crates/core/src/chat_completions/streaming.rs b/litellm-rust/crates/core/src/chat_completions/streaming.rs new file mode 100644 index 00000000000..928ef80b29a --- /dev/null +++ b/litellm-rust/crates/core/src/chat_completions/streaming.rs @@ -0,0 +1,9 @@ +pub trait StreamTransformer { + type Input; + type Output; + type Error; + + fn transform(&mut self, input: Self::Input) -> Result, Self::Error>; + + fn finish(&mut self) -> Result, Self::Error>; +} diff --git a/litellm-rust/crates/core/src/chat_completions/types.rs b/litellm-rust/crates/core/src/chat_completions/types.rs index 7178d594870..75fabdc9f8f 100644 --- a/litellm-rust/crates/core/src/chat_completions/types.rs +++ b/litellm-rust/crates/core/src/chat_completions/types.rs @@ -120,3 +120,83 @@ pub struct ChatCompletionsResponse { pub choices: Vec, pub usage: ChatCompletionsUsage, } + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ChatCompletionToolCallFunctionChunk { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + pub arguments: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_specific_fields: Option>, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ChatCompletionToolCallChunk { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(rename = "type")] + pub tool_type: String, + pub function: ChatCompletionToolCallFunctionChunk, + pub index: i64, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ChatCompletionThinkingBlock { + Thinking { + #[serde(default, skip_serializing_if = "Option::is_none")] + thinking: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + signature: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + cache_control: Option, + }, + RedactedThinking { + #[serde(default, skip_serializing_if = "Option::is_none")] + data: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + cache_control: Option, + }, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct ChatCompletionDelta { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub role: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_calls: 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>, + #[serde(flatten)] + pub extra: Map, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ChatCompletionStreamingChoice { + pub index: u64, + pub delta: ChatCompletionDelta, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub finish_reason: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub logprobs: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ChatCompletionChunk { + pub id: String, + pub created: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + pub object: String, + pub choices: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub usage: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_specific_fields: Option>, +} diff --git a/litellm-rust/crates/core/src/providers/anthropic/chat_completions/mod.rs b/litellm-rust/crates/core/src/providers/anthropic/chat_completions/mod.rs index f239b6921fa..fa7df180f50 100644 --- a/litellm-rust/crates/core/src/providers/anthropic/chat_completions/mod.rs +++ b/litellm-rust/crates/core/src/providers/anthropic/chat_completions/mod.rs @@ -1 +1,2 @@ +pub mod streaming; pub mod transformation; diff --git a/litellm-rust/crates/core/src/providers/anthropic/chat_completions/streaming.rs b/litellm-rust/crates/core/src/providers/anthropic/chat_completions/streaming.rs new file mode 100644 index 00000000000..c373b666abd --- /dev/null +++ b/litellm-rust/crates/core/src/providers/anthropic/chat_completions/streaming.rs @@ -0,0 +1,164 @@ +use std::collections::HashMap; + +use serde_json::Value; + +use crate::chat_completions::Error; +use crate::chat_completions::streaming::StreamTransformer; +use crate::chat_completions::types::{ + ChatCompletionChunk, ChatCompletionThinkingBlock, ChatCompletionToolCallChunk, + ChatCompletionsUsage, +}; +use crate::providers::anthropic::messages::streaming::{ + AnthropicContentBlock, AnthropicContentBlockDelta, AnthropicMessagesStreamEvent, + AnthropicStreamUsage, +}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AnthropicJsonChunkType { + ValidJson, + AccumulatedJson, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AnthropicContentBlockType { + Text, + ToolUse, + ServerToolUse, + Thinking, + RedactedThinking, + Compaction, + ToolResult(String), + Other(String), +} + +#[derive(Clone, Debug, PartialEq)] +pub struct AnthropicContentBlockDeltaEvent { + pub index: u64, + pub delta: AnthropicContentBlockDelta, +} + +pub struct AnthropicChatCompletionsStreamTransformer { + pub content_blocks: Vec, + pub tool_index: i64, + pub json_mode: bool, + pub speed: Option, + pub tool_name_reverse_map: HashMap, + pub response_id: String, + pub served_model: Option, + pub is_response_format_tool: bool, + pub converted_response_format_tool: bool, + pub accumulated_json: String, + pub chunk_type: AnthropicJsonChunkType, + pub current_content_block_type: Option, + pub web_search_results: Vec, + pub web_search_calls: HashMap, + pub compaction_blocks: Vec, + pub reasoning_content_chunks: Vec, + pub server_tool_inputs: HashMap, + pub tool_results: Vec, + pub current_server_tool_id: Option, + pub container_id: Option, +} + +impl AnthropicChatCompletionsStreamTransformer { + pub fn new( + _json_mode: bool, + _speed: Option, + _tool_name_reverse_map: HashMap, + ) -> Self { + todo!() + } + + pub fn check_empty_tool_call_args(&self) -> bool { + todo!() + } + + pub fn handle_usage(&mut self, _usage: AnthropicStreamUsage) -> ChatCompletionsUsage { + todo!() + } + + pub fn handle_content_block_delta( + &mut self, + _index: u64, + _delta: AnthropicContentBlockDelta, + ) -> ( + String, + Option, + Vec, + Option, + Option, + ) { + todo!() + } + + pub fn handle_content_block_start( + &mut self, + _index: u64, + _content_block: AnthropicContentBlock, + ) -> Result { + todo!() + } + + pub fn handle_json_mode_chunk( + &mut self, + _text: String, + _tool_use: Option, + ) -> (String, Option) { + todo!() + } + + pub fn handle_accumulated_json_chunk( + &mut self, + _data: &str, + _is_final: bool, + ) -> Result, Error> { + todo!() + } + + pub fn handle_redacted_thinking_content( + &mut self, + _content_block: &AnthropicContentBlock, + ) -> Vec { + todo!() + } + + pub fn web_search_call_snapshot(&self) -> HashMap { + todo!() + } + + pub fn complete_web_search_call(&mut self, _result: Value) { + todo!() + } + + pub fn build_code_interpreter_results(&self) -> Vec { + todo!() + } + + pub fn handle_message_delta( + &mut self, + _event: AnthropicMessagesStreamEvent, + ) -> (Option, Option, Option) { + todo!() + } + + pub fn chunk_parser( + &mut self, + _event: AnthropicMessagesStreamEvent, + ) -> Result { + todo!() + } +} + +impl StreamTransformer for AnthropicChatCompletionsStreamTransformer { + type Input = AnthropicMessagesStreamEvent; + type Output = ChatCompletionChunk; + type Error = Error; + + fn transform(&mut self, _input: Self::Input) -> Result, Self::Error> { + todo!() + } + + fn finish(&mut self) -> Result, Self::Error> { + todo!() + } +} diff --git a/litellm-rust/crates/core/src/providers/anthropic/messages/mod.rs b/litellm-rust/crates/core/src/providers/anthropic/messages/mod.rs index f239b6921fa..fa7df180f50 100644 --- a/litellm-rust/crates/core/src/providers/anthropic/messages/mod.rs +++ b/litellm-rust/crates/core/src/providers/anthropic/messages/mod.rs @@ -1 +1,2 @@ +pub mod streaming; pub mod transformation; diff --git a/litellm-rust/crates/core/src/providers/anthropic/messages/streaming.rs b/litellm-rust/crates/core/src/providers/anthropic/messages/streaming.rs new file mode 100644 index 00000000000..8b98ea3645b --- /dev/null +++ b/litellm-rust/crates/core/src/providers/anthropic/messages/streaming.rs @@ -0,0 +1,256 @@ +use base64::Engine; +use bytes::Buf; +use futures_util::{Stream, StreamExt}; +use litellm_framing::Framer; +use litellm_framing::aws_event_stream::{AwsEventStreamFrame, AwsEventStreamFramer}; +use litellm_framing::sse::{SseFrame, SseFramer}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +#[derive(Debug, thiserror::Error)] +pub enum AnthropicStreamDecodeError { + #[error("stream framing failed: {0}")] + Framing(#[from] litellm_framing::Error), + #[error("Anthropic SSE frame has no data")] + MissingSseData, + #[error("Anthropic stream event is invalid: {0}")] + InvalidEvent(#[from] serde_json::Error), + #[error("Bedrock event payload has invalid base64: {0}")] + InvalidBedrockPayload(#[from] base64::DecodeError), +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct AnthropicStreamUsage { + #[serde(default)] + pub input_tokens: u64, + #[serde(default)] + pub output_tokens: u64, + #[serde(default)] + pub cache_creation_input_tokens: u64, + #[serde(default)] + pub cache_read_input_tokens: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub server_tool_use: Option, + #[serde(flatten)] + pub extra: Map, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct AnthropicStreamMessage { + pub id: String, + #[serde(rename = "type")] + pub message_type: String, + pub role: String, + pub model: String, + pub content: Vec, + pub stop_reason: Option, + pub stop_sequence: Option, + pub usage: AnthropicStreamUsage, + #[serde(flatten)] + pub extra: Map, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum AnthropicContentBlockDelta { + TextDelta { text: String }, + InputJsonDelta { partial_json: String }, + Citations { citation: Value }, + ThinkingDelta { thinking: String }, + SignatureDelta { signature: String }, + CompactionDelta { content: String }, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct AnthropicContentBlock { + #[serde(rename = "type")] + pub block_type: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub text: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub thinking: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub signature: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub data: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub caller: Option, + #[serde(flatten)] + pub extra: Map, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct AnthropicMessageDelta { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stop_reason: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stop_sequence: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stop_details: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub container: Option, + #[serde(flatten)] + pub extra: Map, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct AnthropicStreamError { + #[serde(rename = "type")] + pub error_type: String, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub details: Option, + #[serde(flatten)] + pub extra: Map, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum AnthropicMessagesStreamEvent { + MessageStart { + message: AnthropicStreamMessage, + }, + ContentBlockStart { + index: u64, + content_block: AnthropicContentBlock, + }, + ContentBlockDelta { + index: u64, + delta: AnthropicContentBlockDelta, + }, + ContentBlockStop { + index: u64, + }, + MessageDelta { + delta: AnthropicMessageDelta, + #[serde(default, skip_serializing_if = "Option::is_none")] + usage: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + context_management: Option, + }, + MessageStop, + Ping, + Error { + error: AnthropicStreamError, + }, +} + +#[derive(Deserialize)] +struct BedrockChunkPayload { + bytes: String, +} + +pub fn decode_anthropic_sse_frame( + frame: SseFrame, +) -> Result { + let data = frame + .data + .ok_or(AnthropicStreamDecodeError::MissingSseData)?; + Ok(serde_json::from_str(&data)?) +} + +pub fn decode_bedrock_anthropic_frame( + frame: AwsEventStreamFrame, +) -> Result { + let payload: BedrockChunkPayload = serde_json::from_slice(&frame.payload)?; + let event = base64::engine::general_purpose::STANDARD.decode(payload.bytes)?; + Ok(serde_json::from_slice(&event)?) +} + +pub fn direct_anthropic_event_stream( + input: S, +) -> impl Stream> + Send +where + S: Stream> + Send, + B: Buf + Send, + E: std::error::Error + Send + Sync + 'static, +{ + SseFramer + .frame(input) + .map(|frame| decode_anthropic_sse_frame(frame?)) +} + +pub fn bedrock_anthropic_event_stream( + input: S, +) -> impl Stream> + Send +where + S: Stream> + Send, + B: Buf + Send, + E: std::error::Error + Send + Sync + 'static, +{ + AwsEventStreamFramer + .frame(input) + .map(|frame| decode_bedrock_anthropic_frame(frame?)) +} + +#[cfg(test)] +mod tests { + use std::io; + + use aws_smithy_eventstream::frame::write_message_to; + use aws_smithy_types::event_stream::{Header, HeaderValue, Message}; + use base64::engine::general_purpose::STANDARD; + use bytes::Bytes; + use futures_util::TryStreamExt; + + use super::*; + + const TEXT_DELTA: &str = + r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello"}}"#; + + #[tokio::test] + async fn direct_anthropic_sse_frames_into_typed_events() { + let wire = format!("event: content_block_delta\ndata: {TEXT_DELTA}\n\n"); + let events = direct_anthropic_event_stream(futures_util::stream::iter( + wire.as_bytes().chunks(3).map(Ok::<_, io::Error>), + )) + .try_collect::>() + .await + .unwrap(); + + assert_eq!( + events, + vec![AnthropicMessagesStreamEvent::ContentBlockDelta { + index: 0, + delta: AnthropicContentBlockDelta::TextDelta { + text: "hello".into(), + }, + }] + ); + } + + #[tokio::test] + async fn bedrock_aws_frames_into_the_same_typed_events() { + let payload = serde_json::json!({"bytes": STANDARD.encode(TEXT_DELTA)}); + let message = Message::new(Bytes::from(serde_json::to_vec(&payload).unwrap())).add_header( + Header::new(":event-type", HeaderValue::String("chunk".into())), + ); + let mut wire = Vec::new(); + write_message_to(&message, &mut wire).unwrap(); + + let events = bedrock_anthropic_event_stream(futures_util::stream::iter( + wire.chunks(3).map(Ok::<_, io::Error>), + )) + .try_collect::>() + .await + .unwrap(); + + assert_eq!( + events, + vec![AnthropicMessagesStreamEvent::ContentBlockDelta { + index: 0, + delta: AnthropicContentBlockDelta::TextDelta { + text: "hello".into(), + }, + }] + ); + } +}