From 4cb47edd2a2d6d56c4dc6dbe99fa8585eeb790dd Mon Sep 17 00:00:00 2001 From: yujonglee Date: Fri, 9 Oct 2026 16:29:46 -0700 Subject: [PATCH] feat(rust): add the DeepSeek Anthropic Messages config (#45614) * feat(rust): add the DeepSeek Anthropic Messages config * refactor(rust): drop the tool discriminator without mutation and use rstest in DeepSeek tests --- .../core-utils/src/get_llm_provider_logic.rs | 2 + .../gateway-inference/tests/messages.rs | 1 + .../inference-messages/src/common_utils.rs | 6 + .../tests/messages/request.rs | 49 ++ .../tests/messages/secrets.rs | 7 + .../src/anthropic/messages/transformation.rs | 2 +- .../crates/llms/src/deepseek/messages/mod.rs | 1 + .../src/deepseek/messages/transformation.rs | 458 ++++++++++++++++++ litellm-rust/crates/llms/src/deepseek/mod.rs | 1 + litellm-rust/crates/llms/src/lib.rs | 1 + litellm/rust_bridge/catalog.py | 2 +- tests/unit/rust_bridge/test_catalog.py | 4 +- 12 files changed, 530 insertions(+), 4 deletions(-) create mode 100644 litellm-rust/crates/llms/src/deepseek/messages/mod.rs create mode 100644 litellm-rust/crates/llms/src/deepseek/messages/transformation.rs create mode 100644 litellm-rust/crates/llms/src/deepseek/mod.rs diff --git a/litellm-rust/crates/core-utils/src/get_llm_provider_logic.rs b/litellm-rust/crates/core-utils/src/get_llm_provider_logic.rs index a2bb462c2a9..6a82511db5c 100644 --- a/litellm-rust/crates/core-utils/src/get_llm_provider_logic.rs +++ b/litellm-rust/crates/core-utils/src/get_llm_provider_logic.rs @@ -18,6 +18,8 @@ pub enum LlmProviders { Bedrock, #[strum(serialize = "cohere")] Cohere, + #[strum(serialize = "deepseek")] + Deepseek, #[strum(serialize = "mistral")] Mistral, #[strum(serialize = "openai")] diff --git a/litellm-rust/crates/gateway-inference/tests/messages.rs b/litellm-rust/crates/gateway-inference/tests/messages.rs index 818fdab27f2..be500bad86c 100644 --- a/litellm-rust/crates/gateway-inference/tests/messages.rs +++ b/litellm-rust/crates/gateway-inference/tests/messages.rs @@ -16,6 +16,7 @@ use wiremock::{ #[rstest] #[case::anthropic("anthropic/test-model", "/v1/messages", true)] #[case::azure("azure_ai/test-model", "/anthropic/v1/messages", false)] +#[case::deepseek("deepseek/test-model", "/anthropic/v1/messages", false)] #[tokio::test] async fn messages_reaches_the_provider_and_preserves_json_or_sse( #[case] model: &str, diff --git a/litellm-rust/crates/inference-messages/src/common_utils.rs b/litellm-rust/crates/inference-messages/src/common_utils.rs index d61761b9348..ea8c7cca4f7 100644 --- a/litellm-rust/crates/inference-messages/src/common_utils.rs +++ b/litellm-rust/crates/inference-messages/src/common_utils.rs @@ -6,6 +6,7 @@ use litellm_llms::{ azure_ai::messages::transformation::AZURE_ANTHROPIC_MESSAGES_CONFIG, base_llm::messages::transformation::BaseMessagesConfig, bedrock::messages::invoke_transformations::anthropic_claude3_transformation::BEDROCK_ANTHROPIC_MESSAGES_CONFIG, + deepseek::messages::transformation::DEEPSEEK_ANTHROPIC_MESSAGES_CONFIG, vertex_ai::messages::transformation::VERTEX_ANTHROPIC_MESSAGES_CONFIG, }; use serde_json::{Map, Value}; @@ -19,6 +20,7 @@ pub(crate) enum MessagesProvider { Anthropic, AzureAi, Bedrock, + Deepseek, VertexAi, } @@ -28,6 +30,7 @@ impl MessagesProvider { Self::Anthropic => LlmProviders::Anthropic, Self::AzureAi => LlmProviders::AzureAi, Self::Bedrock => LlmProviders::Bedrock, + Self::Deepseek => LlmProviders::Deepseek, Self::VertexAi => LlmProviders::VertexAi, } .into() @@ -38,6 +41,7 @@ impl MessagesProvider { Self::Anthropic => &ANTHROPIC_MESSAGES_CONFIG, Self::AzureAi => &AZURE_ANTHROPIC_MESSAGES_CONFIG, Self::Bedrock => &BEDROCK_ANTHROPIC_MESSAGES_CONFIG, + Self::Deepseek => &DEEPSEEK_ANTHROPIC_MESSAGES_CONFIG, Self::VertexAi => &VERTEX_ANTHROPIC_MESSAGES_CONFIG, } } @@ -50,6 +54,7 @@ pub(crate) fn messages_provider(provider: LlmProviders, model: &str) -> Option Some(MessagesProvider::Anthropic), LlmProviders::AzureAi => Some(MessagesProvider::AzureAi), LlmProviders::Bedrock => Some(MessagesProvider::Bedrock), + LlmProviders::Deepseek => Some(MessagesProvider::Deepseek), LlmProviders::VertexAi if model.to_ascii_lowercase().contains("claude") => { Some(MessagesProvider::VertexAi) } @@ -77,6 +82,7 @@ mod tests { #[case::anthropic("anthropic", MessagesProvider::Anthropic)] #[case::azure_ai("azure_ai", MessagesProvider::AzureAi)] #[case::bedrock("bedrock", MessagesProvider::Bedrock)] + #[case::deepseek("deepseek", MessagesProvider::Deepseek)] #[case::vertex_ai("vertex_ai", MessagesProvider::VertexAi)] fn provider_round_trips_through_its_python_name( #[case] name: &str, diff --git a/litellm-rust/crates/inference-messages/tests/messages/request.rs b/litellm-rust/crates/inference-messages/tests/messages/request.rs index b1a989713ec..25a318ecad5 100644 --- a/litellm-rust/crates/inference-messages/tests/messages/request.rs +++ b/litellm-rust/crates/inference-messages/tests/messages/request.rs @@ -12,6 +12,14 @@ use super::*; #[rstest] #[case::anthropic_key("anthropic", Some("sk-ant"), &[], ("x-api-key", "sk-ant"), &["authorization"])] #[case::azure_key("azure_ai", Some("sk-azure"), &[], ("x-api-key", "sk-azure"), &["authorization"])] +#[case::deepseek_key("deepseek", Some("sk-deepseek"), &[], ("x-api-key", "sk-deepseek"), &["authorization"])] +#[case::deepseek_forwards_caller_authorization( + "deepseek", + Some("sk-deepseek"), + &[("Authorization", "Bearer caller")], + ("authorization", "Bearer caller"), + &["x-api-key"] +)] #[case::caller_x_api_key_wins( "azure_ai", Some("rust-fallback-key"), @@ -79,6 +87,7 @@ async fn credentials_become_exactly_one_auth_header( #[rstest] #[case::anthropic("anthropic")] #[case::azure_ai("azure_ai")] +#[case::deepseek("deepseek")] #[tokio::test] async fn a_call_without_credentials_fails_before_sending( call: MessagesCall, @@ -115,6 +124,8 @@ async fn a_call_without_credentials_fails_before_sending( "/v1/messages" )] #[case::azure_ai(MODEL, Some("azure_ai"), "", "/anthropic/v1/messages")] +#[case::deepseek(MODEL, Some("deepseek"), "", "/anthropic/v1/messages")] +#[case::deepseek_openai_compatible_base(MODEL, Some("deepseek"), "/beta", "/anthropic/v1/messages")] #[case::provider_from_model_prefix("anthropic/claude-sonnet-4-5", None, "", "/v1/messages")] #[tokio::test] async fn each_provider_posts_to_its_messages_endpoint( @@ -350,6 +361,7 @@ async fn an_oauth_key_sends_the_browser_access_header_and_the_oauth_beta(call: M #[rstest] #[case::anthropic("anthropic")] #[case::azure_ai("azure_ai")] +#[case::deepseek("deepseek")] #[tokio::test] async fn caller_protocol_headers_win_over_the_defaults(call: MessagesCall, #[case] provider: &str) { let upstream = upstream([message_response()]).await; @@ -579,6 +591,7 @@ async fn replayed_history_is_cleaned_before_sending( #[rstest] #[case::anthropic("anthropic")] #[case::azure("azure_ai")] +#[case::deepseek("deepseek")] #[tokio::test] async fn metadata_is_reduced_to_the_user_id(call: MessagesCall, #[case] provider: &str) { let upstream = upstream([message_response()]).await; @@ -601,6 +614,42 @@ async fn metadata_is_reduced_to_the_user_id(call: MessagesCall, #[case] provider ); } +#[rstest] +#[tokio::test] +async fn deepseek_sends_neither_billing_blocks_nor_the_custom_tool_discriminator( + call: MessagesCall, +) { + let upstream = upstream([message_response()]).await; + + run_message(with_fields( + MessagesCall { + custom_llm_provider: Some("deepseek".into()), + litellm_params: Default::default(), + api_key: Some("sk".into()), + api_base: Some(upstream.uri()), + ..call + }, + json!({ + "system": [ + {"type": "text", "text": "x-anthropic-billing-header: cc_version=1"}, + {"type": "text", "text": "be terse"} + ], + "tools": [{"type": "custom", "name": "get_weather", "input_schema": {"type": "object"}}] + }), + )) + .await; + + let body = only_request(&upstream).await.json(); + assert_eq!( + body["system"], + json!([{"type": "text", "text": "be terse"}]) + ); + assert_eq!( + body["tools"], + json!([{"name": "get_weather", "input_schema": {"type": "object"}}]) + ); +} + #[rstest] #[case::not_streaming(false, ":rawPredict")] #[case::streaming(true, ":streamRawPredict?alt=sse")] diff --git a/litellm-rust/crates/inference-messages/tests/messages/secrets.rs b/litellm-rust/crates/inference-messages/tests/messages/secrets.rs index a33fdd130f1..203cc620d35 100644 --- a/litellm-rust/crates/inference-messages/tests/messages/secrets.rs +++ b/litellm-rust/crates/inference-messages/tests/messages/secrets.rs @@ -18,6 +18,13 @@ use super::*; "/anthropic/v1/messages", &["AZURE_API_KEY", "AZURE_API_BASE"] )] +#[case::deepseek( + "deepseek", + "DEEPSEEK_API_KEY", + "DEEPSEEK_API_BASE", + "/anthropic/v1/messages", + &["DEEPSEEK_API_KEY", "DEEPSEEK_ANTHROPIC_API_BASE", "DEEPSEEK_API_BASE"] +)] #[tokio::test] async fn the_credential_and_base_come_from_the_secret_source( call: MessagesCall, diff --git a/litellm-rust/crates/llms/src/anthropic/messages/transformation.rs b/litellm-rust/crates/llms/src/anthropic/messages/transformation.rs index 7021a4199df..f83b477471b 100644 --- a/litellm-rust/crates/llms/src/anthropic/messages/transformation.rs +++ b/litellm-rust/crates/llms/src/anthropic/messages/transformation.rs @@ -156,7 +156,7 @@ pub(crate) fn update_headers_with_anthropic_beta( /// The betas a request needs on a host other than the first-party API: the Anthropic set /// filtered and renamed through the host's `BetaProvider` policy. -pub fn provider_feature_betas(request: &MessagesRequest, provider: BetaProvider) -> BetaSet { +pub(crate) fn provider_feature_betas(request: &MessagesRequest, provider: BetaProvider) -> BetaSet { feature_betas(request) .iter() .filter_map(|beta| beta.on(provider)) diff --git a/litellm-rust/crates/llms/src/deepseek/messages/mod.rs b/litellm-rust/crates/llms/src/deepseek/messages/mod.rs new file mode 100644 index 00000000000..f239b6921fa --- /dev/null +++ b/litellm-rust/crates/llms/src/deepseek/messages/mod.rs @@ -0,0 +1 @@ +pub mod transformation; diff --git a/litellm-rust/crates/llms/src/deepseek/messages/transformation.rs b/litellm-rust/crates/llms/src/deepseek/messages/transformation.rs new file mode 100644 index 00000000000..43db1529cc8 --- /dev/null +++ b/litellm-rust/crates/llms/src/deepseek/messages/transformation.rs @@ -0,0 +1,458 @@ +use litellm_auth::{CredentialPlacement, SecretValue}; +use litellm_core_utils::settings::resolve_non_empty; +use litellm_llms_types::{ + formats::messages::{MessagesOptionalParams, MessagesRequest, MessagesTool}, + recognized::Recognized, +}; +use litellm_router_types::LitellmParams; +use serde_json::Value; + +use crate::{ + Error, + anthropic::{ + common_utils::{filter_billing_headers_from_system, has_anthropic_credential}, + messages::{ + handler::shape_anthropic_messages_request, + transformation::{ + DEFAULT_HEADERS, transform_messages_request, update_headers_with_anthropic_beta, + }, + }, + }, + base_llm::{ + auth::{AuthScheme, Headers, ValidatedEnvironment}, + messages::{ + context::MessagesTransformContext, + transformation::{BaseMessagesConfig, MESSAGES_PATH_SUFFIX}, + }, + }, +}; + +pub const DEEPSEEK_API_KEY_ENV: &str = "DEEPSEEK_API_KEY"; +pub const DEEPSEEK_ANTHROPIC_API_BASE_ENV: &str = "DEEPSEEK_ANTHROPIC_API_BASE"; +pub const DEEPSEEK_API_BASE_ENV: &str = "DEEPSEEK_API_BASE"; +pub const DEFAULT_DEEPSEEK_ANTHROPIC_API_BASE: &str = "https://api.deepseek.com/anthropic"; + +const ANTHROPIC_PATH_SEGMENT: &str = "/anthropic"; +const CUSTOM_TOOL_TYPE: &str = "custom"; + +/// DeepSeek's Anthropic-compatible Messages API: the Anthropic payload shaping, a key in +/// `x-api-key`, and a host that rejects the explicit `custom` tool discriminator and the +/// first-party billing system blocks. +pub struct DeepSeekAnthropicMessagesConfig; + +pub const DEEPSEEK_ANTHROPIC_MESSAGES_CONFIG: DeepSeekAnthropicMessagesConfig = + DeepSeekAnthropicMessagesConfig; + +impl BaseMessagesConfig for DeepSeekAnthropicMessagesConfig { + fn shape_request( + &self, + request: MessagesRequest, + reasoning_auto_summary: bool, + ) -> Result { + shape_anthropic_messages_request(request, reasoning_auto_summary) + } + + fn get_complete_url( + &self, + api_base: Option<&str>, + _model: &str, + _litellm_params: &LitellmParams, + _stream: bool, + env_lookup: &dyn Fn(&str) -> Option, + ) -> Result { + Ok(complete_deepseek_anthropic_url(api_base, env_lookup)) + } + + fn transform_anthropic_messages_request( + &self, + request: MessagesRequest, + context: &MessagesTransformContext, + ) -> Result { + let request = transform_messages_request( + MessagesRequest { + params: MessagesOptionalParams { + system: request + .params + .system + .and_then(filter_billing_headers_from_system), + ..request.params + }, + ..request + }, + context, + )?; + Ok(MessagesRequest { + params: MessagesOptionalParams { + tools: request + .params + .tools + .map(|tools| tools.into_iter().map(sanitize_tool).collect()), + ..request.params + }, + ..request + }) + } + + fn secret_names(&self) -> &'static [&'static str] { + &[ + DEEPSEEK_API_KEY_ENV, + DEEPSEEK_ANTHROPIC_API_BASE_ENV, + DEEPSEEK_API_BASE_ENV, + ] + } + + /// A forwarded `x-api-key` or `authorization` is the credential; otherwise the DeepSeek + /// key goes in `x-api-key`. + fn validate_environment( + &self, + headers: Headers, + api_key: Option<&str>, + _model: &str, + _litellm_params: &LitellmParams, + env_lookup: &dyn Fn(&str) -> Option, + ) -> Result { + if has_anthropic_credential(&headers) { + return Ok(ValidatedEnvironment { + headers, + auth: AuthScheme::Forwarded, + }); + } + let key = get_api_key(api_key, env_lookup).ok_or(Error::Auth( + litellm_auth::Error::MissingApiKey { + provider: "DeepSeek", + environment_variable: DEEPSEEK_API_KEY_ENV, + }, + ))?; + Ok(ValidatedEnvironment { + headers, + auth: AuthScheme::Credential { + placement: CredentialPlacement::Header("x-api-key"), + secret: SecretValue::new(key), + }, + }) + } + + fn default_headers(&self) -> &'static [(&'static str, &'static str)] { + DEFAULT_HEADERS + } + + fn request_headers(&self, headers: Headers, request: &MessagesRequest) -> Headers { + update_headers_with_anthropic_beta(headers, request) + } +} + +pub fn get_api_key( + api_key: Option<&str>, + env_lookup: &dyn Fn(&str) -> Option, +) -> Option { + resolve_non_empty(api_key, env_lookup, &[DEEPSEEK_API_KEY_ENV]) +} + +pub fn get_api_base(api_base: Option<&str>, env_lookup: &dyn Fn(&str) -> Option) -> String { + resolve_non_empty( + api_base, + env_lookup, + &[DEEPSEEK_ANTHROPIC_API_BASE_ENV, DEEPSEEK_API_BASE_ENV], + ) + .unwrap_or_else(|| DEFAULT_DEEPSEEK_ANTHROPIC_API_BASE.to_string()) +} + +/// Python's `get_complete_url`: a base already ending in the Anthropic messages path is kept, +/// otherwise the OpenAI compatible suffixes are peeled off and `/anthropic/v1/messages` is +/// appended once. +pub fn complete_deepseek_anthropic_url( + api_base: Option<&str>, + env_lookup: &dyn Fn(&str) -> Option, +) -> String { + let api_base = get_api_base(api_base, env_lookup); + let api_base = api_base.trim_end_matches('/'); + if api_base.ends_with(MESSAGES_PATH_SUFFIX) && api_base.contains("/anthropic/") { + return api_base.to_string(); + } + let api_base = [MESSAGES_PATH_SUFFIX, "/v1", "/beta"] + .into_iter() + .fold(api_base, |base, suffix| { + base.strip_suffix(suffix).unwrap_or(base) + }); + if api_base.ends_with(ANTHROPIC_PATH_SEGMENT) || api_base.contains("/anthropic/") { + return format!("{api_base}{MESSAGES_PATH_SUFFIX}"); + } + format!("{api_base}{ANTHROPIC_PATH_SEGMENT}{MESSAGES_PATH_SUFFIX}") +} + +/// DeepSeek rejects Anthropic's explicit `{"type": "custom"}` tool discriminator, so it is +/// dropped; every other tool is sent as given. +fn sanitize_tool(tool: Recognized) -> Recognized { + match tool { + Recognized::Unrecognized(Value::Object(fields)) + if fields.get("type").and_then(Value::as_str) == Some(CUSTOM_TOOL_TYPE) => + { + Recognized::Unrecognized(Value::Object( + fields + .into_iter() + .filter(|(name, _)| name != "type") + .collect(), + )) + } + other => other, + } +} + +#[cfg(test)] +mod tests { + use litellm_auth::CredentialPlacement; + use rstest::rstest; + use serde_json::{Value, json}; + + use super::*; + + fn no_env(_: &str) -> Option { + None + } + + fn request_from(value: Value) -> MessagesRequest { + serde_json::from_value(value).unwrap() + } + + fn transformed(value: Value) -> Value { + serde_json::to_value( + DEEPSEEK_ANTHROPIC_MESSAGES_CONFIG + .transform_anthropic_messages_request( + request_from(value), + &MessagesTransformContext::default(), + ) + .unwrap(), + ) + .unwrap() + } + + #[rstest] + #[case::no_base(None)] + #[case::anthropic_v1("https://api.deepseek.com/anthropic/v1")] + #[case::anthropic("https://api.deepseek.com/anthropic")] + #[case::anthropic_with_trailing_slash("https://api.deepseek.com/anthropic/")] + #[case::host_only("https://api.deepseek.com")] + #[case::openai_v1("https://api.deepseek.com/v1")] + #[case::openai_messages("https://api.deepseek.com/v1/messages")] + #[case::openai_beta("https://api.deepseek.com/beta")] + #[case::complete("https://api.deepseek.com/anthropic/v1/messages")] + fn every_base_spelling_reaches_the_anthropic_messages_endpoint( + #[case] api_base: impl Into>, + ) { + assert_eq!( + complete_deepseek_anthropic_url(api_base.into(), &no_env), + "https://api.deepseek.com/anthropic/v1/messages" + ); + } + + #[rstest] + #[case::proxy_under_anthropic_prefix( + "https://proxy.example/anthropic/v1/messages", + "https://proxy.example/anthropic/v1/messages" + )] + #[case::messages_path_without_the_anthropic_segment_is_rebuilt( + "https://proxy.example/v1/messages", + "https://proxy.example/anthropic/v1/messages" + )] + #[case::anthropic_segment_in_the_middle_is_kept( + "https://proxy.example/anthropic/tenant", + "https://proxy.example/anthropic/tenant/v1/messages" + )] + fn custom_bases_follow_the_python_url_rules(#[case] api_base: &str, #[case] expected: &str) { + assert_eq!( + complete_deepseek_anthropic_url(Some(api_base), &no_env), + expected + ); + } + + #[rstest] + #[case::anthropic_base_wins( + &[("DEEPSEEK_ANTHROPIC_API_BASE", "https://anthropic.example"), ("DEEPSEEK_API_BASE", "https://openai.example/v1")], + "https://anthropic.example/anthropic/v1/messages" + )] + #[case::openai_base_is_the_fallback( + &[("DEEPSEEK_API_BASE", "https://openai.example/v1")], + "https://openai.example/anthropic/v1/messages" + )] + #[case::blank_env_is_absent( + &[("DEEPSEEK_ANTHROPIC_API_BASE", " "), ("DEEPSEEK_API_BASE", "")], + "https://api.deepseek.com/anthropic/v1/messages" + )] + fn the_base_comes_from_the_environment_in_python_order( + #[case] env: &[(&str, &str)], + #[case] expected: &str, + ) { + let lookup = |name: &str| { + env.iter() + .find(|(key, _)| *key == name) + .map(|(_, value)| value.to_string()) + }; + assert_eq!(complete_deepseek_anthropic_url(None, &lookup), expected); + } + + fn validated( + forwarded: &[(&str, &str)], + api_key: Option<&str>, + env: &dyn Fn(&str) -> Option, + ) -> Result { + DEEPSEEK_ANTHROPIC_MESSAGES_CONFIG.validate_environment( + forwarded + .iter() + .map(|(name, value)| (name.to_string(), value.to_string())) + .collect(), + api_key, + "deepseek-v4-pro", + &LitellmParams::default(), + env, + ) + } + + #[rstest] + #[case::param(Some("sk-deepseek"), &[], "sk-deepseek")] + #[case::env(None, &[("DEEPSEEK_API_KEY", "sk-env")], "sk-env")] + #[case::blank_param_falls_back_to_env(Some(" "), &[("DEEPSEEK_API_KEY", "sk-env")], "sk-env")] + fn the_deepseek_key_goes_in_x_api_key( + #[case] api_key: Option<&str>, + #[case] env: &[(&str, &str)], + #[case] expected: &str, + ) { + let lookup = |name: &str| { + env.iter() + .find(|(key, _)| *key == name) + .map(|(_, value)| value.to_string()) + }; + let validated = validated(&[], api_key, &lookup).unwrap(); + assert!(matches!( + validated.auth, + AuthScheme::Credential { + placement: CredentialPlacement::Header("x-api-key"), + ref secret + } if secret.expose() == expected + )); + assert!(validated.headers.is_empty()); + } + + #[rstest] + #[case::x_api_key(&[("X-Api-Key", "caller")])] + #[case::bearer(&[("Authorization", "Bearer caller")])] + fn a_forwarded_credential_is_sent_as_is(#[case] forwarded: &[(&str, &str)]) { + assert!(matches!( + validated(forwarded, Some("sk-deepseek"), &no_env) + .unwrap() + .auth, + AuthScheme::Forwarded + )); + } + + #[rstest] + fn a_call_without_a_key_names_the_deepseek_variable() { + assert!(matches!( + validated(&[], None, &no_env).unwrap_err(), + Error::Auth(litellm_auth::Error::MissingApiKey { + provider: "DeepSeek", + environment_variable: "DEEPSEEK_API_KEY", + }) + )); + } + + #[rstest] + fn secret_names_cover_the_key_and_both_bases() { + assert_eq!( + DEEPSEEK_ANTHROPIC_MESSAGES_CONFIG.secret_names(), + &[ + "DEEPSEEK_API_KEY", + "DEEPSEEK_ANTHROPIC_API_BASE", + "DEEPSEEK_API_BASE" + ] + ); + } + + #[rstest] + fn default_headers_are_anthropic_version_and_json() { + assert_eq!( + DEEPSEEK_ANTHROPIC_MESSAGES_CONFIG.default_headers(), + &[ + ("anthropic-version", "2023-06-01"), + ("content-type", "application/json"), + ] + ); + } + + #[rstest] + fn the_custom_discriminator_is_dropped_and_other_tools_are_kept() { + let body = transformed(json!({ + "model": "deepseek-v4-pro", + "max_tokens": 100, + "messages": [{"role": "user", "content": "Use the tool."}], + "tools": [ + {"type": "custom", "name": "get_weather", "description": "Get weather", "input_schema": {"type": "object"}}, + {"name": "untyped", "input_schema": {"type": "object"}}, + {"type": "web_search_20260209", "name": "web_search", "max_uses": 1}, + {"type": "tool_search_tool_regex_20251119", "name": "tool_search_tool_regex"} + ] + })); + assert_eq!( + body["tools"], + json!([ + {"name": "get_weather", "description": "Get weather", "input_schema": {"type": "object"}}, + {"name": "untyped", "input_schema": {"type": "object"}}, + {"type": "web_search_20260209", "name": "web_search", "max_uses": 1}, + {"type": "tool_search_tool_regex_20251119", "name": "tool_search_tool_regex"} + ]) + ); + } + + #[rstest] + #[case::billing_block_among_others( + json!([ + {"type": "text", "text": "x-anthropic-billing-header: cc_version=1"}, + {"type": "text", "text": "real system prompt"} + ]), + Some(json!([{"type": "text", "text": "real system prompt"}])) + )] + #[case::only_billing_blocks( + json!([{"type": "text", "text": "x-anthropic-billing-header: cc_version=1"}]), + None + )] + #[case::billing_string(json!("x-anthropic-billing-header: cc_version=1"), None)] + #[case::ordinary_string(json!("be terse"), Some(json!("be terse")))] + #[case::billing_text_in_a_non_text_block_is_kept( + json!([{"type": "other", "text": "x-anthropic-billing-header: cc_version=1"}]), + Some(json!([{"type": "other", "text": "x-anthropic-billing-header: cc_version=1"}])) + )] + fn billing_metadata_is_stripped_from_the_system_prompt( + #[case] system: Value, + #[case] expected: Option, + ) { + let body = transformed(json!({ + "model": "deepseek-v4-pro", + "max_tokens": 100, + "system": system, + "messages": [{"role": "user", "content": "hi"}] + })); + assert_eq!(body.get("system").cloned(), expected); + } + + #[rstest] + fn thinking_history_and_params_pass_through() { + let messages = json!([ + {"role": "user", "content": "Use the tool."}, + {"role": "assistant", "content": [ + {"type": "thinking", "thinking": "I should call the tool.", "signature": "sig"}, + {"type": "tool_use", "id": "toolu_123", "name": "get_weather", "input": {"city": "Sao Paulo"}} + ]}, + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_123", "content": "Sunny"}]} + ]); + let body = transformed(json!({ + "model": "deepseek-v4-pro", + "max_tokens": 100, + "thinking": {"type": "enabled", "budget_tokens": 1024}, + "messages": messages + })); + assert_eq!(body["messages"], messages); + assert_eq!( + body["thinking"], + json!({"type": "enabled", "budget_tokens": 1024}) + ); + } +} diff --git a/litellm-rust/crates/llms/src/deepseek/mod.rs b/litellm-rust/crates/llms/src/deepseek/mod.rs new file mode 100644 index 00000000000..ba63992f3cb --- /dev/null +++ b/litellm-rust/crates/llms/src/deepseek/mod.rs @@ -0,0 +1 @@ +pub mod messages; diff --git a/litellm-rust/crates/llms/src/lib.rs b/litellm-rust/crates/llms/src/lib.rs index 957cae95df6..9c62be44fc2 100644 --- a/litellm-rust/crates/llms/src/lib.rs +++ b/litellm-rust/crates/llms/src/lib.rs @@ -4,6 +4,7 @@ pub mod azure_ai; pub mod base_llm; pub mod bedrock; pub mod cohere; +pub mod deepseek; mod error; pub mod mistral; pub mod openai; diff --git a/litellm/rust_bridge/catalog.py b/litellm/rust_bridge/catalog.py index 5024e58b4ca..dda9ed0b566 100644 --- a/litellm/rust_bridge/catalog.py +++ b/litellm/rust_bridge/catalog.py @@ -70,7 +70,7 @@ RULES: Final[Rules] = ( RouteRule(Route.CHAT_COMPLETIONS, Rollout.PYTHON_ONLY), RouteRule(Route.EMBEDDINGS, Rollout.PYTHON_ONLY), RouteRule(Route.OCR, Rollout.RUST_REQUIRED), - RouteRule(Route.MESSAGES, Rollout.RUST_OPT_IN, providers=frozenset({"anthropic", "vertex_ai"})), + RouteRule(Route.MESSAGES, Rollout.RUST_OPT_IN, providers=frozenset({"anthropic", "deepseek", "vertex_ai"})), RouteRule(Route.MESSAGES, Rollout.PYTHON_ONLY), RouteRule(Route.RESPONSES, Rollout.PYTHON_ONLY), RouteRule(Route.TOKEN_COUNTER, Rollout.PYTHON_ONLY), diff --git a/tests/unit/rust_bridge/test_catalog.py b/tests/unit/rust_bridge/test_catalog.py index ea3e61ecc3d..1ba95507d03 100644 --- a/tests/unit/rust_bridge/test_catalog.py +++ b/tests/unit/rust_bridge/test_catalog.py @@ -28,7 +28,7 @@ def isolated_configuration(monkeypatch: pytest.MonkeyPatch) -> Generator[None]: @pytest.mark.parametrize("route", tuple(Route)) @pytest.mark.parametrize( - "provider", (None, "bedrock", "mistral", "anthropic", "vertex_ai", "openai", "azure_ai", "unknown") + "provider", (None, "bedrock", "mistral", "anthropic", "deepseek", "vertex_ai", "openai", "azure_ai", "unknown") ) @pytest.mark.parametrize("process", (None, False, True)) @pytest.mark.parametrize("environment", (None, "0", "1")) @@ -47,7 +47,7 @@ def test_shipped_decisions( if route is Route.OCR or (route is Route.TRANSCRIPTION and provider == "bedrock"): assert catalog.rollout(context) is Rollout.RUST_REQUIRED assert catalog.decision(context) is Decision.RUST_REQUIRED - elif route is Route.MESSAGES and provider in ("anthropic", "vertex_ai"): + elif route is Route.MESSAGES and provider in ("anthropic", "deepseek", "vertex_ai"): assert catalog.rollout(context) is Rollout.RUST_OPT_IN opted_in: Final = environment == "1" or (environment is None and process is True) assert catalog.decision(context) is (Decision.RUST_WITH_FALLBACK if opted_in else Decision.PYTHON)