From 2e0b4621300f3fe6fff2f3f0ddc8db89f7bd69b4 Mon Sep 17 00:00:00 2001 From: Aastha Joshi Date: Tue, 11 Aug 2026 00:55:48 -0700 Subject: [PATCH] feat(rust): add DeepSeek messages provider parity --- .../crates/core/src/messages/common_utils.rs | 2 + .../crates/core/src/messages/prepare.rs | 1 + .../crates/core/src/messages/tests.rs | 154 +++++++++ .../core/src/messages/transformation.rs | 4 + .../src/providers/deepseek/messages/mod.rs | 1 + .../deepseek/messages/transformation.rs | 315 ++++++++++++++++++ .../crates/core/src/providers/deepseek/mod.rs | 1 + litellm-rust/crates/core/src/providers/mod.rs | 1 + 8 files changed, 479 insertions(+) create mode 100644 litellm-rust/crates/core/src/providers/deepseek/messages/mod.rs create mode 100644 litellm-rust/crates/core/src/providers/deepseek/messages/transformation.rs create mode 100644 litellm-rust/crates/core/src/providers/deepseek/mod.rs diff --git a/litellm-rust/crates/core/src/messages/common_utils.rs b/litellm-rust/crates/core/src/messages/common_utils.rs index 9dcfcaa71e3..9a47b14a0cf 100644 --- a/litellm-rust/crates/core/src/messages/common_utils.rs +++ b/litellm-rust/crates/core/src/messages/common_utils.rs @@ -4,6 +4,7 @@ use crate::constants::MESSAGES_ERROR_BODY_MAX_CHARS; use crate::error::{CoreError, CoreResult, json_type_name}; use crate::providers::anthropic::messages::transformation::ANTHROPIC_MESSAGES_CONFIG; use crate::providers::azure_ai::messages::transformation::AZURE_ANTHROPIC_MESSAGES_CONFIG; +use crate::providers::deepseek::messages::transformation::DEEPSEEK_ANTHROPIC_MESSAGES_CONFIG; use super::transformation::AnthropicMessagesProviderConfig; @@ -21,6 +22,7 @@ pub(super) fn messages_provider_config( match provider { "anthropic" => Some(&ANTHROPIC_MESSAGES_CONFIG), "azure_ai" => Some(&AZURE_ANTHROPIC_MESSAGES_CONFIG), + "deepseek" => Some(&DEEPSEEK_ANTHROPIC_MESSAGES_CONFIG), _ => None, } } diff --git a/litellm-rust/crates/core/src/messages/prepare.rs b/litellm-rust/crates/core/src/messages/prepare.rs index 94b5b1eaed7..3a09e273bae 100644 --- a/litellm-rust/crates/core/src/messages/prepare.rs +++ b/litellm-rust/crates/core/src/messages/prepare.rs @@ -33,6 +33,7 @@ pub(super) fn prepare_messages_call( let auth_strategy = config.auth_strategy(); let already_authorized = has_header(&headers, auth_strategy.header_name()) + || (config.accepts_authorization_header() && has_header(&headers, "authorization")) || (config.accepts_bearer_auth() && has_bearer_auth(&headers)); if !already_authorized { let api_key = config.resolve_api_key(request.api_key, &env_lookup)?; diff --git a/litellm-rust/crates/core/src/messages/tests.rs b/litellm-rust/crates/core/src/messages/tests.rs index 9fc1763683b..0059e62f1c6 100644 --- a/litellm-rust/crates/core/src/messages/tests.rs +++ b/litellm-rust/crates/core/src/messages/tests.rs @@ -439,3 +439,157 @@ async fn messages_rejects_unsupported_provider() { assert!(matches!(err, CoreError::InvalidProvider(provider) if provider == "openai")); } + +#[test] +fn provider_config_resolves_deepseek() { + assert!(super::common_utils::messages_provider_config("deepseek").is_some()); +} + +#[test] +fn deepseek_messages_accept_existing_authorization_header() { + let extra_headers = serde_json::from_value(serde_json::json!({ + "Authorization": "custom-auth-value" + })) + .unwrap(); + + let prepared = super::prepare::prepare_messages_call(MessagesRequest { + model: "deepseek-v4-pro", + body: serde_json::json!({ + "model": "deepseek-v4-pro", + "messages": [{"role": "user", "content": "hello"}], + "max_tokens": 100 + }), + api_key: None, + api_base: Some("https://api.deepseek.com/anthropic"), + custom_llm_provider: Some("deepseek"), + extra_headers: Some(extra_headers), + timeout: None, + }) + .unwrap(); + + assert!(prepared.upstream_headers.iter().any(|(name, value)| { + name.eq_ignore_ascii_case("authorization") && value == "custom-auth-value" + })); + assert!( + !prepared + .upstream_headers + .iter() + .any(|(name, _)| name.eq_ignore_ascii_case("x-api-key")) + ); +} + +#[tokio::test] +async fn messages_round_trip_builds_deepseek_anthropic_request() { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("binds"); + let addr = listener.local_addr().expect("addr"); + + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.expect("accepts request"); + let request = read_http_request(&mut socket).await; + let response_body = r#"{"id":"msg_deepseek","type":"message","role":"assistant","content":[{"type":"text","text":"done"}],"model":"deepseek-v4-pro","stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":1,"output_tokens":2}}"#; + socket + .write_all(write_response(response_body).as_bytes()) + .await + .expect("writes response"); + request + }); + + let response = messages(MessagesRequest { + model: "deepseek-v4-pro", + body: json!({ + "model": "deepseek-v4-pro", + "max_tokens": 100, + "system": [ + { + "type": "text", + "text": "x-anthropic-billing-header: cc_version=1" + }, + { + "type": "text", + "text": "Keep this" + } + ], + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "thinking", + "thinking": "I should call the tool.", + "signature": "sig" + } + ] + } + ], + "thinking": { + "type": "enabled", + "budget_tokens": 1024 + }, + "tools": [ + { + "type": "custom", + "name": "get_weather", + "description": "Get weather", + "input_schema": {"type": "object"} + }, + { + "type": "web_search_20260209", + "name": "web_search", + "max_uses": 1 + } + ] + }), + api_key: Some("sk-deepseek"), + api_base: Some(&format!("http://{addr}")), + custom_llm_provider: Some("deepseek"), + extra_headers: None, + timeout: Some(Duration::from_secs(5)), + }) + .await + .expect("deepseek messages request succeeds"); + + assert_eq!(response.content[0]["text"], "done"); + assert_eq!(response.stop_reason.as_deref(), Some("end_turn")); + + let request = server.await.expect("server task completes"); + let (head, body) = request.split_once("\r\n\r\n").expect("has body"); + + assert!(head.starts_with("POST /anthropic/v1/messages "), "{head}"); + + let head_lower = head.to_ascii_lowercase(); + + assert!(head_lower.contains("x-api-key: sk-deepseek"), "{head}"); + assert!( + head_lower.contains("anthropic-version: 2023-06-01"), + "{head}" + ); + assert!( + head_lower.contains("content-type: application/json"), + "{head}" + ); + + let sent_body: Value = serde_json::from_str(body).expect("body is json"); + + assert_eq!( + sent_body["thinking"], + json!({"type": "enabled", "budget_tokens": 1024}) + ); + + assert_eq!( + sent_body["system"], + json!([{"type": "text", "text": "Keep this"}]) + ); + + assert_eq!( + sent_body["tools"][0], + json!({ + "name": "get_weather", + "description": "Get weather", + "input_schema": {"type": "object"} + }) + ); + + assert_eq!(sent_body["tools"][1]["type"], "web_search_20260209"); + + assert_eq!(sent_body["messages"][0]["content"][0]["type"], "thinking"); +} diff --git a/litellm-rust/crates/core/src/messages/transformation.rs b/litellm-rust/crates/core/src/messages/transformation.rs index b478e20d24b..0cade72ee90 100644 --- a/litellm-rust/crates/core/src/messages/transformation.rs +++ b/litellm-rust/crates/core/src/messages/transformation.rs @@ -39,6 +39,10 @@ pub trait AnthropicMessagesProviderConfig: Sync { false } + fn accepts_authorization_header(&self) -> bool { + false + } + fn default_headers(&self) -> &'static [(&'static str, &'static str)] { &[ ("anthropic-version", "2023-06-01"), diff --git a/litellm-rust/crates/core/src/providers/deepseek/messages/mod.rs b/litellm-rust/crates/core/src/providers/deepseek/messages/mod.rs new file mode 100644 index 00000000000..f239b6921fa --- /dev/null +++ b/litellm-rust/crates/core/src/providers/deepseek/messages/mod.rs @@ -0,0 +1 @@ +pub mod transformation; diff --git a/litellm-rust/crates/core/src/providers/deepseek/messages/transformation.rs b/litellm-rust/crates/core/src/providers/deepseek/messages/transformation.rs new file mode 100644 index 00000000000..82ab8338cbc --- /dev/null +++ b/litellm-rust/crates/core/src/providers/deepseek/messages/transformation.rs @@ -0,0 +1,315 @@ +use serde_json::Value; + +use crate::error::{CoreError, CoreResult}; +use crate::messages::transformation::{AnthropicMessagesProviderConfig, MessagesAuthStrategy}; +use crate::messages::types::{AnthropicMessagesRequest, SystemPrompt}; +use crate::providers::anthropic::messages::transformation::non_empty; + +const DEEPSEEK_API_KEY_ENV: &str = "DEEPSEEK_API_KEY"; +const DEEPSEEK_ANTHROPIC_API_BASE_ENV: &str = "DEEPSEEK_ANTHROPIC_API_BASE"; +const DEEPSEEK_API_BASE_ENV: &str = "DEEPSEEK_API_BASE"; +const DEFAULT_DEEPSEEK_ANTHROPIC_API_BASE: &str = "https://api.deepseek.com/anthropic"; +const MESSAGES_PATH_SUFFIX: &str = "/v1/messages"; +const BILLING_HEADER_PREFIX: &str = "x-anthropic-billing-header:"; + +pub struct DeepSeekAnthropicMessagesConfig; + +pub const DEEPSEEK_ANTHROPIC_MESSAGES_CONFIG: DeepSeekAnthropicMessagesConfig = + DeepSeekAnthropicMessagesConfig; + +fn env_value(key: &str, env_lookup: &dyn Fn(&str) -> Option) -> Option { + env_lookup(key).filter(|value| !value.trim().is_empty()) +} + +fn resolve_deepseek_api_base( + api_base: Option<&str>, + env_lookup: &dyn Fn(&str) -> Option, +) -> String { + non_empty(api_base) + .map(str::to_string) + .or_else(|| env_value(DEEPSEEK_ANTHROPIC_API_BASE_ENV, env_lookup)) + .or_else(|| env_value(DEEPSEEK_API_BASE_ENV, env_lookup)) + .unwrap_or_else(|| DEFAULT_DEEPSEEK_ANTHROPIC_API_BASE.to_string()) +} + +pub fn complete_deepseek_messages_url( + api_base: Option<&str>, + env_lookup: &dyn Fn(&str) -> Option, +) -> String { + let base_url = resolve_deepseek_api_base(api_base, env_lookup); + let base_url = base_url.trim_end_matches('/'); + + if base_url.ends_with(MESSAGES_PATH_SUFFIX) && base_url.contains("/anthropic/") { + return base_url.to_string(); + } + + let base_url = base_url + .strip_suffix(MESSAGES_PATH_SUFFIX) + .unwrap_or(base_url); + let base_url = base_url.strip_suffix("/v1").unwrap_or(base_url); + let base_url = base_url.strip_suffix("/beta").unwrap_or(base_url); + + let base_url = if base_url.ends_with("/anthropic") || base_url.contains("/anthropic/") { + base_url.to_string() + } else { + format!("{base_url}/anthropic") + }; + + format!("{base_url}{MESSAGES_PATH_SUFFIX}") +} + +pub fn resolve_deepseek_api_key( + api_key: Option<&str>, + env_lookup: &dyn Fn(&str) -> Option, +) -> CoreResult { + non_empty(api_key) + .map(str::to_string) + .or_else(|| env_value(DEEPSEEK_API_KEY_ENV, env_lookup)) + .ok_or_else(|| { + CoreError::Auth( + "Missing DeepSeek API Key - Set `api_key` or the DEEPSEEK_API_KEY environment variable" + .to_string(), + ) + }) +} + +fn sanitize_tool(tool: Value) -> Value { + match tool { + Value::Object(entries) if entries.get("type").and_then(Value::as_str) == Some("custom") => { + Value::Object( + entries + .into_iter() + .filter(|(key, _)| key != "type") + .collect(), + ) + } + value => value, + } +} + +fn strip_billing_system(system: Option<&SystemPrompt>) -> Option { + match system { + Some(SystemPrompt::Text(text)) if text.starts_with(BILLING_HEADER_PREFIX) => None, + Some(SystemPrompt::Text(text)) => Some(SystemPrompt::Text(text.clone())), + Some(SystemPrompt::Blocks(blocks)) => { + let blocks = blocks + .iter() + .filter(|block| { + let is_text = block.extra.get("type").and_then(Value::as_str) == Some("text"); + let is_billing = block + .extra + .get("text") + .and_then(Value::as_str) + .is_some_and(|text| text.starts_with(BILLING_HEADER_PREFIX)); + !(is_text && is_billing) + }) + .cloned() + .collect::>(); + + (!blocks.is_empty()).then_some(SystemPrompt::Blocks(blocks)) + } + None => None, + } +} + +impl AnthropicMessagesProviderConfig for DeepSeekAnthropicMessagesConfig { + fn complete_url( + &self, + api_base: Option<&str>, + _model: &str, + env_lookup: &dyn Fn(&str) -> Option, + ) -> CoreResult { + Ok(complete_deepseek_messages_url(api_base, env_lookup)) + } + + fn resolve_api_key( + &self, + api_key: Option<&str>, + env_lookup: &dyn Fn(&str) -> Option, + ) -> CoreResult { + resolve_deepseek_api_key(api_key, env_lookup) + } + + fn auth_strategy(&self) -> MessagesAuthStrategy { + MessagesAuthStrategy::Header("x-api-key") + } + + fn accepts_authorization_header(&self) -> bool { + true + } + + fn transform_request( + &self, + request: AnthropicMessagesRequest, + ) -> CoreResult { + let tools = request + .tools + .as_ref() + .map(|tools| tools.iter().cloned().map(sanitize_tool).collect::>()); + let system = strip_billing_system(request.system.as_ref()); + + Ok(AnthropicMessagesRequest { + tools, + system, + ..request + }) + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn url_defaults_to_deepseek_anthropic_endpoint() { + assert_eq!( + complete_deepseek_messages_url(None, &|_| None), + "https://api.deepseek.com/anthropic/v1/messages" + ); + } + + #[test] + fn url_normalization_matches_python() { + for api_base in [ + "https://api.deepseek.com/anthropic/v1", + "https://api.deepseek.com/anthropic", + "https://api.deepseek.com", + "https://api.deepseek.com/v1", + "https://api.deepseek.com/v1/messages", + ] { + assert_eq!( + complete_deepseek_messages_url(Some(api_base), &|_| None), + "https://api.deepseek.com/anthropic/v1/messages" + ); + } + } + + #[test] + fn url_prefers_anthropic_env_then_general_env() { + let with_both = |key: &str| match key { + DEEPSEEK_ANTHROPIC_API_BASE_ENV => Some("https://anthropic.deepseek.test".to_string()), + DEEPSEEK_API_BASE_ENV => Some("https://general.deepseek.test".to_string()), + _ => None, + }; + + assert_eq!( + complete_deepseek_messages_url(None, &with_both), + "https://anthropic.deepseek.test/anthropic/v1/messages" + ); + + let with_general = |key: &str| { + (key == DEEPSEEK_API_BASE_ENV).then(|| "https://general.deepseek.test".to_string()) + }; + + assert_eq!( + complete_deepseek_messages_url(None, &with_general), + "https://general.deepseek.test/anthropic/v1/messages" + ); + } + + #[test] + fn api_key_prefers_param_then_environment() { + assert_eq!( + resolve_deepseek_api_key(Some("sk-param"), &|_| None).unwrap(), + "sk-param" + ); + + let with_env = + |key: &str| (key == DEEPSEEK_API_KEY_ENV).then(|| "sk-deepseek-env".to_string()); + + assert_eq!( + resolve_deepseek_api_key(None, &with_env).unwrap(), + "sk-deepseek-env" + ); + } + + #[test] + fn transform_preserves_thinking_and_sanitizes_custom_tools() { + let request: AnthropicMessagesRequest = serde_json::from_value(json!({ + "model": "deepseek-v4-pro", + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "thinking", + "thinking": "I should call the tool.", + "signature": "sig" + } + ] + } + ], + "max_tokens": 100, + "thinking": { + "type": "enabled", + "budget_tokens": 1024 + }, + "tools": [ + { + "type": "custom", + "name": "get_weather", + "description": "Get weather", + "input_schema": {"type": "object"} + }, + { + "type": "web_search_20260209", + "name": "web_search", + "max_uses": 1 + } + ] + })) + .unwrap(); + + let transformed = DEEPSEEK_ANTHROPIC_MESSAGES_CONFIG + .transform_request(request) + .unwrap(); + let value = serde_json::to_value(transformed).unwrap(); + + assert_eq!( + value["thinking"], + json!({"type": "enabled", "budget_tokens": 1024}) + ); + assert_eq!( + value["tools"][0], + json!({ + "name": "get_weather", + "description": "Get weather", + "input_schema": {"type": "object"} + }) + ); + assert_eq!(value["tools"][1]["type"], "web_search_20260209"); + assert_eq!(value["messages"][0]["content"][0]["type"], "thinking"); + } + + #[test] + fn transform_strips_billing_system_blocks() { + let request: AnthropicMessagesRequest = serde_json::from_value(json!({ + "model": "deepseek-v4-pro", + "messages": [{"role": "user", "content": "hello"}], + "max_tokens": 100, + "system": [ + { + "type": "text", + "text": "x-anthropic-billing-header: cc_version=1" + }, + { + "type": "text", + "text": "Keep this" + } + ] + })) + .unwrap(); + + let transformed = DEEPSEEK_ANTHROPIC_MESSAGES_CONFIG + .transform_request(request) + .unwrap(); + let value = serde_json::to_value(transformed).unwrap(); + + assert_eq!( + value["system"], + json!([{"type": "text", "text": "Keep this"}]) + ); + } +} diff --git a/litellm-rust/crates/core/src/providers/deepseek/mod.rs b/litellm-rust/crates/core/src/providers/deepseek/mod.rs new file mode 100644 index 00000000000..ba63992f3cb --- /dev/null +++ b/litellm-rust/crates/core/src/providers/deepseek/mod.rs @@ -0,0 +1 @@ +pub mod messages; diff --git a/litellm-rust/crates/core/src/providers/mod.rs b/litellm-rust/crates/core/src/providers/mod.rs index 805600d6dbe..f1a88377f25 100644 --- a/litellm-rust/crates/core/src/providers/mod.rs +++ b/litellm-rust/crates/core/src/providers/mod.rs @@ -2,6 +2,7 @@ pub mod anthropic; pub mod azure_ai; #[cfg(feature = "bedrock-auth")] pub mod bedrock; +pub mod deepseek; pub mod mistral; pub mod openai; pub mod vertex_ai;