diff --git a/README.md b/README.md index 44ad5026..27518ae7 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,10 @@ deepseek --provider nvidia-nim deepseek auth set --provider fireworks --api-key "YOUR_FIREWORKS_API_KEY" deepseek --provider fireworks --model deepseek-v4-pro +# Generic OpenAI-compatible endpoint +deepseek auth set --provider openai --api-key "YOUR_OPENAI_COMPATIBLE_API_KEY" +OPENAI_BASE_URL="https://openai-compatible.example/v4" deepseek --provider openai --model glm-5 + # Self-hosted SGLang SGLANG_BASE_URL="http://localhost:30000/v1" deepseek --provider sglang --model deepseek-v4-flash @@ -330,10 +334,11 @@ Key environment variables: | `DEEPSEEK_BASE_URL` | API base URL | | `DEEPSEEK_HTTP_HEADERS` | Optional custom model request headers, e.g. `X-Model-Provider-Id=your-model-provider` | | `DEEPSEEK_MODEL` | Default model | -| `DEEPSEEK_PROVIDER` | `deepseek` (default), `nvidia-nim`, `fireworks`, `sglang`, `vllm`, `ollama` | +| `DEEPSEEK_PROVIDER` | `deepseek` (default), `deepseek-cn`, `nvidia-nim`, `openai`, `openrouter`, `novita`, `fireworks`, `sglang`, `vllm`, `ollama` | | `DEEPSEEK_PROFILE` | Config profile name | | `DEEPSEEK_MEMORY` | Set to `on` to enable user memory | -| `NVIDIA_API_KEY` / `FIREWORKS_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` / `OLLAMA_API_KEY` | Provider auth | +| `NVIDIA_API_KEY` / `OPENAI_API_KEY` / `OPENROUTER_API_KEY` / `NOVITA_API_KEY` / `FIREWORKS_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` / `OLLAMA_API_KEY` | Provider auth | +| `OPENAI_BASE_URL` / `OPENAI_MODEL` | Generic OpenAI-compatible endpoint and model ID | | `SGLANG_BASE_URL` | Self-hosted SGLang endpoint | | `VLLM_BASE_URL` | Self-hosted vLLM endpoint | | `OLLAMA_BASE_URL` | Self-hosted Ollama endpoint | diff --git a/config.example.toml b/config.example.toml index f227cb02..60aa101e 100644 --- a/config.example.toml +++ b/config.example.toml @@ -12,10 +12,11 @@ # Choose which provider to use by default. Per-provider credentials live in the # `[providers.*]` sections near the bottom of # this file — keeping both stored at once means `/provider deepseek` and -# `/provider nvidia-nim` (or `--provider fireworks`, `/provider sglang`, `/provider vllm`, `/provider ollama`) toggle without having to +# `/provider nvidia-nim` (or `--provider openai`, `--provider fireworks`, +# `/provider sglang`, `/provider vllm`, `/provider ollama`) toggle without having to # re-enter keys. Top-level `api_key` / `base_url` are still read as DeepSeek # defaults when `[providers.deepseek]` is absent (backward compatibility). -provider = "deepseek" # deepseek | nvidia-nim | openrouter | novita | fireworks | sglang | vllm | ollama +provider = "deepseek" # deepseek | deepseek-cn | nvidia-nim | openai | openrouter | novita | fireworks | sglang | vllm | ollama api_key = "YOUR_DEEPSEEK_API_KEY" # must be non-empty base_url = "https://api.deepseek.com/beta" # base_url = "https://api.deepseeki.com" # China users @@ -32,6 +33,7 @@ base_url = "https://api.deepseek.com/beta" # deepseek-v4-flash — fast, cost-efficient (legacy aliases: deepseek-chat, deepseek-reasoner) # deepseek-ai/deepseek-v4-pro — NVIDIA NIM-hosted Pro model ID # deepseek-ai/deepseek-v4-flash — NVIDIA NIM-hosted Flash model ID +# gpt-4.1 — default generic OpenAI-compatible model ID # accounts/fireworks/models/deepseek-v4-pro — Fireworks AI Pro model ID # deepseek-ai/DeepSeek-V4-Pro — SGLang self-hosted Pro model ID # deepseek-ai/DeepSeek-V4-Flash — SGLang self-hosted Flash model ID @@ -150,11 +152,13 @@ max_subagents = 10 # optional (1-20) # Per-provider credentials (peer providers — NIM is first-class, not a flag) # ───────────────────────────────────────────────────────────────────────────────── # Providers can be stored at once; `provider = "..."` (top of file) or -# `/provider deepseek` / `/provider nvidia-nim` / `/provider fireworks` switches between them without +# `/provider deepseek` / `/provider nvidia-nim` / `--provider openai` / +# `/provider fireworks` switches between them without # having to re-enter keys. Env vars override anything set here: # DeepSeek: DEEPSEEK_API_KEY, DEEPSEEK_BASE_URL, DEEPSEEK_MODEL # 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 # Fireworks: FIREWORKS_API_KEY, FIREWORKS_BASE_URL # SGLang: SGLANG_BASE_URL, SGLANG_MODEL, optional SGLANG_API_KEY # vLLM: VLLM_BASE_URL, VLLM_MODEL, optional VLLM_API_KEY @@ -173,6 +177,12 @@ max_subagents = 10 # optional (1-20) # base_url = "https://integrate.api.nvidia.com/v1" # model = "deepseek-ai/deepseek-v4-pro" # or deepseek-ai/deepseek-v4-flash +# Generic OpenAI-compatible endpoint +[providers.openai] +# api_key = "YOUR_OPENAI_COMPATIBLE_API_KEY" +# base_url = "https://api.openai.com/v1" +# model = "gpt-4.1" + # Fireworks AI-hosted DeepSeek V4 (https://fireworks.ai) [providers.fireworks] # api_key = "YOUR_FIREWORKS_API_KEY" diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 0bf6a2d1..6d1a1640 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -1374,6 +1374,7 @@ fn build_tui_command( resolved_runtime.provider, ProviderKind::Deepseek | ProviderKind::NvidiaNim + | ProviderKind::Openai | ProviderKind::Openrouter | ProviderKind::Novita | ProviderKind::Fireworks @@ -1382,7 +1383,7 @@ fn build_tui_command( | ProviderKind::Ollama ) { bail!( - "The interactive TUI supports DeepSeek, NVIDIA NIM, OpenRouter, Novita, Fireworks, SGLang, vLLM, and Ollama providers. Remove --provider {} or use `deepseek model ...` for provider registry inspection.", + "The interactive TUI supports DeepSeek, NVIDIA NIM, OpenAI-compatible, OpenRouter, Novita, Fireworks, SGLang, vLLM, and Ollama providers. Remove --provider {} or use `deepseek model ...` for provider registry inspection.", resolved_runtime.provider.as_str() ); } @@ -1401,6 +1402,9 @@ fn build_tui_command( } if let Some(api_key) = resolved_runtime.api_key.as_ref() { cmd.env("DEEPSEEK_API_KEY", api_key); + if resolved_runtime.provider == ProviderKind::Openai { + cmd.env("OPENAI_API_KEY", api_key); + } let source = resolved_runtime .api_key_source .unwrap_or(RuntimeApiKeySource::Env) @@ -1428,6 +1432,9 @@ fn build_tui_command( } if let Some(api_key) = cli.api_key.as_ref() { cmd.env("DEEPSEEK_API_KEY", api_key); + if resolved_runtime.provider == ProviderKind::Openai { + cmd.env("OPENAI_API_KEY", api_key); + } cmd.env("DEEPSEEK_API_KEY_SOURCE", "cli"); } if let Some(base_url) = cli.base_url.as_ref() { @@ -1582,6 +1589,17 @@ mod tests { err.to_string() } + fn command_env(cmd: &Command, name: &str) -> Option { + let name = std::ffi::OsStr::new(name); + cmd.get_envs().find_map(|(key, value)| { + if key == name { + value.map(|v| v.to_string_lossy().into_owned()) + } else { + None + } + }) + } + fn env_lock() -> std::sync::MutexGuard<'static, ()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) @@ -2456,6 +2474,60 @@ mod tests { assert!(cli.skip_onboarding); } + #[test] + fn build_tui_command_allows_openai_and_forwards_provider_key() { + let _lock = env_lock(); + let dir = tempfile::TempDir::new().expect("tempdir"); + let custom = dir + .path() + .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX)); + std::fs::write(&custom, b"").unwrap(); + let custom_str = custom.to_string_lossy().into_owned(); + let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str); + + let cli = parse_ok(&["deepseek", "--provider", "openai"]); + let resolved = ResolvedRuntimeOptions { + provider: ProviderKind::Openai, + model: "glm-5".to_string(), + api_key: Some("resolved-openai-key".to_string()), + api_key_source: Some(RuntimeApiKeySource::Keyring), + base_url: "https://openai-compatible.example/v4".to_string(), + auth_mode: Some("api_key".to_string()), + output_mode: None, + log_level: None, + telemetry: false, + approval_policy: None, + sandbox_mode: None, + http_headers: std::collections::BTreeMap::new(), + }; + + let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command"); + assert_eq!( + command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(), + Some("openai") + ); + assert_eq!( + command_env(&cmd, "DEEPSEEK_MODEL").as_deref(), + Some("glm-5") + ); + assert_eq!( + command_env(&cmd, "DEEPSEEK_BASE_URL").as_deref(), + Some("https://openai-compatible.example/v4") + ); + assert_eq!( + command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(), + Some("resolved-openai-key") + ); + assert_eq!( + command_env(&cmd, "OPENAI_API_KEY").as_deref(), + Some("resolved-openai-key") + ); + assert_eq!( + command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE").as_deref(), + Some("keyring") + ); + } + #[test] fn parses_top_level_prompt_flag_for_canonical_one_shot() { let cli = parse_ok(&["deepseek", "-p", "Reply with exactly OK."]); @@ -2620,25 +2692,14 @@ mod tests { /// custom Windows install layouts and CI test rigs. #[test] fn locate_sibling_tui_binary_honours_env_override() { + let _lock = env_lock(); let dir = tempfile::TempDir::new().expect("tempdir"); let custom = dir .path() .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX)); std::fs::write(&custom, b"").unwrap(); - - // Use a guard so even on test failure the env var clears. - struct EnvGuard; - impl Drop for EnvGuard { - fn drop(&mut self) { - // SAFETY: tests own this env key for the duration of the - // guard; clearing on drop matches the documented teardown - // pattern for `std::env::set_var` in single-threaded tests. - unsafe { std::env::remove_var("DEEPSEEK_TUI_BIN") }; - } - } - let _g = EnvGuard; - // SAFETY: same single-threaded scope contract as the guard above. - unsafe { std::env::set_var("DEEPSEEK_TUI_BIN", &custom) }; + let custom_str = custom.to_string_lossy().into_owned(); + let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str); let resolved = locate_sibling_tui_binary().expect("override must resolve"); assert_eq!(resolved, custom); diff --git a/crates/tui/build.rs b/crates/tui/build.rs index b5e97c3b..ba691038 100644 --- a/crates/tui/build.rs +++ b/crates/tui/build.rs @@ -3,6 +3,7 @@ use std::{path::PathBuf, process::Command}; fn main() { println!("cargo:rerun-if-env-changed=DEEPSEEK_BUILD_SHA"); println!("cargo:rerun-if-env-changed=GITHUB_SHA"); + configure_windows_stack(); let package_version = env!("CARGO_PKG_VERSION"); let build_version = build_sha() @@ -12,6 +13,18 @@ fn main() { println!("cargo:rustc-env=DEEPSEEK_BUILD_VERSION={build_version}"); } +fn configure_windows_stack() { + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { + return; + } + + match std::env::var("CARGO_CFG_TARGET_ENV").as_deref() { + Ok("msvc") => println!("cargo:rustc-link-arg-bin=deepseek-tui=/STACK:8388608"), + Ok("gnu") => println!("cargo:rustc-link-arg-bin=deepseek-tui=-Wl,--stack,8388608"), + _ => {} + } +} + fn build_sha() -> Option { env_sha("DEEPSEEK_BUILD_SHA") .or_else(|| env_sha("GITHUB_SHA")) diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index be262748..38ff11d3 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -360,7 +360,7 @@ fn validate_base_url_security(base_url: &str) -> Result<()> { pub(super) fn versioned_base_url(base_url: &str) -> String { let trimmed = base_url.trim_end_matches('/'); - if trimmed.ends_with("/v1") || trimmed.ends_with("/beta") { + if base_url_has_version_suffix(trimmed) { trimmed.to_string() } else { format!("{trimmed}/v1") @@ -370,12 +370,25 @@ pub(super) fn versioned_base_url(base_url: &str) -> String { fn unversioned_base_url(base_url: &str) -> String { let trimmed = base_url.trim_end_matches('/'); trimmed - .strip_suffix("/v1") - .or_else(|| trimmed.strip_suffix("/beta")) + .rsplit_once('/') + .filter(|(_, segment)| is_version_segment(segment)) + .map(|(base, _)| base) .unwrap_or(trimmed) .to_string() } +fn base_url_has_version_suffix(trimmed: &str) -> bool { + trimmed.rsplit('/').next().is_some_and(is_version_segment) +} + +fn is_version_segment(segment: &str) -> bool { + segment.eq_ignore_ascii_case("beta") + || segment + .strip_prefix('v') + .or_else(|| segment.strip_prefix('V')) + .is_some_and(|rest| !rest.is_empty() && rest.chars().all(|ch| ch.is_ascii_digit())) +} + pub(super) fn api_url(base_url: &str, path: &str) -> String { let path = path.trim_start_matches('/'); if path.starts_with("beta/") { @@ -812,7 +825,7 @@ pub(super) fn apply_reasoning_effort( | ApiProvider::Vllm => { body["thinking"] = json!({ "type": "disabled" }); } - ApiProvider::Ollama => {} + ApiProvider::Openai | ApiProvider::Ollama => {} ApiProvider::NvidiaNim => { body["chat_template_kwargs"] = json!({ "thinking": false, @@ -830,7 +843,7 @@ pub(super) fn apply_reasoning_effort( body["reasoning_effort"] = json!("high"); body["thinking"] = json!({ "type": "enabled" }); } - ApiProvider::Ollama => {} + ApiProvider::Openai | ApiProvider::Ollama => {} ApiProvider::NvidiaNim => { body["chat_template_kwargs"] = json!({ "thinking": true, @@ -849,7 +862,7 @@ pub(super) fn apply_reasoning_effort( body["reasoning_effort"] = json!("max"); body["thinking"] = json!({ "type": "enabled" }); } - ApiProvider::Ollama => {} + ApiProvider::Openai | ApiProvider::Ollama => {} ApiProvider::NvidiaNim => { body["chat_template_kwargs"] = json!({ "thinking": true, @@ -1033,6 +1046,13 @@ mod tests { api_url("https://api.deepseek.com/beta", "chat/completions"), "https://api.deepseek.com/beta/chat/completions" ); + assert_eq!( + api_url( + "https://openai-compatible.example/api/coding/paas/v4", + "chat/completions" + ), + "https://openai-compatible.example/api/coding/paas/v4/chat/completions" + ); } #[test] diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 0f838fa9..beb4921b 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -24,6 +24,8 @@ pub const DEFAULT_DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/beta"; 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"; +pub const DEFAULT_OPENAI_MODEL: &str = "gpt-4.1"; +pub const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1"; pub const DEFAULT_OPENROUTER_MODEL: &str = "deepseek/deepseek-v4-pro"; pub const DEFAULT_OPENROUTER_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash"; pub const DEFAULT_OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1"; @@ -57,6 +59,7 @@ pub enum ApiProvider { Deepseek, DeepseekCN, NvidiaNim, + Openai, Openrouter, Novita, Fireworks, @@ -74,6 +77,7 @@ impl ApiProvider { Some(Self::DeepseekCN) } "nvidia" | "nvidia-nim" | "nvidia_nim" | "nim" => Some(Self::NvidiaNim), + "openai" | "open-ai" => Some(Self::Openai), "openrouter" | "open_router" => Some(Self::Openrouter), "novita" => Some(Self::Novita), "fireworks" | "fireworks-ai" => Some(Self::Fireworks), @@ -90,6 +94,7 @@ impl ApiProvider { Self::Deepseek => "deepseek", Self::DeepseekCN => "deepseek-cn", Self::NvidiaNim => "nvidia-nim", + Self::Openai => "openai", Self::Openrouter => "openrouter", Self::Novita => "novita", Self::Fireworks => "fireworks", @@ -106,6 +111,7 @@ impl ApiProvider { Self::Deepseek => "DeepSeek", Self::DeepseekCN => "DeepSeek (中国)", Self::NvidiaNim => "NVIDIA NIM", + Self::Openai => "OpenAI-compatible", Self::Openrouter => "OpenRouter", Self::Novita => "Novita AI", Self::Fireworks => "Fireworks AI", @@ -122,6 +128,7 @@ impl ApiProvider { Self::Deepseek, Self::DeepseekCN, Self::NvidiaNim, + Self::Openai, Self::Openrouter, Self::Novita, Self::Fireworks, @@ -193,6 +200,19 @@ pub enum RequestPayloadMode { /// in the API payload (after normalization / provider-specific mapping). #[must_use] pub fn provider_capability(provider: ApiProvider, resolved_model: &str) -> ProviderCapability { + if matches!(provider, ApiProvider::Openai) { + return ProviderCapability { + provider, + resolved_model: resolved_model.to_string(), + context_window: crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS, + max_output: 4096, + thinking_supported: false, + cache_telemetry_supported: false, + request_payload_mode: RequestPayloadMode::ChatCompletions, + alias_deprecation: None, + }; + } + if matches!(provider, ApiProvider::Ollama) { return ProviderCapability { provider, @@ -996,6 +1016,8 @@ pub struct ProvidersConfig { #[serde(default)] pub nvidia_nim: ProviderConfig, #[serde(default)] + pub openai: ProviderConfig, + #[serde(default)] pub openrouter: ProviderConfig, #[serde(default)] pub novita: ProviderConfig, @@ -1066,7 +1088,7 @@ impl Config { && ApiProvider::parse(provider).is_none() { anyhow::bail!( - "Invalid provider '{provider}': expected deepseek, deepseek-cn, nvidia-nim, openrouter, novita, fireworks, sglang, vllm, or ollama." + "Invalid provider '{provider}': expected deepseek, deepseek-cn, nvidia-nim, openai, openrouter, novita, fireworks, sglang, vllm, or ollama." ); } if let Some(ref key) = self.api_key @@ -1083,7 +1105,7 @@ impl Config { } if let Some(model) = self.default_text_model.as_deref() && !model.trim().eq_ignore_ascii_case("auto") - && !matches!(self.api_provider(), ApiProvider::Ollama) + && !provider_passes_model_through(self.api_provider()) && normalize_model_name(model).is_none() { anyhow::bail!( @@ -1181,6 +1203,7 @@ impl Config { ApiProvider::Deepseek => &providers.deepseek, ApiProvider::DeepseekCN => &providers.deepseek_cn, ApiProvider::NvidiaNim => &providers.nvidia_nim, + ApiProvider::Openai => &providers.openai, ApiProvider::Openrouter => &providers.openrouter, ApiProvider::Novita => &providers.novita, ApiProvider::Fireworks => &providers.fireworks, @@ -1214,7 +1237,7 @@ impl Config { .provider_config() .and_then(|provider| provider.model.as_deref()) { - if matches!(provider, ApiProvider::Ollama) { + if provider_passes_model_through(provider) { return model.trim().to_string(); } if let Some(normalized) = normalize_model_for_provider(provider, model) { @@ -1222,7 +1245,7 @@ impl Config { } } if let Some(model) = self.default_text_model.as_deref() - && matches!(provider, ApiProvider::Ollama) + && provider_passes_model_through(provider) { return model.trim().to_string(); } @@ -1240,6 +1263,7 @@ impl Config { match provider { ApiProvider::Deepseek | ApiProvider::DeepseekCN => DEFAULT_TEXT_MODEL, ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL, + ApiProvider::Openai => DEFAULT_OPENAI_MODEL, ApiProvider::Openrouter => DEFAULT_OPENROUTER_MODEL, ApiProvider::Novita => DEFAULT_NOVITA_MODEL, ApiProvider::Fireworks => DEFAULT_FIREWORKS_MODEL, @@ -1268,7 +1292,8 @@ impl Config { .as_ref() .filter(|base| base.contains("integrate.api.nvidia.com")) .cloned(), - ApiProvider::Openrouter + ApiProvider::Openai + | ApiProvider::Openrouter | ApiProvider::Novita | ApiProvider::Fireworks | ApiProvider::Sglang @@ -1280,6 +1305,7 @@ impl Config { ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL, ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL, ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL, + ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL, ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL, ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL, ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL, @@ -1305,6 +1331,7 @@ impl Config { let slot = match provider { ApiProvider::Deepseek | ApiProvider::DeepseekCN => "deepseek", ApiProvider::NvidiaNim => "nvidia-nim", + ApiProvider::Openai => "openai", ApiProvider::Openrouter => "openrouter", ApiProvider::Novita => "novita", ApiProvider::Fireworks => "fireworks", @@ -1359,6 +1386,10 @@ impl Config { set NVIDIA_API_KEY/NVIDIA_NIM_API_KEY, or save api_key in ~/.deepseek/config.toml \ with provider = \"nvidia-nim\"." ), + ApiProvider::Openai => anyhow::bail!( + "OpenAI-compatible API key not found. Run 'deepseek auth set --provider openai', \ + set OPENAI_API_KEY, or add [providers.openai] api_key in ~/.deepseek/config.toml." + ), ApiProvider::Openrouter => anyhow::bail!( "OpenRouter API key not found. Run 'deepseek auth set --provider openrouter', \ set OPENROUTER_API_KEY, or add [providers.openrouter] api_key in ~/.deepseek/config.toml." @@ -1830,14 +1861,24 @@ fn apply_env_overrides(config: &mut Config) { config.provider = Some(value); } if let Ok(value) = std::env::var("DEEPSEEK_BASE_URL") { - if matches!(config.api_provider(), ApiProvider::NvidiaNim) { - config - .providers - .get_or_insert_with(ProvidersConfig::default) - .nvidia_nim - .base_url = Some(value); - } else { - config.base_url = Some(value); + match config.api_provider() { + ApiProvider::NvidiaNim => { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .nvidia_nim + .base_url = Some(value); + } + ApiProvider::Openai => { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .openai + .base_url = Some(value); + } + _ => { + config.base_url = Some(value); + } } } if matches!(config.api_provider(), ApiProvider::NvidiaNim) @@ -1851,8 +1892,19 @@ fn apply_env_overrides(config: &mut Config) { .nvidia_nim .base_url = Some(value); } - // OpenRouter / Novita are scoped only on their own provider entry — the - // legacy root `base_url` keeps DeepSeek-only semantics. + // OpenAI-compatible and non-DeepSeek hosted providers are scoped only on + // their own provider entry — the legacy root `base_url` keeps DeepSeek-only + // semantics. + if matches!(config.api_provider(), ApiProvider::Openai) + && let Ok(value) = std::env::var("OPENAI_BASE_URL") + && !value.trim().is_empty() + { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .openai + .base_url = Some(value); + } if matches!(config.api_provider(), ApiProvider::Openrouter) && let Ok(value) = std::env::var("OPENROUTER_BASE_URL") && !value.trim().is_empty() @@ -1919,6 +1971,7 @@ fn apply_env_overrides(config: &mut Config) { ApiProvider::Deepseek => &mut providers.deepseek, ApiProvider::DeepseekCN => &mut providers.deepseek_cn, ApiProvider::NvidiaNim => &mut providers.nvidia_nim, + ApiProvider::Openai => &mut providers.openai, ApiProvider::Openrouter => &mut providers.openrouter, ApiProvider::Novita => &mut providers.novita, ApiProvider::Fireworks => &mut providers.fireworks, @@ -1955,6 +2008,11 @@ fn apply_env_overrides(config: &mut Config) { { config.default_text_model = Some(value); } + if matches!(config.api_provider(), ApiProvider::Openai) + && let Ok(value) = std::env::var("OPENAI_MODEL") + { + config.default_text_model = Some(value); + } if let Ok(value) = std::env::var("DEEPSEEK_MODEL").or_else(|_| std::env::var("DEEPSEEK_DEFAULT_TEXT_MODEL")) { @@ -2133,7 +2191,7 @@ fn apply_env_overrides(config: &mut Config) { fn normalize_model_config(config: &mut Config) { if let Some(model) = config.default_text_model.as_deref() - && !matches!(config.api_provider(), ApiProvider::Ollama) + && !provider_passes_model_through(config.api_provider()) && let Some(normalized) = normalize_model_for_provider(config.api_provider(), model) { config.default_text_model = Some(normalized); @@ -2184,12 +2242,16 @@ fn normalize_model_config(config: &mut Config) { } fn normalize_model_for_provider(provider: ApiProvider, model: &str) -> Option { - if matches!(provider, ApiProvider::Ollama) { + if provider_passes_model_through(provider) { return None; } normalize_model_name(model).map(|normalized| model_for_provider(provider, normalized)) } +fn provider_passes_model_through(provider: ApiProvider) -> bool { + matches!(provider, ApiProvider::Openai | ApiProvider::Ollama) +} + fn model_for_provider(provider: ApiProvider, normalized: String) -> String { let lowered = normalized.to_ascii_lowercase(); match (provider, lowered.as_str()) { @@ -2371,6 +2433,7 @@ fn merge_providers( deepseek: merge_provider_config(base.deepseek, override_cfg.deepseek), deepseek_cn: merge_provider_config(base.deepseek_cn, override_cfg.deepseek_cn), nvidia_nim: merge_provider_config(base.nvidia_nim, override_cfg.nvidia_nim), + openai: merge_provider_config(base.openai, override_cfg.openai), openrouter: merge_provider_config(base.openrouter, override_cfg.openrouter), novita: merge_provider_config(base.novita, override_cfg.novita), fireworks: merge_provider_config(base.fireworks, override_cfg.fireworks), @@ -2787,6 +2850,7 @@ pub fn active_provider_has_env_api_key(config: &Config) -> bool { std::env::var("NVIDIA_API_KEY").is_ok_and(|k| !k.trim().is_empty()) || std::env::var("NVIDIA_NIM_API_KEY").is_ok_and(|k| !k.trim().is_empty()) } + ApiProvider::Openai => std::env::var("OPENAI_API_KEY").is_ok_and(|k| !k.trim().is_empty()), ApiProvider::Openrouter => { std::env::var("OPENROUTER_API_KEY").is_ok_and(|k| !k.trim().is_empty()) } @@ -2813,6 +2877,7 @@ pub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool { let env_var = match provider { ApiProvider::Deepseek | ApiProvider::DeepseekCN => "DEEPSEEK_API_KEY", ApiProvider::NvidiaNim => "NVIDIA_API_KEY", + ApiProvider::Openai => "OPENAI_API_KEY", ApiProvider::Openrouter => "OPENROUTER_API_KEY", ApiProvider::Novita => "NOVITA_API_KEY", ApiProvider::Fireworks => "FIREWORKS_API_KEY", @@ -2880,6 +2945,7 @@ pub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result )); } ApiProvider::NvidiaNim => "providers.nvidia_nim", + ApiProvider::Openai => "providers.openai", ApiProvider::Openrouter => "providers.openrouter", ApiProvider::Novita => "providers.novita", ApiProvider::Fireworks => "providers.fireworks", @@ -2913,6 +2979,7 @@ pub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result )); } ApiProvider::NvidiaNim => "nvidia_nim", + ApiProvider::Openai => "openai", ApiProvider::Openrouter => "openrouter", ApiProvider::Novita => "novita", ApiProvider::Fireworks => "fireworks", @@ -3026,6 +3093,9 @@ mod tests { nvidia_base_url: Option, nvidia_nim_base_url: Option, nvidia_nim_model: Option, + openai_api_key: Option, + openai_base_url: Option, + openai_model: Option, openrouter_api_key: Option, openrouter_base_url: Option, novita_api_key: Option, @@ -3063,6 +3133,9 @@ mod tests { let nvidia_base_url_prev = env::var_os("NVIDIA_BASE_URL"); let nvidia_nim_base_url_prev = env::var_os("NVIDIA_NIM_BASE_URL"); let nvidia_nim_model_prev = env::var_os("NVIDIA_NIM_MODEL"); + let openai_api_key_prev = env::var_os("OPENAI_API_KEY"); + let openai_base_url_prev = env::var_os("OPENAI_BASE_URL"); + let openai_model_prev = env::var_os("OPENAI_MODEL"); let openrouter_api_key_prev = env::var_os("OPENROUTER_API_KEY"); let openrouter_base_url_prev = env::var_os("OPENROUTER_BASE_URL"); let novita_api_key_prev = env::var_os("NOVITA_API_KEY"); @@ -3095,6 +3168,9 @@ mod tests { env::remove_var("NVIDIA_BASE_URL"); env::remove_var("NVIDIA_NIM_BASE_URL"); env::remove_var("NVIDIA_NIM_MODEL"); + env::remove_var("OPENAI_API_KEY"); + env::remove_var("OPENAI_BASE_URL"); + env::remove_var("OPENAI_MODEL"); env::remove_var("OPENROUTER_API_KEY"); env::remove_var("OPENROUTER_BASE_URL"); env::remove_var("NOVITA_API_KEY"); @@ -3127,6 +3203,9 @@ mod tests { nvidia_base_url: nvidia_base_url_prev, nvidia_nim_base_url: nvidia_nim_base_url_prev, nvidia_nim_model: nvidia_nim_model_prev, + openai_api_key: openai_api_key_prev, + openai_base_url: openai_base_url_prev, + openai_model: openai_model_prev, openrouter_api_key: openrouter_api_key_prev, openrouter_base_url: openrouter_base_url_prev, novita_api_key: novita_api_key_prev, @@ -3168,6 +3247,9 @@ mod tests { Self::restore_var("NVIDIA_BASE_URL", self.nvidia_base_url.take()); Self::restore_var("NVIDIA_NIM_BASE_URL", self.nvidia_nim_base_url.take()); Self::restore_var("NVIDIA_NIM_MODEL", self.nvidia_nim_model.take()); + Self::restore_var("OPENAI_API_KEY", self.openai_api_key.take()); + Self::restore_var("OPENAI_BASE_URL", self.openai_base_url.take()); + Self::restore_var("OPENAI_MODEL", self.openai_model.take()); Self::restore_var("OPENROUTER_API_KEY", self.openrouter_api_key.take()); Self::restore_var("OPENROUTER_BASE_URL", self.openrouter_base_url.take()); Self::restore_var("NOVITA_API_KEY", self.novita_api_key.take()); @@ -4260,6 +4342,127 @@ http_headers = { "X-Model-Provider-Id" = "from-file" } Ok(()) } + #[test] + fn openai_provider_uses_openai_compatible_defaults() -> Result<()> { + let config = Config { + provider: Some("openai".to_string()), + ..Default::default() + }; + + config.validate()?; + assert_eq!(config.api_provider(), ApiProvider::Openai); + assert_eq!(config.default_model(), DEFAULT_OPENAI_MODEL); + assert_eq!(config.deepseek_base_url(), DEFAULT_OPENAI_BASE_URL); + Ok(()) + } + + #[test] + fn openai_provider_accepts_custom_model_and_base_url() -> Result<()> { + let _lock = lock_test_env(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "deepseek-tui-openai-table-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root)?; + let _guard = EnvGuard::new(&temp_root); + + let config_path = temp_root.join(".deepseek").join("config.toml"); + ensure_parent_dir(&config_path)?; + fs::write( + &config_path, + r#"provider = "openai" + +[providers.openai] +api_key = "openai-table-key" +base_url = "https://openai-compatible.example/api/coding/paas/v4" +model = "glm-5" +"#, + )?; + + let config = Config::load(None, None)?; + assert_eq!(config.api_provider(), ApiProvider::Openai); + assert_eq!(config.deepseek_api_key()?, "openai-table-key"); + assert_eq!( + config.deepseek_base_url(), + "https://openai-compatible.example/api/coding/paas/v4" + ); + assert_eq!(config.default_model(), "glm-5"); + Ok(()) + } + + #[test] + fn openai_env_overrides_provider_base_url_and_model() -> Result<()> { + let _lock = lock_test_env(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "deepseek-tui-openai-env-test-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root)?; + let _guard = EnvGuard::new(&temp_root); + + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::set_var("DEEPSEEK_PROVIDER", "openai"); + env::set_var("OPENAI_API_KEY", "openai-env-key"); + env::set_var("OPENAI_BASE_URL", "https://openai-compatible.example/v4"); + env::set_var("OPENAI_MODEL", "glm-5"); + } + + let config = Config::load(None, None)?; + assert_eq!(config.api_provider(), ApiProvider::Openai); + assert_eq!(config.deepseek_api_key()?, "openai-env-key"); + assert_eq!( + config.deepseek_base_url(), + "https://openai-compatible.example/v4" + ); + assert_eq!(config.default_model(), "glm-5"); + Ok(()) + } + + #[test] + fn openai_env_accepts_facade_base_url_forwarding() -> Result<()> { + let _lock = lock_test_env(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "deepseek-tui-openai-forwarded-base-url-test-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root)?; + let _guard = EnvGuard::new(&temp_root); + + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::set_var("DEEPSEEK_PROVIDER", "openai"); + env::set_var("OPENAI_API_KEY", "forwarded-openai-key"); + env::set_var("DEEPSEEK_BASE_URL", "https://forwarded-openai.example/v4"); + env::set_var("DEEPSEEK_MODEL", "glm-5"); + } + + let config = Config::load(None, None)?; + assert_eq!(config.api_provider(), ApiProvider::Openai); + assert_eq!(config.deepseek_api_key()?, "forwarded-openai-key"); + assert_eq!( + config.deepseek_base_url(), + "https://forwarded-openai.example/v4" + ); + assert_eq!(config.default_model(), "glm-5"); + Ok(()) + } + #[test] fn openrouter_provider_uses_canonical_defaults() -> Result<()> { let _lock = lock_test_env(); @@ -4621,6 +4824,7 @@ api_key = "novita-table-key" let _guard = EnvGuard::new(&temp_root); let mut config = Config::default(); + assert!(!has_api_key_for(&config, ApiProvider::Openai)); assert!(!has_api_key_for(&config, ApiProvider::Openrouter)); assert!( has_api_key_for(&config, ApiProvider::Sglang), @@ -4634,17 +4838,22 @@ api_key = "novita-table-key" // Safety: test-only environment mutation guarded by a global mutex. unsafe { env::set_var("OPENROUTER_API_KEY", "or-env"); + env::set_var("OPENAI_API_KEY", "openai-env"); } + assert!(has_api_key_for(&config, ApiProvider::Openai)); assert!(has_api_key_for(&config, ApiProvider::Openrouter)); assert!(!has_api_key_for(&config, ApiProvider::Novita)); // Safety: test-only environment mutation guarded by a global mutex. unsafe { env::remove_var("OPENROUTER_API_KEY"); + env::remove_var("OPENAI_API_KEY"); } let mut providers = ProvidersConfig::default(); + providers.openai.api_key = Some("file-openai".to_string()); providers.novita.api_key = Some("file-novita".to_string()); config.providers = Some(providers); + assert!(has_api_key_for(&config, ApiProvider::Openai)); assert!(has_api_key_for(&config, ApiProvider::Novita)); assert!(!has_api_key_for(&config, ApiProvider::Openrouter)); Ok(()) @@ -4722,10 +4931,19 @@ api_key = "novita-table-key" .and_then(toml::Value::as_str), Some("novita-saved-key") ); + save_api_key_for(ApiProvider::Openai, "openai-saved-key")?; save_api_key_for(ApiProvider::Fireworks, "fireworks-saved-key")?; save_api_key_for(ApiProvider::Sglang, "sglang-saved-key")?; let contents = fs::read_to_string(&path)?; let parsed: toml::Value = toml::from_str(&contents)?; + assert_eq!( + parsed + .get("providers") + .and_then(|p| p.get("openai")) + .and_then(|t| t.get("api_key")) + .and_then(toml::Value::as_str), + Some("openai-saved-key") + ); assert_eq!( parsed .get("providers") @@ -4967,6 +5185,22 @@ model = "deepseek-v4-pro" assert!(!cap.cache_telemetry_supported); } + #[test] + fn provider_capability_openai_custom_model_is_chat_completions_without_thinking() { + let cap = provider_capability(ApiProvider::Openai, "glm-5"); + assert_eq!( + cap.context_window, + crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS + ); + assert_eq!(cap.max_output, 4096); + assert!(!cap.thinking_supported); + assert!(!cap.cache_telemetry_supported); + assert_eq!( + cap.request_payload_mode, + RequestPayloadMode::ChatCompletions + ); + } + #[test] fn provider_capability_ollama_is_openai_compatible_without_thinking() { let cap = provider_capability(ApiProvider::Ollama, "deepseek-v3.1:671b"); diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 24127123..88108163 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -370,6 +370,7 @@ impl Engine { let env_var = match provider { ApiProvider::Deepseek | ApiProvider::DeepseekCN => "DEEPSEEK_API_KEY", ApiProvider::NvidiaNim => "NVIDIA_API_KEY/NVIDIA_NIM_API_KEY", + ApiProvider::Openai => "OPENAI_API_KEY", ApiProvider::Openrouter => "OPENROUTER_API_KEY", ApiProvider::Novita => "NOVITA_API_KEY", ApiProvider::Fireworks => "FIREWORKS_API_KEY", diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index b7fff694..616f85d9 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -1336,6 +1336,10 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> { "NVIDIA_API_KEY", "deepseek auth set --provider nvidia-nim --api-key \"...\"", ), + crate::config::ApiProvider::Openai => ( + "OPENAI_API_KEY", + "deepseek auth set --provider openai --api-key \"...\"", + ), crate::config::ApiProvider::Openrouter => ( "OPENROUTER_API_KEY", "deepseek auth set --provider openrouter --api-key \"...\"", @@ -1368,6 +1372,7 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> { "✗".truecolor(red_r, red_g, red_b), match config.api_provider() { crate::config::ApiProvider::NvidiaNim => "nvidia_nim", + crate::config::ApiProvider::Openai => "openai", crate::config::ApiProvider::Openrouter => "openrouter", crate::config::ApiProvider::Novita => "novita", crate::config::ApiProvider::Fireworks => "fireworks", diff --git a/crates/tui/src/tui/provider_picker.rs b/crates/tui/src/tui/provider_picker.rs index 6eefa501..2d147960 100644 --- a/crates/tui/src/tui/provider_picker.rs +++ b/crates/tui/src/tui/provider_picker.rs @@ -88,6 +88,7 @@ impl ProviderPickerView { match provider { ApiProvider::Deepseek | ApiProvider::DeepseekCN => "DEEPSEEK_API_KEY", ApiProvider::NvidiaNim => "NVIDIA_API_KEY", + ApiProvider::Openai => "OPENAI_API_KEY", ApiProvider::Openrouter => "OPENROUTER_API_KEY", ApiProvider::Novita => "NOVITA_API_KEY", ApiProvider::Fireworks => "FIREWORKS_API_KEY", @@ -366,6 +367,17 @@ mod tests { KeyEvent::new(code, KeyModifiers::NONE) } + fn move_to_provider(picker: &mut ProviderPickerView, provider: ApiProvider) { + let max_steps = picker.providers.len(); + for _ in 0..max_steps { + if picker.selected_provider() == provider { + return; + } + picker.handle_key(key(KeyCode::Down)); + } + panic!("provider {provider:?} not found in picker"); + } + #[test] fn picker_lists_all_providers() { let config = Config::default(); @@ -381,6 +393,7 @@ mod tests { "DeepSeek", "DeepSeek (中国)", "NVIDIA NIM", + "OpenAI-compatible", "OpenRouter", "Novita AI", "Fireworks AI", @@ -395,9 +408,7 @@ mod tests { fn ollama_is_selectable_without_key() { let config = Config::default(); let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config); - for _ in 0..8 { - picker.handle_key(key(KeyCode::Down)); - } + move_to_provider(&mut picker, ApiProvider::Ollama); assert_eq!(picker.selected_provider(), ApiProvider::Ollama); assert!(picker.selected_has_key()); let action = picker.handle_key(key(KeyCode::Enter)); @@ -421,10 +432,8 @@ mod tests { fn enter_with_no_key_transitions_to_key_entry_stage() { let config = Config::default(); let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config); - // Move to OpenRouter (index 3), which has no key in default config. - picker.handle_key(key(KeyCode::Down)); - picker.handle_key(key(KeyCode::Down)); - picker.handle_key(key(KeyCode::Down)); + // Move to OpenRouter, which has no key in default config. + move_to_provider(&mut picker, ApiProvider::Openrouter); assert_eq!(picker.selected_provider(), ApiProvider::Openrouter); let action = picker.handle_key(key(KeyCode::Enter)); assert!(matches!(action, ViewAction::None)); @@ -454,10 +463,8 @@ mod tests { fn key_entry_enter_submits_after_typing() { let config = Config::default(); let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config); - // Navigate to Novita (index 4) and trigger key entry. - for _ in 0..4 { - picker.handle_key(key(KeyCode::Down)); - } + // Navigate to Novita and trigger key entry. + move_to_provider(&mut picker, ApiProvider::Novita); picker.handle_key(key(KeyCode::Enter)); assert_eq!(picker.stage, Stage::KeyEntry); for c in "novita-key".chars() { @@ -480,9 +487,7 @@ mod tests { fn key_entry_esc_returns_to_list_without_emitting() { let config = Config::default(); let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config); - picker.handle_key(key(KeyCode::Down)); - picker.handle_key(key(KeyCode::Down)); - picker.handle_key(key(KeyCode::Down)); + move_to_provider(&mut picker, ApiProvider::Openrouter); picker.handle_key(key(KeyCode::Enter)); assert_eq!(picker.stage, Stage::KeyEntry); picker.handle_key(key(KeyCode::Char('a'))); @@ -504,9 +509,7 @@ mod tests { fn key_entry_strips_whitespace_chars() { let config = Config::default(); let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config); - picker.handle_key(key(KeyCode::Down)); - picker.handle_key(key(KeyCode::Down)); - picker.handle_key(key(KeyCode::Down)); + move_to_provider(&mut picker, ApiProvider::Openrouter); picker.handle_key(key(KeyCode::Enter)); assert_eq!(picker.stage, Stage::KeyEntry); for c in "abc def".chars() { diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 11c2826a..28bc1e67 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -5179,6 +5179,7 @@ fn render(f: &mut Frame, app: &mut App) { crate::config::ApiProvider::Deepseek => None, crate::config::ApiProvider::DeepseekCN => None, crate::config::ApiProvider::NvidiaNim => Some("NIM"), + crate::config::ApiProvider::Openai => Some("OpenAI"), crate::config::ApiProvider::Openrouter => Some("OR"), crate::config::ApiProvider::Novita => Some("Novita"), crate::config::ApiProvider::Fireworks => Some("Fireworks"), @@ -5812,6 +5813,7 @@ async fn apply_provider_picker_api_key( return; } ApiProvider::NvidiaNim => &mut providers.nvidia_nim, + ApiProvider::Openai => &mut providers.openai, ApiProvider::Openrouter => &mut providers.openrouter, ApiProvider::Novita => &mut providers.novita, ApiProvider::Fireworks => &mut providers.fireworks, diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 65650ac6..f877a777 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -61,15 +61,20 @@ file, OS keyring backend, environment variable, winning source, and last-four label without printing the key itself. The command only probes the active provider's keyring entry. -For hosted or self-hosted providers, set `provider = "nvidia-nim"`, -`"fireworks"`, `"sglang"`, `"vllm"`, or `"ollama"` or pass `deepseek --provider `. The facade -saves provider credentials to the shared user config and forwards the resolved -key, base URL, provider, and model to the TUI process. Use +For hosted, generic OpenAI-compatible, or self-hosted providers, set +`provider = "nvidia-nim"`, `"openai"`, `"fireworks"`, `"sglang"`, `"vllm"`, or +`"ollama"` or pass `deepseek --provider `. The facade saves provider +credentials to the shared user config and forwards the resolved key, base URL, +provider, and model to the TUI process. Use `deepseek auth set --provider nvidia-nim --api-key "YOUR_NVIDIA_API_KEY"` or +`deepseek auth set --provider openai --api-key "YOUR_OPENAI_COMPATIBLE_API_KEY"` or `deepseek auth set --provider fireworks --api-key "YOUR_FIREWORKS_API_KEY"` to -save hosted-provider keys through the facade. SGLang, vLLM, and Ollama are self-hosted and can run -without an API key by default. Ollama defaults to `http://localhost:11434/v1` and sends model tags -such as `deepseek-coder:1.3b` or `qwen2.5-coder:7b` unchanged. +save provider keys through the facade. The generic `openai` provider defaults +to `https://api.openai.com/v1`, accepts `OPENAI_BASE_URL`, and passes model IDs +through unchanged for OpenAI-compatible gateways. SGLang, vLLM, and Ollama are +self-hosted and can run without an API key by default. Ollama defaults to +`http://localhost:11434/v1` and sends model tags such as `deepseek-coder:1.3b` +or `qwen2.5-coder:7b` unchanged. Third-party OpenAI-compatible gateways that need extra request headers can set `http_headers = { "X-Model-Provider-Id" = "your-model-provider" }` at the top @@ -112,6 +117,13 @@ default_text_model = "deepseek-ai/deepseek-v4-pro" provider = "fireworks" default_text_model = "accounts/fireworks/models/deepseek-v4-pro" +[profiles.openai-compatible] +provider = "openai" + +[profiles.openai-compatible.providers.openai] +base_url = "https://openai-compatible.example/v4" +model = "glm-5" + [profiles.sglang] provider = "sglang" base_url = "http://localhost:30000/v1" @@ -143,11 +155,18 @@ fallbacks after saved config and keyring credentials: - `DEEPSEEK_API_KEY` - `DEEPSEEK_BASE_URL` - `DEEPSEEK_HTTP_HEADERS` (custom model request headers, comma-separated `name=value` pairs) -- `DEEPSEEK_PROVIDER` (`deepseek|nvidia-nim|openrouter|novita|fireworks|sglang|vllm|ollama`) +- `DEEPSEEK_PROVIDER` (`deepseek|deepseek-cn|nvidia-nim|openai|openrouter|novita|fireworks|sglang|vllm|ollama`) - `DEEPSEEK_MODEL` or `DEEPSEEK_DEFAULT_TEXT_MODEL` - `NVIDIA_API_KEY` or `NVIDIA_NIM_API_KEY` (preferred when provider is `nvidia-nim`; falls back to `DEEPSEEK_API_KEY`) - `NVIDIA_NIM_BASE_URL`, `NIM_BASE_URL`, or `NVIDIA_BASE_URL` - `NVIDIA_NIM_MODEL` +- `OPENAI_API_KEY` +- `OPENAI_BASE_URL` +- `OPENAI_MODEL` +- `OPENROUTER_API_KEY` +- `OPENROUTER_BASE_URL` +- `NOVITA_API_KEY` +- `NOVITA_BASE_URL` - `FIREWORKS_API_KEY` - `FIREWORKS_BASE_URL` - `SGLANG_BASE_URL` @@ -332,10 +351,10 @@ If you are upgrading from older releases: ### Core keys (used by the TUI/engine) -- `provider` (string, optional): `deepseek` (default), `deepseek-cn`, `nvidia-nim`, `openrouter`, `novita`, `fireworks`, `sglang`, `vllm`, or `ollama`. `deepseek-cn` uses DeepSeek's mainland China endpoint (`https://api.deepseeki.com`); `nvidia-nim` targets NVIDIA's NIM-hosted DeepSeek endpoints through `https://integrate.api.nvidia.com/v1`; `fireworks` targets `https://api.fireworks.ai/inference/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`. +- `provider` (string, optional): `deepseek` (default), `deepseek-cn`, `nvidia-nim`, `openai`, `openrouter`, `novita`, `fireworks`, `sglang`, `vllm`, or `ollama`. `deepseek-cn` uses DeepSeek's mainland China endpoint (`https://api.deepseeki.com`); `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`; `fireworks` targets `https://api.fireworks.ai/inference/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`. - `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 in v0.8.16, `https://api.deepseeki.com` for `provider = "deepseek-cn"`, or the provider-specific endpoint for hosted/self-hosted providers. Set `https://api.deepseek.com` or `https://api.deepseek.com/v1` explicitly to opt out of DeepSeek beta features. -- `default_text_model` (string, optional): defaults to `deepseek-v4-pro` for DeepSeek, `deepseek-ai/deepseek-v4-pro` for NVIDIA NIM, `accounts/fireworks/models/deepseek-v4-pro` for Fireworks, `deepseek-ai/DeepSeek-V4-Pro` for SGLang/vLLM, and `deepseek-coder:1.3b` for Ollama. 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. Provider-specific mappings translate `deepseek-v4-pro` / `deepseek-v4-flash` to each provider's model ID where supported. Ollama model tags are passed through unchanged. Use `/models` or `deepseek models` to discover live IDs from your configured endpoint. `DEEPSEEK_MODEL` overrides this for a single process. +- `base_url` (string, optional): defaults to `https://api.deepseek.com/beta` for DeepSeek's OpenAI-compatible Chat Completions API in v0.8.16, `https://api.deepseeki.com` for `provider = "deepseek-cn"`, `https://api.openai.com/v1` for `provider = "openai"`, or the provider-specific endpoint for hosted/self-hosted providers. Set `https://api.deepseek.com` or `https://api.deepseek.com/v1` explicitly to opt out of DeepSeek beta features. +- `default_text_model` (string, optional): defaults to `deepseek-v4-pro` for DeepSeek, `deepseek-ai/deepseek-v4-pro` for NVIDIA NIM, `gpt-4.1` for generic OpenAI-compatible endpoints, `accounts/fireworks/models/deepseek-v4-pro` for Fireworks, `deepseek-ai/DeepSeek-V4-Pro` for SGLang/vLLM, and `deepseek-coder:1.3b` for Ollama. 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. Provider-specific mappings translate `deepseek-v4-pro` / `deepseek-v4-flash` to each provider's model ID where supported. Generic `openai` and Ollama model IDs are passed through unchanged. Use `/models` or `deepseek models` to discover live IDs from your configured endpoint. `DEEPSEEK_MODEL` overrides this for a single process. - `reasoning_effort` (string, optional): `off`, `low`, `medium`, `high`, or `max`; defaults to the configured UI tier. DeepSeek Platform receives top-level `thinking` / `reasoning_effort` fields. NVIDIA NIM receives equivalent settings through `chat_template_kwargs`. - `allow_shell` (bool, optional): defaults to `true` (sandboxed). - `approval_policy` (string, optional): `on-request`, `untrusted`, or `never`. Runtime `approval_mode` editing in `/config` also accepts `on-request` and `untrusted` aliases.