From abe866ae0e2fa10e865eded618b391858186b954 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Mon, 22 Jun 2026 22:04:05 -0700 Subject: [PATCH] Add DeepSeek Anthropic-compatible route Add an opt-in deepseek-anthropic provider that resolves to DeepSeek's Anthropic-compatible endpoint and model defaults while leaving the existing DeepSeek chat-completions route unchanged. Route the provider through the existing Anthropic Messages adapter, x-api-key header dialect, and TUI provider config table, with runtime capability metadata marking it as AnthropicMessages. Update the provider docs, provider registry drift checker, and web facts maps so the manual provider implementation stays covered by generated guards. Verified with: cargo fmt --all -- --check; cargo test -p codewhale-config --locked provider_metadata_defaults_match_runtime_helpers; cargo test -p codewhale-config --locked deepseek_anthropic_route_defaults_to_anthropic_endpoint; cargo test -p codewhale-tui --bin codewhale-tui --locked messages_url_tolerates_v1_suffix; cargo test -p codewhale-tui --bin codewhale-tui --locked deepseek_anthropic_uses_anthropic_header_dialect; cargo test -p codewhale-tui --bin codewhale-tui --locked provider_capability_deepseek_anthropic_uses_messages_payload; cargo test -p codewhale-tui --bin codewhale-tui --locked body_maps_reasoning_effort_to_adaptive_thinking_and_effort; cargo test -p codewhale-tui --bin codewhale-tui --locked sse_fixture_decodes_text_thinking_signature_and_tool_use; cargo test -p codewhale-tui --bin codewhale-tui --locked parse_usage_reads_deepseek_cache_and_reasoning_tokens; cargo test -p codewhale-tui --bin codewhale-tui --locked api_provider_metadata_helpers_follow_config_provider_metadata; cargo test -p codewhale-tui --bin codewhale-tui --locked provider_config_key_follows_config_provider_metadata; node web/scripts/derive-facts.mjs; python3 scripts/check-provider-registry.py; ./scripts/release/check-versions.sh; ./scripts/release/check-ohos-deps.sh; git diff --check --- config.example.toml | 9 +- crates/config/src/lib.rs | 31 ++- crates/config/src/provider.rs | 46 ++++- crates/config/src/route/tests.rs | 4 +- crates/config/src/tests.rs | 73 ++++++- crates/tui/src/client.rs | 288 ++++++++++++++++++++++++--- crates/tui/src/client/anthropic.rs | 4 + crates/tui/src/config.rs | 77 +++++-- crates/tui/src/config/tests.rs | 20 ++ crates/tui/src/config_persistence.rs | 1 + crates/tui/src/tui/ui.rs | 3 + docs/CONFIGURATION.md | 9 +- docs/PROVIDERS.md | 22 +- scripts/check-provider-registry.py | 4 +- web/lib/facts-drift.ts | 1 + web/scripts/derive-facts.mjs | 1 + 16 files changed, 531 insertions(+), 62 deletions(-) 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" },