feat(provider): support custom API path suffix for custom: endpoints (#3447)

* feat(provider): support custom API path suffix for custom: endpoints

Allow users to configure a custom API path for custom/compatible
providers instead of hardcoding /v1/chat/completions. Some self-hosted
LLM servers use different API paths.

Adds an optional `api_path` field to:
- Config (top-level and model_providers profile)
- ProviderRuntimeOptions
- OpenAiCompatibleProvider

When set, the custom path is appended to base_url instead of the
default /chat/completions suffix.

Closes #3125

* fix: add missing api_path field to test ModelProviderConfig initializers
This commit is contained in:
Argenis 2026-03-13 17:54:21 -04:00 committed by GitHub
parent 4ca5fa500b
commit c384c34c31
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 58 additions and 0 deletions

View File

@ -3055,6 +3055,7 @@ pub async fn run(
reasoning_enabled: config.runtime.reasoning_enabled,
provider_timeout_secs: Some(config.provider_timeout_secs),
extra_headers: config.extra_headers.clone(),
api_path: config.api_path.clone(),
};
let provider: Box<dyn Provider> = providers::create_routed_provider_with_options(
@ -3586,6 +3587,7 @@ pub async fn process_message(config: Config, message: &str) -> Result<String> {
reasoning_enabled: config.runtime.reasoning_enabled,
provider_timeout_secs: Some(config.provider_timeout_secs),
extra_headers: config.extra_headers.clone(),
api_path: config.api_path.clone(),
};
let provider: Box<dyn Provider> = providers::create_routed_provider_with_options(
provider_name,

View File

@ -3364,6 +3364,7 @@ pub async fn start_channels(config: Config) -> Result<()> {
reasoning_enabled: config.runtime.reasoning_enabled,
provider_timeout_secs: Some(config.provider_timeout_secs),
extra_headers: config.extra_headers.clone(),
api_path: config.api_path.clone(),
};
let provider: Arc<dyn Provider> = Arc::from(
create_resilient_provider_nonblocking(

View File

@ -74,6 +74,10 @@ pub struct Config {
pub api_key: Option<String>,
/// Base URL override for provider API (e.g. "http://10.0.0.1:11434" for remote Ollama)
pub api_url: Option<String>,
/// Custom API path suffix for OpenAI-compatible / custom providers
/// (e.g. "/v2/generate" instead of the default "/v1/chat/completions").
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_path: Option<String>,
/// Default provider ID or alias (e.g. `"openrouter"`, `"ollama"`, `"anthropic"`). Default: `"openrouter"`.
#[serde(alias = "model_provider")]
pub default_provider: Option<String>,
@ -258,6 +262,10 @@ pub struct ModelProviderConfig {
/// Optional base URL for OpenAI-compatible endpoints.
#[serde(default)]
pub base_url: Option<String>,
/// Optional custom API path suffix (e.g. "/v2/generate" instead of the
/// default "/v1/chat/completions"). Only used by OpenAI-compatible / custom providers.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_path: Option<String>,
/// Provider protocol variant ("responses" or "chat_completions").
#[serde(default)]
pub wire_api: Option<String>,
@ -4078,6 +4086,7 @@ impl Default for Config {
config_path: zeroclaw_dir.join("config.toml"),
api_key: None,
api_url: None,
api_path: None,
default_provider: Some("openrouter".to_string()),
default_model: Some("anthropic/claude-sonnet-4.6".to_string()),
model_providers: HashMap::new(),
@ -4913,6 +4922,16 @@ impl Config {
}
}
// Propagate api_path from the profile when not already set at top level.
if self.api_path.is_none() {
if let Some(ref path) = profile.api_path {
let trimmed = path.trim();
if !trimmed.is_empty() {
self.api_path = Some(trimmed.to_string());
}
}
}
if profile.requires_openai_auth
&& self
.api_key
@ -6052,6 +6071,7 @@ default_temperature = 0.7
config_path: PathBuf::from("/tmp/test/config.toml"),
api_key: Some("sk-test-key".into()),
api_url: None,
api_path: None,
default_provider: Some("openrouter".into()),
default_model: Some("gpt-4o".into()),
model_providers: HashMap::new(),
@ -6400,6 +6420,7 @@ tool_dispatcher = "xml"
config_path: config_path.clone(),
api_key: Some("sk-roundtrip".into()),
api_url: None,
api_path: None,
default_provider: Some("openrouter".into()),
default_model: Some("test-model".into()),
model_providers: HashMap::new(),
@ -7595,6 +7616,7 @@ requires_openai_auth = true
azure_openai_resource: None,
azure_openai_deployment: None,
azure_openai_api_version: None,
api_path: None,
},
)]),
..Config::default()
@ -7626,6 +7648,7 @@ requires_openai_auth = true
azure_openai_resource: None,
azure_openai_deployment: None,
azure_openai_api_version: None,
api_path: None,
},
)]),
api_key: None,
@ -7691,6 +7714,7 @@ requires_openai_auth = true
azure_openai_resource: None,
azure_openai_deployment: None,
azure_openai_api_version: None,
api_path: None,
},
)]),
..Config::default()

