From 6062888d1b6c96d9a308c77538e849b1312a4bd5 Mon Sep 17 00:00:00 2001 From: Lucien Loiseau Date: Wed, 18 Feb 2026 12:39:28 +0100 Subject: [PATCH] feat(providers): add OVHcloud AI Endpoints as native provider Route OVHcloud through OpenAiProvider (with proper tool_call_id serialization) instead of OpenAiCompatibleProvider, fixing tool-call round-trips against vLLM-based endpoints. - Add base_url field and with_base_url() constructor to OpenAiProvider - Replace all hardcoded api.openai.com URLs with self.base_url - Pass api_url through for the openai provider arm - Register ovhcloud/ovh provider with env var OVH_AI_ENDPOINTS_ACCESS_TOKEN --- src/providers/mod.rs | 16 +++++++++++++++- src/providers/openai.rs | 16 +++++++++++++--- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/providers/mod.rs b/src/providers/mod.rs index 1888b09..45b30e9 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -324,6 +324,7 @@ fn resolve_provider_credential(name: &str, credential_override: Option<&str>) -> "opencode" | "opencode-zen" => vec!["OPENCODE_API_KEY"], "vercel" | "vercel-ai" => vec!["VERCEL_API_KEY"], "cloudflare" | "cloudflare-ai" => vec!["CLOUDFLARE_API_KEY"], + "ovhcloud" | "ovh" => vec!["OVH_AI_ENDPOINTS_ACCESS_TOKEN"], "astrai" => vec!["ASTRAI_API_KEY"], _ => vec![], }; @@ -405,7 +406,7 @@ pub fn create_provider_with_url( // ── Primary providers (custom implementations) ─────── "openrouter" => Ok(Box::new(openrouter::OpenRouterProvider::new(key))), "anthropic" => Ok(Box::new(anthropic::AnthropicProvider::new(key))), - "openai" => Ok(Box::new(openai::OpenAiProvider::new(key))), + "openai" => Ok(Box::new(openai::OpenAiProvider::with_base_url(api_url, key))), // Ollama uses api_url for custom base URL (e.g. remote Ollama instance) "ollama" => Ok(Box::new(ollama::OllamaProvider::new(api_url, key))), "gemini" | "google" | "google-gemini" => { @@ -527,6 +528,12 @@ pub fn create_provider_with_url( "Astrai", "https://as-trai.com/v1", key, AuthStyle::Bearer, ))), + // ── Cloud AI endpoints ─────────────────────────────── + "ovhcloud" | "ovh" => Ok(Box::new(openai::OpenAiProvider::with_base_url( + Some("https://oai.endpoints.kepler.ai.cloud.ovh.net/v1"), + key, + ))), + // ── Bring Your Own Provider (custom URL) ─────────── // Format: "custom:https://your-api.com" or "custom:http://localhost:1234" name if name.starts_with("custom:") => { @@ -900,6 +907,12 @@ pub fn list_providers() -> Vec { aliases: &["nvidia-nim", "build.nvidia.com"], local: false, }, + ProviderInfo { + name: "ovhcloud", + display_name: "OVHcloud AI Endpoints", + aliases: &["ovh"], + local: false, + }, ] } @@ -1413,6 +1426,7 @@ mod tests { "copilot", "nvidia", "astrai", + "ovhcloud", ]; for name in providers { assert!( diff --git a/src/providers/openai.rs b/src/providers/openai.rs index 6474c01..d23a548 100644 --- a/src/providers/openai.rs +++ b/src/providers/openai.rs @@ -8,6 +8,7 @@ use reqwest::Client; use serde::{Deserialize, Serialize}; pub struct OpenAiProvider { + base_url: String, credential: Option, client: Client, } @@ -136,7 +137,16 @@ impl NativeResponseMessage { impl OpenAiProvider { pub fn new(credential: Option<&str>) -> Self { + Self::with_base_url(None, credential) + } + + /// Create a provider with an optional custom base URL. + /// Defaults to `https://api.openai.com/v1` when `base_url` is `None`. + pub fn with_base_url(base_url: Option<&str>, credential: Option<&str>) -> Self { Self { + base_url: base_url + .map(|u| u.trim_end_matches('/').to_string()) + .unwrap_or_else(|| "https://api.openai.com/v1".to_string()), credential: credential.map(ToString::to_string), client: Client::builder() .timeout(std::time::Duration::from_secs(120)) @@ -281,7 +291,7 @@ impl Provider for OpenAiProvider { let response = self .client - .post("https://api.openai.com/v1/chat/completions") + .post(format!("{}/chat/completions", self.base_url)) .header("Authorization", format!("Bearer {credential}")) .json(&request) .send() @@ -322,7 +332,7 @@ impl Provider for OpenAiProvider { let response = self .client - .post("https://api.openai.com/v1/chat/completions") + .post(format!("{}/chat/completions", self.base_url)) .header("Authorization", format!("Bearer {credential}")) .json(&native_request) .send() @@ -349,7 +359,7 @@ impl Provider for OpenAiProvider { async fn warmup(&self) -> anyhow::Result<()> { if let Some(credential) = self.credential.as_ref() { self.client - .get("https://api.openai.com/v1/models") + .get(format!("{}/models", self.base_url)) .header("Authorization", format!("Bearer {credential}")) .send() .await?