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:
yujonglee 2026-10-09 16:29:46 -07:00 • committed by GitHub
parent a0b3c46cf0
commit 4cb47edd2a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 530 additions and 4 deletions

View file

@ -18,6 +18,8 @@ pub enum LlmProviders {
Bedrock,
#[strum(serialize = "cohere")]
Cohere,
#[strum(serialize = "deepseek")]
Deepseek,
#[strum(serialize = "mistral")]
Mistral,
#[strum(serialize = "openai")]

View file

@ -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,

View file

@ -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,

View file

@ -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")]

View file

@ -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,

View file

@ -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))

View file

@ -0,0 +1 @@
pub mod transformation;

View 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})
);
}
}

View file

@ -0,0 +1 @@
pub mod messages;

View file

@ -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;

View file

@ -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),

View file

@ -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)