|
|
|
@@ -0,0 +1,421 @@
|
|
|
|
|
//! providers: the LLM provider registry (issue #88). `settings.providers`
|
|
|
|
|
//! maps a provider name to its base URL, model list and default model; the
|
|
|
|
|
//! default provider is `settings.default_provider`. Tokens are NOT handled
|
|
|
|
|
//! here — they live in the OS keyring (issue #89).
|
|
|
|
|
|
|
|
|
|
use super::*;
|
|
|
|
|
use crate::cli::ProvidersCmd;
|
|
|
|
|
use crate::config::ProviderDef;
|
|
|
|
|
use crate::events::{Event, EventKind};
|
|
|
|
|
use crate::output::print_json;
|
|
|
|
|
use crate::providers;
|
|
|
|
|
use anyhow::{anyhow, bail, Result};
|
|
|
|
|
use std::path::Path;
|
|
|
|
|
|
|
|
|
|
pub fn run(app: &App, sub: Option<&ProvidersCmd>) -> Result<i32> {
|
|
|
|
|
match sub {
|
|
|
|
|
None => list(app),
|
|
|
|
|
Some(ProvidersCmd::List) => list(app),
|
|
|
|
|
Some(ProvidersCmd::Show { name }) => show(app, name),
|
|
|
|
|
Some(ProvidersCmd::Add {
|
|
|
|
|
name,
|
|
|
|
|
base_url,
|
|
|
|
|
model,
|
|
|
|
|
models,
|
|
|
|
|
}) => add(app, name, base_url, model.as_deref(), models.as_deref()),
|
|
|
|
|
Some(ProvidersCmd::Remove { name }) => remove(app, name),
|
|
|
|
|
Some(ProvidersCmd::Default { name }) => set_default(app, name),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// list / show
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
fn list(app: &App) -> Result<i32> {
|
|
|
|
|
let providers = providers::all(&app.config);
|
|
|
|
|
let default_name = providers::default_name(&app.config);
|
|
|
|
|
if app.json() {
|
|
|
|
|
let rows: Vec<serde_json::Value> = providers
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|(name, def)| provider_json(name, def, default_name == Some(name.as_str())))
|
|
|
|
|
.collect();
|
|
|
|
|
print_json(&rows);
|
|
|
|
|
return Ok(0);
|
|
|
|
|
}
|
|
|
|
|
if providers.is_empty() {
|
|
|
|
|
app.log.info(&crate::i18n::tr(
|
|
|
|
|
"aucun provider enregistré — voir: am providers add <nom> --base-url <url>",
|
|
|
|
|
));
|
|
|
|
|
return Ok(0);
|
|
|
|
|
}
|
|
|
|
|
let mut table = crate::output::Table::new(vec![
|
|
|
|
|
"PROVIDER",
|
|
|
|
|
"BASE URL",
|
|
|
|
|
"DÉFAUT",
|
|
|
|
|
"MODÈLES",
|
|
|
|
|
]);
|
|
|
|
|
for (name, def) in providers {
|
|
|
|
|
let star = if default_name == Some(name.as_str()) {
|
|
|
|
|
"★"
|
|
|
|
|
} else {
|
|
|
|
|
""
|
|
|
|
|
};
|
|
|
|
|
table.row(vec![
|
|
|
|
|
format!("{name}{star}"),
|
|
|
|
|
def.base_url.clone(),
|
|
|
|
|
def.default_model.clone().unwrap_or_default(),
|
|
|
|
|
def.models.join(", "),
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
print!("{}", table.render());
|
|
|
|
|
Ok(0)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn show(app: &App, name: &str) -> Result<i32> {
|
|
|
|
|
let def = providers::get(&app.config, name)
|
|
|
|
|
.ok_or_else(|| anyhow!(crate::tr_fmt!("provider '{}' inconnu", name)))?;
|
|
|
|
|
let is_default = providers::default_name(&app.config) == Some(name);
|
|
|
|
|
if app.json() {
|
|
|
|
|
print_json(&provider_json(name, def, is_default));
|
|
|
|
|
return Ok(0);
|
|
|
|
|
}
|
|
|
|
|
println!("provider: {name}");
|
|
|
|
|
if is_default {
|
|
|
|
|
println!("(provider par défaut)");
|
|
|
|
|
}
|
|
|
|
|
println!(" base_url: {}", def.base_url);
|
|
|
|
|
println!(
|
|
|
|
|
" modèle par défaut: {}",
|
|
|
|
|
def.default_model.as_deref().unwrap_or("-")
|
|
|
|
|
);
|
|
|
|
|
println!(" modèles: {}", def.models.join(", "));
|
|
|
|
|
Ok(0)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// One provider as a JSON object. Tokens are never part of this shape.
|
|
|
|
|
fn provider_json(name: &str, def: &ProviderDef, is_default: bool) -> serde_json::Value {
|
|
|
|
|
serde_json::json!({
|
|
|
|
|
"name": name,
|
|
|
|
|
"base_url": def.base_url,
|
|
|
|
|
"default_model": def.default_model,
|
|
|
|
|
"models": def.models,
|
|
|
|
|
"is_default": is_default,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// add / remove / default
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
fn add(app: &App, name: &str, base_url: &str, model: Option<&str>, models: Option<&str>) -> Result<i32> {
|
|
|
|
|
if !providers::is_valid_name(name) {
|
|
|
|
|
bail!(
|
|
|
|
|
"{}",
|
|
|
|
|
crate::tr_fmt!(
|
|
|
|
|
"nom de provider invalide '{}' — attendu un slug [a-z0-9_-]",
|
|
|
|
|
name
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if base_url.trim().is_empty() {
|
|
|
|
|
bail!("{}", crate::i18n::tr("usage: providers add <nom> --base-url <url> [--model <m>] [--models m1,m2]"));
|
|
|
|
|
}
|
|
|
|
|
let models_list: Vec<String> = models
|
|
|
|
|
.map(|m| m.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect())
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
if app.dry_run() {
|
|
|
|
|
app.log.dry(&format!(
|
|
|
|
|
"would register provider {name} (base_url={base_url}, default_model={})",
|
|
|
|
|
model.unwrap_or("-")
|
|
|
|
|
));
|
|
|
|
|
return Ok(0);
|
|
|
|
|
}
|
|
|
|
|
let existed = providers::get(&app.config, name).is_some();
|
|
|
|
|
let def = ProviderDef {
|
|
|
|
|
base_url: base_url.trim().to_string(),
|
|
|
|
|
default_model: model.map(|m| m.trim().to_string()).filter(|m| !m.is_empty()),
|
|
|
|
|
models: models_list,
|
|
|
|
|
};
|
|
|
|
|
write_provider(app, name, &def)?;
|
|
|
|
|
app.emit(
|
|
|
|
|
&Event::now(EventKind::Provider)
|
|
|
|
|
.with_args(vec![format!("action={}", if existed { "update" } else { "add" }), format!("provider={name}")]),
|
|
|
|
|
);
|
|
|
|
|
if existed {
|
|
|
|
|
app.log.success(&crate::tr_fmt!("provider '{}' mis à jour", name));
|
|
|
|
|
} else {
|
|
|
|
|
app.log.success(&crate::tr_fmt!("provider '{}' ajouté", name));
|
|
|
|
|
app.log.info(&format!(
|
|
|
|
|
"{}",
|
|
|
|
|
crate::tr_fmt!(
|
|
|
|
|
"positionnez le token dans le keyring: am providers set-token {} (issue #89, à venir)",
|
|
|
|
|
name
|
|
|
|
|
)
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
Ok(0)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn remove(app: &App, name: &str) -> Result<i32> {
|
|
|
|
|
if providers::get(&app.config, name).is_none() {
|
|
|
|
|
bail!(crate::tr_fmt!("provider '{}' inconnu", name));
|
|
|
|
|
}
|
|
|
|
|
if providers::default_name(&app.config) == Some(name) {
|
|
|
|
|
bail!(crate::tr_fmt!(
|
|
|
|
|
"le provider par défaut '{}' ne peut pas être supprimé — changez d'abord le provider par défaut",
|
|
|
|
|
name
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
if app.dry_run() {
|
|
|
|
|
app.log.dry(&format!("would remove provider {name}"));
|
|
|
|
|
return Ok(0);
|
|
|
|
|
}
|
|
|
|
|
let path = crate::commands::config_cmd::active_config_path(app)?;
|
|
|
|
|
let mut root = read_yaml(&path)?;
|
|
|
|
|
remove_path(&mut root, &["settings", "providers", name])?;
|
|
|
|
|
write_yaml(&path, &root)?;
|
|
|
|
|
crate::commands::config_cmd::reload_and_validate(&path)?;
|
|
|
|
|
app.emit(
|
|
|
|
|
&Event::now(EventKind::Provider)
|
|
|
|
|
.with_args(vec!["action=remove".to_string(), format!("provider={name}")]),
|
|
|
|
|
);
|
|
|
|
|
app.log.success(&crate::tr_fmt!("provider '{}' supprimé", name));
|
|
|
|
|
Ok(0)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn set_default(app: &App, name: &str) -> Result<i32> {
|
|
|
|
|
if providers::get(&app.config, name).is_none() {
|
|
|
|
|
bail!(crate::tr_fmt!("provider '{}' inconnu", name));
|
|
|
|
|
}
|
|
|
|
|
if app.dry_run() {
|
|
|
|
|
app.log.dry(&format!("would set the default provider to {name}"));
|
|
|
|
|
return Ok(0);
|
|
|
|
|
}
|
|
|
|
|
crate::commands::config_cmd::persist_setting(app, "settings.default_provider", name)?;
|
|
|
|
|
app.emit(
|
|
|
|
|
&Event::now(EventKind::Provider)
|
|
|
|
|
.with_args(vec!["action=default".to_string(), format!("provider={name}")]),
|
|
|
|
|
);
|
|
|
|
|
app.log.success(&crate::tr_fmt!("provider par défaut : {}", name));
|
|
|
|
|
Ok(0)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Config file edition (settings.providers.<name>)
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
/// Write (or update) one provider entry in the active configuration file.
|
|
|
|
|
fn write_provider(app: &App, name: &str, def: &ProviderDef) -> Result<()> {
|
|
|
|
|
let path = crate::commands::config_cmd::active_config_path(app)?;
|
|
|
|
|
let mut root = read_yaml(&path)?;
|
|
|
|
|
let base = [
|
|
|
|
|
"settings",
|
|
|
|
|
"providers",
|
|
|
|
|
name,
|
|
|
|
|
"base_url",
|
|
|
|
|
];
|
|
|
|
|
set_path(&mut root, &base, serde_yaml::Value::String(def.base_url.clone()))?;
|
|
|
|
|
if let Some(m) = &def.default_model {
|
|
|
|
|
set_path(
|
|
|
|
|
&mut root,
|
|
|
|
|
&["settings", "providers", name, "default_model"],
|
|
|
|
|
serde_yaml::Value::String(m.clone()),
|
|
|
|
|
)?;
|
|
|
|
|
}
|
|
|
|
|
if !def.models.is_empty() {
|
|
|
|
|
let seq = serde_yaml::Value::Sequence(
|
|
|
|
|
def.models.iter().map(|m| serde_yaml::Value::String(m.clone())).collect(),
|
|
|
|
|
);
|
|
|
|
|
set_path(&mut root, &["settings", "providers", name, "models"], seq)?;
|
|
|
|
|
}
|
|
|
|
|
write_yaml(&path, &root)?;
|
|
|
|
|
crate::commands::config_cmd::reload_and_validate(&path)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn read_yaml(path: &Path) -> Result<serde_yaml::Value> {
|
|
|
|
|
let text = std::fs::read_to_string(path)
|
|
|
|
|
.map_err(|e| anyhow!("cannot read {}: {e}", path.display()))?;
|
|
|
|
|
serde_yaml::from_str(&text).map_err(|e| anyhow!("{} is not valid YAML: {e}", path.display()))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn write_yaml(path: &Path, root: &serde_yaml::Value) -> Result<()> {
|
|
|
|
|
std::fs::write(path, serde_yaml::to_string(root)?)
|
|
|
|
|
.map_err(|e| anyhow!("cannot write {}: {e}", path.display()))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Set a value at a dotted path, creating intermediate mappings.
|
|
|
|
|
fn set_path(root: &mut serde_yaml::Value, parts: &[&str], value: serde_yaml::Value) -> Result<()> {
|
|
|
|
|
let mut node = root
|
|
|
|
|
.as_mapping_mut()
|
|
|
|
|
.ok_or_else(|| anyhow!("config file must contain a YAML mapping"))?;
|
|
|
|
|
for part in &parts[..parts.len() - 1] {
|
|
|
|
|
let entry = node
|
|
|
|
|
.entry(serde_yaml::Value::String(part.to_string()))
|
|
|
|
|
.or_insert_with(|| serde_yaml::Value::Mapping(Default::default()));
|
|
|
|
|
node = entry
|
|
|
|
|
.as_mapping_mut()
|
|
|
|
|
.ok_or_else(|| anyhow!("'{part}' is not a mapping — cannot descend into it"))?;
|
|
|
|
|
}
|
|
|
|
|
node.insert(
|
|
|
|
|
serde_yaml::Value::String(parts.last().unwrap().to_string()),
|
|
|
|
|
value,
|
|
|
|
|
);
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Remove the key at a dotted path (missing keys are a no-op).
|
|
|
|
|
fn remove_path(root: &mut serde_yaml::Value, parts: &[&str]) -> Result<()> {
|
|
|
|
|
let mut node = root
|
|
|
|
|
.as_mapping_mut()
|
|
|
|
|
.ok_or_else(|| anyhow!("config file must contain a YAML mapping"))?;
|
|
|
|
|
for part in &parts[..parts.len() - 1] {
|
|
|
|
|
let Some(entry) = node.get_mut(serde_yaml::Value::String(part.to_string())) else {
|
|
|
|
|
return Ok(());
|
|
|
|
|
};
|
|
|
|
|
node = entry
|
|
|
|
|
.as_mapping_mut()
|
|
|
|
|
.ok_or_else(|| anyhow!("'{part}' is not a mapping — cannot descend into it"))?;
|
|
|
|
|
}
|
|
|
|
|
node.remove(serde_yaml::Value::String(parts.last().unwrap().to_string()));
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
use crate::app::App;
|
|
|
|
|
use clap::Parser;
|
|
|
|
|
use std::path::PathBuf;
|
|
|
|
|
|
|
|
|
|
/// App whose active config is a file inside a leaked TempDir (same
|
|
|
|
|
/// pattern as config_cmd's tests).
|
|
|
|
|
fn app_with_config(text: &str) -> (App, PathBuf) {
|
|
|
|
|
let guard = tempfile::tempdir().unwrap();
|
|
|
|
|
let dir = guard.path().to_path_buf();
|
|
|
|
|
std::mem::forget(guard);
|
|
|
|
|
let cfg = dir.join("config.yaml");
|
|
|
|
|
std::fs::write(&cfg, text).unwrap();
|
|
|
|
|
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
|
|
|
|
let mut app = App::from_cli(cli).unwrap();
|
|
|
|
|
let mut p = app.paths.clone();
|
|
|
|
|
p.install_dir = dir.join("agents");
|
|
|
|
|
p.bin_dir = dir.join("agents").join("bin");
|
|
|
|
|
p.log_dir = dir.join("logs");
|
|
|
|
|
p.state_file = dir.join("state.json");
|
|
|
|
|
p.probe_cache_file = dir.join("probe-cache.json");
|
|
|
|
|
p.config_dir = Some(dir.join("config"));
|
|
|
|
|
app.paths = p;
|
|
|
|
|
(app, cfg)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const BASE: &str = "version: \"1.0\"\nagents: []\n";
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn set_path_descends_and_writes_sequences() {
|
|
|
|
|
let mut root: serde_yaml::Value = serde_yaml::from_str(BASE).unwrap();
|
|
|
|
|
set_path(
|
|
|
|
|
&mut root,
|
|
|
|
|
&["settings", "providers", "deepseek", "base_url"],
|
|
|
|
|
serde_yaml::Value::String("https://api.deepseek.com".into()),
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
set_path(
|
|
|
|
|
&mut root,
|
|
|
|
|
&["settings", "providers", "deepseek", "models"],
|
|
|
|
|
serde_yaml::Value::Sequence(vec![serde_yaml::Value::String("deepseek-chat".into())]),
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
assert_eq!(root["settings"]["providers"]["deepseek"]["base_url"], "https://api.deepseek.com");
|
|
|
|
|
assert_eq!(root["settings"]["providers"]["deepseek"]["models"][0], "deepseek-chat");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn remove_path_deletes_the_leaf() {
|
|
|
|
|
let mut root: serde_yaml::Value =
|
|
|
|
|
serde_yaml::from_str("version: \"1.0\"\nsettings:\n providers:\n openai:\n base_url: x\nagents: []\n")
|
|
|
|
|
.unwrap();
|
|
|
|
|
remove_path(&mut root, &["settings", "providers", "openai"]).unwrap();
|
|
|
|
|
assert!(root["settings"]["providers"].get("openai").is_none());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn add_then_reload_exposes_the_provider() {
|
|
|
|
|
let (app, cfg) = app_with_config(BASE);
|
|
|
|
|
add(
|
|
|
|
|
&app,
|
|
|
|
|
"deepseek",
|
|
|
|
|
"https://api.deepseek.com",
|
|
|
|
|
Some("deepseek-chat"),
|
|
|
|
|
Some("deepseek-chat, deepseek-reasoner"),
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
// A fresh load of the same config sees the provider.
|
|
|
|
|
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
|
|
|
|
let reloaded = App::from_cli(cli).unwrap();
|
|
|
|
|
let def = providers::get(&reloaded.config, "deepseek").expect("provider persisted");
|
|
|
|
|
assert_eq!(def.base_url, "https://api.deepseek.com");
|
|
|
|
|
assert_eq!(def.default_model.as_deref(), Some("deepseek-chat"));
|
|
|
|
|
assert_eq!(def.models, vec!["deepseek-chat", "deepseek-reasoner"]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn add_rejects_an_invalid_name_or_missing_url() {
|
|
|
|
|
let (app, _cfg) = app_with_config(BASE);
|
|
|
|
|
assert!(add(&app, "OpenAI", "https://x", None, None).is_err());
|
|
|
|
|
assert!(add(&app, "openai", " ", None, None).is_err());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn remove_and_set_default_flow() {
|
|
|
|
|
// Config mutations apply on the next process (same convention as
|
|
|
|
|
// alias/config set): reload between operations like a real CLI user.
|
|
|
|
|
let (app, cfg) = app_with_config(BASE);
|
|
|
|
|
add(&app, "openai", "https://api.openai.com/v1", Some("gpt-5.2"), None).unwrap();
|
|
|
|
|
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
|
|
|
|
let reloaded = App::from_cli(cli).unwrap();
|
|
|
|
|
set_default(&reloaded, "openai").unwrap();
|
|
|
|
|
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
|
|
|
|
let reloaded = App::from_cli(cli).unwrap();
|
|
|
|
|
assert_eq!(providers::default_name(&reloaded.config), Some("openai"));
|
|
|
|
|
// Cannot remove the default provider.
|
|
|
|
|
assert!(remove(&reloaded, "openai").is_err());
|
|
|
|
|
// Removing an unknown provider is an error too.
|
|
|
|
|
assert!(remove(&reloaded, "nope").is_err());
|
|
|
|
|
// After changing the default, removal works.
|
|
|
|
|
add(&reloaded, "deepseek", "https://api.deepseek.com", None, None).unwrap();
|
|
|
|
|
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
|
|
|
|
let reloaded = App::from_cli(cli).unwrap();
|
|
|
|
|
set_default(&reloaded, "deepseek").unwrap();
|
|
|
|
|
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
|
|
|
|
let reloaded = App::from_cli(cli).unwrap();
|
|
|
|
|
assert!(remove(&reloaded, "openai").is_ok());
|
|
|
|
|
let cli2 = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
|
|
|
|
let reloaded2 = App::from_cli(cli2).unwrap();
|
|
|
|
|
assert!(providers::get(&reloaded2.config, "openai").is_none());
|
|
|
|
|
assert_eq!(providers::default_name(&reloaded2.config), Some("deepseek"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn dry_run_writes_nothing() {
|
|
|
|
|
let guard = tempfile::tempdir().unwrap();
|
|
|
|
|
let dir = guard.path().to_path_buf();
|
|
|
|
|
std::mem::forget(guard);
|
|
|
|
|
let cfg = dir.join("config.yaml");
|
|
|
|
|
std::fs::write(&cfg, BASE).unwrap();
|
|
|
|
|
let cli = crate::cli::Cli::parse_from(["am", "--dry-run", "--config", cfg.to_str().unwrap()]);
|
|
|
|
|
let mut app = App::from_cli(cli).unwrap();
|
|
|
|
|
let mut p = app.paths.clone();
|
|
|
|
|
p.install_dir = dir.join("agents");
|
|
|
|
|
p.bin_dir = dir.join("agents").join("bin");
|
|
|
|
|
p.log_dir = dir.join("logs");
|
|
|
|
|
p.state_file = dir.join("state.json");
|
|
|
|
|
p.probe_cache_file = dir.join("probe-cache.json");
|
|
|
|
|
p.config_dir = Some(dir.join("config"));
|
|
|
|
|
app.paths = p;
|
|
|
|
|
add(&app, "openai", "https://api.openai.com/v1", None, None).unwrap();
|
|
|
|
|
let cli2 = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
|
|
|
|
let reloaded = App::from_cli(cli2).unwrap();
|
|
|
|
|
assert!(providers::get(&reloaded.config, "openai").is_none());
|
|
|
|
|
}
|
|
|
|
|
}
|