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
This commit is contained in:
Lucien Loiseau 2026-02-18 12:39:28 +01:00 committed by Chummy
parent 80b60d7b70
commit 6062888d1b
2 changed files with 28 additions and 4 deletions

View file

@ -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<ProviderInfo> {
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!(

View file

@ -8,6 +8,7 @@ use reqwest::Client;
use serde::{Deserialize, Serialize};
pub struct OpenAiProvider {
base_url: String,
credential: Option<String>,
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?