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:
parent
4ca5fa500b
commit
c384c34c31
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
}
|
||||
};
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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)?;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user