diff --git a/config.example.toml b/config.example.toml index 53bb07554..ac858f856 100644 --- a/config.example.toml +++ b/config.example.toml @@ -20,7 +20,7 @@ # `api_key` / `base_url` are # still read as DeepSeek defaults when `[providers.deepseek]` is absent # (backward compatibility). -provider = "deepseek" # deepseek | deepseek-cn | nvidia-nim | openai | atlascloud | wanjie-ark | volcengine | openrouter | xiaomi-mimo | novita | fireworks | siliconflow | siliconflow-CN | arcee | moonshot | zai | stepfun | minimax | sglang | vllm | ollama | huggingface | together | qianfan | openai-codex | anthropic | deepinfra +provider = "deepseek" # deepseek | deepseek-cn | deepseek-anthropic | nvidia-nim | openai | atlascloud | wanjie-ark | volcengine | openrouter | xiaomi-mimo | novita | fireworks | siliconflow | siliconflow-CN | arcee | moonshot | zai | stepfun | minimax | sglang | vllm | ollama | huggingface | together | qianfan | openai-codex | anthropic | deepinfra api_key = "YOUR_DEEPSEEK_API_KEY" # must be non-empty base_url = "https://api.deepseek.com/beta" # provider = "deepseek-cn" # legacy alias (official host is still https://api.deepseek.com) @@ -276,6 +276,7 @@ max_subagents = 10 # optional (1-20) # `--provider siliconflow` / `/provider arcee` / `/provider moonshot` # switches between them without having to re-enter keys. Env vars override anything set here: # DeepSeek: DEEPSEEK_API_KEY, DEEPSEEK_BASE_URL, DEEPSEEK_MODEL +# DeepSeek Anthropic-compatible: DEEPSEEK_API_KEY, DEEPSEEK_ANTHROPIC_BASE_URL # NIM: NVIDIA_API_KEY (or NVIDIA_NIM_API_KEY), NIM_BASE_URL # (or NVIDIA_NIM_BASE_URL / NVIDIA_BASE_URL), NVIDIA_NIM_MODEL # OpenAI-compatible: OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_MODEL @@ -320,6 +321,12 @@ max_subagents = 10 # optional (1-20) # http_headers = { "X-Model-Provider-Id" = "your-model-provider" } # optional custom request headers # path_suffix = "/chat/completions" # override the API path; skips /v1 versioning when set # reasoning_stream_style = "inline_tags" # route ... content into Thinking cells + +# DeepSeek Anthropic-compatible Messages route (opt-in) +# [providers.deepseek_anthropic] +# api_key = "YOUR_DEEPSEEK_API_KEY" +# base_url = "https://api.deepseek.com/anthropic" +# model = "deepseek-v4-pro" # [providers.deepseek.auth] # provider-scoped auth source metadata; command execution lands in a follow-up slice # source = "command" # command = ["secret-tool", "lookup", "service", "codewhale-deepseek"] diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 051fe0733..d8e819bb7 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -26,10 +26,12 @@ use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; pub const CONFIG_FILE_NAME: &str = "config.toml"; pub const PERMISSIONS_FILE_NAME: &str = "permissions.toml"; const DEFAULT_DEEPSEEK_MODEL: &str = "deepseek-v4-pro"; +const DEFAULT_DEEPSEEK_ANTHROPIC_MODEL: &str = DEFAULT_DEEPSEEK_MODEL; const DEFAULT_NVIDIA_NIM_MODEL: &str = "deepseek-ai/deepseek-v4-pro"; const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = "deepseek-ai/deepseek-v4-flash"; const DEFAULT_OPENAI_MODEL: &str = "deepseek-v4-pro"; const DEFAULT_DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/beta"; +const DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL: &str = "https://api.deepseek.com/anthropic"; const DEFAULT_NVIDIA_NIM_BASE_URL: &str = "https://integrate.api.nvidia.com/v1"; const DEFAULT_OPENAI_CODEX_MODEL: &str = "gpt-5.5"; const DEFAULT_ANTHROPIC_MODEL: &str = "claude-sonnet-4-6"; @@ -151,6 +153,13 @@ pub enum ProviderKind { alias = "deepseek-china" )] Deepseek, + #[serde( + alias = "deepseek-anthropic", + alias = "deepseek_anthropic", + alias = "deepseek-claude", + alias = "deepseek_claude" + )] + DeepseekAnthropic, NvidiaNim, #[serde(alias = "open-ai")] Openai, @@ -216,8 +225,9 @@ pub enum ProviderKind { } impl ProviderKind { - pub const ALL: [Self; 26] = [ + pub const ALL: [Self; 27] = [ Self::Deepseek, + Self::DeepseekAnthropic, Self::NvidiaNim, Self::Openai, Self::Atlascloud, @@ -310,6 +320,14 @@ pub struct ProviderConfigToml { pub struct ProvidersToml { #[serde(default)] pub deepseek: ProviderConfigToml, + #[serde( + default, + alias = "deepseek-anthropic", + alias = "deepseekAnthropic", + alias = "deepseek-claude", + alias = "deepseek_claude" + )] + pub deepseek_anthropic: ProviderConfigToml, #[serde(default)] pub nvidia_nim: ProviderConfigToml, #[serde(default)] @@ -411,6 +429,7 @@ impl ProvidersToml { pub fn for_provider(&self, provider: ProviderKind) -> &ProviderConfigToml { match provider { ProviderKind::Deepseek => &self.deepseek, + ProviderKind::DeepseekAnthropic => &self.deepseek_anthropic, ProviderKind::NvidiaNim => &self.nvidia_nim, ProviderKind::Openai => &self.openai, ProviderKind::Atlascloud => &self.atlascloud, @@ -442,6 +461,7 @@ impl ProvidersToml { pub fn for_provider_mut(&mut self, provider: ProviderKind) -> &mut ProviderConfigToml { match provider { ProviderKind::Deepseek => &mut self.deepseek, + ProviderKind::DeepseekAnthropic => &mut self.deepseek_anthropic, ProviderKind::NvidiaNim => &mut self.nvidia_nim, ProviderKind::Openai => &mut self.openai, ProviderKind::Atlascloud => &mut self.atlascloud, @@ -2227,6 +2247,7 @@ impl ConfigToml { } else { configured_base_url.unwrap_or_else(|| match provider { ProviderKind::Deepseek => DEFAULT_DEEPSEEK_BASE_URL.to_string(), + ProviderKind::DeepseekAnthropic => DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL.to_string(), ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL.to_string(), ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL.to_string(), ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL.to_string(), @@ -2803,6 +2824,7 @@ fn canonical_openrouter_recent_model_id(model: &str) -> Option<&'static str> { fn default_model_for_provider(provider: ProviderKind) -> &'static str { match provider { ProviderKind::Deepseek => DEFAULT_DEEPSEEK_MODEL, + ProviderKind::DeepseekAnthropic => DEFAULT_DEEPSEEK_ANTHROPIC_MODEL, ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL, ProviderKind::Openai => DEFAULT_OPENAI_MODEL, ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_MODEL, @@ -2833,6 +2855,7 @@ fn default_model_for_provider(provider: ProviderKind) -> &'static str { fn default_base_url_for_provider(provider: ProviderKind) -> &'static str { match provider { ProviderKind::Deepseek => DEFAULT_DEEPSEEK_BASE_URL, + ProviderKind::DeepseekAnthropic => DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL, ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL, ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL, ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL, @@ -4281,6 +4304,7 @@ struct EnvRuntimeOverrides { verbosity: Option, http_headers: Option>, deepseek_base_url: Option, + deepseek_anthropic_base_url: Option, nvidia_base_url: Option, openai_base_url: Option, atlascloud_base_url: Option, @@ -4402,6 +4426,10 @@ impl EnvRuntimeOverrides { .or_else(|_| std::env::var("DEEPSEEK_BASE_URL")) .ok() .filter(|v| !v.trim().is_empty()), + deepseek_anthropic_base_url: std::env::var("DEEPSEEK_ANTHROPIC_BASE_URL") + .or_else(|_| std::env::var("DEEPSEEK_CLAUDE_BASE_URL")) + .ok() + .filter(|v| !v.trim().is_empty()), nvidia_base_url: std::env::var("NVIDIA_NIM_BASE_URL") .or_else(|_| std::env::var("NIM_BASE_URL")) .or_else(|_| std::env::var("NVIDIA_BASE_URL")) @@ -4552,6 +4580,7 @@ impl EnvRuntimeOverrides { // values (`providers..base_url`) still win when env is unset. match provider { ProviderKind::Deepseek => self.deepseek_base_url.clone(), + ProviderKind::DeepseekAnthropic => self.deepseek_anthropic_base_url.clone(), ProviderKind::NvidiaNim => self.nvidia_base_url.clone(), ProviderKind::Openai => self.openai_base_url.clone(), ProviderKind::Atlascloud => self.atlascloud_base_url.clone(), diff --git a/crates/config/src/provider.rs b/crates/config/src/provider.rs index 862be6651..20a7346f4 100644 --- a/crates/config/src/provider.rs +++ b/crates/config/src/provider.rs @@ -7,6 +7,7 @@ use super::{ DEFAULT_ARCEE_BASE_URL, DEFAULT_ARCEE_MODEL, DEFAULT_ATLASCLOUD_BASE_URL, DEFAULT_ATLASCLOUD_MODEL, DEFAULT_DEEPINFRA_BASE_URL, DEFAULT_DEEPINFRA_MODEL, + DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL, DEFAULT_DEEPSEEK_ANTHROPIC_MODEL, DEFAULT_DEEPSEEK_BASE_URL, DEFAULT_DEEPSEEK_MODEL, DEFAULT_FIREWORKS_BASE_URL, DEFAULT_FIREWORKS_MODEL, DEFAULT_HUGGINGFACE_BASE_URL, DEFAULT_HUGGINGFACE_MODEL, DEFAULT_MINIMAX_BASE_URL, DEFAULT_MINIMAX_MODEL, DEFAULT_MOONSHOT_BASE_URL, @@ -134,6 +135,47 @@ provider!( "deepseek", aliases: ["deep-seek", "deepseek-cn", "deepseek_china", "deepseekcn", "deepseek-china"] ); + +/// Opt-in DeepSeek route that speaks the Anthropic Messages wire protocol. +pub struct DeepseekAnthropic; + +impl Provider for DeepseekAnthropic { + fn id(&self) -> &'static str { + "deepseek-anthropic" + } + + fn kind(&self) -> ProviderKind { + ProviderKind::DeepseekAnthropic + } + + fn display_name(&self) -> &'static str { + "DeepSeek (Anthropic-compatible)" + } + + fn default_base_url(&self) -> &'static str { + DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL + } + + fn default_model(&self) -> &'static str { + DEFAULT_DEEPSEEK_ANTHROPIC_MODEL + } + + fn env_vars(&self) -> &'static [&'static str] { + &["DEEPSEEK_API_KEY"] + } + + fn provider_config_key(&self) -> &'static str { + "deepseek_anthropic" + } + + fn aliases(&self) -> &'static [&'static str] { + &["deepseek_anthropic", "deepseek-claude", "deepseek_claude"] + } + + fn wire(&self) -> WireFormat { + WireFormat::AnthropicMessages + } +} provider!( NvidiaNim, NvidiaNim, @@ -499,6 +541,7 @@ provider!( ); static DEEPSEEK: Deepseek = Deepseek; +static DEEPSEEK_ANTHROPIC: DeepseekAnthropic = DeepseekAnthropic; static NVIDIA_NIM: NvidiaNim = NvidiaNim; static OPENAI: Openai = Openai; static ATLASCLOUD: Atlascloud = Atlascloud; @@ -525,8 +568,9 @@ static STEPFUN: Stepfun = Stepfun; static MINIMAX: Minimax = Minimax; static DEEPINFRA: Deepinfra = Deepinfra; -static PROVIDER_REGISTRY: [&dyn Provider; 26] = [ +static PROVIDER_REGISTRY: [&dyn Provider; 27] = [ &DEEPSEEK, + &DEEPSEEK_ANTHROPIC, &NVIDIA_NIM, &OPENAI, &ATLASCLOUD, diff --git a/crates/config/src/route/tests.rs b/crates/config/src/route/tests.rs index e1eede93a..e74f4e9a6 100644 --- a/crates/config/src/route/tests.rs +++ b/crates/config/src/route/tests.rs @@ -122,7 +122,9 @@ fn descriptor_protocol_matches_provider_wire() { ); let expected = match kind { ProviderKind::OpenaiCodex => RequestProtocol::Responses, - ProviderKind::Anthropic => RequestProtocol::AnthropicMessages, + ProviderKind::DeepseekAnthropic | ProviderKind::Anthropic => { + RequestProtocol::AnthropicMessages + } _ => RequestProtocol::ChatCompletions, }; assert_eq!(d.protocol(), expected, "{kind:?} protocol mismatch"); diff --git a/crates/config/src/tests.rs b/crates/config/src/tests.rs index f369360a8..859455b5a 100644 --- a/crates/config/src/tests.rs +++ b/crates/config/src/tests.rs @@ -536,6 +536,8 @@ fn config_store_secures_persisted_permissions_file() { struct EnvGuard { deepseek_api_key: Option, deepseek_base_url: Option, + deepseek_anthropic_base_url: Option, + deepseek_claude_base_url: Option, deepseek_http_headers: Option, deepseek_model: Option, deepseek_default_text_model: Option, @@ -627,6 +629,8 @@ impl EnvGuard { let guard = Self { deepseek_api_key: env::var_os("DEEPSEEK_API_KEY"), deepseek_base_url: env::var_os("DEEPSEEK_BASE_URL"), + deepseek_anthropic_base_url: env::var_os("DEEPSEEK_ANTHROPIC_BASE_URL"), + deepseek_claude_base_url: env::var_os("DEEPSEEK_CLAUDE_BASE_URL"), deepseek_http_headers: env::var_os("DEEPSEEK_HTTP_HEADERS"), deepseek_model: env::var_os("DEEPSEEK_MODEL"), deepseek_default_text_model: env::var_os("DEEPSEEK_DEFAULT_TEXT_MODEL"), @@ -716,6 +720,8 @@ impl EnvGuard { unsafe { env::remove_var("DEEPSEEK_API_KEY"); env::remove_var("DEEPSEEK_BASE_URL"); + env::remove_var("DEEPSEEK_ANTHROPIC_BASE_URL"); + env::remove_var("DEEPSEEK_CLAUDE_BASE_URL"); env::remove_var("DEEPSEEK_HTTP_HEADERS"); env::remove_var("DEEPSEEK_MODEL"); env::remove_var("DEEPSEEK_DEFAULT_TEXT_MODEL"); @@ -819,6 +825,14 @@ impl Drop for EnvGuard { unsafe { Self::restore_var("DEEPSEEK_API_KEY", self.deepseek_api_key.take()); Self::restore_var("DEEPSEEK_BASE_URL", self.deepseek_base_url.take()); + Self::restore_var( + "DEEPSEEK_ANTHROPIC_BASE_URL", + self.deepseek_anthropic_base_url.take(), + ); + Self::restore_var( + "DEEPSEEK_CLAUDE_BASE_URL", + self.deepseek_claude_base_url.take(), + ); Self::restore_var("DEEPSEEK_HTTP_HEADERS", self.deepseek_http_headers.take()); Self::restore_var("DEEPSEEK_MODEL", self.deepseek_model.take()); Self::restore_var( @@ -2791,6 +2805,61 @@ fn provider_kind_accepts_legacy_deepseek_cn_aliases() { } } +#[test] +fn deepseek_anthropic_route_defaults_to_anthropic_endpoint() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + for alias in [ + "deepseek-anthropic", + "deepseek_anthropic", + "deepseek-claude", + "deepseek_claude", + ] { + assert_eq!( + ProviderKind::parse(alias), + Some(ProviderKind::DeepseekAnthropic) + ); + + let parsed: ConfigToml = + toml::from_str(&format!("provider = \"{alias}\"")).expect("deepseek anthropic alias"); + assert_eq!(parsed.provider, ProviderKind::DeepseekAnthropic); + } + + let provider = provider::resolve_provider("deepseek-anthropic") + .expect("deepseek anthropic metadata resolves"); + assert_eq!(provider.kind(), ProviderKind::DeepseekAnthropic); + assert_eq!(provider.provider_config_key(), "deepseek_anthropic"); + assert_eq!(provider.default_model(), DEFAULT_DEEPSEEK_ANTHROPIC_MODEL); + assert_eq!( + provider.default_base_url(), + DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL + ); + assert_eq!(provider.env_vars(), &["DEEPSEEK_API_KEY"]); + assert_eq!(provider.wire(), provider::WireFormat::AnthropicMessages); + + let config = ConfigToml { + provider: ProviderKind::DeepseekAnthropic, + ..ConfigToml::default() + }; + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::DeepseekAnthropic); + assert_eq!(resolved.base_url, DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL); + assert_eq!(resolved.model, DEFAULT_DEEPSEEK_ANTHROPIC_MODEL); + + unsafe { + std::env::set_var( + "DEEPSEEK_ANTHROPIC_BASE_URL", + "https://gateway.example.test/anthropic", + ); + } + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + assert_eq!(resolved.base_url, "https://gateway.example.test/anthropic"); + unsafe { + std::env::remove_var("DEEPSEEK_ANTHROPIC_BASE_URL"); + } +} + #[test] fn provider_metadata_registry_covers_every_provider_kind_once() { let providers = provider::all_providers(); @@ -2878,7 +2947,9 @@ fn provider_metadata_defaults_match_runtime_helpers() { // is OpenAI-compatible Chat Completions. let expected_wire = match kind { ProviderKind::OpenaiCodex => provider::WireFormat::Responses, - ProviderKind::Anthropic => provider::WireFormat::AnthropicMessages, + ProviderKind::Anthropic | ProviderKind::DeepseekAnthropic => { + provider::WireFormat::AnthropicMessages + } _ => provider::WireFormat::ChatCompletions, }; assert_eq!(provider.wire(), expected_wire); diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 96f3981dc..a670782a5 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -20,7 +20,9 @@ use crate::llm_client::{ sanitize_http_error_body, with_retry, }; use crate::logging; -use crate::models::{MessageRequest, MessageResponse, ServerToolUsage, SystemPrompt, Usage}; +use crate::models::{ + ContentBlock, Message, MessageRequest, MessageResponse, ServerToolUsage, SystemPrompt, Usage, +}; pub(super) fn to_api_tool_name(name: &str) -> String { let mut out = String::new(); @@ -777,7 +779,7 @@ fn build_default_headers( let mut headers = HeaderMap::new(); headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); let api_key = api_key.trim(); - if api_provider == ApiProvider::Anthropic { + if api_provider_uses_anthropic_messages(api_provider) { // #3014: the Messages API authenticates with `x-api-key` (never // `Authorization: Bearer`) and pins the wire contract via // `anthropic-version`. @@ -786,19 +788,20 @@ fn build_default_headers( HeaderValue::from_static("2023-06-01"), ); } - let auth_header_name = if !api_key.is_empty() && api_provider == ApiProvider::Anthropic { - Some(HeaderName::from_static("x-api-key")) - } else if !api_key.is_empty() - && api_provider == ApiProvider::XiaomiMimo - && (xiaomi_mimo_base_url_uses_token_plan(base_url) - || xiaomi_mimo_api_key_uses_token_plan(api_key)) - { - Some(HeaderName::from_static("api-key")) - } else if !api_key.is_empty() { - Some(AUTHORIZATION) - } else { - None - }; + let auth_header_name = + if !api_key.is_empty() && api_provider_uses_anthropic_messages(api_provider) { + Some(HeaderName::from_static("x-api-key")) + } else if !api_key.is_empty() + && api_provider == ApiProvider::XiaomiMimo + && (xiaomi_mimo_base_url_uses_token_plan(base_url) + || xiaomi_mimo_api_key_uses_token_plan(api_key)) + { + Some(HeaderName::from_static("api-key")) + } else if !api_key.is_empty() { + Some(AUTHORIZATION) + } else { + None + }; if let Some(header_name) = auth_header_name.as_ref() { let header_value = if *header_name == AUTHORIZATION { HeaderValue::from_str(&format!("Bearer {api_key}"))? @@ -832,6 +835,75 @@ fn is_auth_dialect_header(header_name: &HeaderName) -> bool { || header_name == HeaderName::from_static("x-api-key") } +fn api_provider_uses_anthropic_messages(api_provider: ApiProvider) -> bool { + matches!( + api_provider, + ApiProvider::Anthropic | ApiProvider::DeepseekAnthropic + ) +} + +fn api_provider_skips_models_probe(api_provider: ApiProvider) -> bool { + matches!(api_provider, ApiProvider::DeepseekAnthropic) +} + +fn translation_system_prompt(target_language: &str) -> String { + format!( + "You are a professional translator. Your ONLY task is to translate text to {target_language}. \ + Rules:\n\ + 1. Output ONLY the translation, nothing else — no explanations, no notes, no quotes.\n\ + 2. Preserve all code blocks (```...```), URLs, file paths, command names, \ + and technical terms like API names, function names, and library names untranslated.\n\ + 3. Keep Markdown formatting (headings, lists, bold, italics, links) intact.\n\ + 4. Translate all natural-language prose naturally and professionally.\n\ + 5. Do NOT add any prefix, suffix, or commentary.\n\ + 6. If the input is already in {target_language} or contains no prose to translate, \ + return it as-is." + ) +} + +fn translation_message_request(text: &str, model: String, target_language: &str) -> MessageRequest { + MessageRequest { + model, + messages: vec![Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: text.to_string(), + cache_control: None, + }], + }], + max_tokens: 4096, + system: Some(SystemPrompt::Text(translation_system_prompt( + target_language, + ))), + tools: None, + tool_choice: None, + metadata: None, + thinking: None, + reasoning_effort: Some("off".to_string()), + stream: Some(false), + temperature: Some(0.1), + top_p: None, + } +} + +fn translation_text_from_response(response: &MessageResponse) -> Result { + let translated = response + .content + .iter() + .filter_map(|block| match block { + ContentBlock::Text { text, .. } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join("") + .trim() + .to_string(); + if translated.is_empty() { + bail!("translate: Anthropic Messages response did not contain text content"); + } + Ok(translated) +} + fn xiaomi_mimo_base_url_uses_token_plan(base_url: &str) -> bool { let normalized = base_url.trim().to_ascii_lowercase(); let without_scheme = normalized @@ -873,29 +945,25 @@ impl DeepSeekClient { model: &str, target_language: &str, ) -> Result { + let model = wire_model_for_provider(self.api_provider, model); + if api_provider_uses_anthropic_messages(self.api_provider) { + let response = self + .handle_anthropic_message(translation_message_request(text, model, target_language)) + .await?; + return translation_text_from_response(&response); + } + let url = api_url_with_suffix( &self.base_url, "chat/completions", self.path_suffix.as_deref(), ); - let model = wire_model_for_provider(self.api_provider, model); let mut body = serde_json::json!({ "model": model, "messages": [ { "role": "system", - "content": format!( - "You are a professional translator. Your ONLY task is to translate text to {target_language}. \ - Rules:\n\ - 1. Output ONLY the translation, nothing else — no explanations, no notes, no quotes.\n\ - 2. Preserve all code blocks (```...```), URLs, file paths, command names, \ - and technical terms like API names, function names, and library names untranslated.\n\ - 3. Keep Markdown formatting (headings, lists, bold, italics, links) intact.\n\ - 4. Translate all natural-language prose naturally and professionally.\n\ - 5. Do NOT add any prefix, suffix, or commentary.\n\ - 6. If the input is already in {target_language} or contains no prose to translate, \ - return it as-is." - ) + "content": translation_system_prompt(target_language) }, { "role": "user", @@ -1072,6 +1140,11 @@ impl DeepSeekClient { if !should_probe { return; } + if api_provider_skips_models_probe(self.api_provider) { + self.mark_request_success().await; + logging::info("Skipping /models recovery probe for provider without a models endpoint"); + return; + } let health_url = api_url(&self.base_url, "models"); let probe = self.http_client.get(health_url).send().await; match probe { @@ -1202,6 +1275,10 @@ impl LlmClient for DeepSeekClient { } async fn health_check(&self) -> Result { + if api_provider_skips_models_probe(self.api_provider) { + self.mark_request_success().await; + return Ok(true); + } let health_url = api_url(&self.base_url, "models"); self.wait_for_rate_limit().await; let response = self.http_client.get(health_url).send().await; @@ -1229,7 +1306,7 @@ impl LlmClient for DeepSeekClient { if self.api_provider == ApiProvider::OpenaiCodex { return self.handle_responses_message(request).await; } - if self.api_provider == ApiProvider::Anthropic { + if api_provider_uses_anthropic_messages(self.api_provider) { return self.handle_anthropic_message(request).await; } self.create_message_chat(&request).await @@ -1242,7 +1319,7 @@ impl LlmClient for DeepSeekClient { if self.api_provider == ApiProvider::OpenaiCodex { return self.handle_responses_stream(request).await; } - if self.api_provider == ApiProvider::Anthropic { + if api_provider_uses_anthropic_messages(self.api_provider) { return self.handle_anthropic_stream(request).await; } self.handle_chat_completion_stream(request).await @@ -1364,7 +1441,7 @@ pub(super) fn apply_reasoning_effort( // #3024: Ollama OpenAI-compat endpoint accepts think param. body["think"] = json!(false); } - ApiProvider::Anthropic => { + ApiProvider::Anthropic | ApiProvider::DeepseekAnthropic => { // #3014: thinking/effort shaping happens natively inside // client/anthropic.rs (adaptive thinking + output_config), // not via OpenAI-dialect fields. @@ -1444,7 +1521,7 @@ pub(super) fn apply_reasoning_effort( // #3024: Ollama think param. body["think"] = json!(true); } - ApiProvider::Anthropic => { + ApiProvider::Anthropic | ApiProvider::DeepseekAnthropic => { // #3014: thinking/effort shaping happens natively inside // client/anthropic.rs (adaptive thinking + output_config), // not via OpenAI-dialect fields. @@ -1511,7 +1588,7 @@ pub(super) fn apply_reasoning_effort( // #3024: Ollama think param. body["think"] = json!(true); } - ApiProvider::Anthropic => { + ApiProvider::Anthropic | ApiProvider::DeepseekAnthropic => { // #3014: thinking/effort shaping happens natively inside // client/anthropic.rs (adaptive thinking + output_config), // not via OpenAI-dialect fields. @@ -1616,6 +1693,12 @@ impl DeepSeekClient { suffix: &str, max_tokens: u32, ) -> anyhow::Result { + if api_provider_uses_anthropic_messages(self.api_provider) { + bail!( + "FIM completion is not supported for {} because it uses the Anthropic Messages protocol", + self.api_provider.display_name() + ); + } let url = api_url_with_suffix(&self.base_url, "beta/completions", None); let model = wire_model_for_provider(self.api_provider, model); let body = json!({ @@ -1679,10 +1762,13 @@ mod tests { parse_chat_message, parse_sse_chunk, sanitize_thinking_mode_messages, tool_to_chat, tool_to_chat_for_base_url, }; + use crate::config::{ProviderConfig, ProvidersConfig}; use crate::models::{ ContentBlock, ContentBlockStart, Delta, Message, MessageRequest, StreamEvent, Tool, }; use serde_json::json; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; fn test_tool(name: &str) -> Tool { Tool { @@ -1701,6 +1787,22 @@ mod tests { } } + fn deepseek_anthropic_client(server: &MockServer) -> DeepSeekClient { + let _ = rustls::crypto::ring::default_provider().install_default(); + let mut providers = ProvidersConfig::default(); + providers.deepseek_anthropic = ProviderConfig { + api_key: Some("ds-test".to_string()), + base_url: Some(server.uri()), + ..ProviderConfig::default() + }; + DeepSeekClient::new(&Config { + provider: Some("deepseek-anthropic".to_string()), + providers: Some(providers), + ..Config::default() + }) + .expect("deepseek anthropic client") + } + #[test] fn parse_speech_audio_response_accepts_message_audio() { let encoded = general_purpose::STANDARD.encode(b"hi"); @@ -2082,6 +2184,126 @@ mod tests { assert!(headers.get("x-api-key").is_none()); } + #[test] + fn deepseek_anthropic_uses_anthropic_header_dialect() { + let mut extra = HashMap::new(); + extra.insert("Authorization".to_string(), "Bearer wrong".to_string()); + extra.insert("api-key".to_string(), "wrong".to_string()); + let headers = DeepSeekClient::default_headers_for_provider( + "ds-test", + &extra, + ApiProvider::DeepseekAnthropic, + crate::config::DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL, + ) + .expect("headers"); + + assert_eq!( + headers + .get("x-api-key") + .and_then(|value| value.to_str().ok()), + Some("ds-test") + ); + assert_eq!( + headers + .get("anthropic-version") + .and_then(|value| value.to_str().ok()), + Some("2023-06-01") + ); + assert!( + headers.get(AUTHORIZATION).is_none(), + "Anthropic-compatible DeepSeek route must not use Bearer auth" + ); + assert!( + headers.get("api-key").is_none(), + "Anthropic-compatible DeepSeek route must not inherit MiMo auth headers" + ); + } + + #[tokio::test] + async fn deepseek_anthropic_translate_uses_messages_endpoint() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/messages")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "msg_1", + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": "Hola"}], + "model": "deepseek-chat", + "stop_reason": "end_turn", + "stop_sequence": null, + "usage": {"input_tokens": 3, "output_tokens": 1} + }))) + .expect(1) + .mount(&server) + .await; + + let client = deepseek_anthropic_client(&server); + let translated = client + .translate("Hello", "deepseek-chat", "Spanish") + .await + .expect("translation succeeds"); + + assert_eq!(translated, "Hola"); + let requests = server.received_requests().await.expect("recorded requests"); + assert_eq!(requests.len(), 1); + let body: Value = serde_json::from_slice(&requests[0].body).expect("json body"); + assert_eq!( + body.pointer("/messages/0/role").and_then(Value::as_str), + Some("user") + ); + assert_eq!( + body.pointer("/messages/0/content/0/text") + .and_then(Value::as_str), + Some("Hello") + ); + assert!( + body.get("thinking").is_none(), + "translation disables thinking: {body}" + ); + assert!( + body.get("system") + .and_then(Value::as_str) + .is_some_and(|system| system.contains("Spanish")), + "target language should be in system prompt: {body}" + ); + } + + #[tokio::test] + async fn deepseek_anthropic_health_check_skips_models_probe() { + let server = MockServer::start().await; + let client = deepseek_anthropic_client(&server); + + assert!(client.health_check().await.expect("health check")); + let requests = server.received_requests().await.expect("recorded requests"); + assert!( + requests.is_empty(), + "DeepSeek Anthropic-compatible route must not probe /models" + ); + } + + #[tokio::test] + async fn deepseek_anthropic_fim_fails_without_http_request() { + let server = MockServer::start().await; + let client = deepseek_anthropic_client(&server); + + let err = client + .fim_completion("deepseek-chat", "fn main() {", "}", 16) + .await + .expect_err("FIM is unsupported"); + let message = err.to_string(); + assert!( + message.contains("FIM completion is not supported"), + "{message}" + ); + assert!(message.contains("Anthropic Messages protocol"), "{message}"); + let requests = server.received_requests().await.expect("recorded requests"); + assert!( + requests.is_empty(), + "unsupported FIM should fail locally before any HTTP call" + ); + } + #[test] fn custom_api_key_header_is_allowed_without_primary_provider_key() { let mut extra = HashMap::new(); diff --git a/crates/tui/src/client/anthropic.rs b/crates/tui/src/client/anthropic.rs index 4a21517b3..ec506b5d0 100644 --- a/crates/tui/src/client/anthropic.rs +++ b/crates/tui/src/client/anthropic.rs @@ -954,5 +954,9 @@ mod tests { anthropic_messages_url("https://gateway.example/v1"), "https://gateway.example/v1/messages" ); + assert_eq!( + anthropic_messages_url("https://api.deepseek.com/anthropic"), + "https://api.deepseek.com/anthropic/v1/messages" + ); } } diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 143347d5b..29e21a289 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -79,6 +79,8 @@ fn resolve_subagent_heartbeat_timeout_secs(raw: Option, api_timeout_secs: u pub const DEFAULT_TEXT_MODEL: &str = "deepseek-v4-pro"; pub const DEFAULT_DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/beta"; +pub const DEFAULT_DEEPSEEK_ANTHROPIC_MODEL: &str = DEFAULT_TEXT_MODEL; +pub const DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL: &str = "https://api.deepseek.com/anthropic"; pub const DEFAULT_NVIDIA_NIM_MODEL: &str = "deepseek-ai/deepseek-v4-pro"; pub const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = "deepseek-ai/deepseek-v4-flash"; pub const DEFAULT_NVIDIA_NIM_BASE_URL: &str = "https://integrate.api.nvidia.com/v1"; @@ -234,6 +236,7 @@ pub const DEFAULT_MINIMAX_BASE_URL: &str = "https://api.minimax.io/v1"; pub enum ApiProvider { Deepseek, DeepseekCN, + DeepseekAnthropic, NvidiaNim, Openai, Atlascloud, @@ -359,7 +362,9 @@ impl ApiProvider { #[must_use] pub fn credential_url(self) -> Option<&'static str> { Some(match self { - Self::Deepseek | Self::DeepseekCN => "https://platform.deepseek.com/api_keys", + Self::Deepseek | Self::DeepseekCN | Self::DeepseekAnthropic => { + "https://platform.deepseek.com/api_keys" + } Self::NvidiaNim => "https://build.nvidia.com/settings/api-keys", Self::Openai => "https://platform.openai.com/api-keys", Self::Atlascloud => "https://atlascloud.ai/docs/en/api-keys", @@ -392,9 +397,10 @@ impl ApiProvider { /// `ApiProvider` discriminant → `ProviderKind` lookup. /// Index 1 is `None` for the legacy `DeepseekCN` variant. - const KIND_LOOKUP: [Option; 27] = [ + const KIND_LOOKUP: [Option; 28] = [ Some(codewhale_config::ProviderKind::Deepseek), None, // DeepseekCN + Some(codewhale_config::ProviderKind::DeepseekAnthropic), Some(codewhale_config::ProviderKind::NvidiaNim), Some(codewhale_config::ProviderKind::Openai), Some(codewhale_config::ProviderKind::Atlascloud), @@ -423,8 +429,9 @@ impl ApiProvider { ]; /// `ProviderKind` discriminant → `ApiProvider` lookup. - const FROM_KIND_LOOKUP: [Self; 26] = [ + const FROM_KIND_LOOKUP: [Self; 27] = [ Self::Deepseek, + Self::DeepseekAnthropic, Self::NvidiaNim, Self::Openai, Self::Atlascloud, @@ -497,6 +504,10 @@ fn subagent_provider_key_matches(key: &str, provider: ApiProvider) -> bool { normalized.as_str(), "deepseek_cn" | "deepseek_china" | "deepseekcn" ), + ApiProvider::DeepseekAnthropic => matches!( + normalized.as_str(), + "deepseek_anthropic" | "deepseek_claude" | "deepseek_anthropic_api" + ), ApiProvider::Openrouter => matches!(normalized.as_str(), "openrouter" | "open_router"), ApiProvider::OpenaiCodex => matches!( normalized.as_str(), @@ -645,7 +656,10 @@ pub fn provider_capability(provider: ApiProvider, resolved_model: &str) -> Provi } let model_lower = resolved_model.to_ascii_lowercase(); - let alias_deprecation = if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) { + let alias_deprecation = if matches!( + provider, + ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::DeepseekAnthropic + ) { deepseek_alias_deprecation(&model_lower) } else { None @@ -695,8 +709,11 @@ pub fn provider_capability(provider: ApiProvider, resolved_model: &str) -> Provi | ApiProvider::Volcengine ); - // Request payload mode: all current providers use chat completions. - let request_payload_mode = RequestPayloadMode::ChatCompletions; + let request_payload_mode = if matches!(provider, ApiProvider::DeepseekAnthropic) { + RequestPayloadMode::AnthropicMessages + } else { + RequestPayloadMode::ChatCompletions + }; ProviderCapability { provider, @@ -787,7 +804,9 @@ pub(crate) fn normalize_custom_model_id(model: &str) -> Option { #[must_use] pub fn requested_model_for_provider(provider: ApiProvider, model: &str) -> Option { match provider { - ApiProvider::Deepseek | ApiProvider::DeepseekCN => normalize_model_name(model), + ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::DeepseekAnthropic => { + normalize_model_name(model) + } _ => normalize_custom_model_id(model), } } @@ -1155,8 +1174,10 @@ pub fn normalize_model_name_for_provider(provider: ApiProvider, model: &str) -> return Some(provider_model); } } - if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) - && let Some(canonical) = canonical_official_deepseek_model_id(&normalized) + if matches!( + provider, + ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::DeepseekAnthropic + ) && let Some(canonical) = canonical_official_deepseek_model_id(&normalized) { // When the user's input already matches a known model id // case-insensitively, keep their original casing; only rewrite @@ -1202,7 +1223,9 @@ pub fn wire_model_for_provider(provider: ApiProvider, model: &str) -> String { #[must_use] pub fn model_completion_names_for_provider(provider: ApiProvider) -> Vec<&'static str> { match provider { - ApiProvider::Deepseek | ApiProvider::DeepseekCN => OFFICIAL_DEEPSEEK_MODELS.to_vec(), + ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::DeepseekAnthropic => { + OFFICIAL_DEEPSEEK_MODELS.to_vec() + } ApiProvider::NvidiaNim => vec![DEFAULT_NVIDIA_NIM_MODEL, DEFAULT_NVIDIA_NIM_FLASH_MODEL], ApiProvider::Openrouter => { let mut models = vec![DEFAULT_OPENROUTER_MODEL, DEFAULT_OPENROUTER_FLASH_MODEL]; @@ -2635,6 +2658,14 @@ pub struct ProvidersConfig { pub deepseek: ProviderConfig, #[serde(default, alias = "deepseekCn")] pub deepseek_cn: ProviderConfig, + #[serde( + default, + alias = "deepseek-anthropic", + alias = "deepseekAnthropic", + alias = "deepseek-claude", + alias = "deepseek_claude" + )] + pub deepseek_anthropic: ProviderConfig, #[serde(default, alias = "nvidiaNim")] pub nvidia_nim: ProviderConfig, #[serde(default)] @@ -2973,6 +3004,7 @@ impl Config { Some(match provider { ApiProvider::Deepseek => &providers.deepseek, ApiProvider::DeepseekCN => &providers.deepseek_cn, + ApiProvider::DeepseekAnthropic => &providers.deepseek_anthropic, ApiProvider::NvidiaNim => &providers.nvidia_nim, ApiProvider::Openai => &providers.openai, ApiProvider::Atlascloud => &providers.atlascloud, @@ -3016,6 +3048,7 @@ impl Config { match provider { ApiProvider::Deepseek => &mut providers.deepseek, ApiProvider::DeepseekCN => &mut providers.deepseek_cn, + ApiProvider::DeepseekAnthropic => &mut providers.deepseek_anthropic, ApiProvider::NvidiaNim => &mut providers.nvidia_nim, ApiProvider::Openai => &mut providers.openai, ApiProvider::Atlascloud => &mut providers.atlascloud, @@ -3165,6 +3198,7 @@ impl Config { match provider { ApiProvider::Deepseek | ApiProvider::DeepseekCN => DEFAULT_TEXT_MODEL, + ApiProvider::DeepseekAnthropic => DEFAULT_DEEPSEEK_ANTHROPIC_MODEL, ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL, ApiProvider::Openai => DEFAULT_OPENAI_MODEL, ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_MODEL, @@ -3205,6 +3239,7 @@ impl Config { // entries or the corresponding `*_BASE_URL` env var. let root_base = match provider { ApiProvider::Deepseek | ApiProvider::DeepseekCN => self.base_url.clone(), + ApiProvider::DeepseekAnthropic => None, ApiProvider::NvidiaNim => self .base_url .as_ref() @@ -3254,6 +3289,7 @@ impl Config { match provider { ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL, ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL, + ApiProvider::DeepseekAnthropic => DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL, ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL, ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL, ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL, @@ -4348,6 +4384,13 @@ fn apply_env_overrides(config: &mut Config) { ApiProvider::Deepseek | ApiProvider::DeepseekCN => { config.base_url = Some(value); } + ApiProvider::DeepseekAnthropic => { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .deepseek_anthropic + .base_url = Some(value); + } ApiProvider::NvidiaNim => { config .providers @@ -4710,6 +4753,7 @@ fn apply_env_overrides(config: &mut Config) { let entry = match provider { ApiProvider::Deepseek => &mut providers.deepseek, ApiProvider::DeepseekCN => &mut providers.deepseek_cn, + ApiProvider::DeepseekAnthropic => &mut providers.deepseek_anthropic, ApiProvider::NvidiaNim => &mut providers.nvidia_nim, ApiProvider::Openai => &mut providers.openai, ApiProvider::Atlascloud => &mut providers.atlascloud, @@ -4900,14 +4944,19 @@ fn apply_env_overrides(config: &mut Config) { // (issue #1714). Mirror the OPENAI_MODEL branch above for every // non-DeepSeek provider. let provider = config.api_provider(); - if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) { + if matches!( + provider, + ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::DeepseekAnthropic + ) { config.default_text_model = Some(value); } else { let providers = config .providers .get_or_insert_with(ProvidersConfig::default); let entry = match provider { - ApiProvider::Deepseek | ApiProvider::DeepseekCN => unreachable!( + ApiProvider::Deepseek + | ApiProvider::DeepseekCN + | ApiProvider::DeepseekAnthropic => unreachable!( "DeepSeek providers are handled in the if branch above (issue #1714)" ), ApiProvider::NvidiaNim => &mut providers.nvidia_nim, @@ -5639,6 +5688,10 @@ fn merge_providers( (Some(base), Some(override_cfg)) => Some(ProvidersConfig { deepseek: merge_provider_config(base.deepseek, override_cfg.deepseek), deepseek_cn: merge_provider_config(base.deepseek_cn, override_cfg.deepseek_cn), + deepseek_anthropic: merge_provider_config( + base.deepseek_anthropic, + override_cfg.deepseek_anthropic, + ), nvidia_nim: merge_provider_config(base.nvidia_nim, override_cfg.nvidia_nim), openai: merge_provider_config(base.openai, override_cfg.openai), anthropic: merge_provider_config(base.anthropic, override_cfg.anthropic), diff --git a/crates/tui/src/config/tests.rs b/crates/tui/src/config/tests.rs index 9fb1bb1f2..c372e2b1d 100644 --- a/crates/tui/src/config/tests.rs +++ b/crates/tui/src/config/tests.rs @@ -6127,6 +6127,26 @@ fn provider_capability_deepseek_v4_pro_has_1m_window_and_thinking() { ); } +#[test] +fn provider_capability_deepseek_anthropic_uses_messages_payload() { + let cap = provider_capability( + ApiProvider::DeepseekAnthropic, + DEFAULT_DEEPSEEK_ANTHROPIC_MODEL, + ); + assert_eq!( + cap.context_window, + crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS + ); + assert_eq!(cap.max_output, 384_000); + assert!(cap.thinking_supported); + assert!(!cap.cache_telemetry_supported); + assert_eq!( + cap.request_payload_mode, + RequestPayloadMode::AnthropicMessages + ); + assert!(cap.alias_deprecation.is_none()); +} + #[test] fn provider_capability_deepseek_v4_flash_has_1m_window_and_thinking() { let cap = provider_capability(ApiProvider::Deepseek, "deepseek-v4-flash"); diff --git a/crates/tui/src/config_persistence.rs b/crates/tui/src/config_persistence.rs index 39d00bbf0..0731e8616 100644 --- a/crates/tui/src/config_persistence.rs +++ b/crates/tui/src/config_persistence.rs @@ -290,6 +290,7 @@ fn provider_base_url_table_key(provider: ApiProvider) -> anyhow::Result<&'static ApiProvider::Deepseek | ApiProvider::DeepseekCN => { anyhow::bail!("DeepSeek uses the root base_url setting") } + ApiProvider::DeepseekAnthropic => Ok("deepseek_anthropic"), ApiProvider::NvidiaNim => Ok("nvidia_nim"), ApiProvider::Openai => Ok("openai"), ApiProvider::Anthropic => Ok("anthropic"), diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index fd62d94ad..5629d980c 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -8278,6 +8278,7 @@ fn render(f: &mut Frame, app: &mut App) { let provider_label = match app.api_provider { crate::config::ApiProvider::Deepseek => None, crate::config::ApiProvider::DeepseekCN => None, + crate::config::ApiProvider::DeepseekAnthropic => Some("DS-A"), crate::config::ApiProvider::NvidiaNim => Some("NIM"), crate::config::ApiProvider::Openai => Some("OpenAI"), crate::config::ApiProvider::Anthropic => Some("Claude"), @@ -9416,6 +9417,7 @@ async fn apply_provider_picker_api_key( // Guarded by the outer `if` above; safety net against refactors. return; } + ApiProvider::DeepseekAnthropic => &mut providers.deepseek_anthropic, ApiProvider::NvidiaNim => &mut providers.nvidia_nim, ApiProvider::Openai => &mut providers.openai, ApiProvider::Atlascloud => &mut providers.atlascloud, @@ -9481,6 +9483,7 @@ fn set_provider_auth_mode_in_memory(config: &mut Config, provider: ApiProvider, .get_or_insert_with(ProvidersConfig::default); let entry: &mut ProviderConfig = match provider { ApiProvider::Deepseek | ApiProvider::DeepseekCN => return, + ApiProvider::DeepseekAnthropic => &mut providers.deepseek_anthropic, ApiProvider::NvidiaNim => &mut providers.nvidia_nim, ApiProvider::Openai => &mut providers.openai, ApiProvider::Atlascloud => &mut providers.atlascloud, diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 35ca775f3..43002cbcb 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -453,13 +453,14 @@ aliases. When both forms are set the `CODEWHALE_*` value wins; the `DEEPSEEK_*` form is kept for older shells: - `CODEWHALE_PROVIDER` (preferred) / `DEEPSEEK_PROVIDER` (legacy alias) — - `deepseek|nvidia-nim|openai|atlascloud|wanjie-ark|volcengine|openrouter|xiaomi-mimo|novita|fireworks|siliconflow|arcee|siliconflow-CN|moonshot|sglang|vllm|ollama|huggingface|together|qianfan|openai-codex|anthropic|zai|stepfun|minimax|deepinfra` + `deepseek|deepseek-anthropic|nvidia-nim|openai|atlascloud|wanjie-ark|volcengine|openrouter|xiaomi-mimo|novita|fireworks|siliconflow|arcee|siliconflow-CN|moonshot|sglang|vllm|ollama|huggingface|together|qianfan|openai-codex|anthropic|zai|stepfun|minimax|deepinfra` - `CODEWHALE_MODEL` (preferred) / `DEEPSEEK_MODEL` (legacy alias) — default model for the active provider - `CODEWHALE_BASE_URL` (preferred) / `DEEPSEEK_BASE_URL` (legacy alias) — base URL for the active provider Remaining variables: - `DEEPSEEK_API_KEY` +- `DEEPSEEK_ANTHROPIC_BASE_URL` - `DEEPSEEK_HTTP_HEADERS` (custom model request headers, comma-separated `name=value` pairs) - `DEEPSEEK_DEFAULT_TEXT_MODEL` (extra legacy alias of `DEEPSEEK_MODEL`) - `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` (stream idle timeout in seconds; default `300`, clamped to `1..=3600`) @@ -1014,14 +1015,14 @@ If you are upgrading from older releases: ### Core keys (used by the TUI/engine) -- `provider` (string, optional): `deepseek` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`, `openrouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, `arcee`, `siliconflow-CN`, `moonshot`, `sglang`, `vllm`, `ollama`, `huggingface`, `together`, `qianfan`, `openai-codex`, `anthropic`, `zai`, `stepfun`, `minimax`, or `deepinfra`. Legacy `deepseek-cn` configs are still accepted as an alias for `deepseek`; DeepSeek uses the same official host [`https://api.deepseek.com`](https://api-docs.deepseek.com/) worldwide. `nvidia-nim` targets NVIDIA's NIM-hosted DeepSeek endpoints through `https://integrate.api.nvidia.com/v1`; `openai` targets a generic OpenAI-compatible endpoint, defaulting to `https://api.openai.com/v1`; `atlascloud` targets AtlasCloud's OpenAI-compatible endpoint at `https://api.atlascloud.ai/v1`; `wanjie-ark` targets Wanjie Ark's OpenAI-compatible endpoint at `https://maas-openapi.wanjiedata.com/api/v1`; `volcengine` targets Volcengine Ark's OpenAI-compatible coding endpoint at `https://ark.cn-beijing.volces.com/api/coding/v3`; `openrouter` targets `https://openrouter.ai/api/v1`; `xiaomi-mimo` targets Xiaomi MiMo's OpenAI-compatible endpoint, using `https://token-plan-sgp.xiaomimimo.com/v1` by default for Token Plan keys (`tp-...`) and `https://api.xiaomimimo.com/v1` for pay-as-you-go keys; set `base_url` explicitly if your Token Plan account uses the China region; `novita` targets `https://api.novita.ai/openai/v1`; `fireworks` targets `https://api.fireworks.ai/inference/v1`; `siliconflow` targets SiliconFlow, defaulting to `https://api.siliconflow.com/v1`; `arcee` targets Arcee AI's OpenAI-compatible endpoint at `https://api.arcee.ai/api/v1`; `siliconflow-CN` targets the SiliconFlow China regional endpoint through `[providers.siliconflow_cn]`; `moonshot` targets Moonshot/Kimi, defaulting to `https://api.moonshot.ai/v1`; `sglang` targets a self-hosted OpenAI-compatible endpoint, defaulting to `http://localhost:30000/v1`; `vllm` targets a self-hosted vLLM OpenAI-compatible endpoint, defaulting to `http://localhost:8000/v1`; `ollama` targets Ollama's OpenAI-compatible endpoint, defaulting to `http://localhost:11434/v1`; `huggingface` targets Hugging Face Inference Providers at `https://router.huggingface.co/v1`; `together` targets Together AI at `https://api.together.xyz/v1`; `qianfan` targets Baidu Qianfan at `https://api.baiduqianfan.ai/v1`; `openai-codex` targets ChatGPT/Codex OAuth; `anthropic` targets Claude's native Messages API; `zai` targets Z.ai at `https://api.z.ai/api/coding/paas/v4`; `stepfun` targets StepFun at `https://api.stepfun.ai/v1`; `minimax` targets MiniMax at `https://api.minimax.io/v1`; `deepinfra` targets DeepInfra at `https://api.deepinfra.com/v1/openai`. +- `provider` (string, optional): `deepseek` (default), `deepseek-anthropic`, `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`, `openrouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, `arcee`, `siliconflow-CN`, `moonshot`, `sglang`, `vllm`, `ollama`, `huggingface`, `together`, `qianfan`, `openai-codex`, `anthropic`, `zai`, `stepfun`, `minimax`, or `deepinfra`. Legacy `deepseek-cn` configs are still accepted as an alias for `deepseek`; DeepSeek uses the same official host [`https://api.deepseek.com`](https://api-docs.deepseek.com/) worldwide. `deepseek-anthropic` targets DeepSeek's Anthropic Messages-compatible endpoint at `https://api.deepseek.com/anthropic` using `DEEPSEEK_API_KEY`; `nvidia-nim` targets NVIDIA's NIM-hosted DeepSeek endpoints through `https://integrate.api.nvidia.com/v1`; `openai` targets a generic OpenAI-compatible endpoint, defaulting to `https://api.openai.com/v1`; `atlascloud` targets AtlasCloud's OpenAI-compatible endpoint at `https://api.atlascloud.ai/v1`; `wanjie-ark` targets Wanjie Ark's OpenAI-compatible endpoint at `https://maas-openapi.wanjiedata.com/api/v1`; `volcengine` targets Volcengine Ark's OpenAI-compatible coding endpoint at `https://ark.cn-beijing.volces.com/api/coding/v3`; `openrouter` targets `https://openrouter.ai/api/v1`; `xiaomi-mimo` targets Xiaomi MiMo's OpenAI-compatible endpoint, using `https://token-plan-sgp.xiaomimimo.com/v1` by default for Token Plan keys (`tp-...`) and `https://api.xiaomimimo.com/v1` for pay-as-you-go keys; set `base_url` explicitly if your Token Plan account uses the China region; `novita` targets `https://api.novita.ai/openai/v1`; `fireworks` targets `https://api.fireworks.ai/inference/v1`; `siliconflow` targets SiliconFlow, defaulting to `https://api.siliconflow.com/v1`; `arcee` targets Arcee AI's OpenAI-compatible endpoint at `https://api.arcee.ai/api/v1`; `siliconflow-CN` targets the SiliconFlow China regional endpoint through `[providers.siliconflow_cn]`; `moonshot` targets Moonshot/Kimi, defaulting to `https://api.moonshot.ai/v1`; `sglang` targets a self-hosted OpenAI-compatible endpoint, defaulting to `http://localhost:30000/v1`; `vllm` targets a self-hosted vLLM OpenAI-compatible endpoint, defaulting to `http://localhost:8000/v1`; `ollama` targets Ollama's OpenAI-compatible endpoint, defaulting to `http://localhost:11434/v1`; `huggingface` targets Hugging Face Inference Providers at `https://router.huggingface.co/v1`; `together` targets Together AI at `https://api.together.xyz/v1`; `qianfan` targets Baidu Qianfan at `https://api.baiduqianfan.ai/v1`; `openai-codex` targets ChatGPT/Codex OAuth; `anthropic` targets Claude's native Messages API; `zai` targets Z.ai at `https://api.z.ai/api/coding/paas/v4`; `stepfun` targets StepFun at `https://api.stepfun.ai/v1`; `minimax` targets MiniMax at `https://api.minimax.io/v1`; `deepinfra` targets DeepInfra at `https://api.deepinfra.com/v1/openai`. - `api_key` (string, required for hosted providers): must be non-empty for DeepSeek/hosted providers (or set the provider API key env var). Self-hosted SGLang, vLLM, and Ollama can omit it. -- `base_url` (string, optional): defaults to `https://api.deepseek.com/beta` for DeepSeek's OpenAI-compatible Chat Completions API, including legacy `provider = "deepseek-cn"` configs. Other defaults are `https://integrate.api.nvidia.com/v1` for `nvidia-nim`, `https://api.openai.com/v1` for `openai`, `https://api.atlascloud.ai/v1` for `atlascloud`, `https://maas-openapi.wanjiedata.com/api/v1` for `wanjie-ark`, `https://ark.cn-beijing.volces.com/api/coding/v3` for `volcengine`, `https://openrouter.ai/api/v1` for `openrouter`, `https://token-plan-sgp.xiaomimimo.com/v1` for `xiaomi-mimo` when the API key starts with `tp-...` and `https://api.xiaomimimo.com/v1` otherwise, `https://api.novita.ai/openai/v1` for `novita`, `https://api.fireworks.ai/inference/v1` for `fireworks`, `https://api.siliconflow.com/v1` for `siliconflow`, `https://api.siliconflow.cn/v1` for `siliconflow-CN`, `https://api.arcee.ai/api/v1` for `arcee`, `https://api.moonshot.ai/v1` for `moonshot`, `https://api.minimax.io/v1` for `minimax`, `https://api.z.ai/api/coding/paas/v4` for `zai`, `https://api.stepfun.ai/v1` for `stepfun`, `https://api.deepinfra.com/v1/openai` for `deepinfra`, `https://router.huggingface.co/v1` for `huggingface`, `https://api.together.xyz/v1` for `together`, `https://api.baiduqianfan.ai/v1` for `qianfan`, `https://chatgpt.com/backend-api` for `openai-codex`, `https://api.anthropic.com` for `anthropic`, `http://localhost:30000/v1` for `sglang`, `http://localhost:8000/v1` for `vllm`, and `http://localhost:11434/v1` for `ollama`. Set `base_url = "https://token-plan-cn.xiaomimimo.com/v1"` explicitly if your Xiaomi MiMo Token Plan account is provisioned in the China region. Set `https://api.deepseek.com` or `https://api.deepseek.com/v1` explicitly to opt out of DeepSeek beta features. +- `base_url` (string, optional): defaults to `https://api.deepseek.com/beta` for DeepSeek's OpenAI-compatible Chat Completions API, including legacy `provider = "deepseek-cn"` configs. Other defaults are `https://api.deepseek.com/anthropic` for `deepseek-anthropic`, `https://integrate.api.nvidia.com/v1` for `nvidia-nim`, `https://api.openai.com/v1` for `openai`, `https://api.atlascloud.ai/v1` for `atlascloud`, `https://maas-openapi.wanjiedata.com/api/v1` for `wanjie-ark`, `https://ark.cn-beijing.volces.com/api/coding/v3` for `volcengine`, `https://openrouter.ai/api/v1` for `openrouter`, `https://token-plan-sgp.xiaomimimo.com/v1` for `xiaomi-mimo` when the API key starts with `tp-...` and `https://api.xiaomimimo.com/v1` otherwise, `https://api.novita.ai/openai/v1` for `novita`, `https://api.fireworks.ai/inference/v1` for `fireworks`, `https://api.siliconflow.com/v1` for `siliconflow`, `https://api.siliconflow.cn/v1` for `siliconflow-CN`, `https://api.arcee.ai/api/v1` for `arcee`, `https://api.moonshot.ai/v1` for `moonshot`, `https://api.minimax.io/v1` for `minimax`, `https://api.z.ai/api/coding/paas/v4` for `zai`, `https://api.stepfun.ai/v1` for `stepfun`, `https://api.deepinfra.com/v1/openai` for `deepinfra`, `https://router.huggingface.co/v1` for `huggingface`, `https://api.together.xyz/v1` for `together`, `https://api.baiduqianfan.ai/v1` for `qianfan`, `https://chatgpt.com/backend-api` for `openai-codex`, `https://api.anthropic.com` for `anthropic`, `http://localhost:30000/v1` for `sglang`, `http://localhost:8000/v1` for `vllm`, and `http://localhost:11434/v1` for `ollama`. Set `base_url = "https://token-plan-cn.xiaomimimo.com/v1"` explicitly if your Xiaomi MiMo Token Plan account is provisioned in the China region. Set `https://api.deepseek.com` or `https://api.deepseek.com/v1` explicitly to opt out of DeepSeek beta features. - `path_suffix` (string, optional provider-table key): override the chat-completions path for OpenAI-compatible gateways that do not serve `/v1/chat/completions`. For example, `[providers.openai] path_suffix = "/chat/completions"` sends chat requests to the unversioned base URL plus `/chat/completions`; `models` and `beta/*` requests keep their normal routing. - `reasoning_stream_style` (string, optional provider-table key): override how streaming reasoning is separated from answer text for the active provider route. Use `separate_field` for `reasoning_content` / `reasoning` deltas, `inline_tags` for gateways that stream `...` inside `delta.content`, or `none` to render incoming content exactly as answer text. - `[providers..auth]` (table, optional): provider-scoped auth source metadata. `source = "command"` stores a command argv plus optional `timeout_ms`; `source = "secret"` stores a `secret_id`. This slice lets provider readiness, `/provider`, and doctor JSON report the auth source class without exposing command argv output or secret values; executing commands and resolving external secret material is handled by the follow-up resolver work. - `insecure_skip_tls_verify` (bool, optional provider-table key): legacy compatibility key, disabled by default. When true on the active provider table, provider clients reject the configuration instead of skipping TLS certificate verification. Use `SSL_CERT_FILE` for corporate or private CA bundles; `codewhale doctor` reports stale uses of this setting. -- `default_text_model` (string, optional): defaults to `deepseek-v4-pro` for DeepSeek and generic OpenAI-compatible endpoints, `deepseek-ai/deepseek-v4-pro` for NVIDIA NIM, `deepseek-ai/deepseek-v4-flash` for AtlasCloud, `deepseek-reasoner` for Wanjie Ark, `DeepSeek-V4-Pro` for Volcengine Ark, `deepseek/deepseek-v4-pro` for OpenRouter and Novita, `mimo-v2.5-pro` for Xiaomi MiMo, `accounts/fireworks/models/deepseek-v4-pro` for Fireworks, `deepseek-ai/DeepSeek-V4-Pro` for SiliconFlow and DeepInfra, `trinity-large-thinking` for Arcee AI, `kimi-k2.7-code` for Moonshot, `MiniMax-M3` for MiniMax, `GLM-5.2` for Z.ai, `step-3.7-flash` for StepFun, `ernie-4.0-turbo-8k` for Qianfan, `deepseek-ai/DeepSeek-V4-Pro` for SGLang/vLLM, and `deepseek-coder:1.3b` for Ollama. Hugging Face and Together AI both default to `deepseek-ai/DeepSeek-V4-Pro`; `openai-codex` defaults to `gpt-5.5`; `anthropic` defaults to `claude-sonnet-4-6`. Current public DeepSeek IDs are `deepseek-v4-pro` and `deepseek-v4-flash`, both with 1M context windows, 384K max output, and thinking mode enabled by default. Legacy `deepseek-chat` and `deepseek-reasoner` remain compatibility aliases for `deepseek-v4-flash` until July 24, 2026, except SiliconFlow maps `deepseek-reasoner` and `deepseek-r1` to its Pro model while `deepseek-chat` and `deepseek-v3` map to Flash. Provider-specific mappings translate `deepseek-v4-pro` / `deepseek-v4-flash` to each provider's model ID where supported. OpenRouter also recognizes recent large IDs such as `arcee-ai/trinity-large-thinking`, `minimax/minimax-m3`, `minimax/minimax-2.7`, `xiaomi/mimo-v2.5-pro`, `qwen/qwen3.6-flash`, `qwen/qwen3.6-35b-a3b`, `qwen/qwen3.6-max-preview`, `qwen/qwen3.6-27b`, `qwen/qwen3.6-plus`, `qwen/qwen3.7-max`, `google/gemma-4-31b-it`, `moonshotai/kimi-k2.7-code`, `moonshotai/kimi-k2.6`, `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free`, and `nvidia/nemotron-3-ultra-550b-a55b`; direct Arcee uses bare IDs such as `trinity-large-thinking` and `trinity-large-preview`; direct Moonshot recognizes `kimi-k2.7-code`, `kimi-k2.6`, and Kimi Code's stable `kimi-for-coding`; direct MiniMax recognizes `MiniMax-M3` and the documented M2.x chat model IDs; direct Xiaomi MiMo recognizes chat IDs `mimo-v2.5-pro` and `mimo-v2.5`, while TTS IDs are selected through `codewhale speech` / `tts`. Generic `openai`, `atlascloud`, `wanjie-ark`, `xiaomi-mimo`, `arcee`, `moonshot`, `minimax`, `zai`, `stepfun`, `qianfan`, and Ollama model IDs are passed through unchanged after known aliases are normalized. OpenRouter and SiliconFlow provider configs with a custom `base_url` also preserve explicit model values, which lets OpenAI-compatible gateways accept bare model IDs. Use `/models` or `codewhale models` to discover live IDs from your configured endpoint. `CODEWHALE_MODEL` overrides this for a single process; `DEEPSEEK_MODEL` is the legacy alias. +- `default_text_model` (string, optional): defaults to `deepseek-v4-pro` for DeepSeek, `deepseek-anthropic`, and generic OpenAI-compatible endpoints, `deepseek-ai/deepseek-v4-pro` for NVIDIA NIM, `deepseek-ai/deepseek-v4-flash` for AtlasCloud, `deepseek-reasoner` for Wanjie Ark, `DeepSeek-V4-Pro` for Volcengine Ark, `deepseek/deepseek-v4-pro` for OpenRouter and Novita, `mimo-v2.5-pro` for Xiaomi MiMo, `accounts/fireworks/models/deepseek-v4-pro` for Fireworks, `deepseek-ai/DeepSeek-V4-Pro` for SiliconFlow and DeepInfra, `trinity-large-thinking` for Arcee AI, `kimi-k2.7-code` for Moonshot, `MiniMax-M3` for MiniMax, `GLM-5.2` for Z.ai, `step-3.7-flash` for StepFun, `ernie-4.0-turbo-8k` for Qianfan, `deepseek-ai/DeepSeek-V4-Pro` for SGLang/vLLM, and `deepseek-coder:1.3b` for Ollama. Hugging Face and Together AI both default to `deepseek-ai/DeepSeek-V4-Pro`; `openai-codex` defaults to `gpt-5.5`; `anthropic` defaults to `claude-sonnet-4-6`. Current public DeepSeek IDs are `deepseek-v4-pro` and `deepseek-v4-flash`, both with 1M context windows, 384K max output, and thinking mode enabled by default. Legacy `deepseek-chat` and `deepseek-reasoner` remain compatibility aliases for `deepseek-v4-flash` until July 24, 2026, except SiliconFlow maps `deepseek-reasoner` and `deepseek-r1` to its Pro model while `deepseek-chat` and `deepseek-v3` map to Flash. Provider-specific mappings translate `deepseek-v4-pro` / `deepseek-v4-flash` to each provider's model ID where supported. OpenRouter also recognizes recent large IDs such as `arcee-ai/trinity-large-thinking`, `minimax/minimax-m3`, `minimax/minimax-2.7`, `xiaomi/mimo-v2.5-pro`, `qwen/qwen3.6-flash`, `qwen/qwen3.6-35b-a3b`, `qwen/qwen3.6-max-preview`, `qwen/qwen3.6-27b`, `qwen/qwen3.6-plus`, `qwen/qwen3.7-max`, `google/gemma-4-31b-it`, `moonshotai/kimi-k2.7-code`, `moonshotai/kimi-k2.6`, `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free`, and `nvidia/nemotron-3-ultra-550b-a55b`; direct Arcee uses bare IDs such as `trinity-large-thinking` and `trinity-large-preview`; direct Moonshot recognizes `kimi-k2.7-code`, `kimi-k2.6`, and Kimi Code's stable `kimi-for-coding`; direct MiniMax recognizes `MiniMax-M3` and the documented M2.x chat model IDs; direct Xiaomi MiMo recognizes chat IDs `mimo-v2.5-pro` and `mimo-v2.5`, while TTS IDs are selected through `codewhale speech` / `tts`. Generic `openai`, `atlascloud`, `wanjie-ark`, `xiaomi-mimo`, `arcee`, `moonshot`, `minimax`, `zai`, `stepfun`, `qianfan`, and Ollama model IDs are passed through unchanged after known aliases are normalized. OpenRouter and SiliconFlow provider configs with a custom `base_url` also preserve explicit model values, which lets OpenAI-compatible gateways accept bare model IDs. Use `/models` or `codewhale models` to discover live IDs from your configured endpoint. `CODEWHALE_MODEL` overrides this for a single process; `DEEPSEEK_MODEL` is the legacy alias. - `reasoning_effort` (string, optional): `off`, `low`, `medium`, `high`, `max`, `xhigh`, or `ultracode`; defaults to the configured UI tier. DeepSeek Platform receives top-level `thinking` / `reasoning_effort` fields. OpenAI Codex normalizes stale `off` to `low` and sends `max` / `ultracode` as Responses `xhigh`. Z.ai receives documented `thinking` controls and treats enabled thinking as the GLM coding high/max lane. NVIDIA NIM receives equivalent settings through `chat_template_kwargs`. - `verbosity` (string, optional): `normal` or `concise`. `normal` keeps the default conversational prompt. `concise` appends a prompt discipline block diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index e93b7f391..84884f981 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -28,11 +28,11 @@ Sources to keep in sync: The canonical provider IDs are: -`deepseek`, `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`, -`openrouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, `arcee`, -`siliconflow-CN`, `moonshot`, `sglang`, `vllm`, `ollama`, `huggingface`, -`together`, `qianfan`, `openai-codex`, `anthropic`, `zai`, `stepfun`, -`minimax`, and `deepinfra`. +`deepseek`, `deepseek-anthropic`, `nvidia-nim`, `openai`, `atlascloud`, +`wanjie-ark`, `volcengine`, `openrouter`, `xiaomi-mimo`, `novita`, `fireworks`, +`siliconflow`, `arcee`, `siliconflow-CN`, `moonshot`, `sglang`, `vllm`, +`ollama`, `huggingface`, `together`, `qianfan`, `openai-codex`, `anthropic`, +`zai`, `stepfun`, `minimax`, and `deepinfra`. Use any of these surfaces to select a provider: @@ -45,6 +45,12 @@ Use any of these surfaces to select a provider: as legacy aliases for `deepseek`. They do not select a different official host; DeepSeek uses the same official API host worldwide. +`deepseek_anthropic`, `deepseek-claude`, and `deepseek_claude` select +`deepseek-anthropic`, the opt-in DeepSeek route that speaks the Anthropic +Messages API at `https://api.deepseek.com/anthropic`. It keeps the normal +DeepSeek API key path but uses `x-api-key` plus `anthropic-version: 2023-06-01` +instead of Bearer auth. + `huggingface`, `hugging-face`, `hugging_face`, and `hf` all select the Hugging Face Inference Providers route. This is the OpenAI-compatible router path for chat/inference, not Hub browsing, model-card inspection, uploads, or @@ -72,6 +78,7 @@ the listed provider env vars. | Provider ID | TOML table | Wire protocol | Auth env vars | | --- | --- | --- | --- | | `deepseek` | `[providers.deepseek]` | OpenAI Chat Completions | `DEEPSEEK_API_KEY` | +| `deepseek-anthropic` | `[providers.deepseek_anthropic]` | Anthropic Messages | `DEEPSEEK_API_KEY` | | `nvidia-nim` | `[providers.nvidia_nim]` | OpenAI Chat Completions | `NVIDIA_API_KEY`, `NVIDIA_NIM_API_KEY`, `DEEPSEEK_API_KEY` | | `openai` | `[providers.openai]` | OpenAI Chat Completions | `OPENAI_API_KEY` | | `atlascloud` | `[providers.atlascloud]` | OpenAI Chat Completions | `ATLASCLOUD_API_KEY` | @@ -101,8 +108,8 @@ the listed provider env vars. Default base URLs and models for each route are listed in the shipped provider table below. The wire protocol values above are derived from `crates/config/src/provider.rs`: `ChatCompletions` is the default, -`openai-codex` overrides to `Responses`, and `anthropic` overrides to -`AnthropicMessages`. +`openai-codex` overrides to `Responses`, and `deepseek-anthropic` plus +`anthropic` override to `AnthropicMessages`. ## Auth And Env Rules @@ -227,6 +234,7 @@ the same links where possible. | Provider ID | TOML table | Auth env | Base URL env and default | Default or static models | Notes | | --- | --- | --- | --- | --- | --- | | `deepseek` | `[providers.deepseek]` | `DEEPSEEK_API_KEY` | `CODEWHALE_BASE_URL` / `DEEPSEEK_BASE_URL`; default `https://api.deepseek.com/beta` | `deepseek-v4-pro`, `deepseek-v4-flash`; compatibility aliases `deepseek-chat`, `deepseek-reasoner` | First-class default. Beta URL enables strict tool mode, chat prefix completion, and FIM completion. Set `https://api.deepseek.com` or `/v1` explicitly to opt out of beta-only features. | +| `deepseek-anthropic` | `[providers.deepseek_anthropic]` | `DEEPSEEK_API_KEY` | `DEEPSEEK_ANTHROPIC_BASE_URL`; default `https://api.deepseek.com/anthropic` | `deepseek-v4-pro`, `deepseek-v4-flash`; compatibility aliases `deepseek-chat`, `deepseek-reasoner` | Opt-in DeepSeek route for the Anthropic Messages wire protocol. Uses `/v1/messages`, `x-api-key`, and `anthropic-version: 2023-06-01`. Keep `provider = "deepseek"` for the default Chat Completions path. | | `nvidia-nim` | `[providers.nvidia_nim]` | `NVIDIA_API_KEY`, `NVIDIA_NIM_API_KEY`, fallback `DEEPSEEK_API_KEY` | `NVIDIA_NIM_BASE_URL`, `NIM_BASE_URL`, `NVIDIA_BASE_URL`; default `https://integrate.api.nvidia.com/v1` | `deepseek-ai/deepseek-v4-pro`, `deepseek-ai/deepseek-v4-flash` | Hosted DeepSeek V4 through NVIDIA NIM. `NVIDIA_NIM_MODEL` is accepted by the TUI config path. | | `openai` | `[providers.openai]` | `OPENAI_API_KEY` | `OPENAI_BASE_URL`; default `https://api.openai.com/v1` | Registry entries: `deepseek-v4-pro`, `deepseek-v4-flash`; default config model `deepseek-v4-pro` | Generic OpenAI-compatible route for gateways and custom endpoints, including Alibaba Bailian / Model Studio DashScope when configured with that endpoint. Use this for explicit third-party OpenAI-compatible routes instead of inventing a new provider ID. `OPENAI_MODEL` is accepted. | | `atlascloud` | `[providers.atlascloud]` | `ATLASCLOUD_API_KEY` | `ATLASCLOUD_BASE_URL`; default `https://api.atlascloud.ai/v1` | Default `deepseek-ai/deepseek-v4-flash`; explicit `vendor/model-id` values pass through when AtlasCloud is selected | OpenAI-compatible hosted route. `ATLASCLOUD_MODEL` is accepted by the TUI config path, the static `ModelRegistry` keeps DeepSeek V4 fallback rows, and provider-hinted CLI model IDs are sent to AtlasCloud exactly as requested. Use Atlas Cloud's own catalog or Coding Plan page for the current provider-owned model list and pricing. | diff --git a/scripts/check-provider-registry.py b/scripts/check-provider-registry.py index 4d41ce566..34e504d07 100644 --- a/scripts/check-provider-registry.py +++ b/scripts/check-provider-registry.py @@ -126,8 +126,10 @@ def provider_kind_ids(config_rs: str) -> dict[str, str]: provider_rs, ) ids: dict[str, str] = {variant: provider_id for variant, provider_id in pairs} - # OpenaiCodex and Anthropic use manual impls rather than the provider!() macro + # OpenaiCodex, Anthropic, and DeepseekAnthropic use manual impls rather + # than the provider!() macro. for variant_name, id_literal in [ + ("DeepseekAnthropic", "deepseek-anthropic"), ("OpenaiCodex", "openai-codex"), ("Anthropic", "anthropic"), ]: diff --git a/web/lib/facts-drift.ts b/web/lib/facts-drift.ts index 8eddad71f..a1a78eebb 100644 --- a/web/lib/facts-drift.ts +++ b/web/lib/facts-drift.ts @@ -77,6 +77,7 @@ function deriveProvidersFromConfig(cfg: string): ProviderFact[] { // so the binary rejects it — keep it out of the docs. Issue #1104. const labelMap: Record = { Deepseek: { id: "deepseek", label: "DeepSeek", env: "DEEPSEEK_API_KEY" }, + DeepseekAnthropic: { id: "deepseek-anthropic", label: "DeepSeek Anthropic", env: "DEEPSEEK_API_KEY / ANTHROPIC_API_KEY" }, NvidiaNim: { id: "nvidia-nim", label: "NVIDIA NIM", env: "NVIDIA_API_KEY / NVIDIA_NIM_API_KEY" }, Openai: { id: "openai", label: "OpenAI-compatible", env: "OPENAI_API_KEY" }, Atlascloud: { id: "atlascloud", label: "AtlasCloud", env: "ATLASCLOUD_API_KEY" }, diff --git a/web/scripts/derive-facts.mjs b/web/scripts/derive-facts.mjs index 0338ea45b..694deeb0f 100644 --- a/web/scripts/derive-facts.mjs +++ b/web/scripts/derive-facts.mjs @@ -66,6 +66,7 @@ function deriveProviders() { // shared ProviderKind, so we exclude it until that lands. Issue #1104. const labelMap = { Deepseek: { id: "deepseek", label: "DeepSeek", env: "DEEPSEEK_API_KEY" }, + DeepseekAnthropic: { id: "deepseek-anthropic", label: "DeepSeek Anthropic", env: "DEEPSEEK_API_KEY / ANTHROPIC_API_KEY" }, NvidiaNim: { id: "nvidia-nim", label: "NVIDIA NIM", env: "NVIDIA_API_KEY / NVIDIA_NIM_API_KEY" }, Openai: { id: "openai", label: "OpenAI-compatible", env: "OPENAI_API_KEY" }, Atlascloud: { id: "atlascloud", label: "AtlasCloud", env: "ATLASCLOUD_API_KEY" },