Compare commits

..

7 Commits

Author SHA1 Message Date
dadc178fd6 Add timeouts and improve health checks
Changes:
- Health checks now have 5 second timeout (was no timeout)
- Chat requests have 120 second timeout (was no timeout)
- Health checks verify response body structure, not just status code
- Ollama: Verifies 'models' field exists in /api/tags response
- OpenAI: Verifies 'data' field exists in /models response

This prevents false-positive health checks where the endpoint
responds but actual API calls timeout (524 Gateway Timeout).

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-19 15:37:21 +01:00
9f1b5f908d Add /connect command to REPL for provider health check
New REPL command:
  /connect - Check connectivity to all configured providers

Features:
- Lists all providers with health status (✓/✗)
- Shows healthy/unhealthy status for each
- Summarizes overall health

Updated:
- Repl struct now holds provider_registry reference
- handle_command() is now async to support health checks
- Help text updated with new command

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-19 15:17:47 +01:00
b52d82dec5 Allow zero-config models - use provider default
Users can now omit the models section entirely from agent config.
The agent will automatically use the default model from an available provider.

Changes:
- Agent.get_model(): Falls back to provider name ("ollama" or "openai")
- ProviderRegistry.resolve_provider(): Handles bare provider names
- OpenAiProvider.parse_model(): Uses default_model when spec is "openai"
- OllamaProvider.parse_model(): Uses default_model when spec is "ollama"

Example minimal config (no models needed):
[[providers.ollama]]
base_url = "http://localhost:11434"
default_model = "llama3"

[agents.default]
description = "Default agent"
system_prompt = "You are helpful."
tools = ["file", "shell", "git"]
# No [agents.default.models] needed!

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-19 15:08:07 +01:00
aa778579ee Add fallback models when not configured
When no models are specified in agent config, use sensible defaults:
- Chat/Default tasks: ollama/llama3
- Code/Planning/ToolUse: openai/gpt-4o

This allows minimal configs without explicit model routing.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-19 14:52:04 +01:00
d874194d95 Make agents.<name>.models optional
Changes:
- agents.<name>.models is now optional (uses serde default)
- If models not specified, uses default model for all task types
- AgentModels.for_task() now takes TaskType enum and returns cloned String
- Falls back to default model if task-specific model not set
- Default agent config simplified to only set default model

Example minimal agent config:
[agents.default]
description = "Default agent"
system_prompt = "You are helpful."
tools = ["file", "shell", "git"]
# No models needed - uses provider default

Example with only default model:
[agents.default.models]
default = "ollama/llama3"
# All tasks use llama3

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-19 14:44:49 +01:00
bd8350b52a Fix REPL panic: make run() async
The REPL was using block_on() inside an already running tokio runtime,
which caused a panic. Changed run() to be async and await directly.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-19 14:33:35 +01:00
b66d474cd3 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>
2026-03-19 14:27:39 +01:00
9 changed files with 262 additions and 99 deletions

View File

