//! Runtime assembly: configuration to agent (`SMH-SPEC-SPEC0001`, //! Providers). //! //! One shape serves eval and RPC. Mock providers run scripted responses for //! offline use; real providers post through the vendor transport with //! credentials resolved from the environment. use smith::config::Config; use smith::error::Result; use smith::provider::RecordSink; use smith::provider::StreamFn; use smith_ai::auth::AuthStore; use smith_ai::models::ProviderKind; use smith_ai::transport::{VendorEndpoint, transport}; use smith_core::agent::Agent; use smith_core::store::open_session; use smith_core::tools::ToolSession; use std::path::{Path, PathBuf}; /// Which provider a runtime uses. #[derive(Clone, Debug, PartialEq, Eq)] pub enum RuntimeProvider { /// Scripted responses; `smh-mock` in the catalog. Mock(Vec>), /// OpenAI-compatible endpoint. OpenAiCompatible { /// Base URL, for example `https://api.openai.com/v1`. base_url: String, /// Model id. model: String, }, /// Anthropic Messages endpoint. Anthropic { /// Base URL. base_url: String, /// Model id. model: String, }, /// Google Gemini endpoint. Google { /// Base URL. base_url: String, /// Model id. model: String, }, } impl RuntimeProvider { /// The catalog model id this provider serves. #[must_use] pub fn model(&self) -> &str { match self { Self::Mock(_) => "mock-smith", Self::OpenAiCompatible { model, .. } | Self::Anthropic { model, .. } | Self::Google { model, .. } => model, } } const fn vendor_kind(&self) -> ProviderKind { match self { Self::Mock(_) | Self::OpenAiCompatible { .. } => ProviderKind::OpenAiCompatible, Self::Anthropic { .. } => ProviderKind::Anthropic, Self::Google { .. } => ProviderKind::Google, } } } /// Everything needed to assemble a runtime. #[derive(Clone, Debug)] pub struct RuntimeConfig { /// Provider selection. pub provider: RuntimeProvider, /// Working directory for tool effects. pub workdir: PathBuf, /// Session file path; created when missing. pub session_path: PathBuf, /// Explicit credential override; the highest-precedence source. pub api_key: Option, /// Credential store directory; the binary passes the XDG default, tests /// an isolated one so a developer's own credentials never leak in. pub auth_dir: PathBuf, } impl RuntimeConfig { /// A mock runtime under `workdir` with a fresh session file. #[must_use] pub fn mock(workdir: impl Into, script: Vec>) -> Self { let workdir = workdir.into(); let session_path = workdir.join("session.smh"); // An auth store beside the session keeps mock runtimes isolated. let auth_dir = workdir.join("auth"); Self { provider: RuntimeProvider::Mock(script), workdir, session_path, api_key: None, auth_dir, } } } impl RuntimeProvider { /// A real provider endpoint with an explicit model; the base URL falls /// back to the vendor default. #[must_use] pub fn real(kind: ProviderKind, base_url: Option, model: String) -> Self { let base_url = base_url.unwrap_or_else(|| smith_ai::auth::default_base_url(kind).to_string()); match kind { ProviderKind::OpenAiCompatible | ProviderKind::Plugin => { Self::OpenAiCompatible { base_url, model } } ProviderKind::Anthropic => Self::Anthropic { base_url, model }, ProviderKind::Google => Self::Google { base_url, model }, } } } /// Assemble an agent from a configuration, opening or creating its session. /// /// # Errors /// /// Returns configuration, session, or credential-resolution failures; a /// missing credential for a real provider fails here, before any dispatch. pub fn build_agent(config: &RuntimeConfig) -> Result { let smith_config = Config::default_valid(); let opened = open_session(Path::new(&config.session_path), smith_config)?; let tools = ToolSession::with_session(config.workdir.clone(), opened.session, opened.writer); let (sink, drain) = smith::provider::record_channel(); let stream = resolve_stream( &config.provider, config.api_key.clone(), &AuthStore::at(&config.auth_dir), sink, )?; Ok(Agent::with_provider_records( stream, tools, config.provider.model().to_string(), Some(drain), )) } fn resolve_stream( provider: &RuntimeProvider, api_key: Option, store: &AuthStore, records: RecordSink, ) -> Result { match provider { RuntimeProvider::Mock(script) => Ok(crate::mock_stream(script.clone())), RuntimeProvider::OpenAiCompatible { base_url, model } | RuntimeProvider::Anthropic { base_url, model } | RuntimeProvider::Google { base_url, model } => { let kind = provider.vendor_kind(); let api_key = match api_key { Some(explicit) => explicit, None => resolve_credential(store, kind)?, }; Ok(transport(VendorEndpoint { kind, base_url: base_url.clone(), model: model.clone(), api_key, executor: crate::http::default_executor(), records, })) } } } /// Credentials resolve `store` first, then the provider environment /// variable; a miss names both sources and fails before dispatch. /// /// # Errors /// /// Returns an authentication fault naming the provider and every source /// tried, without network access. fn resolve_credential(store: &AuthStore, kind: ProviderKind) -> Result { match store.load(kind) { Ok(Some(secret)) => Ok(secret), Ok(None) => { let var = smith_ai::auth::credential_env_var(kind); match std::env::var(var) { Ok(value) if !value.is_empty() => Ok(value), _ => Err(smith::error::SmithError::Provider { fault: smith::error::ProviderFault::Authentication { message: format!( "no credential for {} (tried store, env {var})", kind.slug() ), }, }), } } Err(error) => Err(error), } } #[cfg(test)] mod tests { use super::*; use crate::mock_text_reply; #[test] fn mock_runtime_completes_and_persists_a_turn() { let dir = std::env::temp_dir().join(format!("smith_harness_{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap_or(()); let config = RuntimeConfig::mock(dir.join("work"), vec![mock_text_reply("assembled")]); let mut agent = build_agent(&config).unwrap(); let outcome = agent.run_turn("hello").unwrap(); assert_eq!(outcome.text, "assembled"); drop(agent); let _ = std::fs::remove_dir_all(&dir); } #[test] fn real_providers_fail_fast_without_credentials() { let dir = std::env::temp_dir().join(format!("smith_harness_auth_{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap_or(()); // The credential check reads the environment; a missing variable // is the normal fresh-runner state, so the test does not mutate it. let config = RuntimeConfig { provider: RuntimeProvider::OpenAiCompatible { base_url: "https://example.test/v1".to_string(), model: "gpt-5.1".to_string(), }, api_key: None, ..RuntimeConfig::mock(&dir, Vec::new()) }; let Err(err) = build_agent(&config) else { unreachable!("no credential must fail assembly"); }; assert_eq!(err.code(), "PROVIDER_AUTH"); let _ = std::fs::remove_dir_all(&dir); } #[test] fn stored_credential_resolves_without_network() { let home = std::env::temp_dir().join(format!("smith_harness_store_{}", std::process::id())); let _ = std::fs::remove_dir_all(&home); let store = AuthStore::at(&home); store .save(smith_ai::models::ProviderKind::Anthropic, "sk-from-store") .unwrap_or(()); assert_eq!( resolve_credential(&store, smith_ai::models::ProviderKind::Anthropic) .unwrap_or_default(), "sk-from-store" ); let _ = std::fs::remove_dir_all(&home); } #[test] fn real_provider_defaults_base_url_per_vendor() { let anthropic = RuntimeProvider::real( smith_ai::models::ProviderKind::Anthropic, None, "m".to_string(), ); let RuntimeProvider::Anthropic { base_url, model } = anthropic else { unreachable!("anthropic kind builds the anthropic endpoint"); }; assert_eq!(base_url, "https://api.anthropic.com"); assert_eq!(model, "m"); let overridden = RuntimeProvider::real( smith_ai::models::ProviderKind::Google, Some("https://proxy.test".to_string()), "m".to_string(), ); let RuntimeProvider::Google { base_url, .. } = overridden else { unreachable!("google kind builds the google endpoint"); }; assert_eq!(base_url, "https://proxy.test"); } }