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/<path> 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
This commit is contained in:
RefuseOdd
2026-06-02 00:39:11 +00:00
committed by Hunter Bown
parent 45562822f0
commit d2999bb402
4 changed files with 61 additions and 6 deletions
+1
View File
@@ -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]
+4
View File
@@ -199,6 +199,7 @@ pub struct ProviderConfigToml {
pub auth_mode: Option<String>,
#[serde(default)]
pub http_headers: BTreeMap<String, String>,
pub path_suffix: Option<String>,
}
#[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]
+54 -6
View File
@@ -157,6 +157,7 @@ pub struct DeepSeekClient {
default_model: String,
connection_health: Arc<AsyncMutex<ConnectionHealth>>,
rate_limiter: Arc<AsyncMutex<TokenBucket>>,
path_suffix: Option<String>,
}
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<String> {
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<Vec<AvailableModel>> {
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<bool> {
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<String> {
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"
);
}
}
+2
View File
@@ -1858,6 +1858,7 @@ pub struct ProviderConfig {
pub model: Option<String>,
pub auth_mode: Option<String>,
pub http_headers: Option<HashMap<String, String>>,
pub path_suffix: Option<String>,
}
#[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),
}
}