@@ -76,9 +76,24 @@ impl Agent {
/// Get the model for a specific task type /// Get the model for a specific task type
fn get_model(&self, task_type: TaskType) -> Result<String> { fn get_model(&self, task_type: TaskType) -> Result<String> {
self.config // First try to get model from agent config
.get_model_for_task(task_type) if let Some(model) = self.config.get_model_for_task(task_type) {
.ok_or_else(|| AgentError::ConfigError(format!("No model configured for {:?}", task_type))) return Ok(model);
}
// Fallback: use the default model from any available provider
// Prefer ollama for local-first usage
if self.provider_registry.get("ollama").is_some() {
return Ok("ollama".to_string());
}
if self.provider_registry.get("openai").is_some() {
return Ok("openai".to_string());
}
// No providers available
Err(AgentError::ConfigError(
"No model configured and no providers available".to_string()
))
} }
/// Get available tools as provider Tool definitions /// Get available tools as provider Tool definitions

View File

@@ -147,15 +147,8 @@ pub struct AgentRuntimeConfig {
impl AgentRuntimeConfig { impl AgentRuntimeConfig {
pub fn get_model_for_task(&self, task_type: TaskType) -> Option<String> { pub fn get_model_for_task(&self, task_type: TaskType) -> Option<String> {
let model_str = match task_type { // Use the AgentModels helper which handles fallback to default
TaskType::Chat => self.config.models.chat.as_ref(), self.config.models.for_task(task_type)
TaskType::Code => self.config.models.code.as_ref(),
TaskType::Planning => self.config.models.planning.as_ref(),
TaskType::ToolUse => self.config.models.tool_use.as_ref(),
TaskType::Default => self.config.models.default.as_ref(),
};
model_str.cloned()
} }
} }

View File

@@ -1,21 +1,26 @@
use crate::agents::{AgentRequest, AgentTrait}; use crate::agents::{AgentRequest, AgentTrait};
use crate::providers::TaskType; use crate::providers::{ProviderRegistry, TaskType};
use std::io::{self, Write}; use std::io::{self, Write};
use std::sync::Arc;
/// Interactive REPL for svibe /// Interactive REPL for svibe
pub struct Repl { pub struct Repl {
agent: std::sync::Arc<dyn AgentTrait>, agent: Arc<dyn AgentTrait>,
provider_registry: Arc<ProviderRegistry>,
} }
impl Repl { impl Repl {
pub fn new(agent: std::sync::Arc<dyn AgentTrait>) -> Self { pub fn new(agent: Arc<dyn AgentTrait>, provider_registry: Arc<ProviderRegistry>) -> Self {
Self { agent } Self {
agent,
provider_registry,
}
} }
/// Run the REPL /// Run the REPL (async version)
pub fn run(&self) -> Result<(), Box<dyn std::error::Error>> { pub async fn run(&self) -> Result<(), Box<dyn std::error::Error>> {
println!("svibe REPL - Type 'exit' or 'quit' to leave"); println!("svibe REPL - Type 'exit' or 'quit' to leave");
println!("Commands: /help, /clear, /status, /agents, /tools"); println!("Commands: /help, /clear, /status, /agents, /tools, /connect");
println!(); println!();
let stdin = io::stdin(); let stdin = io::stdin();
@@ -44,24 +49,12 @@ impl Repl {
} }
if input.starts_with('/') { if input.starts_with('/') {
self.handle_command(input)?; self.handle_command(input).await?;
continue; continue;
} }
// Process as a task - need to use tokio runtime // Process as a task
let input = input.to_string(); let result = self.process_task(input).await;
let agent = self.agent.clone();
let rt = tokio::runtime::Handle::current();
let result = rt.block_on(async move {
let request = AgentRequest {
task: input,
task_type: TaskType::Default,
context: vec![],
history: vec![],
};
agent.process(request).await
});
match result { match result {
Ok(response) => { Ok(response) => {
@@ -81,7 +74,17 @@ impl Repl {
Ok(()) Ok(())
} }
fn handle_command(&self, input: &str) -> Result<(), Box<dyn std::error::Error>> { async fn process_task(&self, input: &str) -> Result<crate::agents::AgentResponse, crate::agents::AgentError> {
let request = AgentRequest {
task: input.to_string(),
task_type: TaskType::Default,
context: vec![],
history: vec![],
};
self.agent.process(request).await
}
async fn handle_command(&self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
let parts: Vec<&str> = input.split_whitespace().collect(); let parts: Vec<&str> = input.split_whitespace().collect();
match parts.get(0).copied() { match parts.get(0).copied() {
@@ -94,6 +97,7 @@ Available commands:
/status Show agent status and metrics /status Show agent status and metrics
/agents List available agents /agents List available agents
/tools List available tools /tools List available tools
/connect Check connectivity to all providers
/exit, /quit, :q Exit the REPL /exit, /quit, :q Exit the REPL
Just type your task to get started! Just type your task to get started!
@@ -128,6 +132,9 @@ Metrics:
Some("tools") | Some("/tools") => { Some("tools") | Some("/tools") => {
println!("Tool listing not available in this context"); println!("Tool listing not available in this context");
} }
Some("connect") | Some("/connect") => {
self.check_connectivity().await?;
}
_ => { _ => {
println!("Unknown command. Type /help for available commands."); println!("Unknown command. Type /help for available commands.");
} }
@@ -135,4 +142,35 @@ Metrics:
Ok(()) Ok(())
} }
async fn check_connectivity(&self) -> Result<(), Box<dyn std::error::Error>> {
println!("\nChecking provider connectivity...\n");
let health = self.provider_registry.health_check_all().await;
let providers = self.provider_registry.list();
if providers.is_empty() {
println!("No providers configured.");
return Ok(());
}
let mut all_healthy = true;
for provider in providers {
let healthy = health.get(provider).copied().unwrap_or(false);
let status = if healthy { "✓" } else { "✗" };
println!(" {} {}: {}", provider, status, if healthy { "healthy" } else { "unhealthy" });
if !healthy {
all_healthy = false;
}
}
println!();
if all_healthy {
println!("All providers are healthy!");
} else {
println!("Some providers are unhealthy. Check your configuration.");
}
Ok(())
}
} }

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);
} }
} }
@@ -232,11 +248,9 @@ fn default_agent_config() -> AgentConfig {
AgentConfig { AgentConfig {
description: "Default general-purpose agent".to_string(), description: "Default general-purpose agent".to_string(),
models: AgentModels { models: AgentModels {
chat: Some("ollama/llama3".to_string()), // Only set default model - will be used for all task types
code: Some("openai/gpt-4o".to_string()),
planning: Some("openai/gpt-4o".to_string()),
tool_use: Some("openai/gpt-4o".to_string()),
default: Some("ollama/llama3".to_string()), default: Some("ollama/llama3".to_string()),
..Default::default()
}, },
system_prompt: "You are a helpful AI assistant. Help the user with their tasks.".to_string(), system_prompt: "You are a helpful AI assistant. Help the user with their tasks.".to_string(),
tools: vec!["file".to_string(), "shell".to_string(), "git".to_string()], tools: vec!["file".to_string(), "shell".to_string(), "git".to_string()],
@@ -249,13 +263,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

@@ -1,6 +1,8 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use crate::providers::TaskType;
/// Main configuration structure /// Main configuration structure
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config { pub struct Config {
@@ -28,18 +30,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 +69,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 +77,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 +116,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,
@@ -121,7 +133,8 @@ pub struct AgentConfig {
#[serde(default)] #[serde(default)]
pub description: String, pub description: String,
/// Model routing configuration /// Model routing configuration (optional, uses default model for all tasks if not specified)
#[serde(default)]
pub models: AgentModels, pub models: AgentModels,
/// System prompt for this agent /// System prompt for this agent
@@ -142,6 +155,7 @@ pub struct AgentConfig {
} }
/// Model routing for different task types /// Model routing for different task types
/// If no models are specified, the default model is used for all tasks
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AgentModels { pub struct AgentModels {
/// Model for general chat/conversation /// Model for general chat/conversation
@@ -160,21 +174,34 @@ pub struct AgentModels {
#[serde(default)] #[serde(default)]
pub tool_use: Option<String>, pub tool_use: Option<String>,
/// Fallback model when task type is unclear /// Fallback model for all tasks (used if task-specific models not set)
#[serde(default)] #[serde(default)]
pub default: Option<String>, pub default: Option<String>,
} }
impl AgentModels { impl AgentModels {
/// Get the model for a specific task type /// Get the model for a specific task type
pub fn for_task(&self, task_type: &str) -> Option<&str> { /// Falls back to default model if task-specific model not set
match task_type { pub fn for_task(&self, task_type: TaskType) -> Option<String> {
"chat" => self.chat.as_deref(), let task_model = match task_type {
"code" => self.code.as_deref(), TaskType::Chat => self.chat.as_ref(),
"planning" => self.planning.as_deref(), TaskType::Code => self.code.as_ref(),
"tool_use" => self.tool_use.as_deref(), TaskType::Planning => self.planning.as_ref(),
_ => self.default.as_deref(), TaskType::ToolUse => self.tool_use.as_ref(),
TaskType::Default => self.default.as_ref(),
};
// Return task-specific model if set, otherwise use default
task_model.or(self.default.as_ref()).cloned()
} }
/// Check if any models are configured
pub fn is_empty(&self) -> bool {
self.chat.is_none() &&
self.code.is_none() &&
self.planning.is_none() &&
self.tool_use.is_none() &&
self.default.is_none()
} }
} }

View File

@@ -74,8 +74,8 @@ async fn main() -> anyhow::Result<()> {
match cli.command { match cli.command {
None | Some(Commands::Repl) => { None | Some(Commands::Repl) => {
// Start REPL // Start REPL
let repl = Repl::new(agent); let repl = Repl::new(agent, provider_registry.clone());
if let Err(e) = repl.run() { if let Err(e) = repl.run().await {
eprintln!("REPL error: {}", e); eprintln!("REPL error: {}", e);
} }
} }

View File

@@ -46,7 +46,11 @@ impl OllamaProvider {
// Handle "ollama/model-name" format // Handle "ollama/model-name" format
if let Some(stripped) = model_spec.strip_prefix("ollama/") { if let Some(stripped) = model_spec.strip_prefix("ollama/") {
stripped.to_string() stripped.to_string()
} else if model_spec == "ollama" {
// Just provider name - use default model
self.default_model.clone()
} else { } else {
// Use as-is (could be a model name)
model_spec.to_string() model_spec.to_string()
} }
} }
@@ -94,9 +98,20 @@ impl Provider for OllamaProvider {
} }
async fn health_check(&self) -> Result<bool> { async fn health_check(&self) -> Result<bool> {
// First check if the API is reachable
let url = format!("{}/api/tags", self.base_url); let url = format!("{}/api/tags", self.base_url);
let response = self.apply_auth(self.client.get(&url)).send().await?; let response = self.apply_auth(self.client.get(&url))
Ok(response.status().is_success()) .timeout(std::time::Duration::from_secs(5))
.send()
.await?;
if !response.status().is_success() {
return Ok(false);
}
// Also verify we can parse the response (deeper health check)
let body: serde_json::Value = response.json().await?;
Ok(body.get("models").is_some())
} }
async fn list_models(&self) -> Result<Vec<String>> { async fn list_models(&self) -> Result<Vec<String>> {
@@ -155,6 +170,7 @@ impl Provider for OllamaProvider {
self.client self.client
.post(&url) .post(&url)
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.timeout(std::time::Duration::from_secs(120)) // 2 minute timeout for chat
) )
.json(&ollama_request) .json(&ollama_request)
.send() .send()

View File

@@ -27,7 +27,11 @@ impl OpenAiProvider {
// Handle "openai/model-name" format // Handle "openai/model-name" format
if let Some(stripped) = model_spec.strip_prefix("openai/") { if let Some(stripped) = model_spec.strip_prefix("openai/") {
stripped.to_string() stripped.to_string()
} else if model_spec == "openai" {
// Just provider name - use default model
self.default_model.clone()
} else { } else {
// Use as-is (could be a model name)
model_spec.to_string() model_spec.to_string()
} }
} }
@@ -45,10 +49,17 @@ impl Provider for OpenAiProvider {
.client .client
.get(&url) .get(&url)
.header("Authorization", format!("Bearer {}", self.api_key)) .header("Authorization", format!("Bearer {}", self.api_key))
.timeout(std::time::Duration::from_secs(5))
.send() .send()
.await?; .await?;
Ok(response.status().is_success()) if !response.status().is_success() {
return Ok(false);
}
// Verify we can parse the response
let body: serde_json::Value = response.json().await?;
Ok(body.get("data").is_some())
} }
async fn list_models(&self) -> Result<Vec<String>> { async fn list_models(&self) -> Result<Vec<String>> {
@@ -148,6 +159,7 @@ impl Provider for OpenAiProvider {
.post(&url) .post(&url)
.header("Authorization", format!("Bearer {}", self.api_key)) .header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.timeout(std::time::Duration::from_secs(120)) // 2 minute timeout for chat
.json(&req_body) .json(&req_body)
.send() .send()
.await?; .await?;

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());
if let Some(key) = api_key {
let provider = OpenAiProvider::new( let provider = OpenAiProvider::new(
&openai_config.base_url, &openai_config.base_url,
&api_key, &key,
&openai_config.default_model, &openai_config.default_model,
); );
registry.register(&registry_name, Arc::new(provider));
registry.register("openai", 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)
@@ -73,20 +103,29 @@ impl ProviderRegistry {
} }
/// Resolve a model specification to a provider /// Resolve a model specification to a provider
/// Format: "provider/model-name" or just "model-name" (uses default) /// Format:
/// - "provider/model-name" - use specific model
/// - "provider" - use provider's default model
/// - "model-name" - try to find provider with this model as default
pub fn resolve_provider(&self, model_spec: &str) -> Result<(Arc<dyn Provider>, String)> { pub fn resolve_provider(&self, model_spec: &str) -> Result<(Arc<dyn Provider>, String)> {
if let Some((provider_name, model_name)) = model_spec.split_once('/') { if let Some((provider_name, model_name)) = model_spec.split_once('/') {
let provider = self.get_or_error(provider_name)?; let provider = self.get_or_error(provider_name)?;
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 // Check if it's just a provider name (no model specified)
// For now, default to openai if available // In this case, use the provider's default model
if let Some(provider) = self.get("openai") { if let Some(provider) = self.get(model_spec) {
return Ok((provider, model_spec.to_string())); return Ok((provider, model_spec.to_string()));
} }
// Try to find a provider that has this model as default
// Prefer Ollama for local-first
if let Some(provider) = self.get("ollama") { 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("openai") {
return Ok((provider, model_spec.to_string()));
}
Err(ProviderError::UnknownProvider( Err(ProviderError::UnknownProvider(
"No providers available".to_string(), "No providers available".to_string(),
)) ))
@@ -109,6 +148,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 {