Add support for multiple providers of same type

Features:
- Multiple OpenAI instances (e.g., OpenAI + local AI proxy)
- Multiple Ollama instances (e.g., local + remote)
- API key not required if at least one provider works without it
- Provider instances can have optional names for reference

Config format (TOML arrays):
[[providers.openai]]
name = "openai-main"
api_key = "sk-..."
base_url = "https://api.openai.com/v1"

[[providers.openai]]
name = "local-ai"
base_url = "http://localhost:8080/v1"

[[providers.ollama]]
name = "local"
base_url = "http://localhost:11434"

[[providers.ollama]]
name = "remote"
base_url = "http://192.168.1.100:11434"
bearer_token = "token"

Changes:
- config/types.rs: Changed providers from Option<T> to Vec<T>
- config/loader.rs: Updated merge logic for provider arrays
- providers/registry.rs: Register all provider instances, skip those without API keys if alternatives exist

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
2026-03-19 14:27:39 +01:00
parent d4cebe21c7
commit b66d474cd3
3 changed files with 108 additions and 43 deletions

View File

@@ -1,6 +1,5 @@
use super::{Config, ConfigError, Result, OpenAiConfig, OllamaConfig, AgentConfig, AgentModels}; use super::{Config, ConfigError, Result, OpenAiConfig, OllamaConfig, AgentConfig, AgentModels};
use super::types::{default_openai_base_url, default_openai_model, default_ollama_base_url, default_ollama_model}; use super::types::{default_openai_base_url, default_openai_model, default_ollama_base_url, default_ollama_model};
use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
/// Configuration loader /// Configuration loader
@@ -138,17 +137,20 @@ fn find_local_config() -> Option<PathBuf> {
/// Merge environment variables into config /// Merge environment variables into config
impl Config { impl Config {
fn merge_env(&mut self) { fn merge_env(&mut self) {
// OpenAI API key from environment // OpenAI API key from environment (applies to first instance without key)
if let Some(ref mut openai) = self.providers.openai { for openai in &mut self.providers.openai {
if openai.api_key.is_none() { if openai.api_key.is_none() {
openai.api_key = std::env::var("OPENAI_API_KEY").ok(); if let Ok(key) = std::env::var("OPENAI_API_KEY") {
openai.api_key = Some(key);
break; // Only apply to first one
}
} }
} }
// Ollama base URL from environment // Ollama base URL from environment (applies to all instances)
if let Some(ref mut ollama) = self.providers.ollama { for ollama in &mut self.providers.ollama {
if let Ok(url) = std::env::var("OLLAMA_HOST") { if let Ok(url) = std::env::var("OLLAMA_HOST") {
if !url.is_empty() { if !url.is_empty() && ollama.base_url == default_ollama_base_url() {
ollama.base_url = url; ollama.base_url = url;
} }
} }
@@ -157,10 +159,16 @@ impl Config {
/// Merge another config into this one (other overrides self) /// Merge another config into this one (other overrides self)
fn merge(&mut self, other: Config) { fn merge(&mut self, other: Config) {
// Merge providers // Merge OpenAI providers (extend list)
if let Some(other_openai) = other.providers.openai { for other_openai in other.providers.openai {
if let Some(ref mut self_openai) = self.providers.openai { // Check if we should merge with existing or add new
// Merge OpenAI configs let existing = self.providers.openai.iter_mut().find(|p| {
p.name == other_openai.name ||
(p.name.is_none() && other_openai.name.is_none())
});
if let Some(self_openai) = existing {
// Merge with existing provider
if other_openai.api_key.is_some() { if other_openai.api_key.is_some() {
self_openai.api_key = other_openai.api_key; self_openai.api_key = other_openai.api_key;
} }
@@ -171,13 +179,20 @@ impl Config {
self_openai.default_model = other_openai.default_model; self_openai.default_model = other_openai.default_model;
} }
} else { } else {
self.providers.openai = Some(other_openai); // Add as new provider
self.providers.openai.push(other_openai);
} }
} }
if let Some(other_ollama) = other.providers.ollama { // Merge Ollama providers (extend list)
if let Some(ref mut self_ollama) = self.providers.ollama { for other_ollama in other.providers.ollama {
// Merge Ollama configs let existing = self.providers.ollama.iter_mut().find(|p| {
p.name == other_ollama.name ||
(p.name.is_none() && other_ollama.name.is_none())
});
if let Some(self_ollama) = existing {
// Merge with existing provider
if other_ollama.base_url != default_ollama_base_url() { if other_ollama.base_url != default_ollama_base_url() {
self_ollama.base_url = other_ollama.base_url; self_ollama.base_url = other_ollama.base_url;
} }
@@ -194,7 +209,8 @@ impl Config {
self_ollama.password = other_ollama.password; self_ollama.password = other_ollama.password;
} }
} else { } else {
self.providers.ollama = Some(other_ollama); // Add as new provider
self.providers.ollama.push(other_ollama);
} }
} }
@@ -249,13 +265,17 @@ fn default_agent_config() -> AgentConfig {
pub fn create_default_config() -> Result<Config> { pub fn create_default_config() -> Result<Config> {
let mut config = Config::default(); let mut config = Config::default();
config.providers.openai = Some(OpenAiConfig { // Add default OpenAI provider
api_key: None, // User must set this config.providers.openai.push(OpenAiConfig {
name: Some("default".to_string()),
api_key: None, // User must set this or use env var
base_url: default_openai_base_url(), base_url: default_openai_base_url(),
default_model: default_openai_model(), default_model: default_openai_model(),
}); });
config.providers.ollama = Some(OllamaConfig { // Add default Ollama provider
config.providers.ollama.push(OllamaConfig {
name: Some("default".to_string()),
base_url: default_ollama_base_url(), base_url: default_ollama_base_url(),
default_model: default_ollama_model(), default_model: default_ollama_model(),
bearer_token: None, bearer_token: None,

View File

@@ -28,18 +28,22 @@ fn default_agent() -> String {
/// Provider configurations /// Provider configurations
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProvidersConfig { pub struct ProvidersConfig {
/// OpenAI provider config /// OpenAI provider instances (can have multiple)
#[serde(default)] #[serde(default)]
pub openai: Option<OpenAiConfig>, pub openai: Vec<OpenAiConfig>,
/// Ollama provider config /// Ollama provider instances (can have multiple)
#[serde(default)] #[serde(default)]
pub ollama: Option<OllamaConfig>, pub ollama: Vec<OllamaConfig>,
} }
/// OpenAI configuration /// Named OpenAI configuration
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenAiConfig { pub struct OpenAiConfig {
/// Name for this provider instance (optional, for reference)
#[serde(default)]
pub name: Option<String>,
/// API key (can also be set via OPENAI_API_KEY env var) /// API key (can also be set via OPENAI_API_KEY env var)
pub api_key: Option<String>, pub api_key: Option<String>,
@@ -63,6 +67,7 @@ pub fn default_openai_model() -> String {
impl Default for OpenAiConfig { impl Default for OpenAiConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
name: None,
api_key: None, api_key: None,
base_url: default_openai_base_url(), base_url: default_openai_base_url(),
default_model: default_openai_model(), default_model: default_openai_model(),
@@ -70,9 +75,13 @@ impl Default for OpenAiConfig {
} }
} }
/// Ollama configuration /// Named Ollama configuration
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OllamaConfig { pub struct OllamaConfig {
/// Name for this provider instance (optional, for reference)
#[serde(default)]
pub name: Option<String>,
/// Base URL /// Base URL
#[serde(default = "default_ollama_base_url")] #[serde(default = "default_ollama_base_url")]
pub base_url: String, pub base_url: String,
@@ -105,6 +114,7 @@ pub fn default_ollama_model() -> String {
impl Default for OllamaConfig { impl Default for OllamaConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
name: None,
base_url: default_ollama_base_url(), base_url: default_ollama_base_url(),
default_model: default_ollama_model(), default_model: default_ollama_model(),
bearer_token: None, bearer_token: None,

View File

@@ -16,27 +16,50 @@ impl ProviderRegistry {
} }
/// Create a registry from configuration /// Create a registry from configuration
/// Does not require API key if at least one provider is configured
pub fn from_config(config: &Config) -> Result<Self> { pub fn from_config(config: &Config) -> Result<Self> {
let mut registry = Self::new(); let mut registry = Self::new();
let mut has_any_provider = false;
// Register OpenAI provider if configured // Register all OpenAI provider instances
if let Some(ref openai_config) = config.providers.openai { for (i, openai_config) in config.providers.openai.iter().enumerate() {
let name = openai_config
.name
.clone()
.unwrap_or_else(|| format!("openai-{}", i + 1));
let registry_name = if i == 0 { "openai".to_string() } else { name.clone() };
// Get API key from config or environment
let api_key = openai_config let api_key = openai_config
.api_key .api_key
.clone() .clone()
.ok_or_else(|| ProviderError::AuthError("OpenAI API key not configured".to_string()))?; .or_else(|| std::env::var("OPENAI_API_KEY").ok());
let provider = OpenAiProvider::new( if let Some(key) = api_key {
&openai_config.base_url, let provider = OpenAiProvider::new(
&api_key, &openai_config.base_url,
&openai_config.default_model, &key,
); &openai_config.default_model,
);
registry.register("openai", Arc::new(provider)); registry.register(&registry_name, Arc::new(provider));
has_any_provider = true;
} else if config.providers.ollama.is_empty() {
// Only error if no other providers are available
return Err(ProviderError::AuthError(
"OpenAI API key not configured. Set api_key in config or OPENAI_API_KEY env var, or configure Ollama provider.".to_string()
));
}
// If no API key but Ollama is available, just skip this OpenAI instance
} }
// Register Ollama provider if configured // Register all Ollama provider instances
if let Some(ref ollama_config) = config.providers.ollama { for (i, ollama_config) in config.providers.ollama.iter().enumerate() {
let name = ollama_config
.name
.clone()
.unwrap_or_else(|| format!("ollama-{}", i + 1));
let registry_name = if i == 0 { "ollama".to_string() } else { name.clone() };
let mut provider = OllamaProvider::new( let mut provider = OllamaProvider::new(
&ollama_config.base_url, &ollama_config.base_url,
&ollama_config.default_model, &ollama_config.default_model,
@@ -50,7 +73,14 @@ impl ProviderRegistry {
provider = provider.with_basic_auth(username, password); provider = provider.with_basic_auth(username, password);
} }
registry.register("ollama", Arc::new(provider)); registry.register(&registry_name, Arc::new(provider));
has_any_provider = true;
}
if !has_any_provider && config.providers.openai.is_empty() && config.providers.ollama.is_empty() {
return Err(ProviderError::AuthError(
"No providers configured. Add at least one OpenAI or Ollama provider.".to_string()
));
} }
Ok(registry) Ok(registry)
@@ -80,11 +110,11 @@ impl ProviderRegistry {
Ok((provider, model_name.to_string())) Ok((provider, model_name.to_string()))
} else { } else {
// Try to find a provider that has this model as default // Try to find a provider that has this model as default
// For now, default to openai if available // Prefer Ollama for local-first
if let Some(provider) = self.get("openai") { if let Some(provider) = self.get("ollama") {
return Ok((provider, model_spec.to_string())); return Ok((provider, model_spec.to_string()));
} }
if let Some(provider) = self.get("ollama") { if let Some(provider) = self.get("openai") {
return Ok((provider, model_spec.to_string())); return Ok((provider, model_spec.to_string()));
} }
Err(ProviderError::UnknownProvider( Err(ProviderError::UnknownProvider(
@@ -109,6 +139,11 @@ impl ProviderRegistry {
results results
} }
/// Check if any provider is available
pub fn has_providers(&self) -> bool {
!self.providers.is_empty()
}
} }
impl Default for ProviderRegistry { impl Default for ProviderRegistry {