diff --git a/src/banner.rs b/src/banner.rs index 5f82edf..948f6ae 100644 --- a/src/banner.rs +++ b/src/banner.rs @@ -3,13 +3,21 @@ use std::path::Path; use crate::consts::{AUTHOR, HOMEPAGE, REPO, format_number}; +use crate::provider::ProviderStatus; use crate::thinker::TokenUsage; +/// Auth status for a single provider in the banner. +pub struct BannerProvider<'a> { + pub display_name: &'a str, + pub auth_status: &'a str, + pub is_active: bool, +} + /// Session configuration for display in the startup banner. pub struct BannerInfo<'a> { pub provider: &'a str, pub model: &'a str, - pub auth_status: &'a str, + pub providers: &'a [ProviderStatus], pub shell_mode: &'a str, pub working_dir: &'a Path, pub memory: &'a str, @@ -17,6 +25,31 @@ pub struct BannerInfo<'a> { /// Print the startup banner with session info. pub fn print_banner(info: &BannerInfo) { + // Build auth lines aligned with other banner fields. + // Field label column is 10 chars ("provider ", "shell ", etc.). + let mut auth_lines = String::new(); + for status in info.providers { + let marker = if status.id == info.provider { + " ← active" + } else { + "" + }; + let label = if auth_lines.is_empty() { + "auth " + } else { + " " + }; + auth_lines.push_str(&format!( + " {label}{} ({}){}\n", + status.display_name, status.auth_status, marker, + )); + } + + // Fallback if no providers + if auth_lines.is_empty() { + auth_lines = " auth N/A\n".to_string(); + } + println!( r#" ╔═══════════════════════════════════════╗ @@ -29,8 +62,7 @@ pub fn print_banner(info: &BannerInfo) { home {} repo {} provider {} ({}) - auth {} - shell {} +{} shell {} workdir {} memory {} "#, @@ -40,7 +72,7 @@ pub fn print_banner(info: &BannerInfo) { REPO, info.provider, info.model, - info.auth_status, + auth_lines, info.shell_mode, info.working_dir.display(), info.memory, @@ -67,15 +99,58 @@ mod tests { #[test] fn print_banner_does_not_panic() { + let statuses = vec![ProviderStatus { + id: "anthropic", + display_name: "Anthropic (Claude Pro/Max)", + auth_status: "OAuth ✓".to_string(), + }]; + let info = BannerInfo { + provider: "anthropic", + model: "claude-sonnet-4-20250514", + providers: &statuses, + shell_mode: "read-only", + working_dir: &PathBuf::from("/tmp/test"), + memory: "ephemeral", + }; + // Just verify it doesn't panic + print_banner(&info); + } + + #[test] + fn print_banner_multiple_providers() { + let statuses = vec![ + ProviderStatus { + id: "anthropic", + display_name: "Anthropic (Claude Pro/Max)", + auth_status: "OAuth ✓".to_string(), + }, + ProviderStatus { + id: "google", + display_name: "Google (Gemini)", + auth_status: "not authenticated".to_string(), + }, + ]; + let info = BannerInfo { + provider: "anthropic", + model: "claude-sonnet-4-20250514", + providers: &statuses, + shell_mode: "read-only", + working_dir: &PathBuf::from("/tmp/test"), + memory: "ephemeral", + }; + print_banner(&info); + } + + #[test] + fn print_banner_no_providers() { let info = BannerInfo { provider: "human", model: "—", - auth_status: "N/A", + providers: &[], shell_mode: "read-only", working_dir: &PathBuf::from("/tmp/test"), memory: "ephemeral", }; - // Just verify it doesn't panic print_banner(&info); } diff --git a/src/commands/login.rs b/src/commands/login.rs index 1ffad59..3285e21 100644 --- a/src/commands/login.rs +++ b/src/commands/login.rs @@ -3,7 +3,7 @@ use tokio::io::AsyncBufReadExt; use super::{Command, CommandResult, SessionInfo, StateChange}; use crate::commands::parse_menu_choice; -use crate::provider::all_login_providers; +use crate::provider::{all_login_providers, provider_auth_status}; pub struct LoginCommand; @@ -63,11 +63,16 @@ impl Command for LoginCommand { match config.login(info.db_path).await { Ok(()) => { println!(" ✓ logged in to {}", config.display_name()); - // Only update REPL auth status if logging into the current provider if config.id() == info.provider { - CommandResult::StateChanged(StateChange::Auth("OAuth ✓".to_string())) + // Same provider — derive actual auth status from storage + let status = provider_auth_status(info.db_path, config.id()); + CommandResult::StateChanged(StateChange::Auth(status)) } else { - CommandResult::Handled + // Different provider — switch to it (use its default model) + CommandResult::StateChanged(StateChange::Provider( + config.id().to_string(), + None, + )) } } Err(e) => { diff --git a/src/commands/logout.rs b/src/commands/logout.rs index 05ea63b..4c3fc2a 100644 --- a/src/commands/logout.rs +++ b/src/commands/logout.rs @@ -2,6 +2,8 @@ use async_trait::async_trait; use super::{Command, CommandResult, SessionInfo, StateChange}; use crate::auth; +use crate::auth::storage::AuthStorage; +use crate::provider::all_login_providers; pub struct LogoutCommand; @@ -22,10 +24,35 @@ impl Command for LogoutCommand { return CommandResult::Handled; } println!(" ✓ logged out from {provider}"); + + // Try to fall back to another authenticated provider + if let Some(fallback_id) = find_authenticated_provider(info.db_path, provider) { + println!(" → switching to {fallback_id}"); + return CommandResult::StateChanged(StateChange::Provider(fallback_id, None)); + } + CommandResult::StateChanged(StateChange::Auth("not authenticated".to_string())) } } +/// Find another provider that is authenticated (stored credentials or env var), skipping `exclude`. +fn find_authenticated_provider(db_path: &str, exclude: &str) -> Option { + let storage = AuthStorage::open(db_path).ok()?; + for config in all_login_providers() { + if config.id() == exclude { + continue; + } + let has_stored = storage.get(config.id()).ok().flatten().is_some(); + let has_env = std::env::var(config.env_var()) + .map(|k| !k.is_empty()) + .unwrap_or(false); + if has_stored || has_env { + return Some(config.id().to_string()); + } + } + None +} + #[cfg(test)] mod tests { use super::*; @@ -34,6 +61,8 @@ mod tests { #[tokio::test] async fn returns_auth_changed_when_no_credentials() { + // With :memory: db, no other provider is authenticated, + // so logout returns Auth (not Provider fallback). assert!(matches!( LogoutCommand.execute(&test_info()).await, CommandResult::StateChanged(StateChange::Auth(_)) @@ -63,4 +92,64 @@ mod tests { // Note: the command opens its own connection to :memory:, // so this tests the command flow, not the same DB instance. } + + #[test] + fn find_authenticated_provider_returns_none_when_empty() { + assert!(find_authenticated_provider(":memory:", "anthropic").is_none()); + } + + #[test] + fn find_authenticated_provider_skips_excluded() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("fallback.db"); + let db_str = db_path.to_str().unwrap(); + + let storage = AuthStorage::open(db_str).unwrap(); + storage + .set( + "anthropic", + Credential::ApiKey { + key: "key".to_string(), + }, + ) + .unwrap(); + drop(storage); + + // Excluding anthropic should return None (only anthropic has creds) + assert!(find_authenticated_provider(db_str, "anthropic").is_none()); + } + + #[test] + fn find_authenticated_provider_finds_other() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("fallback2.db"); + let db_str = db_path.to_str().unwrap(); + + let storage = AuthStorage::open(db_str).unwrap(); + storage + .set( + "anthropic", + Credential::ApiKey { + key: "key-a".to_string(), + }, + ) + .unwrap(); + storage + .set( + "google", + Credential::ApiKey { + key: "key-g".to_string(), + }, + ) + .unwrap(); + drop(storage); + + // Excluding anthropic should find google + let fallback = find_authenticated_provider(db_str, "anthropic"); + assert_eq!(fallback.as_deref(), Some("google")); + + // Excluding google should find anthropic + let fallback = find_authenticated_provider(db_str, "google"); + assert_eq!(fallback.as_deref(), Some("anthropic")); + } } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 778c2e3..70cbc67 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -45,6 +45,10 @@ pub enum StateChange { Auth(String), /// Active model changed (new model ID). Model(String), + /// Active provider changed (provider ID, optional model override). + /// The REPL should rebuild the thinker for this provider. + /// Pass `None` as the model to use the provider's default. + Provider(String, Option), } /// What the REPL should do after a command runs. @@ -328,6 +332,35 @@ mod tests { } } + #[tokio::test] + async fn state_changed_provider_variant_carries_value() { + struct FakeProviderCommand; + + #[async_trait] + impl Command for FakeProviderCommand { + fn name(&self) -> &str { + "/fakeprovider" + } + fn description(&self) -> &str { + "test" + } + async fn execute(&self, _info: &SessionInfo<'_>) -> CommandResult { + CommandResult::StateChanged(StateChange::Provider("google".to_string(), None)) + } + } + + let mut reg = CommandRegistry::new(); + reg.register(Arc::new(FakeProviderCommand)); + + match reg.dispatch("/fakeprovider", &test_info()).await { + CommandResult::StateChanged(StateChange::Provider(id, model)) => { + assert_eq!(id, "google"); + assert!(model.is_none()); + } + other => panic!("expected StateChanged(Provider), got: {other:?}"), + } + } + #[test] fn format_label_no_aliases() { assert_eq!(format_label("/whoami", &[]), "/whoami"); diff --git a/src/commands/model.rs b/src/commands/model.rs index dcd9f4d..47fa035 100644 --- a/src/commands/model.rs +++ b/src/commands/model.rs @@ -2,10 +2,19 @@ use async_trait::async_trait; use tokio::io::AsyncBufReadExt; use super::{Command, CommandResult, SessionInfo, StateChange}; +use crate::auth::storage::AuthStorage; use crate::commands::parse_menu_choice; +use crate::provider::{all_login_providers, build_provider_by_id}; +use crate::thinker::ModelInfo; pub struct ModelCommand; +/// A model entry in the combined list, tracking which provider it belongs to. +struct ModelEntry { + provider_id: String, + model: ModelInfo, +} + #[async_trait] impl Command for ModelCommand { fn name(&self) -> &str { @@ -25,32 +34,96 @@ impl Command for ModelCommand { } }; - let models = match engine.models().await { - Ok(m) => m, + // Collect models from all authenticated providers. + // For the active provider, use the engine's thinker. + // For others, build temporary thinkers. + let mut all_entries: Vec = Vec::new(); + let mut provider_order: Vec = Vec::new(); + + // Active provider first + match engine.models().await { + Ok(models) => { + if !models.is_empty() { + provider_order.push(info.provider.to_string()); + for model in models { + all_entries.push(ModelEntry { + provider_id: info.provider.to_string(), + model, + }); + } + } + } Err(e) => { - eprintln!(" ✗ failed to fetch models: {e}"); - return CommandResult::Handled; + eprintln!(" ✗ failed to fetch {} models: {e}", info.provider); } - }; + } - if models.is_empty() { - println!(" no models available for {}", info.provider); + // Other authenticated providers + let other_providers = find_other_authenticated_providers(info.db_path, info.provider); + for provider_id in &other_providers { + let setup = + match build_provider_by_id(provider_id, info.db_path, None, info.debug.clone()) { + Ok(s) => s, + Err(e) => { + eprintln!(" ✗ failed to load {provider_id}: {e}"); + continue; + } + }; + match setup.thinker.models().await { + Ok(models) if !models.is_empty() => { + provider_order.push(provider_id.clone()); + for model in models { + all_entries.push(ModelEntry { + provider_id: provider_id.clone(), + model, + }); + } + } + Ok(_) => {} // empty — skip silently + Err(e) => { + eprintln!(" ✗ failed to fetch {provider_id} models: {e}"); + } + } + } + + if all_entries.is_empty() { + println!(" no models available"); return CommandResult::Handled; } - let current = info.model; + let current_model = info.model; + let current_provider = info.provider; - // Find the current model's index (1-based) for the default - let current_idx = models.iter().position(|m| m.id == current).map(|i| i + 1); + // Find the current model's flat index (1-based) for the default + let current_idx = all_entries + .iter() + .position(|e| e.model.id == current_model && e.provider_id == current_provider) + .map(|i| i + 1); - println!(" Available models for {}:\n", info.provider); - for (i, model) in models.iter().enumerate() { - let marker = if model.id == current { - " ← current" + // Display models grouped by provider + let mut flat_idx = 0; + for provider_id in &provider_order { + let display_name = provider_display_name(provider_id); + let active = if provider_id == current_provider { + " ← active" } else { "" }; - println!(" {}. {}{}", i + 1, model.display_name, marker); + println!("\n {display_name}{active}:"); + + for entry in &all_entries { + if entry.provider_id != *provider_id { + continue; + } + flat_idx += 1; + let marker = + if entry.model.id == current_model && entry.provider_id == current_provider { + " ← current" + } else { + "" + }; + println!(" {flat_idx:>3}. {}{marker}", entry.model.display_name); + } } // Prompt with default @@ -80,7 +153,7 @@ impl Command for ModelCommand { return CommandResult::Handled; } - let choice = match parse_menu_choice(&input, models.len()) { + let choice = match parse_menu_choice(&input, all_entries.len()) { Some(n) => n, None => { eprintln!(" ✗ invalid selection: {input}"); @@ -88,15 +161,60 @@ impl Command for ModelCommand { } }; - let selected = &models[choice - 1]; + let selected = &all_entries[choice - 1]; - if selected.id == current { - println!(" already using {}", selected.display_name); + if selected.model.id == current_model && selected.provider_id == current_provider { + println!(" already using {}", selected.model.display_name); return CommandResult::Handled; } - println!(" ✓ model changed to {}", selected.display_name); - CommandResult::StateChanged(StateChange::Model(selected.id.clone())) + if selected.provider_id == current_provider { + // Same provider — just change the model + println!(" ✓ model changed to {}", selected.model.display_name); + CommandResult::StateChanged(StateChange::Model(selected.model.id.clone())) + } else { + // Different provider — switch provider and model + println!( + " ✓ switched to {} ({})", + selected.model.display_name, + provider_display_name(&selected.provider_id) + ); + CommandResult::StateChanged(StateChange::Provider( + selected.provider_id.clone(), + Some(selected.model.id.clone()), + )) + } + } +} + +/// Find providers (other than `exclude`) that have stored credentials or env vars. +fn find_other_authenticated_providers(db_path: &str, exclude: &str) -> Vec { + let storage = match AuthStorage::open(db_path) { + Ok(s) => s, + Err(_) => return Vec::new(), + }; + let mut result = Vec::new(); + for config in all_login_providers() { + if config.id() == exclude { + continue; + } + let has_stored = storage.get(config.id()).ok().flatten().is_some(); + let has_env = std::env::var(config.env_var()) + .map(|k| !k.is_empty()) + .unwrap_or(false); + if has_stored || has_env { + result.push(config.id().to_string()); + } + } + result +} + +/// Get a human-readable display name for a provider id. +fn provider_display_name(id: &str) -> &str { + match id { + "anthropic" => "Anthropic (Claude)", + "google" => "Google (Gemini)", + _ => id, } } @@ -118,4 +236,21 @@ mod tests { let result = ModelCommand.execute(&info).await; assert!(matches!(result, CommandResult::Handled)); } + + #[test] + fn provider_display_name_known() { + assert_eq!(provider_display_name("anthropic"), "Anthropic (Claude)"); + assert_eq!(provider_display_name("google"), "Google (Gemini)"); + } + + #[test] + fn provider_display_name_unknown_returns_id() { + assert_eq!(provider_display_name("unknown"), "unknown"); + } + + #[test] + fn find_other_authenticated_empty_db() { + let result = find_other_authenticated_providers(":memory:", "anthropic"); + assert!(result.is_empty()); + } } diff --git a/src/main.rs b/src/main.rs index dcee983..1e10eda 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,7 +15,10 @@ use golem::debug::DebugMode; use golem::engine::Engine; use golem::engine::react::{ReactConfig, ReactEngine}; use golem::memory::sqlite::SqliteMemory; -use golem::provider::{LoginProvider, Provider, build_provider, handle_login, handle_logout}; +use golem::provider::{ + LoginProvider, Provider, all_provider_statuses, build_provider, build_provider_by_id, + handle_login, handle_logout, +}; use golem::tools::ToolRegistry; use golem::tools::exit::ExitTool; use golem::tools::shell::{ShellConfig, ShellMode, ShellTool}; @@ -111,7 +114,7 @@ async fn main() -> anyhow::Result<()> { // Wire up debug mode and provider let debug = DebugMode::new(cli.debug); let setup = build_provider(&cli.provider, &db_path, cli.model.clone(), debug.clone())?; - let provider_name = setup.name; + let mut provider_name = setup.name; let mut model_name = setup.model; let mut auth_status = setup.auth_status; @@ -143,10 +146,11 @@ async fn main() -> anyhow::Result<()> { "read-only" }; + let provider_statuses = all_provider_statuses(&db_path); print_banner(&BannerInfo { provider: provider_name, model: &model_name, - auth_status: &auth_status, + providers: &provider_statuses, shell_mode: shell_label, working_dir: &working_dir, memory: &memory_label, @@ -261,6 +265,23 @@ async fn main() -> anyhow::Result<()> { } model_name = new_model; } + StateChange::Provider(new_id, model_override) => { + match build_provider_by_id(&new_id, &db_path, model_override, debug.clone()) + { + Ok(new_setup) => { + engine.set_thinker(new_setup.thinker).await; + provider_name = new_setup.name; + model_name = new_setup.model.clone(); + auth_status = new_setup.auth_status; + if let Err(e) = app_config.set("model", &new_setup.model) { + eprintln!(" warning: failed to persist model preference: {e}"); + } + } + Err(e) => { + eprintln!(" ✗ failed to switch provider: {e}"); + } + } + } } continue; } diff --git a/src/provider.rs b/src/provider.rs index 26dfa11..c692669 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -255,6 +255,54 @@ pub struct ProviderSetup { pub auth_status: String, } +/// Auth status for a single provider, used in the startup banner. +pub struct ProviderStatus { + pub id: &'static str, + pub display_name: &'static str, + pub auth_status: String, +} + +/// Get auth status for all configurable providers. +pub fn all_provider_statuses(db_path: &str) -> Vec { + let auth = match AuthStorage::open(db_path) { + Ok(a) => a, + Err(_) => return Vec::new(), + }; + all_login_providers() + .into_iter() + .map(|config| { + let status = check_auth_status(&auth, config.as_ref()); + ProviderStatus { + id: config.id(), + display_name: config.display_name(), + auth_status: status, + } + }) + .collect() +} + +/// Get the auth status string for a single provider (e.g. `"OAuth ✓"`, `"not authenticated"`). +pub fn provider_auth_status(db_path: &str, provider_id: &str) -> String { + let Some(config) = provider_config_by_id(provider_id) else { + return "not authenticated".to_string(); + }; + let Ok(auth) = AuthStorage::open(db_path) else { + return "not authenticated".to_string(); + }; + check_auth_status(&auth, config.as_ref()) +} + +/// Returns true if the provider has stored credentials or an env var set. +pub fn is_authenticated(db_path: &str, provider_id: &str) -> bool { + let Some(config) = provider_config_by_id(provider_id) else { + return false; + }; + let Ok(auth) = AuthStorage::open(db_path) else { + return false; + }; + check_auth_status(&auth, config.as_ref()) != "not authenticated" +} + /// Check auth status for a provider: stored credential → env var → not authenticated. fn check_auth_status(auth: &AuthStorage, config: &dyn ProviderConfig) -> String { match auth.get(config.id()) { @@ -282,6 +330,31 @@ fn resolve_model(cli_model: Option, db_path: &str) -> Option { }) } +/// Build the thinker, auth status, and model for a provider identified by string id. +/// Pass `model: None` to use the provider's default model. +/// Used when switching providers at runtime (e.g. after `/login` or `/model`). +pub fn build_provider_by_id( + provider_id: &str, + db_path: &str, + model: Option, + debug: DebugMode, +) -> Result { + let config = provider_config_by_id(provider_id) + .ok_or_else(|| anyhow::anyhow!("unknown provider: {provider_id}"))?; + + let auth = AuthStorage::open(db_path)?; + let auth_status = check_auth_status(&auth, config.as_ref()); + let thinker = config.build_thinker(model, auth, debug); + let display = thinker.model().to_string(); + + Ok(ProviderSetup { + thinker, + name: config.id(), + model: display, + auth_status, + }) +} + /// Build the thinker, auth status, and model for the selected provider. pub fn build_provider( provider: &Provider, @@ -442,4 +515,84 @@ mod tests { assert!(provider_config_by_id("unknown").is_none()); assert!(provider_config_by_id("human").is_none()); } + + #[test] + fn all_provider_statuses_returns_all_providers() { + let statuses = all_provider_statuses(":memory:"); + let ids: Vec<&str> = statuses.iter().map(|s| s.id).collect(); + assert!(ids.contains(&"anthropic")); + assert!(ids.contains(&"google")); + } + + #[test] + fn all_provider_statuses_includes_display_names() { + for status in all_provider_statuses(":memory:") { + assert!(!status.display_name.is_empty()); + assert!(!status.auth_status.is_empty()); + } + } + + #[test] + fn provider_auth_status_not_authenticated() { + assert_eq!( + provider_auth_status(":memory:", "anthropic"), + "not authenticated" + ); + } + + #[test] + fn provider_auth_status_with_credentials() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("auth-status.db"); + let db_str = db_path.to_str().unwrap(); + + let storage = AuthStorage::open(db_str).unwrap(); + storage + .set( + "anthropic", + Credential::ApiKey { + key: "test".to_string(), + }, + ) + .unwrap(); + drop(storage); + + assert_eq!(provider_auth_status(db_str, "anthropic"), "API key ✓"); + } + + #[test] + fn provider_auth_status_unknown_provider() { + assert_eq!( + provider_auth_status(":memory:", "unknown"), + "not authenticated" + ); + } + + #[test] + fn is_authenticated_false_without_credentials() { + assert!(!is_authenticated(":memory:", "anthropic")); + assert!(!is_authenticated(":memory:", "google")); + assert!(!is_authenticated(":memory:", "unknown")); + } + + #[test] + fn is_authenticated_true_with_credentials() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("auth-check.db"); + let db_str = db_path.to_str().unwrap(); + + let storage = AuthStorage::open(db_str).unwrap(); + storage + .set( + "anthropic", + Credential::ApiKey { + key: "test".to_string(), + }, + ) + .unwrap(); + drop(storage); + + assert!(is_authenticated(db_str, "anthropic")); + assert!(!is_authenticated(db_str, "google")); + } } diff --git a/tests/login_test.rs b/tests/login_test.rs index 90e91ff..a0039c1 100644 --- a/tests/login_test.rs +++ b/tests/login_test.rs @@ -3,7 +3,9 @@ use golem::auth::storage::{AuthStorage, Credential}; use golem::commands::{CommandRegistry, CommandResult, SessionInfo}; use golem::config::Config; use golem::debug::DebugMode; -use golem::provider::{Provider, all_login_providers, build_provider, provider_config_by_id}; +use golem::provider::{ + Provider, all_login_providers, build_provider, build_provider_by_id, provider_config_by_id, +}; use golem::thinker::TokenUsage; fn test_info(provider: &str) -> SessionInfo<'_> { @@ -341,3 +343,90 @@ fn logout_one_provider_preserves_others() { ); } } + +// ── build_provider_by_id ────────────────────────────────────────── + +#[test] +fn build_provider_by_id_returns_anthropic() { + let setup = build_provider_by_id("anthropic", ":memory:", None, DebugMode::default()).unwrap(); + assert_eq!(setup.name, "anthropic"); + assert!(!setup.model.is_empty()); +} + +#[test] +fn build_provider_by_id_returns_google() { + let setup = build_provider_by_id("google", ":memory:", None, DebugMode::default()).unwrap(); + assert_eq!(setup.name, "google"); + assert!(!setup.model.is_empty()); +} + +#[test] +fn build_provider_by_id_unknown_returns_error() { + let result = build_provider_by_id("unknown", ":memory:", None, DebugMode::default()); + assert!(result.is_err()); + let err = result.err().unwrap(); + assert!(err.to_string().contains("unknown")); +} + +#[test] +fn build_provider_by_id_human_returns_error() { + // "human" has no ProviderConfig, so it should fail + let result = build_provider_by_id("human", ":memory:", None, DebugMode::default()); + assert!(result.is_err()); +} + +#[test] +fn build_provider_by_id_uses_default_model_not_config_db() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("by-id-model.db"); + let db_str = db_path.to_str().unwrap(); + + // Store a model in config DB + let config = Config::open(db_str).unwrap(); + config.set("model", "persisted-model").unwrap(); + drop(config); + + // build_provider_by_id should ignore the config DB and use the provider's default + let setup = build_provider_by_id("anthropic", db_str, None, DebugMode::default()).unwrap(); + assert_ne!( + setup.model, "persisted-model", + "build_provider_by_id should use provider default, not config DB" + ); +} + +#[test] +fn build_provider_by_id_detects_auth_status() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("by-id-auth.db"); + let db_str = db_path.to_str().unwrap(); + + let storage = AuthStorage::open(db_str).unwrap(); + storage + .set( + "google", + Credential::OAuth(OAuthCredentials { + access: "token".to_string(), + refresh: "refresh".to_string(), + expires: u64::MAX, + client_hint: None, + }), + ) + .unwrap(); + drop(storage); + + let setup = build_provider_by_id("google", db_str, None, DebugMode::default()).unwrap(); + assert_eq!(setup.auth_status, "OAuth ✓"); +} + +#[test] +fn build_provider_by_id_with_model_override() { + let setup = build_provider_by_id( + "anthropic", + ":memory:", + Some("custom-model".to_string()), + DebugMode::default(), + ) + .unwrap(); + assert_eq!(setup.model, "custom-model"); + assert_eq!(setup.name, "anthropic"); +}