mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
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
This commit is contained in:
parent
a0b3c46cf0
commit
4cb47edd2a
12 changed files with 530 additions and 4 deletions
|
|
@ -18,6 +18,8 @@ pub enum LlmProviders {
|
|||
Bedrock,
|
||||
#[strum(serialize = "cohere")]
|
||||
Cohere,
|
||||
#[strum(serialize = "deepseek")]
|
||||
Deepseek,
|
||||
#[strum(serialize = "mistral")]
|
||||
Mistral,
|
||||
#[strum(serialize = "openai")]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<M
|
|||
LlmProviders::Anthropic => 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,
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
1
litellm-rust/crates/llms/src/deepseek/messages/mod.rs
Normal file
1
litellm-rust/crates/llms/src/deepseek/messages/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub mod transformation;
|
||||
458
litellm-rust/crates/llms/src/deepseek/messages/transformation.rs
Normal file
458
litellm-rust/crates/llms/src/deepseek/messages/transformation.rs
Normal file
|
|
@ -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<MessagesRequest, Error> {
|
||||
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<String>,
|
||||
) -> Result<String, Error> {
|
||||
Ok(complete_deepseek_anthropic_url(api_base, env_lookup))
|
||||
}
|
||||
|
||||
fn transform_anthropic_messages_request(
|
||||
&self,
|
||||
request: MessagesRequest,
|
||||
context: &MessagesTransformContext,
|
||||
) -> Result<MessagesRequest, Error> {
|
||||
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<String>,
|
||||
) -> Result<ValidatedEnvironment, Error> {
|
||||
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<String>,
|
||||
) -> Option<String> {
|
||||
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>) -> 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>,
|
||||
) -> 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<MessagesTool>) -> Recognized<MessagesTool> {
|
||||
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<String> {
|
||||
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<Option<&'static str>>,
|
||||
) {
|
||||
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<String>,
|
||||
) -> Result<ValidatedEnvironment, Error> {
|
||||
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<Value>,
|
||||
) {
|
||||
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})
|
||||
);
|
||||
}
|
||||
}
|
||||
1
litellm-rust/crates/llms/src/deepseek/mod.rs
Normal file
1
litellm-rust/crates/llms/src/deepseek/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub mod messages;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue