From d2999bb402c550f446dc5d25cce6d4610a8ec29d Mon Sep 17 00:00:00 2001 From: RefuseOdd Date: Tue, 2 Jun 2026 00:39:11 +0000 Subject: [PATCH] Add path_suffix to ProviderConfigToml and ProviderConfig Adds an optional path_suffix field that lets users override the API path for OpenAI-compatible endpoints. When set, the suffix replaces the default /v1/ pattern, enabling use with endpoints that don't accept /v1/ prefixes (e.g. /chat/completions instead of /v1/chat/completions). Changes: - ProviderConfigToml (config crate): path_suffix field - ProviderConfig (tui crate): path_suffix field - merge_provider_config: propagates path_suffix - merge_project_provider_config: propagates path_suffix - api_url: delegates to new api_url_with_suffix function - api_url_with_suffix: uses suffix when present, skips /v1 versioning - DeepSeekClient: reads path_suffix from config, passes to URL builder - config.example.toml: documents the new option - Tests for the new URL building behavior Closes #2089 --- config.example.toml | 1 + crates/config/src/lib.rs | 4 +++ crates/tui/src/client.rs | 60 ++++++++++++++++++++++++++++++++++++---- crates/tui/src/config.rs | 2 ++ 4 files changed, 61 insertions(+), 6 deletions(-) diff --git a/config.example.toml b/config.example.toml index 6c73426f..b5343535 100644 --- a/config.example.toml +++ b/config.example.toml @@ -273,6 +273,7 @@ max_subagents = 10 # optional (1-20) # base_url = "https://your-provider.example/v1" # model = "deepseek-ai/DeepSeek-V4-Pro" # http_headers = { "X-Model-Provider-Id" = "your-model-provider" } # optional custom request headers +# path_suffix = "/chat/completions" # override the API path; skips /v1 versioning when set # NVIDIA NIM-hosted DeepSeek V4 (https://build.nvidia.com) [providers.nvidia_nim] diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index e245caa6..7135300d 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -199,6 +199,7 @@ pub struct ProviderConfigToml { pub auth_mode: Option, #[serde(default)] pub http_headers: BTreeMap, + pub path_suffix: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -1522,6 +1523,9 @@ fn merge_project_provider_config(target: &mut ProviderConfigToml, source: &Provi if source.model.is_some() { target.model = source.model.clone(); } + if source.path_suffix.is_some() { + target.path_suffix = source.path_suffix.clone(); + } } #[must_use] diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 3e3ca487..f5ac56a6 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -157,6 +157,7 @@ pub struct DeepSeekClient { default_model: String, connection_health: Arc>, rate_limiter: Arc>, + path_suffix: Option, } const CONNECTION_FAILURE_THRESHOLD: u32 = 2; @@ -323,6 +324,7 @@ impl Clone for DeepSeekClient { default_model: self.default_model.clone(), connection_health: self.connection_health.clone(), rate_limiter: self.rate_limiter.clone(), + path_suffix: self.path_suffix.clone(), } } } @@ -418,10 +420,17 @@ fn is_version_segment(segment: &str) -> bool { } pub(super) fn api_url(base_url: &str, path: &str) -> String { + api_url_with_suffix(base_url, path, None) +} + +pub(super) fn api_url_with_suffix(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); } + if let Some(suffix) = path_suffix { + return format!("{}/{}", base_url.trim_end_matches('/'), suffix.trim_start_matches('/')); + } 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 @@ -569,9 +578,15 @@ impl DeepSeekClient { let retry = config.retry_policy(); let default_model = config.default_model(); let http_headers = config.http_headers(); + let path_suffix = config + .provider_config_for(api_provider) + .and_then(|p| p.path_suffix.clone()); logging::info(format!("API provider: {}", api_provider.as_str())); logging::info(format!("API base URL: {base_url}")); + if let Some(suffix) = &path_suffix { + logging::info(format!("API path suffix override: {suffix}")); + } if !http_headers.is_empty() { logging::info(format!( "{} custom HTTP header(s) configured", @@ -594,6 +609,7 @@ impl DeepSeekClient { default_model, connection_health: Arc::new(AsyncMutex::new(ConnectionHealth::default())), rate_limiter: Arc::new(AsyncMutex::new(TokenBucket::from_env())), + path_suffix, }) } @@ -687,7 +703,7 @@ impl DeepSeekClient { model: &str, target_language: &str, ) -> Result { - let url = api_url(&self.base_url, "chat/completions"); + let url = api_url_with_suffix(&self.base_url, "chat/completions", self.path_suffix.as_deref()); let model = wire_model_for_provider(self.api_provider, model); let mut body = serde_json::json!({ "model": model, @@ -734,7 +750,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_with_suffix(&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(); @@ -876,7 +892,7 @@ impl DeepSeekClient { if !should_probe { return; } - let health_url = api_url(&self.base_url, "models"); + let health_url = api_url_with_suffix(&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 +1008,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_with_suffix(&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 { @@ -1315,7 +1331,7 @@ impl DeepSeekClient { suffix: &str, max_tokens: u32, ) -> anyhow::Result { - let url = api_url(&self.base_url, "beta/completions"); + let url = api_url_with_suffix(&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, @@ -3461,8 +3477,40 @@ mod tests { unsafe { std::env::set_var("DEEPSEEK_FORCE_HTTP1", value) }; assert!( !force_http1_from_env(), - "{value:?} should NOT be parsed as truthy", + "{value:?} should NOT be parsed as truthy" ); } } + + #[test] + fn api_url_with_suffix_uses_suffix_path() { + assert_eq!( + api_url_with_suffix( + "https://api.example.com/v1", + "chat/completions", + Some("/chat/completions") + ), + "https://api.example.com/chat/completions" + ); + } + + #[test] + fn api_url_with_suffix_handles_leading_slash() { + assert_eq!( + api_url_with_suffix( + "https://api.example.com/v1", + "chat/completions", + Some("chat/completions") + ), + "https://api.example.com/chat/completions" + ); + } + + #[test] + fn api_url_with_suffix_default_behavior_without_suffix() { + assert_eq!( + api_url_with_suffix("https://api.deepseek.com", "chat/completions", None), + "https://api.deepseek.com/v1/chat/completions" + ); + } } diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index f15ad3fc..c6889638 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -1858,6 +1858,7 @@ pub struct ProviderConfig { pub model: Option, pub auth_mode: Option, pub http_headers: Option>, + pub path_suffix: Option, } #[derive(Debug, Clone, Default, Deserialize)] @@ -4318,6 +4319,7 @@ fn merge_provider_config(base: ProviderConfig, override_cfg: ProviderConfig) -> model: override_cfg.model.or(base.model), auth_mode: override_cfg.auth_mode.or(base.auth_mode), http_headers: override_cfg.http_headers.or(base.http_headers), + path_suffix: override_cfg.path_suffix.or(base.path_suffix), } }