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:
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user