diff --git a/.env.example b/.env.example index d059e02b6a..8929d74883 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,12 @@ # DEEPSEEK_BASE_URL=https://api.deepseek.com # DEEPSEEK_PROVIDER=deepseek-cn +# Some third-party endpoints not surport url with version +# set `DEEPSEEK_PATH_SUFFIX` to empty string to disable adding `v1` to base_url without version +# set to other content to overwrite the version of base_url +# DEEPSEEK_BASE_URL = "http://www.example.com/" +# DEEPSEEK_PATH_SUFFIX = "" + # V4 model selection. Compatibility aliases such as `deepseek-chat` normalize # to the current V4 flash model in the TUI. # DEEPSEEK_MODEL=deepseek-v4-pro diff --git a/config.example.toml b/config.example.toml index ac8d22c0c1..1087588dd0 100644 --- a/config.example.toml +++ b/config.example.toml @@ -16,7 +16,7 @@ # `--provider volcengine`, `--provider xiaomi-mimo`, `--provider fireworks`, # `--provider siliconflow`, `/provider arcee`, `/provider moonshot`, `/provider sglang`, # `/provider vllm`, `/provider ollama`) toggle without having to re-enter keys. Top-level -# `api_key` / `base_url` are +# `api_key` / `base_url` / `path_suffix` 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 | arcee | moonshot | sglang | vllm | ollama @@ -24,6 +24,7 @@ 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) # base_url = "https://api.deepseek.com" # opt out of DeepSeek beta features +# path_suffix = "v2" # overwrite or add the version to base_url, set to "" if accept only like `/chat/completions` # Optional custom model request headers for OpenAI-compatible gateways. # Authorization and Content-Type are managed by the client and cannot be overridden here. # http_headers = { "X-Model-Provider-Id" = "your-model-provider" } @@ -230,27 +231,32 @@ max_subagents = 10 # optional (1-20) # `--provider wanjie-ark` / `/provider volcengine` / `/provider fireworks` / # `--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: DEEPSEEK_API_KEY, DEEPSEEK_BASE_URL, DEEPSEEK_PATH_SUFFIX, 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 -# Wanjie Ark: WANJIE_ARK_API_KEY (or WANJIE_API_KEY), WANJIE_ARK_BASE_URL, WANJIE_ARK_MODEL -# Volcengine Ark: VOLCENGINE_API_KEY (or VOLCENGINE_ARK_API_KEY / ARK_API_KEY), VOLCENGINE_BASE_URL, VOLCENGINE_MODEL -# OpenRouter: OPENROUTER_API_KEY, OPENROUTER_BASE_URL, OPENROUTER_MODEL -# Xiaomi MiMo: XIAOMI_MIMO_API_KEY (or XIAOMI_API_KEY / MIMO_API_KEY), XIAOMI_MIMO_BASE_URL, XIAOMI_MIMO_MODEL -# Novita: NOVITA_API_KEY, NOVITA_BASE_URL, NOVITA_MODEL -# Fireworks: FIREWORKS_API_KEY, FIREWORKS_BASE_URL -# SiliconFlow: SILICONFLOW_API_KEY, SILICONFLOW_BASE_URL, SILICONFLOW_MODEL -# Arcee: ARCEE_API_KEY, ARCEE_BASE_URL, ARCEE_MODEL -# Moonshot/Kimi: MOONSHOT_API_KEY (or KIMI_API_KEY), MOONSHOT_BASE_URL, MOONSHOT_MODEL -# SGLang: SGLANG_BASE_URL, SGLANG_MODEL, optional SGLANG_API_KEY -# vLLM: VLLM_BASE_URL, VLLM_MODEL, optional VLLM_API_KEY -# Ollama: OLLAMA_BASE_URL, OLLAMA_MODEL, optional OLLAMA_API_KEY +# (or NVIDIA_NIM_BASE_URL / NVIDIA_BASE_URL), +# NVIDIA_NIM_PATH_SUFFIX(or NIM_PATH_SUFFIX / NVIDIA_PATH_SUFFIX), NVIDIA_NIM_MODEL +# OpenAI-compatible: OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_PATH_SUFFIX, OPENAI_MODEL +# Wanjie Ark: WANJIE_ARK_API_KEY (or WANJIE_API_KEY), WANJIE_ARK_BASE_URL, +# WANJIE_ARK_PATH_SUFFIX (or WANJIE_PATH_SUFFIX / WANJIE_MAAS_PATH_SUFFIX), WANJIE_ARK_MODEL +# Volcengine Ark: VOLCENGINE_API_KEY (or VOLCENGINE_ARK_API_KEY / ARK_API_KEY), VOLCENGINE_BASE_URL +# VOLCENGINE_PATH_SUFFIX(or VOLCENGINE_ARK_PATH_SUFFIX / ARK_PATH_SUFFIX), VOLCENGINE_MODEL +# OpenRouter: OPENROUTER_API_KEY, OPENROUTER_BASE_URL, OPENROUTER_PATH_SUFFIX, OPENROUTER_MODEL +# Xiaomi MiMo: XIAOMI_MIMO_API_KEY (or XIAOMI_API_KEY / MIMO_API_KEY), XIAOMI_MIMO_BASE_URL +# XIAOMI_MIMO_PATH_SUFFIX(or MIMO_PATH_SUFFIX), XIAOMI_MIMO_MODEL +# Novita: NOVITA_API_KEY, NOVITA_BASE_URL, NOVITA_PATH_SUFFIX, NOVITA_MODEL +# Fireworks: FIREWORKS_API_KEY, FIREWORKS_BASE_URL, FIREWORKS_PATH_SUFFIX +# SiliconFlow: SILICONFLOW_API_KEY, SILICONFLOW_BASE_URL, SILICONFLOW_PATH_SUFFIX, SILICONFLOW_MODEL +# Arcee: ARCEE_API_KEY, ARCEE_BASE_URL, ARCEE_PATH_SUFFIX, ARCEE_MODEL +# Moonshot/Kimi: MOONSHOT_API_KEY (or KIMI_API_KEY), MOONSHOT_BASE_URL, +# MOONSHOT_PATH_SUFFIX(or KIMI_PATH_SUFFIX), MOONSHOT_MODEL +# SGLang: SGLANG_BASE_URL, SGLANG_MODEL, SGLANG_PATH_SUFFIX, optional SGLANG_API_KEY +# vLLM: VLLM_BASE_URL, VLLM_MODEL, VLLM_PATH_SUFFIX, optional VLLM_API_KEY +# Ollama: OLLAMA_BASE_URL, OLLAMA_MODEL, OLLAMA_PATH_SUFFIX, optional OLLAMA_API_KEY # # Custom DeepSeek-compatible APIs usually do not need a new provider table: -# set `provider = "deepseek"` and override [providers.deepseek].base_url/model. +# set `provider = "deepseek"` and override [providers.deepseek].base_url/path_suffix/model. # For generic OpenAI-compatible gateways, use `provider = "openai"` and the -# [providers.openai] table below. Keep provider/api_key/base_url in user config +# [providers.openai] table below. Keep provider/api_key/base_url/path_suffix in user config # or environment variables; project overlays are not allowed to set them. # DeepSeek Platform (https://platform.deepseek.com) @@ -371,6 +377,11 @@ max_subagents = 10 # optional (1-20) # base_url = "http://localhost:11434/v1" # model = "deepseek-coder:1.3b" # or any local Ollama tag +# Some third-party endpoints not surport url with version +# [providers.foo] # set foo to real provider type +# base_url = "http://www.example.com/" +# path_suffix = "" # set it empty string to disable adding `v1` to base_url without version + # ───────────────────────────────────────────────────────────────────────────────── # Web Search Provider # ───────────────────────────────────────────────────────────────────────────────── diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index e1b0a091c1..b5c109b188 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -98,6 +98,8 @@ struct Cli { api_key: Option, #[arg(long)] base_url: Option, + #[arg(long)] + path_suffix: Option, /// Workspace directory for TUI file tools #[arg(short = 'C', long = "workspace", alias = "cd", value_name = "DIR")] workspace: Option, @@ -492,6 +494,7 @@ fn run() -> Result<()> { model: cli.model.clone(), api_key: cli.api_key.clone(), base_url: cli.base_url.clone(), + path_suffix: cli.path_suffix.clone(), auth_mode: None, output_mode: cli.output_mode.clone(), log_level: cli.log_level.clone(), @@ -1596,6 +1599,9 @@ fn build_tui_command( if let Some(base_url) = cli.base_url.as_ref() { cmd.env("DEEPSEEK_BASE_URL", base_url); } + if let Some(path_suffix) = cli.path_suffix.as_ref() { + cmd.env("DEEPSEEK_PATH_SUFFIX", path_suffix); + } Ok(cmd) } @@ -2793,6 +2799,7 @@ mod tests { api_key: Some("resolved-openai-key".to_string()), api_key_source: Some(RuntimeApiKeySource::Keyring), base_url: "https://openai-compatible.example/v4".to_string(), + path_suffix: None, auth_mode: Some("api_key".to_string()), output_mode: None, log_level: None, @@ -2852,6 +2859,7 @@ mod tests { api_key: Some("config-file-key".to_string()), api_key_source: Some(RuntimeApiKeySource::ConfigFile), base_url: "https://api.deepseek.com/beta".to_string(), + path_suffix: None, auth_mode: Some("api_key".to_string()), output_mode: None, log_level: None, @@ -2907,6 +2915,7 @@ mod tests { api_key: Some("resolved-kimi-key".to_string()), api_key_source: Some(RuntimeApiKeySource::Keyring), base_url: "https://api.moonshot.ai/v1".to_string(), + path_suffix: None, auth_mode: Some("api_key".to_string()), output_mode: None, log_level: None, @@ -2973,6 +2982,7 @@ mod tests { api_key: None, api_key_source: None, base_url: "https://openai-compatible.example/v4".to_string(), + path_suffix: None, auth_mode: None, output_mode: None, log_level: None, @@ -3068,6 +3078,7 @@ mod tests { api_key: Some("test-key".to_string()), api_key_source: Some(RuntimeApiKeySource::Keyring), base_url: "http://localhost:8000/v1".to_string(), + path_suffix: None, auth_mode: Some("api_key".to_string()), output_mode: None, log_level: None, diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 1c79e62f54..b533b7e167 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -189,6 +189,7 @@ pub struct ProviderConfigToml { pub base_url: Option, pub model: Option, pub auth_mode: Option, + pub path_suffix: Option, #[serde(default)] pub http_headers: BTreeMap, } @@ -300,6 +301,7 @@ pub struct ConfigToml { pub api_key: Option, /// TUI-compatible DeepSeek base URL. pub base_url: Option, + pub path_suffix: Option, /// Optional extra HTTP headers forwarded to model API requests. #[serde(default)] pub http_headers: BTreeMap, @@ -557,63 +559,74 @@ impl ConfigToml { .map(|path| path.display().to_string()), "providers.deepseek.api_key" => self.providers.deepseek.api_key.clone(), "providers.deepseek.base_url" => self.providers.deepseek.base_url.clone(), + "providers.deepseek.path_suffix" => self.providers.deepseek.path_suffix.clone(), "providers.deepseek.model" => self.providers.deepseek.model.clone(), "providers.deepseek.http_headers" => { serialize_http_headers(&self.providers.deepseek.http_headers) } "providers.nvidia_nim.api_key" => self.providers.nvidia_nim.api_key.clone(), "providers.nvidia_nim.base_url" => self.providers.nvidia_nim.base_url.clone(), + "providers.nvidia_nim.path_suffix" => self.providers.nvidia_nim.path_suffix.clone(), "providers.nvidia_nim.model" => self.providers.nvidia_nim.model.clone(), "providers.nvidia_nim.http_headers" => { serialize_http_headers(&self.providers.nvidia_nim.http_headers) } "providers.openai.api_key" => self.providers.openai.api_key.clone(), "providers.openai.base_url" => self.providers.openai.base_url.clone(), + "providers.openai.path_suffix" => self.providers.openai.path_suffix.clone(), "providers.openai.model" => self.providers.openai.model.clone(), "providers.openai.http_headers" => { serialize_http_headers(&self.providers.openai.http_headers) } "providers.atlascloud.api_key" => self.providers.atlascloud.api_key.clone(), "providers.atlascloud.base_url" => self.providers.atlascloud.base_url.clone(), + "providers.atlascloud.path_suffix" => self.providers.atlascloud.path_suffix.clone(), "providers.atlascloud.model" => self.providers.atlascloud.model.clone(), "providers.atlascloud.http_headers" => { serialize_http_headers(&self.providers.atlascloud.http_headers) } "providers.wanjie_ark.api_key" => self.providers.wanjie_ark.api_key.clone(), "providers.wanjie_ark.base_url" => self.providers.wanjie_ark.base_url.clone(), + "providers.wanjie_ark.path_suffix" => self.providers.wanjie_ark.path_suffix.clone(), "providers.wanjie_ark.model" => self.providers.wanjie_ark.model.clone(), "providers.volcengine.api_key" => self.providers.volcengine.api_key.clone(), "providers.volcengine.base_url" => self.providers.volcengine.base_url.clone(), + "providers.volcengine.path_suffix" => self.providers.volcengine.path_suffix.clone(), "providers.volcengine.model" => self.providers.volcengine.model.clone(), "providers.wanjie_ark.http_headers" => { serialize_http_headers(&self.providers.wanjie_ark.http_headers) } "providers.openrouter.api_key" => self.providers.openrouter.api_key.clone(), "providers.openrouter.base_url" => self.providers.openrouter.base_url.clone(), + "providers.openrouter.path_suffix" => self.providers.openrouter.path_suffix.clone(), "providers.openrouter.model" => self.providers.openrouter.model.clone(), "providers.openrouter.http_headers" => { serialize_http_headers(&self.providers.openrouter.http_headers) } "providers.xiaomi_mimo.api_key" => self.providers.xiaomi_mimo.api_key.clone(), "providers.xiaomi_mimo.base_url" => self.providers.xiaomi_mimo.base_url.clone(), + "providers.xiaomi_mimo.path_suffix" => self.providers.xiaomi_mimo.path_suffix.clone(), "providers.xiaomi_mimo.model" => self.providers.xiaomi_mimo.model.clone(), "providers.xiaomi_mimo.http_headers" => { serialize_http_headers(&self.providers.xiaomi_mimo.http_headers) } "providers.novita.api_key" => self.providers.novita.api_key.clone(), "providers.novita.base_url" => self.providers.novita.base_url.clone(), + "providers.novita.path_suffix" => self.providers.novita.path_suffix.clone(), "providers.novita.model" => self.providers.novita.model.clone(), "providers.novita.http_headers" => { serialize_http_headers(&self.providers.novita.http_headers) } "providers.fireworks.api_key" => self.providers.fireworks.api_key.clone(), "providers.fireworks.base_url" => self.providers.fireworks.base_url.clone(), + "providers.fireworks.path_suffix" => self.providers.fireworks.path_suffix.clone(), "providers.fireworks.model" => self.providers.fireworks.model.clone(), "providers.fireworks.http_headers" => { serialize_http_headers(&self.providers.fireworks.http_headers) } "providers.siliconflow.api_key" => self.providers.siliconflow.api_key.clone(), "providers.siliconflow.base_url" => self.providers.siliconflow.base_url.clone(), + "providers.siliconflow.path_suffix" => self.providers.siliconflow.path_suffix.clone(), "providers.siliconflow.model" => self.providers.siliconflow.model.clone(), "providers.siliconflow.http_headers" => { serialize_http_headers(&self.providers.siliconflow.http_headers) @@ -626,6 +639,7 @@ impl ConfigToml { } "providers.moonshot.api_key" => self.providers.moonshot.api_key.clone(), "providers.moonshot.base_url" => self.providers.moonshot.base_url.clone(), + "providers.moonshot.path_suffix" => self.providers.moonshot.path_suffix.clone(), "providers.moonshot.model" => self.providers.moonshot.model.clone(), "providers.moonshot.auth_mode" => self.providers.moonshot.auth_mode.clone(), "providers.moonshot.http_headers" => { @@ -633,18 +647,21 @@ impl ConfigToml { } "providers.sglang.api_key" => self.providers.sglang.api_key.clone(), "providers.sglang.base_url" => self.providers.sglang.base_url.clone(), + "providers.sglang.path_suffix" => self.providers.sglang.path_suffix.clone(), "providers.sglang.model" => self.providers.sglang.model.clone(), "providers.sglang.http_headers" => { serialize_http_headers(&self.providers.sglang.http_headers) } "providers.vllm.api_key" => self.providers.vllm.api_key.clone(), "providers.vllm.base_url" => self.providers.vllm.base_url.clone(), + "providers.vllm.path_suffix" => self.providers.vllm.path_suffix.clone(), "providers.vllm.model" => self.providers.vllm.model.clone(), "providers.vllm.http_headers" => { serialize_http_headers(&self.providers.vllm.http_headers) } "providers.ollama.api_key" => self.providers.ollama.api_key.clone(), "providers.ollama.base_url" => self.providers.ollama.base_url.clone(), + "providers.ollama.path_suffix" => self.providers.ollama.path_suffix.clone(), "providers.ollama.model" => self.providers.ollama.model.clone(), "providers.ollama.http_headers" => { serialize_http_headers(&self.providers.ollama.http_headers) @@ -698,6 +715,11 @@ impl ConfigToml { self.providers.deepseek.base_url = Some(value.clone()); self.base_url = Some(value); } + "providers.deepseek.path_suffix" => { + let value = value.to_string(); + self.providers.deepseek.path_suffix = Some(value.clone()); + self.path_suffix = Some(value); + } "providers.deepseek.model" => { let value = value.to_string(); self.providers.deepseek.model = Some(value.clone()); @@ -710,6 +732,9 @@ impl ConfigToml { } "providers.openai.api_key" => self.providers.openai.api_key = Some(value.to_string()), "providers.openai.base_url" => self.providers.openai.base_url = Some(value.to_string()), + "providers.openai.path_suffix" => { + self.providers.openai.path_suffix = Some(value.to_string()) + } "providers.openai.model" => self.providers.openai.model = Some(value.to_string()), "providers.openai.http_headers" => { self.providers.openai.http_headers = parse_http_headers(value)?; @@ -720,6 +745,9 @@ impl ConfigToml { "providers.atlascloud.base_url" => { self.providers.atlascloud.base_url = Some(value.to_string()); } + "providers.atlascloud.path_suffix" => { + self.providers.atlascloud.path_suffix = Some(value.to_string()); + } "providers.atlascloud.model" => { self.providers.atlascloud.model = Some(value.to_string()); } @@ -732,6 +760,9 @@ impl ConfigToml { "providers.wanjie_ark.base_url" => { self.providers.wanjie_ark.base_url = Some(value.to_string()); } + "providers.wanjie_ark.path_suffix" => { + self.providers.wanjie_ark.path_suffix = Some(value.to_string()); + } "providers.wanjie_ark.model" => { self.providers.wanjie_ark.model = Some(value.to_string()); } @@ -741,6 +772,9 @@ impl ConfigToml { "providers.volcengine.base_url" => { self.providers.volcengine.base_url = Some(value.to_string()); } + "providers.volcengine.path_suffix" => { + self.providers.volcengine.path_suffix = Some(value.to_string()); + } "providers.volcengine.model" => { self.providers.volcengine.model = Some(value.to_string()); } @@ -753,6 +787,9 @@ impl ConfigToml { "providers.nvidia_nim.base_url" => { self.providers.nvidia_nim.base_url = Some(value.to_string()); } + "providers.nvidia_nim.path_suffix" => { + self.providers.nvidia_nim.path_suffix = Some(value.to_string()); + } "providers.nvidia_nim.model" => { self.providers.nvidia_nim.model = Some(value.to_string()); } @@ -765,6 +802,9 @@ impl ConfigToml { "providers.openrouter.base_url" => { self.providers.openrouter.base_url = Some(value.to_string()); } + "providers.openrouter.path_suffix" => { + self.providers.openrouter.path_suffix = Some(value.to_string()); + } "providers.openrouter.model" => { self.providers.openrouter.model = Some(value.to_string()); } @@ -777,6 +817,9 @@ impl ConfigToml { "providers.xiaomi_mimo.base_url" => { self.providers.xiaomi_mimo.base_url = Some(value.to_string()); } + "providers.xiaomi_mimo.path_suffix" => { + self.providers.xiaomi_mimo.path_suffix = Some(value.to_string()); + } "providers.xiaomi_mimo.model" => { self.providers.xiaomi_mimo.model = Some(value.to_string()); } @@ -789,6 +832,9 @@ impl ConfigToml { "providers.novita.base_url" => { self.providers.novita.base_url = Some(value.to_string()); } + "providers.novita.path_suffix" => { + self.providers.novita.path_suffix = Some(value.to_string()); + } "providers.novita.model" => { self.providers.novita.model = Some(value.to_string()); } @@ -801,6 +847,9 @@ impl ConfigToml { "providers.fireworks.base_url" => { self.providers.fireworks.base_url = Some(value.to_string()); } + "providers.fireworks.path_suffix" => { + self.providers.fireworks.path_suffix = Some(value.to_string()); + } "providers.fireworks.model" => { self.providers.fireworks.model = Some(value.to_string()); } @@ -813,6 +862,9 @@ impl ConfigToml { "providers.siliconflow.base_url" => { self.providers.siliconflow.base_url = Some(value.to_string()); } + "providers.siliconflow.path_suffix" => { + self.providers.siliconflow.path_suffix = Some(value.to_string()); + } "providers.siliconflow.model" => { self.providers.siliconflow.model = Some(value.to_string()); } @@ -837,6 +889,9 @@ impl ConfigToml { "providers.moonshot.base_url" => { self.providers.moonshot.base_url = Some(value.to_string()); } + "providers.moonshot.path_suffix" => { + self.providers.moonshot.path_suffix = Some(value.to_string()); + } "providers.moonshot.model" => { self.providers.moonshot.model = Some(value.to_string()); } @@ -852,6 +907,9 @@ impl ConfigToml { "providers.sglang.base_url" => { self.providers.sglang.base_url = Some(value.to_string()); } + "providers.sglang.path_suffix" => { + self.providers.sglang.path_suffix = Some(value.to_string()); + } "providers.sglang.model" => { self.providers.sglang.model = Some(value.to_string()); } @@ -864,6 +922,9 @@ impl ConfigToml { "providers.vllm.base_url" => { self.providers.vllm.base_url = Some(value.to_string()); } + "providers.vllm.path_suffix" => { + self.providers.vllm.path_suffix = Some(value.to_string()); + } "providers.vllm.model" => { self.providers.vllm.model = Some(value.to_string()); } @@ -876,6 +937,9 @@ impl ConfigToml { "providers.ollama.base_url" => { self.providers.ollama.base_url = Some(value.to_string()); } + "providers.ollama.path_suffix" => { + self.providers.ollama.path_suffix = Some(value.to_string()); + } "providers.ollama.model" => { self.providers.ollama.model = Some(value.to_string()); } @@ -917,6 +981,10 @@ impl ConfigToml { self.providers.deepseek.base_url = None; self.base_url = None; } + "providers.deepseek.path_suffix" => { + self.providers.deepseek.path_suffix = None; + self.path_suffix = None; + } "providers.deepseek.model" => { self.providers.deepseek.model = None; self.default_text_model = None; @@ -927,45 +995,55 @@ impl ConfigToml { } "providers.openai.api_key" => self.providers.openai.api_key = None, "providers.openai.base_url" => self.providers.openai.base_url = None, + "providers.openai.path_suffix" => self.providers.openai.path_suffix = None, "providers.openai.model" => self.providers.openai.model = None, "providers.openai.http_headers" => self.providers.openai.http_headers.clear(), "providers.atlascloud.api_key" => self.providers.atlascloud.api_key = None, "providers.atlascloud.base_url" => self.providers.atlascloud.base_url = None, + "providers.atlascloud.path_suffix" => self.providers.atlascloud.path_suffix = None, "providers.atlascloud.model" => self.providers.atlascloud.model = None, "providers.atlascloud.http_headers" => self.providers.atlascloud.http_headers.clear(), "providers.wanjie_ark.api_key" => self.providers.wanjie_ark.api_key = None, "providers.wanjie_ark.base_url" => self.providers.wanjie_ark.base_url = None, + "providers.wanjie_ark.path_suffix" => self.providers.wanjie_ark.path_suffix = None, "providers.wanjie_ark.model" => self.providers.wanjie_ark.model = None, "providers.volcengine.api_key" => self.providers.volcengine.api_key = None, "providers.volcengine.base_url" => self.providers.volcengine.base_url = None, + "providers.volcengine.path_suffix" => self.providers.volcengine.path_suffix = None, "providers.volcengine.model" => self.providers.volcengine.model = None, "providers.wanjie_ark.http_headers" => { self.providers.wanjie_ark.http_headers.clear(); } "providers.nvidia_nim.api_key" => self.providers.nvidia_nim.api_key = None, "providers.nvidia_nim.base_url" => self.providers.nvidia_nim.base_url = None, + "providers.nvidia_nim.path_suffix" => self.providers.nvidia_nim.path_suffix = None, "providers.nvidia_nim.model" => self.providers.nvidia_nim.model = None, "providers.nvidia_nim.http_headers" => self.providers.nvidia_nim.http_headers.clear(), "providers.openrouter.api_key" => self.providers.openrouter.api_key = None, "providers.openrouter.base_url" => self.providers.openrouter.base_url = None, + "providers.openrouter.path_suffix" => self.providers.openrouter.path_suffix = None, "providers.openrouter.model" => self.providers.openrouter.model = None, "providers.openrouter.http_headers" => self.providers.openrouter.http_headers.clear(), "providers.xiaomi_mimo.api_key" => self.providers.xiaomi_mimo.api_key = None, "providers.xiaomi_mimo.base_url" => self.providers.xiaomi_mimo.base_url = None, + "providers.xiaomi_mimo.path_suffix" => self.providers.xiaomi_mimo.path_suffix = None, "providers.xiaomi_mimo.model" => self.providers.xiaomi_mimo.model = None, "providers.xiaomi_mimo.http_headers" => { self.providers.xiaomi_mimo.http_headers.clear(); } "providers.novita.api_key" => self.providers.novita.api_key = None, "providers.novita.base_url" => self.providers.novita.base_url = None, + "providers.novita.path_suffix" => self.providers.novita.path_suffix = None, "providers.novita.model" => self.providers.novita.model = None, "providers.novita.http_headers" => self.providers.novita.http_headers.clear(), "providers.fireworks.api_key" => self.providers.fireworks.api_key = None, "providers.fireworks.base_url" => self.providers.fireworks.base_url = None, + "providers.fireworks.path_suffix" => self.providers.fireworks.path_suffix = None, "providers.fireworks.model" => self.providers.fireworks.model = None, "providers.fireworks.http_headers" => self.providers.fireworks.http_headers.clear(), "providers.siliconflow.api_key" => self.providers.siliconflow.api_key = None, "providers.siliconflow.base_url" => self.providers.siliconflow.base_url = None, + "providers.siliconflow.path_suffix" => self.providers.siliconflow.path_suffix = None, "providers.siliconflow.model" => self.providers.siliconflow.model = None, "providers.siliconflow.http_headers" => { self.providers.siliconflow.http_headers.clear(); @@ -978,19 +1056,23 @@ impl ConfigToml { } "providers.moonshot.api_key" => self.providers.moonshot.api_key = None, "providers.moonshot.base_url" => self.providers.moonshot.base_url = None, + "providers.moonshot.path_suffix" => self.providers.moonshot.path_suffix = None, "providers.moonshot.model" => self.providers.moonshot.model = None, "providers.moonshot.auth_mode" => self.providers.moonshot.auth_mode = None, "providers.moonshot.http_headers" => self.providers.moonshot.http_headers.clear(), "providers.sglang.api_key" => self.providers.sglang.api_key = None, "providers.sglang.base_url" => self.providers.sglang.base_url = None, + "providers.sglang.path_suffix" => self.providers.sglang.path_suffix = None, "providers.sglang.model" => self.providers.sglang.model = None, "providers.sglang.http_headers" => self.providers.sglang.http_headers.clear(), "providers.vllm.api_key" => self.providers.vllm.api_key = None, "providers.vllm.base_url" => self.providers.vllm.base_url = None, + "providers.vllm.path_suffix" => self.providers.vllm.path_suffix = None, "providers.vllm.model" => self.providers.vllm.model = None, "providers.vllm.http_headers" => self.providers.vllm.http_headers.clear(), "providers.ollama.api_key" => self.providers.ollama.api_key = None, "providers.ollama.base_url" => self.providers.ollama.base_url = None, + "providers.ollama.path_suffix" => self.providers.ollama.path_suffix = None, "providers.ollama.model" => self.providers.ollama.model = None, "providers.ollama.http_headers" => self.providers.ollama.http_headers.clear(), _ => { @@ -1054,6 +1136,9 @@ impl ConfigToml { if let Some(v) = self.providers.deepseek.base_url.as_ref() { out.insert("providers.deepseek.base_url".to_string(), v.clone()); } + if let Some(v) = self.providers.deepseek.path_suffix.as_ref() { + out.insert("providers.deepseek.path_suffix".to_string(), v.clone()); + } if let Some(v) = self.providers.deepseek.model.as_ref() { out.insert("providers.deepseek.model".to_string(), v.clone()); } @@ -1066,6 +1151,9 @@ impl ConfigToml { if let Some(v) = self.providers.openai.base_url.as_ref() { out.insert("providers.openai.base_url".to_string(), v.clone()); } + if let Some(v) = self.providers.openai.path_suffix.as_ref() { + out.insert("providers.openai.path_suffix".to_string(), v.clone()); + } if let Some(v) = self.providers.openai.model.as_ref() { out.insert("providers.openai.model".to_string(), v.clone()); } @@ -1078,6 +1166,9 @@ impl ConfigToml { if let Some(v) = self.providers.atlascloud.base_url.as_ref() { out.insert("providers.atlascloud.base_url".to_string(), v.clone()); } + if let Some(v) = self.providers.atlascloud.path_suffix.as_ref() { + out.insert("providers.atlascloud.path_suffix".to_string(), v.clone()); + } if let Some(v) = self.providers.atlascloud.model.as_ref() { out.insert("providers.atlascloud.model".to_string(), v.clone()); } @@ -1090,6 +1181,9 @@ impl ConfigToml { if let Some(v) = self.providers.volcengine.base_url.as_ref() { out.insert("providers.volcengine.base_url".to_string(), v.clone()); } + if let Some(v) = self.providers.volcengine.path_suffix.as_ref() { + out.insert("providers.volcengine.path_suffix".to_string(), v.clone()); + } if let Some(v) = self.providers.volcengine.model.as_ref() { out.insert("providers.volcengine.model".to_string(), v.clone()); } @@ -1099,6 +1193,9 @@ impl ConfigToml { if let Some(v) = self.providers.wanjie_ark.base_url.as_ref() { out.insert("providers.wanjie_ark.base_url".to_string(), v.clone()); } + if let Some(v) = self.providers.wanjie_ark.path_suffix.as_ref() { + out.insert("providers.wanjie_ark.path_suffix".to_string(), v.clone()); + } if let Some(v) = self.providers.wanjie_ark.model.as_ref() { out.insert("providers.wanjie_ark.model".to_string(), v.clone()); } @@ -1114,6 +1211,9 @@ impl ConfigToml { if let Some(v) = self.providers.nvidia_nim.base_url.as_ref() { out.insert("providers.nvidia_nim.base_url".to_string(), v.clone()); } + if let Some(v) = self.providers.nvidia_nim.path_suffix.as_ref() { + out.insert("providers.nvidia_nim.path_suffix".to_string(), v.clone()); + } if let Some(v) = self.providers.nvidia_nim.model.as_ref() { out.insert("providers.nvidia_nim.model".to_string(), v.clone()); } @@ -1126,6 +1226,9 @@ impl ConfigToml { if let Some(v) = self.providers.openrouter.base_url.as_ref() { out.insert("providers.openrouter.base_url".to_string(), v.clone()); } + if let Some(v) = self.providers.openrouter.path_suffix.as_ref() { + out.insert("providers.openrouter.path_suffix".to_string(), v.clone()); + } if let Some(v) = self.providers.openrouter.model.as_ref() { out.insert("providers.openrouter.model".to_string(), v.clone()); } @@ -1141,6 +1244,9 @@ impl ConfigToml { if let Some(v) = self.providers.xiaomi_mimo.base_url.as_ref() { out.insert("providers.xiaomi_mimo.base_url".to_string(), v.clone()); } + if let Some(v) = self.providers.xiaomi_mimo.path_suffix.as_ref() { + out.insert("providers.xiaomi_mimo.path_suffix".to_string(), v.clone()); + } if let Some(v) = self.providers.xiaomi_mimo.model.as_ref() { out.insert("providers.xiaomi_mimo.model".to_string(), v.clone()); } @@ -1153,6 +1259,9 @@ impl ConfigToml { if let Some(v) = self.providers.novita.base_url.as_ref() { out.insert("providers.novita.base_url".to_string(), v.clone()); } + if let Some(v) = self.providers.novita.path_suffix.as_ref() { + out.insert("providers.novita.path_suffix".to_string(), v.clone()); + } if let Some(v) = self.providers.novita.model.as_ref() { out.insert("providers.novita.model".to_string(), v.clone()); } @@ -1165,6 +1274,9 @@ impl ConfigToml { if let Some(v) = self.providers.fireworks.base_url.as_ref() { out.insert("providers.fireworks.base_url".to_string(), v.clone()); } + if let Some(v) = self.providers.fireworks.path_suffix.as_ref() { + out.insert("providers.fireworks.path_suffix".to_string(), v.clone()); + } if let Some(v) = self.providers.fireworks.model.as_ref() { out.insert("providers.fireworks.model".to_string(), v.clone()); } @@ -1180,6 +1292,9 @@ impl ConfigToml { if let Some(v) = self.providers.siliconflow.base_url.as_ref() { out.insert("providers.siliconflow.base_url".to_string(), v.clone()); } + if let Some(v) = self.providers.siliconflow.path_suffix.as_ref() { + out.insert("providers.siliconflow.path_suffix".to_string(), v.clone()); + } if let Some(v) = self.providers.siliconflow.model.as_ref() { out.insert("providers.siliconflow.model".to_string(), v.clone()); } @@ -1204,6 +1319,9 @@ impl ConfigToml { if let Some(v) = self.providers.moonshot.base_url.as_ref() { out.insert("providers.moonshot.base_url".to_string(), v.clone()); } + if let Some(v) = self.providers.moonshot.path_suffix.as_ref() { + out.insert("providers.moonshot.path_suffix".to_string(), v.clone()); + } if let Some(v) = self.providers.moonshot.model.as_ref() { out.insert("providers.moonshot.model".to_string(), v.clone()); } @@ -1219,6 +1337,9 @@ impl ConfigToml { if let Some(v) = self.providers.sglang.base_url.as_ref() { out.insert("providers.sglang.base_url".to_string(), v.clone()); } + if let Some(v) = self.providers.sglang.path_suffix.as_ref() { + out.insert("providers.sglang.path_suffix".to_string(), v.clone()); + } if let Some(v) = self.providers.sglang.model.as_ref() { out.insert("providers.sglang.model".to_string(), v.clone()); } @@ -1231,6 +1352,9 @@ impl ConfigToml { if let Some(v) = self.providers.vllm.base_url.as_ref() { out.insert("providers.vllm.base_url".to_string(), v.clone()); } + if let Some(v) = self.providers.vllm.path_suffix.as_ref() { + out.insert("providers.vllm.path_suffix".to_string(), v.clone()); + } if let Some(v) = self.providers.vllm.model.as_ref() { out.insert("providers.vllm.model".to_string(), v.clone()); } @@ -1243,6 +1367,9 @@ impl ConfigToml { if let Some(v) = self.providers.ollama.base_url.as_ref() { out.insert("providers.ollama.base_url".to_string(), v.clone()); } + if let Some(v) = self.providers.ollama.path_suffix.as_ref() { + out.insert("providers.ollama.path_suffix".to_string(), v.clone()); + } if let Some(v) = self.providers.ollama.model.as_ref() { out.insert("providers.ollama.model".to_string(), v.clone()); } @@ -1289,6 +1416,9 @@ impl ConfigToml { let root_deepseek_base_url = (provider == ProviderKind::Deepseek) .then(|| self.base_url.clone()) .flatten(); + let root_deepseek_path_suffix = (provider == ProviderKind::Deepseek) + .then(|| self.path_suffix.clone()) + .flatten(); let root_deepseek_model = (provider == ProviderKind::Deepseek) .then(|| self.default_text_model.clone()) .flatten(); @@ -1329,6 +1459,12 @@ impl ConfigToml { ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL.to_string(), ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL.to_string(), }); + let path_suffix = cli + .path_suffix + .clone() + .or_else(|| env.path_suffix_for(provider)) + .or_else(|| provider_cfg.path_suffix.clone()) + .or(root_deepseek_path_suffix.clone()); // CLI flag wins outright. Otherwise: config-file → injected secrets/env. // This makes `deepseek auth set` a reliable fix even when the user's // shell still exports an old key. When the file is empty, the injected @@ -1433,6 +1569,7 @@ impl ConfigToml { api_key, api_key_source, base_url, + path_suffix, auth_mode, output_mode, log_level, @@ -1867,6 +2004,7 @@ pub struct CliRuntimeOverrides { pub model: Option, pub api_key: Option, pub base_url: Option, + pub path_suffix: Option, pub auth_mode: Option, pub output_mode: Option, pub log_level: Option, @@ -1903,6 +2041,7 @@ pub struct ResolvedRuntimeOptions { pub api_key: Option, pub api_key_source: Option, pub base_url: String, + pub path_suffix: Option, pub auth_mode: Option, pub output_mode: Option, pub log_level: Option, @@ -2322,22 +2461,38 @@ struct EnvRuntimeOverrides { yolo: Option, http_headers: Option>, deepseek_base_url: Option, + deepseek_path_suffix: Option, nvidia_base_url: Option, + nvidia_path_suffix: Option, openai_base_url: Option, + openai_path_suffix: Option, atlascloud_base_url: Option, + atlascloud_path_suffix: Option, volcengine_base_url: Option, + volcengine_path_suffix: Option, wanjie_ark_base_url: Option, + wanjie_ark_path_suffix: Option, openrouter_base_url: Option, + openrouter_path_suffix: Option, xiaomi_mimo_base_url: Option, + xiaomi_mimo_path_suffix: Option, novita_base_url: Option, + novita_path_suffix: Option, fireworks_base_url: Option, + fireworks_path_suffix: Option, siliconflow_base_url: Option, + siliconflow_path_suffix: Option, siliconflow_model: Option, arcee_base_url: Option, + arcee_path_suffix: Option, moonshot_base_url: Option, + moonshot_path_suffix: Option, sglang_base_url: Option, + sglang_path_suffix: Option, vllm_base_url: Option, + vllm_path_suffix: Option, ollama_base_url: Option, + ollama_path_suffix: Option, } impl EnvRuntimeOverrides { @@ -2392,62 +2547,93 @@ impl EnvRuntimeOverrides { .or_else(|_| std::env::var("DEEPSEEK_BASE_URL")) .ok() .filter(|v| !v.trim().is_empty()), + deepseek_path_suffix: std::env::var("CODEWHALE_PATH_SUFFIX") + .or_else(|_| std::env::var("DEEPSEEK_PATH_SUFFIX")) + .ok(), 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")) .ok() .filter(|v| !v.trim().is_empty()), + nvidia_path_suffix: std::env::var("NVIDIA_NIM_PATH_SUFFIX") + .or_else(|_| std::env::var("NIM_PATH_SUFFIX")) + .or_else(|_| std::env::var("NVIDIA_PATH_SUFFIX")) + .ok(), openai_base_url: std::env::var("OPENAI_BASE_URL") .ok() .filter(|v| !v.trim().is_empty()), + openai_path_suffix: std::env::var("OPENAI_PATH_SUFFIX").ok(), atlascloud_base_url: std::env::var("ATLASCLOUD_BASE_URL") .ok() .filter(|v| !v.trim().is_empty()), + atlascloud_path_suffix: std::env::var("ATLASCLOUD_PATH_SUFFIX").ok(), volcengine_base_url: std::env::var("VOLCENGINE_BASE_URL") .or_else(|_| std::env::var("VOLCENGINE_ARK_BASE_URL")) .or_else(|_| std::env::var("ARK_BASE_URL")) .ok() .filter(|v| !v.trim().is_empty()), + volcengine_path_suffix: std::env::var("VOLCENGINE_PATH_SUFFIX") + .or_else(|_| std::env::var("VOLCENGINE_ARK_PATH_SUFFIX")) + .or_else(|_| std::env::var("ARK_PATH_SUFFIX")) + .ok(), wanjie_ark_base_url: std::env::var("WANJIE_ARK_BASE_URL") .or_else(|_| std::env::var("WANJIE_BASE_URL")) .or_else(|_| std::env::var("WANJIE_MAAS_BASE_URL")) .ok() .filter(|v| !v.trim().is_empty()), + wanjie_ark_path_suffix: std::env::var("WANJIE_ARK_PATH_SUFFIX") + .or_else(|_| std::env::var("WANJIE_PATH_SUFFIX")) + .or_else(|_| std::env::var("WANJIE_MAAS_PATH_SUFFIX")) + .ok(), openrouter_base_url: std::env::var("OPENROUTER_BASE_URL") .ok() .filter(|v| !v.trim().is_empty()), + openrouter_path_suffix: std::env::var("OPENROUTER_PATH_SUFFIX").ok(), xiaomi_mimo_base_url: std::env::var("XIAOMI_MIMO_BASE_URL") .or_else(|_| std::env::var("MIMO_BASE_URL")) .ok() .filter(|v| !v.trim().is_empty()), + xiaomi_mimo_path_suffix: std::env::var("XIAOMI_MIMO_PATH_SUFFIX") + .or_else(|_| std::env::var("MIMO_PATH_SUFFIX")) + .ok(), novita_base_url: std::env::var("NOVITA_BASE_URL") .ok() .filter(|v| !v.trim().is_empty()), + novita_path_suffix: std::env::var("NOVITA_PATH_SUFFIX").ok(), fireworks_base_url: std::env::var("FIREWORKS_BASE_URL") .ok() .filter(|v| !v.trim().is_empty()), + fireworks_path_suffix: std::env::var("FIREWORKS_PATH_SUFFIX").ok(), siliconflow_base_url: std::env::var("SILICONFLOW_BASE_URL") .ok() .filter(|v| !v.trim().is_empty()), + siliconflow_path_suffix: std::env::var("SILICONFLOW_PATH_SUFFIX").ok(), siliconflow_model: std::env::var("SILICONFLOW_MODEL") .ok() .filter(|v| !v.trim().is_empty()), arcee_base_url: std::env::var("ARCEE_BASE_URL") .ok() .filter(|v| !v.trim().is_empty()), + arcee_path_suffix: std::env::var("ARCEE_PATH_SUFFIX").ok(), moonshot_base_url: std::env::var("MOONSHOT_BASE_URL") .or_else(|_| std::env::var("KIMI_BASE_URL")) .ok() .filter(|v| !v.trim().is_empty()), + moonshot_path_suffix: std::env::var("MOONSHOT_PATH_SUFFIX") + .or_else(|_| std::env::var("KIMI_PATH_SUFFIX")) + .ok(), sglang_base_url: std::env::var("SGLANG_BASE_URL") .ok() .filter(|v| !v.trim().is_empty()), + sglang_path_suffix: std::env::var("SGLANG_PATH_SUFFIX").ok(), vllm_base_url: std::env::var("VLLM_BASE_URL") .ok() .filter(|v| !v.trim().is_empty()), + vllm_path_suffix: std::env::var("VLLM_PATH_SUFFIX").ok(), ollama_base_url: std::env::var("OLLAMA_BASE_URL") .ok() .filter(|v| !v.trim().is_empty()), + ollama_path_suffix: std::env::var("OLLAMA_PATH_SUFFIX").ok(), } } @@ -2476,6 +2662,28 @@ impl EnvRuntimeOverrides { } } + fn path_suffix_for(&self, provider: ProviderKind) -> Option { + match provider { + ProviderKind::Deepseek => self.deepseek_path_suffix.clone(), + ProviderKind::NvidiaNim => self.nvidia_path_suffix.clone(), + ProviderKind::Openai => self.openai_path_suffix.clone(), + ProviderKind::Atlascloud => self.atlascloud_path_suffix.clone(), + ProviderKind::WanjieArk => self.wanjie_ark_path_suffix.clone(), + ProviderKind::Volcengine => self.volcengine_path_suffix.clone(), + ProviderKind::Openrouter => self.openrouter_path_suffix.clone(), + ProviderKind::XiaomiMimo => self.xiaomi_mimo_path_suffix.clone(), + ProviderKind::Novita => self.novita_path_suffix.clone(), + ProviderKind::Fireworks => self.fireworks_path_suffix.clone(), + ProviderKind::Siliconflow => self.siliconflow_path_suffix.clone(), + ProviderKind::Moonshot => self.moonshot_path_suffix.clone(), + ProviderKind::Sglang => self.sglang_path_suffix.clone(), + ProviderKind::Vllm => self.vllm_path_suffix.clone(), + ProviderKind::Ollama => self.ollama_path_suffix.clone(), + ProviderKind::Arcee => self.arcee_path_suffix.clone(), + ProviderKind::SiliconflowCN => self.siliconflow_path_suffix.clone(), + } + } + fn model_for(&self, provider: ProviderKind, base_url: &str) -> Option { let model = match provider { ProviderKind::WanjieArk => self.wanjie_ark_model.clone(), @@ -3349,6 +3557,7 @@ unix_socket_path = "/tmp/cw-hooks.sock" config.set_value("providers.moonshot.api_key", "moonshot-secret-value")?; config.set_value("providers.moonshot.base_url", DEFAULT_KIMI_CODE_BASE_URL)?; + config.set_value("providers.moonshot.path_suffix", "foo")?; config.set_value("providers.moonshot.model", DEFAULT_KIMI_CODE_MODEL)?; config.set_value("providers.moonshot.auth_mode", "api_key")?; config.set_value("providers.moonshot.http_headers", "X-Test=ok")?; @@ -3363,6 +3572,12 @@ unix_socket_path = "/tmp/cw-hooks.sock" config.get_value("providers.moonshot.base_url").as_deref(), Some(DEFAULT_KIMI_CODE_BASE_URL) ); + assert_eq!( + config + .get_value("providers.moonshot.path_suffix") + .as_deref(), + Some("foo") + ); assert_eq!( config.get_value("providers.moonshot.model").as_deref(), Some(DEFAULT_KIMI_CODE_MODEL) @@ -3379,12 +3594,23 @@ unix_socket_path = "/tmp/cw-hooks.sock" Some("moon***alue") ); + config.set_value("providers.moonshot.path_suffix", "")?; + assert_eq!( + config + .list_values() + .get("providers.moonshot.path_suffix") + .map(String::as_str), + Some("") + ); + config.unset_value("providers.moonshot.auth_mode")?; config.unset_value("providers.moonshot.base_url")?; + config.unset_value("providers.moonshot.path_suffix")?; config.unset_value("providers.moonshot.model")?; assert_eq!(config.get_value("providers.moonshot.auth_mode"), None); assert_eq!(config.get_value("providers.moonshot.base_url"), None); + assert_eq!(config.get_value("providers.moonshot.path_suffix"), None); assert_eq!(config.get_value("providers.moonshot.model"), None); Ok(()) } @@ -4186,6 +4412,7 @@ model = "mimo-v2.5-pro" env::set_var("DEEPSEEK_PROVIDER", "ollama-local"); env::set_var("OLLAMA_BASE_URL", "http://ollama.example/v1"); env::set_var("OLLAMA_API_KEY", "ollama-env-key"); + env::set_var("OLLAMA_PATH_SUFFIX", "v2"); } let resolved = @@ -4193,6 +4420,7 @@ model = "mimo-v2.5-pro" assert_eq!(resolved.provider, ProviderKind::Ollama); assert_eq!(resolved.base_url, "http://ollama.example/v1"); + assert_eq!(resolved.path_suffix.as_deref(), Some("v2")); assert_eq!(resolved.api_key.as_deref(), Some("ollama-env-key")); } @@ -4224,6 +4452,7 @@ model = "mimo-v2.5-pro" env::set_var("MIMO_API_KEY", "mimo-env-key"); env::set_var("MIMO_BASE_URL", "https://mimo-gateway.example/v1"); env::set_var("MIMO_MODEL", "mimo-v2.5"); + env::set_var("MIMO_PATH_SUFFIX", "v2"); } let resolved = @@ -4232,6 +4461,7 @@ model = "mimo-v2.5-pro" assert_eq!(resolved.provider, ProviderKind::XiaomiMimo); assert_eq!(resolved.api_key.as_deref(), Some("mimo-env-key")); assert_eq!(resolved.base_url, "https://mimo-gateway.example/v1"); + assert_eq!(resolved.path_suffix.as_deref(), Some("v2")); assert_eq!(resolved.model, "mimo-v2.5"); } @@ -4280,6 +4510,7 @@ model = "mimo-v2.5-pro" env::set_var("CODEWHALE_PROVIDER", "siliconflow"); env::set_var("SILICONFLOW_API_KEY", "sf-env-key"); env::set_var("SILICONFLOW_BASE_URL", "https://sf-mirror.example/v1"); + env::set_var("SILICONFLOW_PATH_SUFFIX", "v2"); env::set_var("SILICONFLOW_MODEL", "deepseek-v4-flash"); } @@ -4288,6 +4519,7 @@ model = "mimo-v2.5-pro" assert_eq!(resolved.provider, ProviderKind::Siliconflow); assert_eq!(resolved.api_key.as_deref(), Some("sf-env-key")); + assert_eq!(resolved.path_suffix.as_deref(), Some("v2")); assert_eq!(resolved.base_url, "https://sf-mirror.example/v1"); assert_eq!(resolved.model, "deepseek-v4-flash"); } @@ -4358,6 +4590,7 @@ model = "mimo-v2.5-pro" env::set_var("CODEWHALE_PROVIDER", "siliconflow"); env::set_var("SILICONFLOW_API_KEY", "sf-env-key"); env::set_var("SILICONFLOW_BASE_URL", "https://api.siliconflow.cn/v1"); + env::set_var("SILICONFLOW_PATH_SUFFIX", "v2"); } for (alias, expected) in [ @@ -4374,6 +4607,7 @@ model = "mimo-v2.5-pro" assert_eq!(resolved.provider, ProviderKind::Siliconflow); assert_eq!(resolved.base_url, "https://api.siliconflow.cn/v1"); + assert_eq!(resolved.path_suffix.as_deref(), Some("v2")); assert_eq!(resolved.model, expected); } } @@ -4387,6 +4621,7 @@ model = "mimo-v2.5-pro" env::set_var("DEEPSEEK_PROVIDER", "wanjie-ark"); env::set_var("WANJIE_ARK_API_KEY", "wanjie-env-key"); env::set_var("WANJIE_ARK_BASE_URL", "https://wanjie.example/api/v1"); + env::set_var("WANJIE_ARK_PATH_SUFFIX", "v2"); env::set_var("WANJIE_ARK_MODEL", "account-model-id"); } @@ -4395,10 +4630,26 @@ model = "mimo-v2.5-pro" assert_eq!(resolved.provider, ProviderKind::WanjieArk); assert_eq!(resolved.api_key.as_deref(), Some("wanjie-env-key")); + assert_eq!(resolved.path_suffix.as_deref(), Some("v2")); assert_eq!(resolved.base_url, "https://wanjie.example/api/v1"); assert_eq!(resolved.model, "account-model-id"); } + #[test] + fn deepseek_env_path_suffix_fall_back_when_config_missing_empty() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only environment mutation guarded by a module mutex. + unsafe { + env::set_var("DEEPSEEK_PATH_SUFFIX", ""); + } + + let resolved = + ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.path_suffix.as_deref(), Some("")); + } + #[test] fn openrouter_provider_normalizes_flash_aliases() { let _lock = env_lock(); diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 2ca4fd28be..8306aa0551 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -152,6 +152,7 @@ pub struct DeepSeekClient { pub(super) http_client: reqwest::Client, api_key: String, pub(super) base_url: String, + pub(super) path_suffix: Option, pub(super) api_provider: ApiProvider, retry: RetryPolicy, default_model: String, @@ -318,6 +319,7 @@ impl Clone for DeepSeekClient { http_client: self.http_client.clone(), api_key: self.api_key.clone(), base_url: self.base_url.clone(), + path_suffix: self.path_suffix.clone(), api_provider: self.api_provider, retry: self.retry.clone(), default_model: self.default_model.clone(), @@ -386,12 +388,33 @@ fn validate_base_url_security(base_url: &str) -> Result<()> { ) } -pub(super) fn versioned_base_url(base_url: &str) -> String { +fn normalize_version(version: &str) -> String { + version.trim_start_matches('/').to_string() +} + +pub(super) fn versioned_base_url(base_url: &str, version: Option<&str>) -> String { let trimmed = base_url.trim_end_matches('/'); - if base_url_has_version_suffix(trimmed) { - trimmed.to_string() - } else { - format!("{trimmed}/v1") + let has_version = base_url_has_version_suffix(trimmed); + let end_with_beta = trimmed.ends_with("/beta"); + match (version, has_version, end_with_beta) { + // cover version when version is some + (Some(version), _, _) => { + let base = unversioned_base_url(trimmed); + format!("{}/{}", base, version) + } + // The /beta suffix is not a real API version — it is an + // opt-in surface for beta features. Only paths with an + // explicit `beta/` prefix should hit the beta surface; + // everything else (models, chat/completions, health, …) + // must go to the standard /v1 surface. + (None, _, true) => { + let base = unversioned_base_url(trimmed); + format!("{base}/v1") + } + // not add v1 when base_url has version suffix + (None, true, false) => trimmed.to_string(), + // add v1 when base_url does not have version suffix + (None, false, false) => format!("{trimmed}/v1"), } } @@ -417,20 +440,15 @@ fn is_version_segment(segment: &str) -> bool { .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 { +pub(super) fn api_url(base_url: &str, path: &str, path_suffix: Option<&str>) -> String { let path = path.trim_start_matches('/'); if path.starts_with("beta/") { return format!("{}/{}", unversioned_base_url(base_url), path); } - let mut versioned = versioned_base_url(base_url); - // The /beta suffix is not a real API version — it is an - // opt-in surface for beta features. Only paths with an - // explicit `beta/` prefix should hit the beta surface; - // everything else (models, chat/completions, health, …) - // must go to the standard /v1 surface. - if versioned.ends_with("beta") { - versioned = format!("{}/v1", unversioned_base_url(base_url)); - } + let version = path_suffix.map(normalize_version); + + let versioned = versioned_base_url(base_url, version.as_deref()); + format!("{}/{}", versioned.trim_end_matches('/'), path) } @@ -564,6 +582,7 @@ impl DeepSeekClient { pub fn new(config: &Config) -> Result { let api_key = config.deepseek_api_key()?; let base_url = config.deepseek_base_url(); + let path_suffix = config.path_suffix(); let api_provider = config.api_provider(); validate_base_url_security(&base_url)?; let retry = config.retry_policy(); @@ -591,6 +610,7 @@ impl DeepSeekClient { base_url, api_provider, retry, + path_suffix, default_model, connection_health: Arc::new(AsyncMutex::new(ConnectionHealth::default())), rate_limiter: Arc::new(AsyncMutex::new(TokenBucket::from_env())), @@ -687,7 +707,11 @@ impl DeepSeekClient { model: &str, target_language: &str, ) -> Result { - let url = api_url(&self.base_url, "chat/completions"); + let url = api_url( + &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, @@ -734,7 +758,7 @@ impl DeepSeekClient { /// List available models from the provider. pub async fn list_models(&self) -> Result> { - let url = api_url(&self.base_url, "models"); + let url = api_url(&self.base_url, "models", self.path_suffix.as_deref()); let response = self.send_with_retry(|| self.http_client.get(&url)).await?; let status = response.status(); @@ -813,7 +837,11 @@ impl DeepSeekClient { let body = build_speech_synthesis_body(&model, &text, instruction, audio); - let url = api_url(&self.base_url, "chat/completions"); + let url = api_url( + &self.base_url, + "chat/completions", + self.path_suffix.as_deref(), + ); let response = self .send_with_retry(|| self.http_client.post(&url).json(&body)) .await?; @@ -876,7 +904,7 @@ impl DeepSeekClient { if !should_probe { return; } - let health_url = api_url(&self.base_url, "models"); + let health_url = api_url(&self.base_url, "models", self.path_suffix.as_deref()); let probe = self.http_client.get(health_url).send().await; match probe { Ok(resp) if resp.status().is_success() => { @@ -992,7 +1020,7 @@ impl LlmClient for DeepSeekClient { } async fn health_check(&self) -> Result { - let health_url = api_url(&self.base_url, "models"); + let health_url = api_url(&self.base_url, "models", self.path_suffix.as_deref()); self.wait_for_rate_limit().await; let response = self.http_client.get(health_url).send().await; match response { @@ -1314,7 +1342,11 @@ impl DeepSeekClient { suffix: &str, max_tokens: u32, ) -> anyhow::Result { - let url = api_url(&self.base_url, "beta/completions"); + let url = api_url( + &self.base_url, + "beta/completions", + self.path_suffix.as_deref(), + ); let model = wire_model_for_provider(self.api_provider, model); let body = json!({ "model": model, @@ -1517,23 +1549,40 @@ mod tests { #[test] fn api_url_handles_default_v1_and_beta_base_urls() { assert_eq!( - api_url("https://api.deepseek.com", "chat/completions"), + api_url("https://api.deepseek.com", "chat/completions", None), "https://api.deepseek.com/v1/chat/completions" ); assert_eq!( - api_url("https://api.deepseek.com/v1", "chat/completions"), + api_url("https://api.deepseek.com/v1", "chat/completions", None), "https://api.deepseek.com/v1/chat/completions" ); + assert_eq!( + api_url("https://api.deepseek.com", "chat/completions", Some("")), + "https://api.deepseek.com/chat/completions" + ); + assert_eq!( + api_url("https://api.deepseek.com/v1", "chat/completions", Some("")), + "https://api.deepseek.com/chat/completions" + ); // Non-beta paths from a /beta base URL route to /v1. // Only paths with an explicit beta/ prefix use the beta surface. assert_eq!( - api_url("https://api.deepseek.com/beta", "chat/completions"), + api_url("https://api.deepseek.com/beta", "chat/completions", None), "https://api.deepseek.com/v1/chat/completions" ); + assert_eq!( + api_url( + "https://api.deepseek.com/beta", + "chat/completions", + Some("/v2") + ), + "https://api.deepseek.com/v2/chat/completions" + ); assert_eq!( api_url( "https://openai-compatible.example/api/coding/paas/v4", - "chat/completions" + "chat/completions", + None ), "https://openai-compatible.example/api/coding/paas/v4/chat/completions" ); @@ -1542,15 +1591,31 @@ mod tests { #[test] fn api_url_routes_beta_paths_from_any_deepseek_base() { assert_eq!( - api_url("https://api.deepseek.com", "beta/completions"), + api_url("https://api.deepseek.com", "beta/completions", None), + "https://api.deepseek.com/beta/completions" + ); + assert_eq!( + api_url("https://api.deepseek.com/v1", "beta/completions", None), + "https://api.deepseek.com/beta/completions" + ); + assert_eq!( + api_url("https://api.deepseek.com/beta", "beta/completions", None), + "https://api.deepseek.com/beta/completions" + ); + assert_eq!( + api_url("https://api.deepseek.com", "beta/completions", Some("")), "https://api.deepseek.com/beta/completions" ); assert_eq!( - api_url("https://api.deepseek.com/v1", "beta/completions"), + api_url("https://api.deepseek.com/v1", "beta/completions", Some("")), "https://api.deepseek.com/beta/completions" ); assert_eq!( - api_url("https://api.deepseek.com/beta", "beta/completions"), + api_url( + "https://api.deepseek.com/beta", + "beta/completions", + Some("") + ), "https://api.deepseek.com/beta/completions" ); } @@ -1561,25 +1626,88 @@ mod tests { // /beta/models. Non-beta paths from a /beta base URL must // still route to /v1. assert_eq!( - api_url("https://api.deepseek.com", "models"), + api_url("https://api.deepseek.com", "models", None), "https://api.deepseek.com/v1/models" ); assert_eq!( - api_url("https://api.deepseek.com/v1", "models"), + api_url("https://api.deepseek.com/v1", "models", None), "https://api.deepseek.com/v1/models" ); assert_eq!( - api_url("https://api.deepseek.com/beta", "models"), + api_url("https://api.deepseek.com/beta", "models", None), "https://api.deepseek.com/v1/models" ); + // if path_suffix not none, then the path_suffix will be added to the url + assert_eq!( + api_url("https://api.deepseek.com", "models", Some("")), + "https://api.deepseek.com/models" + ); + assert_eq!( + api_url("https://api.deepseek.com/beta", "models", Some("")), + "https://api.deepseek.com/models" + ); + // explicit v versions other than /v1 should be preserved assert_eq!( api_url( "https://openai-compatible.example/api/coding/paas/v4", - "models" + "models", + None ), "https://openai-compatible.example/api/coding/paas/v4/models" ); + assert_eq!( + api_url( + "https://openai-compatible.example/api/coding/paas/v4", + "models", + Some("") + ), + "https://openai-compatible.example/api/coding/paas/models" + ); + } + + #[test] + fn api_url_with_path_suffix() { + assert_eq!( + api_url("https://api.deepseek.com", "models", Some("")), + "https://api.deepseek.com/models" + ); + assert_eq!( + api_url("https://api.deepseek.com", "models", Some("v2")), + "https://api.deepseek.com/v2/models" + ); + assert_eq!( + api_url("https://api.deepseek.com", "models", Some("/v2")), + "https://api.deepseek.com/v2/models" + ); + assert_eq!( + api_url("https://api.deepseek.com", "models", Some("foo/v2")), + "https://api.deepseek.com/foo/v2/models" + ); + assert_eq!( + api_url("https://api.deepseek.com", "models", Some("/foo/v2")), + "https://api.deepseek.com/foo/v2/models" + ); + } + + #[test] + fn api_url_path_suffix_cover_version() { + assert_eq!( + api_url("https://api.deepseek.com/v1", "models", Some("v2")), + "https://api.deepseek.com/v2/models" + ); + assert_eq!( + api_url("https://api.deepseek.com/foo/v1", "models", Some("v2")), + "https://api.deepseek.com/foo/v2/models" + ); + assert_eq!( + api_url("https://api.deepseek.com/beta", "models", Some("v2")), + "https://api.deepseek.com/v2/models" + ); + assert_eq!( + api_url("https://api.deepseek.com/foo/beta", "models", Some("v2")), + "https://api.deepseek.com/foo/v2/models" + ); } #[test] diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index e8fb42c4c9..0cb506392f 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -137,7 +137,11 @@ impl DeepSeekClient { self.api_provider, ); - let url = api_url(&self.base_url, "chat/completions"); + let url = api_url( + &self.base_url, + "chat/completions", + self.path_suffix.as_deref(), + ); let open_timeout = stream_open_timeout(); let response = match tokio_timeout( open_timeout, @@ -245,7 +249,11 @@ impl DeepSeekClient { self.api_provider, ); - let url = api_url(&self.base_url, "chat/completions"); + let url = api_url( + &self.base_url, + "chat/completions", + self.path_suffix.as_deref(), + ); let response = self .send_with_retry(|| self.http_client.post(&url).json(&body)) .await?; diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 28091d2029..25222ea07b 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -1490,6 +1490,7 @@ pub struct Config { pub provider: Option, pub api_key: Option, pub base_url: Option, + pub path_suffix: Option, /// Optional extra HTTP headers sent to model API requests. pub http_headers: Option>, pub default_text_model: Option, @@ -1827,6 +1828,7 @@ pub struct ProviderConfig { pub base_url: Option, pub model: Option, pub auth_mode: Option, + pub path_suffix: Option, pub http_headers: Option>, } @@ -2303,6 +2305,21 @@ impl Config { .to_string() } + /// Return the configured path suffix (normalized). + #[must_use] + pub fn path_suffix(&self) -> Option { + let provider = self.api_provider(); + let provider_path_suffix = self + .provider_config_for(provider) + .and_then(|provider| provider.path_suffix.clone()); + + let root_path_suffix = match provider { + ApiProvider::Deepseek | ApiProvider::DeepseekCN => self.path_suffix.clone(), + _ => None, + }; + provider_path_suffix.or(root_path_suffix) + } + /// Return the configured API base URL (normalized). #[must_use] pub fn deepseek_base_url(&self) -> String { @@ -3145,6 +3162,18 @@ fn codewhale_env_var( .ok_or(std::env::VarError::NotPresent) } +/// Read a CodeWhale env var, preferring the `CODEWHALE_*` form over the +/// legacy `DEEPSEEK_*` form. Empty values are allowed. +fn codewhale_env_var_allow_empty( + codewhale_name: &str, + legacy_name: &str, +) -> Result { + std::env::var(codewhale_name) + .ok() + .or_else(|| std::env::var(legacy_name).ok()) + .ok_or(std::env::VarError::NotPresent) +} + fn apply_env_overrides(config: &mut Config) { if let Ok(value) = codewhale_env_var("CODEWHALE_PROVIDER", "DEEPSEEK_PROVIDER") { config.provider = Some(value); @@ -3261,6 +3290,127 @@ fn apply_env_overrides(config: &mut Config) { } } } + if let Ok(value) = + codewhale_env_var_allow_empty("CODEWHALE_PATH_SUFFIX", "DEEPSEEK_PATH_SUFFIX") + { + match config.api_provider() { + ApiProvider::Deepseek | ApiProvider::DeepseekCN => { + config.path_suffix = Some(value); + } + ApiProvider::NvidiaNim => { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .nvidia_nim + .path_suffix = Some(value); + } + ApiProvider::Openai => { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .openai + .path_suffix = Some(value); + } + ApiProvider::Openrouter => { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .openrouter + .path_suffix = Some(value); + } + ApiProvider::XiaomiMimo => { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .xiaomi_mimo + .path_suffix = Some(value); + } + ApiProvider::WanjieArk => { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .wanjie_ark + .path_suffix = Some(value); + } + ApiProvider::Novita => { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .novita + .path_suffix = Some(value); + } + ApiProvider::Fireworks => { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .fireworks + .path_suffix = Some(value); + } + ApiProvider::Siliconflow => { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .siliconflow + .path_suffix = Some(value); + } + ApiProvider::Moonshot => { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .moonshot + .path_suffix = Some(value); + } + ApiProvider::Sglang => { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .sglang + .path_suffix = Some(value); + } + ApiProvider::Vllm => { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .vllm + .path_suffix = Some(value); + } + ApiProvider::Ollama => { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .ollama + .path_suffix = Some(value); + } + ApiProvider::Volcengine => { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .volcengine + .path_suffix = Some(value); + } + ApiProvider::Atlascloud => { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .atlascloud + .path_suffix = Some(value); + } + ApiProvider::SiliconflowCn => { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .siliconflow + .path_suffix = Some(value); + } + ApiProvider::Arcee => { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .arcee + .path_suffix = Some(value); + } + } + } if matches!(config.api_provider(), ApiProvider::NvidiaNim) && let Ok(value) = std::env::var("NVIDIA_NIM_BASE_URL") .or_else(|_| std::env::var("NIM_BASE_URL")) @@ -3446,6 +3596,151 @@ fn apply_env_overrides(config: &mut Config) { .ollama .base_url = Some(value); } + // Provider-specific PATH_SUFFIX environment variables + if matches!(config.api_provider(), ApiProvider::NvidiaNim) + && let Ok(value) = std::env::var("NVIDIA_NIM_PATH_SUFFIX") + .or_else(|_| std::env::var("NIM_PATH_SUFFIX")) + .or_else(|_| std::env::var("NVIDIA_PATH_SUFFIX")) + { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .nvidia_nim + .path_suffix = Some(value); + } + if matches!(config.api_provider(), ApiProvider::Openai) + && let Ok(value) = std::env::var("OPENAI_PATH_SUFFIX") + { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .openai + .path_suffix = Some(value); + } + if matches!(config.api_provider(), ApiProvider::Atlascloud) + && let Ok(value) = std::env::var("ATLASCLOUD_PATH_SUFFIX") + { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .atlascloud + .path_suffix = Some(value); + } + if matches!(config.api_provider(), ApiProvider::Volcengine) + && let Ok(value) = std::env::var("VOLCENGINE_PATH_SUFFIX") + .or_else(|_| std::env::var("VOLCENGINE_ARK_PATH_SUFFIX")) + .or_else(|_| std::env::var("ARK_PATH_SUFFIX")) + { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .volcengine + .path_suffix = Some(value); + } + if matches!(config.api_provider(), ApiProvider::WanjieArk) + && let Ok(value) = std::env::var("WANJIE_ARK_PATH_SUFFIX") + .or_else(|_| std::env::var("WANJIE_PATH_SUFFIX")) + .or_else(|_| std::env::var("WANJIE_MAAS_PATH_SUFFIX")) + { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .wanjie_ark + .path_suffix = Some(value); + } + if matches!(config.api_provider(), ApiProvider::Openrouter) + && let Ok(value) = std::env::var("OPENROUTER_PATH_SUFFIX") + { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .openrouter + .path_suffix = Some(value); + } + if matches!(config.api_provider(), ApiProvider::XiaomiMimo) + && let Ok(value) = + std::env::var("XIAOMI_MIMO_PATH_SUFFIX").or_else(|_| std::env::var("MIMO_PATH_SUFFIX")) + { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .xiaomi_mimo + .path_suffix = Some(value); + } + if matches!(config.api_provider(), ApiProvider::Novita) + && let Ok(value) = std::env::var("NOVITA_PATH_SUFFIX") + { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .novita + .path_suffix = Some(value); + } + if matches!(config.api_provider(), ApiProvider::Fireworks) + && let Ok(value) = std::env::var("FIREWORKS_PATH_SUFFIX") + { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .fireworks + .path_suffix = Some(value); + } + if (matches!(config.api_provider(), ApiProvider::Siliconflow) + || matches!(config.api_provider(), ApiProvider::SiliconflowCn)) + && let Ok(value) = std::env::var("SILICONFLOW_PATH_SUFFIX") + { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .siliconflow + .path_suffix = Some(value); + } + if matches!(config.api_provider(), ApiProvider::Arcee) + && let Ok(value) = std::env::var("ARCEE_PATH_SUFFIX") + { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .arcee + .path_suffix = Some(value); + } + if matches!(config.api_provider(), ApiProvider::Moonshot) + && let Ok(value) = + std::env::var("MOONSHOT_PATH_SUFFIX").or_else(|_| std::env::var("KIMI_PATH_SUFFIX")) + { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .moonshot + .path_suffix = Some(value); + } + if matches!(config.api_provider(), ApiProvider::Sglang) + && let Ok(value) = std::env::var("SGLANG_PATH_SUFFIX") + { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .sglang + .path_suffix = Some(value); + } + if matches!(config.api_provider(), ApiProvider::Vllm) + && let Ok(value) = std::env::var("VLLM_PATH_SUFFIX") + { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .vllm + .path_suffix = Some(value); + } + if matches!(config.api_provider(), ApiProvider::Ollama) + && let Ok(value) = std::env::var("OLLAMA_PATH_SUFFIX") + { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .ollama + .path_suffix = Some(value); + } if matches!(config.api_provider(), ApiProvider::Sglang) && let Ok(value) = std::env::var("SGLANG_MODEL") { @@ -4053,6 +4348,7 @@ fn merge_config(base: Config, override_cfg: Config) -> Config { provider: override_cfg.provider.or(base.provider), api_key: override_cfg.api_key.or(base.api_key), base_url: override_cfg.base_url.or(base.base_url), + path_suffix: override_cfg.path_suffix.or(base.path_suffix), http_headers: override_cfg.http_headers.or(base.http_headers), default_text_model: override_cfg.default_text_model.or(base.default_text_model), auth_mode: override_cfg.auth_mode.or(base.auth_mode), @@ -4134,6 +4430,7 @@ fn merge_provider_config(base: ProviderConfig, override_cfg: ProviderConfig) -> base_url: override_cfg.base_url.or(base.base_url), model: override_cfg.model.or(base.model), auth_mode: override_cfg.auth_mode.or(base.auth_mode), + path_suffix: override_cfg.path_suffix.or(base.path_suffix), http_headers: override_cfg.http_headers.or(base.http_headers), } } @@ -5476,6 +5773,31 @@ mod tests { ollama_api_key: Option, ollama_base_url: Option, ollama_model: Option, + // PATH_SUFFIX environment variables + deepseek_path_suffix: Option, + codewhale_path_suffix: Option, + nvidia_nim_path_suffix: Option, + nim_path_suffix: Option, + nvidia_path_suffix: Option, + openai_path_suffix: Option, + atlascloud_path_suffix: Option, + volcengine_path_suffix: Option, + volcengine_ark_path_suffix: Option, + ark_path_suffix: Option, + wanjie_ark_path_suffix: Option, + wanjie_path_suffix: Option, + wanjie_maas_path_suffix: Option, + openrouter_path_suffix: Option, + xiaomi_mimo_path_suffix: Option, + mimo_path_suffix: Option, + novita_path_suffix: Option, + fireworks_path_suffix: Option, + siliconflow_path_suffix: Option, + moonshot_path_suffix: Option, + kimi_path_suffix: Option, + sglang_path_suffix: Option, + vllm_path_suffix: Option, + ollama_path_suffix: Option, } impl EnvGuard { @@ -5558,6 +5880,31 @@ mod tests { let ollama_api_key_prev = env::var_os("OLLAMA_API_KEY"); let ollama_base_url_prev = env::var_os("OLLAMA_BASE_URL"); let ollama_model_prev = env::var_os("OLLAMA_MODEL"); + // PATH_SUFFIX environment variables + let deepseek_path_suffix_prev = env::var_os("DEEPSEEK_PATH_SUFFIX"); + let codewhale_path_suffix_prev = env::var_os("CODEWHALE_PATH_SUFFIX"); + let nvidia_nim_path_suffix_prev = env::var_os("NVIDIA_NIM_PATH_SUFFIX"); + let nim_path_suffix_prev = env::var_os("NIM_PATH_SUFFIX"); + let nvidia_path_suffix_prev = env::var_os("NVIDIA_PATH_SUFFIX"); + let openai_path_suffix_prev = env::var_os("OPENAI_PATH_SUFFIX"); + let atlascloud_path_suffix_prev = env::var_os("ATLASCLOUD_PATH_SUFFIX"); + let volcengine_path_suffix_prev = env::var_os("VOLCENGINE_PATH_SUFFIX"); + let volcengine_ark_path_suffix_prev = env::var_os("VOLCENGINE_ARK_PATH_SUFFIX"); + let ark_path_suffix_prev = env::var_os("ARK_PATH_SUFFIX"); + let wanjie_ark_path_suffix_prev = env::var_os("WANJIE_ARK_PATH_SUFFIX"); + let wanjie_path_suffix_prev = env::var_os("WANJIE_PATH_SUFFIX"); + let wanjie_maas_path_suffix_prev = env::var_os("WANJIE_MAAS_PATH_SUFFIX"); + let openrouter_path_suffix_prev = env::var_os("OPENROUTER_PATH_SUFFIX"); + let xiaomi_mimo_path_suffix_prev = env::var_os("XIAOMI_MIMO_PATH_SUFFIX"); + let mimo_path_suffix_prev = env::var_os("MIMO_PATH_SUFFIX"); + let novita_path_suffix_prev = env::var_os("NOVITA_PATH_SUFFIX"); + let fireworks_path_suffix_prev = env::var_os("FIREWORKS_PATH_SUFFIX"); + let siliconflow_path_suffix_prev = env::var_os("SILICONFLOW_PATH_SUFFIX"); + let moonshot_path_suffix_prev = env::var_os("MOONSHOT_PATH_SUFFIX"); + let kimi_path_suffix_prev = env::var_os("KIMI_PATH_SUFFIX"); + let sglang_path_suffix_prev = env::var_os("SGLANG_PATH_SUFFIX"); + let vllm_path_suffix_prev = env::var_os("VLLM_PATH_SUFFIX"); + let ollama_path_suffix_prev = env::var_os("OLLAMA_PATH_SUFFIX"); // Safety: test-only environment mutation guarded by a global mutex. unsafe { env::set_var("HOME", &home_str); @@ -5635,6 +5982,31 @@ mod tests { env::remove_var("OLLAMA_API_KEY"); env::remove_var("OLLAMA_BASE_URL"); env::remove_var("OLLAMA_MODEL"); + // PATH_SUFFIX environment variables + env::remove_var("DEEPSEEK_PATH_SUFFIX"); + env::remove_var("CODEWHALE_PATH_SUFFIX"); + env::remove_var("NVIDIA_NIM_PATH_SUFFIX"); + env::remove_var("NIM_PATH_SUFFIX"); + env::remove_var("NVIDIA_PATH_SUFFIX"); + env::remove_var("OPENAI_PATH_SUFFIX"); + env::remove_var("ATLASCLOUD_PATH_SUFFIX"); + env::remove_var("VOLCENGINE_PATH_SUFFIX"); + env::remove_var("VOLCENGINE_ARK_PATH_SUFFIX"); + env::remove_var("ARK_PATH_SUFFIX"); + env::remove_var("WANJIE_ARK_PATH_SUFFIX"); + env::remove_var("WANJIE_PATH_SUFFIX"); + env::remove_var("WANJIE_MAAS_PATH_SUFFIX"); + env::remove_var("OPENROUTER_PATH_SUFFIX"); + env::remove_var("XIAOMI_MIMO_PATH_SUFFIX"); + env::remove_var("MIMO_PATH_SUFFIX"); + env::remove_var("NOVITA_PATH_SUFFIX"); + env::remove_var("FIREWORKS_PATH_SUFFIX"); + env::remove_var("SILICONFLOW_PATH_SUFFIX"); + env::remove_var("MOONSHOT_PATH_SUFFIX"); + env::remove_var("KIMI_PATH_SUFFIX"); + env::remove_var("SGLANG_PATH_SUFFIX"); + env::remove_var("VLLM_PATH_SUFFIX"); + env::remove_var("OLLAMA_PATH_SUFFIX"); } Self { home: home_prev, @@ -5712,6 +6084,31 @@ mod tests { ollama_api_key: ollama_api_key_prev, ollama_base_url: ollama_base_url_prev, ollama_model: ollama_model_prev, + // PATH_SUFFIX environment variables + deepseek_path_suffix: deepseek_path_suffix_prev, + codewhale_path_suffix: codewhale_path_suffix_prev, + nvidia_nim_path_suffix: nvidia_nim_path_suffix_prev, + nim_path_suffix: nim_path_suffix_prev, + nvidia_path_suffix: nvidia_path_suffix_prev, + openai_path_suffix: openai_path_suffix_prev, + atlascloud_path_suffix: atlascloud_path_suffix_prev, + volcengine_path_suffix: volcengine_path_suffix_prev, + volcengine_ark_path_suffix: volcengine_ark_path_suffix_prev, + ark_path_suffix: ark_path_suffix_prev, + wanjie_ark_path_suffix: wanjie_ark_path_suffix_prev, + wanjie_path_suffix: wanjie_path_suffix_prev, + wanjie_maas_path_suffix: wanjie_maas_path_suffix_prev, + openrouter_path_suffix: openrouter_path_suffix_prev, + xiaomi_mimo_path_suffix: xiaomi_mimo_path_suffix_prev, + mimo_path_suffix: mimo_path_suffix_prev, + novita_path_suffix: novita_path_suffix_prev, + fireworks_path_suffix: fireworks_path_suffix_prev, + siliconflow_path_suffix: siliconflow_path_suffix_prev, + moonshot_path_suffix: moonshot_path_suffix_prev, + kimi_path_suffix: kimi_path_suffix_prev, + sglang_path_suffix: sglang_path_suffix_prev, + vllm_path_suffix: vllm_path_suffix_prev, + ollama_path_suffix: ollama_path_suffix_prev, } } } @@ -5804,6 +6201,43 @@ mod tests { Self::restore_var("OLLAMA_API_KEY", self.ollama_api_key.take()); Self::restore_var("OLLAMA_BASE_URL", self.ollama_base_url.take()); Self::restore_var("OLLAMA_MODEL", self.ollama_model.take()); + // PATH_SUFFIX environment variables + Self::restore_var("DEEPSEEK_PATH_SUFFIX", self.deepseek_path_suffix.take()); + Self::restore_var("CODEWHALE_PATH_SUFFIX", self.codewhale_path_suffix.take()); + Self::restore_var("NVIDIA_NIM_PATH_SUFFIX", self.nvidia_nim_path_suffix.take()); + Self::restore_var("NIM_PATH_SUFFIX", self.nim_path_suffix.take()); + Self::restore_var("NVIDIA_PATH_SUFFIX", self.nvidia_path_suffix.take()); + Self::restore_var("OPENAI_PATH_SUFFIX", self.openai_path_suffix.take()); + Self::restore_var("ATLASCLOUD_PATH_SUFFIX", self.atlascloud_path_suffix.take()); + Self::restore_var("VOLCENGINE_PATH_SUFFIX", self.volcengine_path_suffix.take()); + Self::restore_var( + "VOLCENGINE_ARK_PATH_SUFFIX", + self.volcengine_ark_path_suffix.take(), + ); + Self::restore_var("ARK_PATH_SUFFIX", self.ark_path_suffix.take()); + Self::restore_var("WANJIE_ARK_PATH_SUFFIX", self.wanjie_ark_path_suffix.take()); + Self::restore_var("WANJIE_PATH_SUFFIX", self.wanjie_path_suffix.take()); + Self::restore_var( + "WANJIE_MAAS_PATH_SUFFIX", + self.wanjie_maas_path_suffix.take(), + ); + Self::restore_var("OPENROUTER_PATH_SUFFIX", self.openrouter_path_suffix.take()); + Self::restore_var( + "XIAOMI_MIMO_PATH_SUFFIX", + self.xiaomi_mimo_path_suffix.take(), + ); + Self::restore_var("MIMO_PATH_SUFFIX", self.mimo_path_suffix.take()); + Self::restore_var("NOVITA_PATH_SUFFIX", self.novita_path_suffix.take()); + Self::restore_var("FIREWORKS_PATH_SUFFIX", self.fireworks_path_suffix.take()); + Self::restore_var( + "SILICONFLOW_PATH_SUFFIX", + self.siliconflow_path_suffix.take(), + ); + Self::restore_var("MOONSHOT_PATH_SUFFIX", self.moonshot_path_suffix.take()); + Self::restore_var("KIMI_PATH_SUFFIX", self.kimi_path_suffix.take()); + Self::restore_var("SGLANG_PATH_SUFFIX", self.sglang_path_suffix.take()); + Self::restore_var("VLLM_PATH_SUFFIX", self.vllm_path_suffix.take()); + Self::restore_var("OLLAMA_PATH_SUFFIX", self.ollama_path_suffix.take()); } } } @@ -7442,11 +7876,13 @@ http_headers = { "X-Model-Provider-Id" = "from-file" } unsafe { env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim"); env::set_var("NIM_BASE_URL", "https://short-nim.example/v1"); + env::set_var("NVIDIA_PATH_SUFFIX", "/v2"); } let config = Config::load(None, None)?; assert_eq!(config.api_provider(), ApiProvider::NvidiaNim); assert_eq!(config.deepseek_base_url(), "https://short-nim.example/v1"); + assert_eq!(config.path_suffix().as_deref(), Some("/v2")); Ok(()) } @@ -7469,6 +7905,7 @@ http_headers = { "X-Model-Provider-Id" = "from-file" } unsafe { env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim"); env::set_var("DEEPSEEK_BASE_URL", "https://forwarded-nim.example/v1"); + env::set_var("NVIDIA_PATH_SUFFIX", "/v2"); } let config = Config::load(None, None)?; @@ -7477,6 +7914,7 @@ http_headers = { "X-Model-Provider-Id" = "from-file" } config.deepseek_base_url(), "https://forwarded-nim.example/v1" ); + assert_eq!(config.path_suffix().as_deref(), Some("/v2")); Ok(()) } @@ -7567,6 +8005,7 @@ model = "mimo-v2.5-pro" env::set_var("DEEPSEEK_PROVIDER", "mimo"); env::set_var("MIMO_API_KEY", "mimo-env-key"); env::set_var("MIMO_BASE_URL", "https://mimo-gateway.example/v1"); + env::set_var("MIMO_PATH_SUFFIX", "/v2"); env::set_var("MIMO_MODEL", "mimo-v2.5"); } @@ -7578,6 +8017,7 @@ model = "mimo-v2.5-pro" "https://mimo-gateway.example/v1" ); assert_eq!(config.default_model(), "mimo-v2.5"); + assert_eq!(config.path_suffix().as_deref(), Some("/v2")); Ok(()) } @@ -7614,6 +8054,7 @@ model = "mimo-v2.5-pro" env::set_var("DEEPSEEK_PROVIDER", "atlascloud"); env::set_var("ATLASCLOUD_API_KEY", "atlascloud-env-key"); env::set_var("ATLASCLOUD_BASE_URL", "https://api.atlascloud.ai/v1"); + env::set_var("ATLASCLOUD_PATH_SUFFIX", "/v2"); env::set_var("ATLASCLOUD_MODEL", "deepseek-ai/deepseek-v4-flash"); } @@ -7621,6 +8062,7 @@ model = "mimo-v2.5-pro" assert_eq!(config.api_provider(), ApiProvider::Atlascloud); assert_eq!(config.deepseek_api_key()?, "atlascloud-env-key"); assert_eq!(config.deepseek_base_url(), "https://api.atlascloud.ai/v1"); + assert_eq!(config.path_suffix().as_deref(), Some("/v2")); assert_eq!(config.default_model(), "deepseek-ai/deepseek-v4-flash"); Ok(()) } @@ -7658,6 +8100,7 @@ model = "mimo-v2.5-pro" env::set_var("DEEPSEEK_PROVIDER", "ark-wanjie"); env::set_var("WANJIE_ARK_API_KEY", "wanjie-env-key"); env::set_var("WANJIE_ARK_BASE_URL", "https://wanjie.example/api/v1"); + env::set_var("WANJIE_ARK_PATH_SUFFIX", "/v2"); env::set_var("WANJIE_ARK_MODEL", "wanjie-model-id"); } @@ -7665,6 +8108,7 @@ model = "mimo-v2.5-pro" assert_eq!(config.api_provider(), ApiProvider::WanjieArk); assert_eq!(config.deepseek_api_key()?, "wanjie-env-key"); assert_eq!(config.deepseek_base_url(), "https://wanjie.example/api/v1"); + assert_eq!(config.path_suffix().as_deref(), Some("/v2")); assert_eq!(config.default_model(), "wanjie-model-id"); Ok(()) } @@ -7825,6 +8269,7 @@ model = "glm-5" 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_PATH_SUFFIX", "/v2"); env::set_var("OPENAI_MODEL", "glm-5"); } @@ -7835,6 +8280,7 @@ model = "glm-5" config.deepseek_base_url(), "https://openai-compatible.example/v4" ); + assert_eq!(config.path_suffix().as_deref(), Some("/v2")); assert_eq!(config.default_model(), "glm-5"); Ok(()) } @@ -7859,6 +8305,7 @@ model = "glm-5" 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_PATH_SUFFIX", "/v2"); env::set_var("DEEPSEEK_MODEL", "glm-5"); } @@ -7869,6 +8316,7 @@ model = "glm-5" config.deepseek_base_url(), "https://forwarded-openai.example/v4" ); + assert_eq!(config.path_suffix().as_deref(), Some("/v2")); assert_eq!(config.default_model(), "glm-5"); Ok(()) } @@ -8090,19 +8538,24 @@ model = "qwen2.5-coder:7b" unsafe { env::set_var("DEEPSEEK_PROVIDER", "ollama"); env::set_var("DEEPSEEK_BASE_URL", "http://ollama.remote:11434/v1"); + env::set_var("DEEPSEEK_PATH_SUFFIX", "/v2"); } let config = Config::load(None, None)?; assert_eq!(config.api_provider(), ApiProvider::Ollama); assert_eq!(config.deepseek_base_url(), "http://ollama.remote:11434/v1"); + assert_eq!(config.path_suffix().as_deref(), Some("/v2")); // Safety: test-only environment mutation guarded by a global mutex. unsafe { env::set_var("DEEPSEEK_PROVIDER", "vllm"); env::set_var("DEEPSEEK_BASE_URL", "http://vllm.remote:8000/v1"); + env::set_var("DEEPSEEK_PATH_SUFFIX", ""); } let config = Config::load(None, None)?; assert_eq!(config.api_provider(), ApiProvider::Vllm); assert_eq!(config.deepseek_base_url(), "http://vllm.remote:8000/v1"); + + assert_eq!(config.path_suffix().as_deref(), Some("")); Ok(()) } @@ -8125,6 +8578,7 @@ model = "qwen2.5-coder:7b" unsafe { env::set_var("DEEPSEEK_PROVIDER", "vllm"); env::set_var("VLLM_BASE_URL", "http://192.168.0.110:8000/v1"); + env::set_var("VLLM_PATH_SUFFIX", "/v2"); env::set_var("DEEPSEEK_MODEL", "deepseek-v4-flash"); } @@ -8132,6 +8586,7 @@ model = "qwen2.5-coder:7b" assert_eq!(config.api_provider(), ApiProvider::Vllm); assert_eq!(config.deepseek_base_url(), "http://192.168.0.110:8000/v1"); assert_eq!(config.default_model(), "deepseek-v4-flash"); + assert_eq!(config.path_suffix().as_deref(), Some("/v2")); Ok(()) } @@ -8154,12 +8609,14 @@ model = "qwen2.5-coder:7b" unsafe { env::set_var("DEEPSEEK_PROVIDER", "ollama-local"); env::set_var("OLLAMA_BASE_URL", "http://ollama.example/v1"); + env::set_var("OLLAMA_PATH_SUFFIX", "/v2"); env::set_var("OLLAMA_MODEL", "deepseek-coder-v2:16b"); } let config = Config::load(None, None)?; assert_eq!(config.api_provider(), ApiProvider::Ollama); assert_eq!(config.deepseek_base_url(), "http://ollama.example/v1"); + assert_eq!(config.path_suffix().as_deref(), Some("/v2")); assert_eq!(config.default_model(), "deepseek-coder-v2:16b"); Ok(()) } @@ -8238,6 +8695,7 @@ model = "qwen2.5-coder:7b" env::set_var("CODEWHALE_PROVIDER", "siliconflow"); env::set_var("SILICONFLOW_API_KEY", "sf-env-key"); env::set_var("SILICONFLOW_BASE_URL", "https://sf-mirror.example/v1"); + env::set_var("SILICONFLOW_PATH_SUFFIX", "/v2"); env::set_var("SILICONFLOW_MODEL", "deepseek-v4-flash"); } @@ -8245,6 +8703,7 @@ model = "qwen2.5-coder:7b" assert_eq!(config.api_provider(), ApiProvider::Siliconflow); assert_eq!(config.deepseek_api_key()?, "sf-env-key"); assert_eq!(config.deepseek_base_url(), "https://sf-mirror.example/v1"); + assert_eq!(config.path_suffix().as_deref(), Some("/v2")); assert_eq!(config.default_model(), "deepseek-v4-flash"); Ok(()) } @@ -8365,6 +8824,7 @@ model = "arcee-trinity-large-preview" env::set_var("CODEWHALE_PROVIDER", "siliconflow-CN"); env::set_var("SILICONFLOW_API_KEY", "sf-env-key"); env::set_var("SILICONFLOW_BASE_URL", "https://api.siliconflow.cn/v1"); + env::set_var("SILICONFLOW_PATH_SUFFIX", "/v2"); env::set_var("SILICONFLOW_MODEL", "deepseek-reasoner"); } @@ -8372,6 +8832,7 @@ model = "arcee-trinity-large-preview" assert_eq!(config.api_provider(), ApiProvider::SiliconflowCn); assert_eq!(config.deepseek_api_key()?, "sf-env-key"); assert_eq!(config.deepseek_base_url(), "https://api.siliconflow.cn/v1"); + assert_eq!(config.path_suffix().as_deref(), Some("/v2")); assert_eq!(config.default_model(), DEFAULT_SILICONFLOW_MODEL); Ok(()) } @@ -8395,11 +8856,56 @@ model = "arcee-trinity-large-preview" unsafe { env::set_var("DEEPSEEK_PROVIDER", "openrouter"); env::set_var("OPENROUTER_BASE_URL", "https://or-mirror.example/v1"); + env::set_var("OPENROUTER_PATH_SUFFIX", "/v2"); } let config = Config::load(None, None)?; assert_eq!(config.api_provider(), ApiProvider::Openrouter); assert_eq!(config.deepseek_base_url(), "https://or-mirror.example/v1"); + assert_eq!(config.path_suffix().as_deref(), Some("/v2")); + Ok(()) + } + + #[test] + fn path_suffix_env_overrides_default() -> 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!( + "codewhale-tui-or-path-suffix-{}-{}", + 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::remove_var("DEEPSEEK_PATH_SUFFIX"); + } + let config = Config::load(None, None)?; + assert_eq!(config.path_suffix().as_deref(), None); + + unsafe { + env::set_var("DEEPSEEK_PATH_SUFFIX", "/v2"); + } + let config = Config::load(None, None)?; + assert_eq!(config.path_suffix().as_deref(), Some("/v2")); + + unsafe { + env::set_var("DEEPSEEK_PATH_SUFFIX", ""); + } + let config = Config::load(None, None)?; + assert_eq!(config.path_suffix().as_deref(), Some("")); + + unsafe { + env::set_var("DEEPSEEK_PATH_SUFFIX", "foo/v2"); + } + let config = Config::load(None, None)?; + assert_eq!(config.path_suffix().as_deref(), Some("foo/v2")); + Ok(()) } @@ -8665,12 +9171,14 @@ api_key = "kimi-code-env-key" unsafe { env::set_var("CODEWHALE_PROVIDER", "moonshot"); env::set_var("CODEWHALE_BASE_URL", "https://api.kimi.com/coding/v1"); + env::set_var("CODEWHALE_PATH_SUFFIX", "/v2"); } let config = Config::load(None, None)?; assert_eq!(config.api_provider(), ApiProvider::Moonshot); assert_eq!(config.deepseek_base_url(), DEFAULT_KIMI_CODE_BASE_URL); assert_eq!(config.default_model(), DEFAULT_KIMI_CODE_MODEL); + assert_eq!(config.path_suffix().as_deref(), Some("/v2")); assert_eq!(config.deepseek_api_key()?, "kimi-code-env-key"); assert!(has_api_key_for(&config, ApiProvider::Moonshot)); Ok(()) diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index df867d9859..ec7bcda82d 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -2059,6 +2059,7 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> { } } println!(" · base_url: {}", config.deepseek_base_url()); + println!(" · path_suffix: {:?}", config.path_suffix()); let model = config .default_text_model .clone() @@ -2467,6 +2468,7 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt let api_target = doctor_api_target(config); println!(" · provider: {}", api_target.provider); println!(" · base_url: {}", api_target.base_url); + println!(" · path_suffix: {:?}", api_target.path_suffix); println!(" · model: {}", api_target.model); let strict_tool_mode = doctor_strict_tool_mode_status(config); let strict_icon = match strict_tool_mode.status { @@ -3246,6 +3248,7 @@ fn run_doctor_json( "source": api_key_state, }, "base_url": api_target.base_url, + "path_suffix": api_target.path_suffix, "default_text_model": api_target.model, "strict_tool_mode": { "enabled": strict_tool_mode.enabled, @@ -3400,6 +3403,7 @@ fn doctor_search_provider_json(config: &Config) -> serde_json::Value { struct DoctorApiTarget { provider: &'static str, base_url: String, + path_suffix: Option, model: String, } @@ -3417,6 +3421,7 @@ fn doctor_api_target(config: &Config) -> DoctorApiTarget { DoctorApiTarget { provider: provider.as_str(), base_url: config.deepseek_base_url(), + path_suffix: config.path_suffix(), model: config.default_model(), } } @@ -6145,6 +6150,7 @@ mod doctor_endpoint_tests { assert_eq!(target.provider, "deepseek"); assert_eq!(target.base_url, crate::config::DEFAULT_DEEPSEEK_BASE_URL); + assert_eq!(target.path_suffix, None); assert_eq!(target.model, crate::config::DEFAULT_TEXT_MODEL); } @@ -6160,6 +6166,7 @@ mod doctor_endpoint_tests { assert_eq!(target.provider, "deepseek-cn"); assert_eq!(target.base_url, crate::config::DEFAULT_DEEPSEEKCN_BASE_URL); assert_eq!(target.base_url, crate::config::DEFAULT_DEEPSEEK_BASE_URL); + assert_eq!(target.path_suffix, None); assert_eq!(target.model, crate::config::DEFAULT_TEXT_MODEL); } @@ -6999,6 +7006,7 @@ model = "deepseek-ai/deepseek-v4-pro" r#" api_key = "ATTACKER_KEY" base_url = "https://evil.example.com" +path_suffix = "/v2" provider = "nvidia-nim" mcp_config_path = "/tmp/attacker-mcp.json" "#, @@ -7006,6 +7014,7 @@ mcp_config_path = "/tmp/attacker-mcp.json" let mut config = Config { api_key: Some("USER_KEY".to_string()), base_url: Some("https://api.deepseek.com".to_string()), + path_suffix: Some("/v2".to_string()), ..Config::default() }; merge_project_config(&mut config, tmp.path()); @@ -7019,6 +7028,11 @@ mcp_config_path = "/tmp/attacker-mcp.json" Some("https://api.deepseek.com"), "user base_url must survive project-config attack" ); + assert_eq!( + config.path_suffix.as_deref(), + Some("/v2"), + "user path_suffix must survive project-config attack" + ); assert_eq!( config.provider, None, "project-scope provider must be denied" diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index cd1e3ae576..dbb3adce3c 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -57,6 +57,7 @@ Supported keys in the project overlay (top-level fields only): | `model` | override `default_text_model` | | `api_key` | use a per-repo key (typically read from `.env`, **not committed**) | | `base_url` | point at a self-hosted endpoint | +| `path_suffix` | cover the version of base_url | | `reasoning_effort` | force `"high"` / `"max"` for a complex repo | | `approval_policy` | `"never"` / `"on-request"` / `"untrusted"` for opinionated repos | | `sandbox_mode` | `"read-only"` / `"workspace-write"` / `"danger-full-access"` | @@ -307,6 +308,7 @@ aliases. When both forms are set the `CODEWHALE_*` value wins; the `deepseek|nvidia-nim|openai|atlascloud|wanjie-ark|volcengine|openrouter|xiaomi-mimo|novita|fireworks|siliconflow|siliconflow-CN|arcee|moonshot|sglang|vllm|ollama` - `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 +- `CODEWHALE_PATH_SUFFIX` (preferred) / `DEEPSEEK_PATH_SUFFIX` (legacy alias) — path suffix for the active provider Remaining variables: @@ -317,44 +319,58 @@ Remaining variables: - `DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS` (connection setup + response-header wait in seconds; default `45`, clamped to `5..=300`; distinct from the per-chunk idle timeout) - `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_PATH_SUFFIX`, `NIM_PATH_SUFFIX`, or `NVIDIA_PATH_SUFFIX` - `NVIDIA_NIM_MODEL` - `OPENAI_API_KEY` - `OPENAI_BASE_URL` +- `OPENAI_PATH_SUFFIX` - `OPENAI_MODEL` - `ATLASCLOUD_API_KEY` - `ATLASCLOUD_BASE_URL` +- `ATLASCLOUD_PATH_SUFFIX` - `ATLASCLOUD_MODEL` - `WANJIE_ARK_API_KEY`, `WANJIE_API_KEY`, or `WANJIE_MAAS_API_KEY` - `WANJIE_ARK_BASE_URL`, `WANJIE_BASE_URL`, or `WANJIE_MAAS_BASE_URL` +- `WANJIE_ARK_PATH_SUFFIX`, `WANJIE_PATH_SUFFIX`, or `WANJIE_MAAS_PATH_SUFFIX` - `WANJIE_ARK_MODEL`, `WANJIE_MODEL`, or `WANJIE_MAAS_MODEL` - `VOLCENGINE_API_KEY`, `VOLCENGINE_ARK_API_KEY`, or `ARK_API_KEY` - `VOLCENGINE_BASE_URL`, `VOLCENGINE_ARK_BASE_URL`, or `ARK_BASE_URL` +- `VOLCENGINE_PATH_SUFFIX`, `VOLCENGINE_ARK_PATH_SUFFIX`, or `ARK_PATH_SUFFIX` - `VOLCENGINE_MODEL` or `VOLCENGINE_ARK_MODEL` - `OPENROUTER_API_KEY` - `OPENROUTER_BASE_URL` +- `OPENROUTER_PATH_SUFFIX` - `XIAOMI_MIMO_API_KEY`, `XIAOMI_API_KEY`, or `MIMO_API_KEY` - `XIAOMI_MIMO_BASE_URL` or `MIMO_BASE_URL` +- `XIAOMI_MIMO_PATH_SUFFIX` or `MIMO_PATH_SUFFIX` - `XIAOMI_MIMO_MODEL` or `MIMO_MODEL` - `NOVITA_API_KEY` - `NOVITA_BASE_URL` +- `NOVITA_PATH_SUFFIX` - `FIREWORKS_API_KEY` - `FIREWORKS_BASE_URL` +- `FIREWORKS_PATH_SUFFIX` - `SILICONFLOW_API_KEY` - `SILICONFLOW_BASE_URL` +- `SILICONFLOW_PATH_SUFFIX` - `SILICONFLOW_MODEL` - `ARCEE_API_KEY` - `ARCEE_BASE_URL` - `ARCEE_MODEL` - `MOONSHOT_API_KEY` or `KIMI_API_KEY` - `MOONSHOT_BASE_URL` or `KIMI_BASE_URL` +- `MOONSHOT_PATH_SUFFIX` or `KIMI_PATH_SUFFIX` - `MOONSHOT_MODEL`, `KIMI_MODEL_NAME`, or `KIMI_MODEL` - `SGLANG_BASE_URL` +- `SGLANG_PATH_SUFFIX` - `SGLANG_MODEL` - `SGLANG_API_KEY` (optional; many localhost SGLang servers do not require auth) - `VLLM_BASE_URL` +- `VLLM_PATH_SUFFIX` - `VLLM_MODEL` - `VLLM_API_KEY` (optional; many localhost vLLM servers do not require auth) - `OLLAMA_BASE_URL` +- `OLLAMA_PATH_SUFFIX` - `OLLAMA_MODEL` - `OLLAMA_API_KEY` (optional; many localhost Ollama servers do not require auth) - `DEEPSEEK_LOG_LEVEL` or `RUST_LOG` (`info`/`debug`/`trace` enables lightweight verbose logs) @@ -686,6 +702,7 @@ If you are upgrading from older releases: - `provider` (string, optional): `deepseek` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`, `openrouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, `siliconflow-CN`, `arcee`, `moonshot`, `sglang`, `vllm`, or `ollama`. 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 at `https://api.xiaomimimo.com/v1`; `novita` targets `https://api.novita.ai/v1`; `fireworks` targets `https://api.fireworks.ai/inference/v1`; `siliconflow` targets SiliconFlow, defaulting to `https://api.siliconflow.com/v1`; `siliconflow-CN` targets the SiliconFlow China regional endpoint while sharing `[providers.siliconflow]`; `arcee` targets Arcee AI's OpenAI-compatible endpoint at `https://api.arcee.ai/api/v1`; `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`. - `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://api.xiaomimimo.com/v1` for `xiaomi-mimo`, `https://api.novita.ai/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`, `http://localhost:30000/v1` for `sglang`, `http://localhost:8000/v1` for `vllm`, and `http://localhost:11434/v1` for `ollama`. Set `https://api.deepseek.com` or `https://api.deepseek.com/v1` explicitly to opt out of DeepSeek beta features. +- `path_suffix` (string, optional): defaults to None. Overwrite the version of base_url. Set to empty string to disable adding version to base_url. - `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, `trinity-large-thinking` for Arcee AI, `kimi-k2.6` for Moonshot, `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, 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`, `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`, `google/gemma-4-31b-it`, and `moonshotai/kimi-k2.6`; direct Arcee uses bare IDs such as `trinity-large-thinking` and `trinity-large-preview`; 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`, 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`, 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 `false`; shell tools must be explicitly enabled.