View File

@ -353,6 +353,7 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
reasoning_enabled: config.runtime.reasoning_enabled,
provider_timeout_secs: Some(config.provider_timeout_secs),
extra_headers: config.extra_headers.clone(),
api_path: config.api_path.clone(),
},
)?);
let model = config

View File

@ -134,6 +134,7 @@ pub async fn run_wizard(force: bool) -> Result<Config> {
Some(api_key)
},
api_url: provider_api_url,
api_path: None,
default_provider: Some(provider),
default_model: Some(model),
model_providers: std::collections::HashMap::new(),
@ -490,6 +491,7 @@ async fn run_quick_setup_with_home(
s
}),
api_url: None,
api_path: None,
default_provider: Some(provider_name.clone()),
default_model: Some(model.clone()),
model_providers: std::collections::HashMap::new(),

View File

@ -41,6 +41,9 @@ pub struct OpenAiCompatibleProvider {
timeout_secs: u64,
/// Extra HTTP headers to include in all API requests.
extra_headers: std::collections::HashMap<String, String>,
/// Custom API path suffix (e.g. "/v2/generate").
/// When set, overrides the default `/chat/completions` path detection.
api_path: Option<String>,
}
/// How the provider expects the API key to be sent.
@ -176,6 +179,7 @@ impl OpenAiCompatibleProvider {
native_tool_calling: !merge_system_into_user,
timeout_secs: 120,
extra_headers: std::collections::HashMap::new(),
api_path: None,
}
}
@ -194,6 +198,13 @@ impl OpenAiCompatibleProvider {
self
}
/// Set a custom API path suffix for this provider.
/// When set, replaces the default `/chat/completions` path.
pub fn with_api_path(mut self, api_path: Option<String>) -> Self {
self.api_path = api_path;
self
}
/// Collect all `system` role messages, concatenate their content,
/// and prepend to the first `user` message. Drop all system messages.
/// Used for providers (e.g. MiniMax) that reject `role: system`.
@ -273,6 +284,12 @@ impl OpenAiCompatibleProvider {
/// This allows custom providers with non-standard endpoints (e.g., VolcEngine ARK uses
/// `/api/coding/v3/chat/completions` instead of `/v1/chat/completions`).
fn chat_completions_url(&self) -> String {
// If a custom api_path is configured, use it directly.
if let Some(ref api_path) = self.api_path {
let separator = if api_path.starts_with('/') { "" } else { "/" };
return format!("{}{separator}{api_path}", self.base_url);
}
let has_full_endpoint = reqwest::Url::parse(&self.base_url)
.map(|url| {
url.path()

View File

@ -683,6 +683,9 @@ pub struct ProviderRuntimeOptions {
/// Extra HTTP headers to include in provider API requests.
/// These are merged from the config file and `ZEROCLAW_EXTRA_HEADERS` env var.
pub extra_headers: std::collections::HashMap<String, String>,
/// Custom API path suffix for OpenAI-compatible providers
/// (e.g. "/v2/generate" instead of the default "/chat/completions").
pub api_path: Option<String>,
}
impl Default for ProviderRuntimeOptions {
@ -695,6 +698,7 @@ impl Default for ProviderRuntimeOptions {
reasoning_enabled: None,
provider_timeout_secs: None,
extra_headers: std::collections::HashMap::new(),
api_path: None,
}
}
}
@ -1006,6 +1010,7 @@ fn create_provider_with_url_and_options(
let compat = {
let timeout = options.provider_timeout_secs;
let extra_headers = options.extra_headers.clone();
let api_path = options.api_path.clone();
move |p: OpenAiCompatibleProvider| -> Box<dyn Provider> {
let mut p = p;
if let Some(t) = timeout {
@ -1014,6 +1019,9 @@ fn create_provider_with_url_and_options(
if !extra_headers.is_empty() {
p = p.with_extra_headers(extra_headers.clone());
}
if api_path.is_some() {
p = p.with_api_path(api_path.clone());
}
Box::new(p)
}
};

View File

@ -1019,6 +1019,7 @@ data: [DONE]
reasoning_enabled: None,
provider_timeout_secs: None,
extra_headers: std::collections::HashMap::new(),
api_path: None,
};
let provider =
OpenAiCodexProvider::new(&options, None).expect("provider should initialize");

View File

@ -381,6 +381,7 @@ pub fn all_tools_with_runtime(
reasoning_enabled: root_config.runtime.reasoning_enabled,
provider_timeout_secs: Some(root_config.provider_timeout_secs),
extra_headers: root_config.extra_headers.clone(),
api_path: root_config.api_path.clone(),
},
)
.with_parent_tools(Arc::clone(&parent_tools))

View File

@ -153,6 +153,7 @@ async fn openai_codex_second_vision_support() -> Result<()> {
reasoning_enabled: None,
provider_timeout_secs: None,
extra_headers: std::collections::HashMap::new(),
api_path: None,
};
let provider = zeroclaw::providers::create_provider_with_options("openai-codex", None, &opts)?;