feat: v0.7.0 — registre de providers LLM : am providers list/show/add/remove/default, settings.providers + default_provider (closes #88)

This commit is contained in:
2026-08-19 15:32:45 -04:00
parent 1948e3bc79
commit d38cd7352f
18 changed files with 823 additions and 13 deletions
+79
View File
@@ -139,6 +139,30 @@ pub struct Settings {
/// Plugin scripts (issue #75): default timeout and enable list.
#[serde(default)]
pub plugins: Option<PluginSettings>,
/// Name of the provider used by default (issue #88) — the provider
/// whose default model is applied at install/run when none is given.
#[serde(default)]
pub default_provider: Option<String>,
/// LLM provider registry (issue #88): provider name -> base URL,
/// available models and default model. API tokens are NOT stored here —
/// they live in the OS keyring under `providers/<name>/api_key`
/// (issue #89).
#[serde(default)]
pub providers: Option<BTreeMap<String, ProviderDef>>,
}
/// One entry of the LLM provider registry (issue #88).
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct ProviderDef {
/// Base URL of the provider API (e.g. https://api.openai.com/v1).
pub base_url: String,
/// Model used by default for this provider.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_model: Option<String>,
/// Models offered by this provider.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub models: Vec<String>,
}
/// Plugin scripts settings (issue #75): how long one plugin may run and
@@ -248,6 +272,24 @@ impl Settings {
self.stop_timeout_secs
}
}
/// Look up one provider of the registry (issue #88).
pub fn provider(&self, name: &str) -> Option<&ProviderDef> {
self.providers.as_ref().and_then(|m| m.get(name))
}
/// Name of the default provider (issue #88).
pub fn default_provider_name(&self) -> Option<&str> {
self.default_provider.as_deref()
}
/// The default provider entry, when both the name and the definition
/// exist (issue #88).
pub fn default_provider(&self) -> Option<(&String, &ProviderDef)> {
let name = self.default_provider.as_ref()?;
let def = self.provider(name)?;
Some((name, def))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -769,6 +811,21 @@ pub fn merge(base: &mut Config, overlay: Config) {
if o.plugins.is_some() {
s.plugins = o.plugins;
}
if o.default_provider.is_some() {
s.default_provider = o.default_provider;
}
// Providers merge key by key (issue #88): an overlay adds or replaces
// one provider without wiping the others.
if let Some(overlay_providers) = o.providers {
match s.providers.as_mut() {
Some(map) => {
for (k, v) in overlay_providers {
map.insert(k, v);
}
}
None => s.providers = Some(overlay_providers),
}
}
for (k, v) in o.hooks {
s.hooks.insert(k, v);
}
@@ -834,6 +891,28 @@ pub fn validate(config: &Config) -> Vec<String> {
problems.push(format!("agent '{}': dependency without a name", agent.name));
}
}
if let Some(d) = &config.settings.default_provider {
if config.settings.provider(d).is_none() {
problems.push(format!(
"settings.default_provider '{d}' is not defined in settings.providers"
));
}
}
if let Some(providers) = &config.settings.providers {
for (name, p) in providers {
if p.base_url.trim().is_empty() {
problems.push(format!("provider '{name}': 'base_url' is required"));
}
if !name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
{
problems.push(format!(
"provider name '{name}' must be a lowercase slug ([a-z0-9_-])"
));
}
}
}
}
problems
}