Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
1 line
30 KiB
JSON
1 line
30 KiB
JSON
{"repo": "aristocrat71/odyn", "files": {"crates/odyn-core/src/config.rs": {"000cd983054700397a0a118d3093ecafc52cfac8": {"t": 1786068333.7544093, "n": 28948, "h": "4ac96263c156747e", "c": "//! `odyn.toml`: which providers exist, how much of the brain to inject, and how\n//! Spotlight behaves.\n//!\n//! API keys come from `api_key` in the file, or — for a file that must never\n//! contain one — from the environment variable `api_key_env` names. Either is\n//! read only when that provider is actually built.\n\nuse std::collections::BTreeMap;\nuse std::path::{Path, PathBuf};\n\nuse crate::brevity::Brevity;\nuse crate::chat::ChatProvider;\nuse crate::providers::ollama::OllamaProvider;\nuse crate::providers::openai_compat::{OpenAiCompatProvider, ProviderInitError};\n\nconst CONFIG_FILE_NAME: &str = \"odyn.toml\";\nconst CONFIG_PATH_ENV: &str = \"ODYN_CONFIG\";\nconst DEFAULT_OLLAMA_BASE_URL: &str = \"http://localhost:11434\";\n\n/// Written on first run and then parsed as the loaded configuration, so the\n/// file on disk and the running configuration can never disagree.\nconst DEFAULT_CONFIG: &str = r#\"# Odyn configuration.\n#\n# Keys: put the key itself in `api_key`, or point `api_key_env` at an\n# environment variable holding it — then the key never lives in this file.\n\ndefault_provider = \"ollama\"\n\n# Local models, no key required.\n[providers.ollama]\nkind = \"ollama\"\nbase_url = \"http://localhost:11434\"\nkeep_alive = \"5m\" # how long a model stays in RAM; \"0\" unloads it immediately\n\n# Any number of OpenAI-compatible endpoints can be added. Uncomment one, give\n# it a key, and point `default_provider` at it.\n#\n# [providers.deepseek]\n# kind = \"openai_compat\"\n# base_url = \"https://api.deepseek.com\"\n# api_key_env = \"DEEPSEEK_API_KEY\"\n# default_model = \"deepseek-chat\"\n#\n# [providers.zen]\n# kind = \"openai_compat\"\n# base_url = \"https://opencode.ai/zen/v1\"\n# api_key = \"your key\"\n\n[memory]\ncore_budget_tokens = 500\nepisodic_top_k = 6\nepisodic_cap_tokens = 900\nsimilarity_edge_threshold = 0.78\n\n[style]\nbrevity = \"off\" # off | lite | full | ultra — default for new conversations\n\n[spotlight]\nhotkey = \"Alt+Space\" # Option+Space on macOS, Alt+Space elsewhere\nbrevity = \"full\" # spotlight answers should be terse\n# provider = \"ollama\" # falls back to default_provider when unset\n# model = \"llama3.3:8b\"\n\"#;\n\n#[derive(Debug, thiserror::Error)]\npub enum ConfigError {\n #[error(\"could not read {}: {source}\", path.display())]\n Read {\n path: PathBuf,\n source: std::io::Error,\n },\n #[error(\"could not create {}: {source}\", path.display())]\n Directory {\n path: PathBuf,\n source: std::io::Error,\n },\n #[error(\"could not write {}: {source}\", path.display())]\n Write {\n path: PathBuf,\n source: std::io::Error,\n },\n #[error(\"could not locate a config directory; set {CONFIG_PATH_ENV} to a config file path\")]\n NoConfigDir,\n /// toml's own message, which names the offending key and quotes its line.\n #[error(\"invalid config: {0}\")]\n Parse(String),\n #[error(\"invalid config: {key}: {message}\")]\n Invalid { key: String, message: String },\n #[error(\"no such key: {0}\")]\n UnknownKey(String),\n #[error(\"no provider named `{0}` is configured\")]\n UnknownProvider(String),\n #[error(\"providers.{name}.api_key_env: environment variable `{var}` is empty or not set\")]\n MissingApiKey { name: String, var: String },\n /// Deliberately silent about the value: what sits in a key field wrongly\n /// is, more often than not, the key.\n #[error(\"providers.{name}.api_key_env: must name an environment variable (letters, digits, underscores) — a literal key belongs in `api_key`\")]\n BadKeyEnvName { name: String },\n #[error(\"providers.{name}: {source}\")]\n Provider {\n name: String,\n source: ProviderInitError,\n },\n}\n\n#[derive(Debug, Clone, PartialEq, serde::Deserialize)]\n#[serde(deny_unknown_fields)]\npub struct Config {\n pub default_provider: String,\n #[serde(default)]\n pub providers: BTreeMap<String, ProviderConfig>,\n #[serde(default)]\n pub memory: MemoryConfig,\n #[serde(default)]\n pub style: StyleConfig,\n #[serde(default)]\n pub spotlight: SpotlightConfig,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]\n#[serde(tag = \"kind\", deny_unknown_fields)]\npub enum ProviderConfig {\n #[serde(rename = \"openai_compat\")]\n OpenAiCompat {\n base_url: String,\n /// The key itself. Set from the providers view; wins over `api_key_env`.\n api_key: Option<String>,\n api_key_env: Option<String>,\n default_model: Option<String>,\n },\n #[serde(rename = \"ollama\")]\n Ollama {\n #[serde(default = \"default_ollama_base_url\")]\n base_url: String,\n keep_alive: Option<String>,\n },\n}\n\n#[derive(Debug, Clone, PartialEq, serde::Deserialize)]\n#[serde(deny_unknown_fields, default)]\npub struct MemoryConfig {\n pub core_budget_tokens: u32,\n pub episodic_top_k: u32,\n pub episodic_cap_tokens: u32,\n pub similarity_edge_threshold: f32,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize)]\n#[serde(deny_unknown_fields, default)]\npub struct StyleConfig {\n /// The brevity every conversation starts with, unless it chose its own.\n pub brevity: Brevity,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]\n#[serde(deny_unknown_fields, default)]\npub struct SpotlightConfig {\n pub hotkey: String,\n pub provider: Option<String>,\n pub model: Option<String>,\n pub brevity: Brevity,\n}\n\nimpl Default for MemoryConfig {\n fn default() -> Self {\n Self {\n core_budget_tokens: 500,\n episodic_top_k: 6,\n episodic_cap_tokens: 900,\n similarity_edge_threshold: 0.78,\n }\n }\n}\n\nimpl Default for SpotlightConfig {\n fn default() -> Self {\n Self {\n // `Alt` is Option on macOS and Alt everywhere else, so one\n // accelerator is the same physical chord on every platform.\n hotkey: \"Alt+Space\".to_string(),\n provider: None,\n model: None,\n brevity: Brevity::Full,\n }\n }\n}\n\nimpl ProviderConfig {\n /// The `kind` value from the file, so callers can label a provider without\n /// matching on this enum.\n pub fn kind(&self) -> &'static str {\n match self {\n Self::OpenAiCompat { .. } => \"openai_compat\",\n Self::Ollama { .. } => \"ollama\",\n }\n }\n\n pub fn base_url(&self) -> &str {\n match self {\n Self::OpenAiCompat { base_url, .. } | Self::Ollama { base_url, .. } => base_url,\n }\n }\n}\n\nimpl Config {\n /// Loads `ODYN_CONFIG`, or `odyn.toml` in the platform config directory.\n pub fn load() -> Result<Self, ConfigError> {\n Self::load_from(config_path()?)\n }\n\n pub fn load_from(path: impl AsRef<Path>) -> Result<Self, ConfigError> {\n Self::parse(&read_or_create(path.as_ref())?)\n }\n\n pub fn parse(text: &str) -> Result<Self, ConfigError> {\n let config: Self =\n toml::from_str(text).map_err(|err| ConfigError::Parse(err.to_string()))?;\n config.validate()?;\n Ok(config)\n }\n\n /// The model a new conversation starts on for `provider`. Only\n /// `openai_compat` entries declare one; Ollama has no such notion, so the\n /// answer there is `None` until a model is chosen.\n pub fn default_model(&self, provider: &str) -> Option<&str> {\n match self.providers.get(provider) {\n Some(ProviderConfig::OpenAiCompat { default_model, .. }) => default_model.as_deref(),\n _ => None,\n }\n }\n\n fn validate(&self) -> Result<(), ConfigError> {\n if !self.providers.contains_key(&self.default_provider) {\n return Err(unconfigured(\"default_provider\", &self.default_provider));\n }\n if let Some(name) = &self.spotlight.provider {\n if !self.providers.contains_key(name) {\n return Err(unconfigured(\"spotlight.provider\", name));\n }\n }\n // One provider's mistakes stay that provider's: they surface from\n // `ProviderRegistry::provider` when it is used, so a broken zen entry\n // never stops Odyn from talking to a local Ollama.\n if self.memory.episodic_top_k == 0 {\n return Err(invalid(\"memory.episodic_top_k\", \"must be at least 1\"));\n }\n let threshold = self.memory.similarity_edge_threshold;\n if !(threshold > 0.0 && threshold <= 1.0) {\n return Err(invalid(\n \"memory.similarity_edge_threshold\",\n \"must be greater than 0 and at most 1\",\n ));\n }\n Ok(())\n }\n}\n\n/// Providers are built on demand: an unset `DEEPSEEK_API_KEY` must not stop\n/// Odyn from talking to a local Ollama.\n#[derive(Debug, Clone)]\npub struct ProviderRegistry {\n default_provider: String,\n providers: BTreeMap<String, ProviderConfig>,\n}\n\nimpl ProviderRegistry {\n pub fn from_config(config: &Config) -> Result<Self, ConfigError> {\n config.validate()?;\n Ok(Self {\n default_provider: config.default_provider.clone(),\n providers: config.providers.clone(),\n })\n }\n\n pub fn default_provider_name(&self) -> &str {\n &self.default_provider\n }\n\n /// Sorted, so menus and `--help` output never reshuffle.\n pub fn names(&self) -> Vec<&str> {\n self.providers.keys().map(String::as_str).collect()\n }\n\n pub fn kind(&self, name: &str) -> Option<&'static str> {\n self.providers.get(name).map(ProviderConfig::kind)\n }\n\n pub fn provider(&self, name: &str) -> Result<Box<dyn ChatProvider>, ConfigError> {\n let config = self\n .providers\n .get(name)\n .ok_or_else(|| ConfigError::UnknownProvider(name.to_string()))?;\n let built = |source| ConfigError::Provider {\n name: name.to_string(),\n source,\n };\n match config {\n ProviderConfig::OpenAiCompat {\n base_url,\n api_key,\n api_key_env,\n ..\n } => {\n // A literal `api_key` wins; `api_key_env` is read otherwise.\n // Neither means no auth header, which is what keyless\n // endpoints expect.\n let api_key = match api_key.as_deref().map(str::trim) {\n Some(key) if !key.is_empty() => Some(key.to_string()),\n _ => api_key_env\n .as_deref()\n .map(|var| read_api_key(name, var))\n .transpose()?,\n };\n let provider =\n OpenAiCompatProvider::new(base_url, api_key, Vec::new()).map_err(built)?;\n Ok(Box::new(provider))\n }\n ProviderConfig::Ollama {\n base_url,\n keep_alive,\n } => {\n let provider = OllamaProvider::new(base_url, keep_alive.clone()).map_err(built)?;\n Ok(Box::new(provider))\n }\n }\n }\n}\n\nfn read_api_key(name: &str, var: &str) -> Result<String, ConfigError> {\n if !is_env_var_name(var) {\n return Err(ConfigError::BadKeyEnvName {\n name: name.to_string(),\n });\n }\n std::env::var(var)\n .ok()\n .filter(|key| !key.trim().is_empty())\n .ok_or_else(|| ConfigError::MissingApiKey {\n name: name.to_string(),\n var: var.to_string(),\n })\n}\n\n/// A missing file is created from the default template rather than being an\n/// error: the first run should just work.\npub(crate) fn read_or_create(path: &Path) -> Result<String, ConfigError> {\n match std::fs::read_to_string(path) {\n Ok(text) => Ok(text),\n Err(err) if err.kind() == std::io::ErrorKind::NotFound => {\n write_default(path)?;\n Ok(DEFAULT_CONFIG.to_string())\n }\n Err(source) => Err(ConfigError::Read {\n path: path.to_path_buf(),\n source,\n }),\n }\n}\n\nfn write_default(path: &Path) -> Result<(), ConfigError> {\n if let Some(parent) = path\n .parent()\n .filter(|parent| !parent.as_os_str().is_empty())\n {\n std::fs::create_dir_all(parent).map_err(|source| ConfigError::Directory {\n path: parent.to_path_buf(),\n source,\n })?;\n }\n std::fs::write(path, DEFAULT_CONFIG).map_err(|source| ConfigError::Write {\n path: path.to_path_buf(),\n source,\n })\n}\n\n/// `ODYN_CONFIG`, or `odyn.toml` in the platform config directory.\npub fn config_path() -> Result<PathBuf, ConfigError> {\n if let Some(path) = std::env::var_os(CONFIG_PATH_ENV).filter(|path| !path.is_empty()) {\n return Ok(PathBuf::from(path));\n }\n let dirs = directories::ProjectDirs::from(\"\", \"\", \"odyn\").ok_or(ConfigError::NoConfigDir)?;\n Ok(dirs.config_dir().join(CONFIG_FILE_NAME))\n}\n\n/// The file's text and the path it came from. Read on demand rather than kept\n/// beside the parsed `Config`: the GUI shows the file as it is now, edits and\n/// all, not as it was when the app started.\npub fn read_config_file() -> Result<(PathBuf, String), ConfigError> {\n let path = config_path()?;\n let text = read_or_create(&path)?;\n Ok((path, text))\n}\n\nfn default_ollama_base_url() -> String {\n DEFAULT_OLLAMA_BASE_URL.to_string()\n}\n\nfn is_env_var_name(value: &str) -> bool {\n !value.is_empty()\n && !value.starts_with(|c: char| c.is_ascii_digit())\n && value.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')\n}\n\npub(crate) fn invalid(key: impl Into<String>, message: &str) -> ConfigError {\n ConfigError::Invalid {\n key: key.into(),\n message: message.to_string(),\n }\n}\n\nfn unconfigured(key: &str, name: &str) -> ConfigError {\n invalid(key, &format!(\"no provider named `{name}` is configured\"))\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::chat::{ChatError, ChatRequest, Message, Role};\n use std::net::TcpListener;\n use std::sync::atomic::{AtomicU64, Ordering};\n\n /// The sample from IMPLEMENTATION_PLAN.md, comments included.\n const SAMPLE: &str = r#\"\ndefault_provider = \"deepseek\"\n[providers.deepseek] # any number of OpenAI-compatible entries\nkind = \"openai_compat\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"DEEPSEEK_API_KEY\" # v1: keys via env var reference only\ndefault_model = \"deepseek-chat\"\n[providers.zen]\nkind = \"openai_compat\"\nbase_url = \"https://api.opencode.ai/zen/v1\"\napi_key_env = \"OPENCODE_API_KEY\"\n[providers.ollama]\nkind = \"ollama\"\nbase_url = \"http://localhost:11434\"\nkeep_alive = \"5m\"\n[memory]\ncore_budget_tokens = 500\nepisodic_top_k = 6\nepisodic_cap_tokens = 900\nsimilarity_edge_threshold = 0.78\n[spotlight]\nhotkey = \"CmdOrCtrl+Shift+Space\"\nprovider = \"ollama\"\nmodel = \"llama3.3:8b\"\n\"#;\n\n const MINIMAL: &str = r#\"\ndefault_provider = \"ollama\"\n[providers.ollama]\nkind = \"ollama\"\n\"#;\n\n /// A unique directory under the system temp dir, removed on drop.\n struct TempDir(PathBuf);\n\n impl TempDir {\n fn new(label: &str) -> Self {\n static COUNTER: AtomicU64 = AtomicU64::new(0);\n let unique = COUNTER.fetch_add(1, Ordering::Relaxed);\n Self(\n std::env::temp_dir()\n .join(format!(\"odyn-test-{}-{label}-{unique}\", std::process::id())),\n )\n }\n\n fn config(&self) -> PathBuf {\n self.0.join(CONFIG_FILE_NAME)\n }\n }\n\n impl Drop for TempDir {\n fn drop(&mut self) {\n let _ = std::fs::remove_dir_all(&self.0);\n }\n }\n\n /// `Box<dyn ChatProvider>` is not `Debug`, so `expect_err` is unavailable.\n fn provider_error(registry: &ProviderRegistry, name: &str) -> String {\n match registry.provider(name) {\n Err(err) => err.to_string(),\n Ok(_) => panic!(\"expected building `{name}` to fail\"),\n }\n }\n\n #[test]\n fn parses_the_documented_sample() {\n let config = Config::parse(SAMPLE).expect(\"parse sample\");\n\n assert_eq!(config.default_provider, \"deepseek\");\n assert_eq!(\n config.providers[\"deepseek\"],\n ProviderConfig::OpenAiCompat {\n base_url: \"https://api.deepseek.com\".to_string(),\n api_key: None,\n api_key_env: Some(\"DEEPSEEK_API_KEY\".to_string()),\n default_model: Some(\"deepseek-chat\".to_string()),\n }\n );\n assert_eq!(\n config.providers[\"zen\"],\n ProviderConfig::OpenAiCompat {\n base_url: \"https://api.opencode.ai/zen/v1\".to_string(),\n api_key: None,\n api_key_env: Some(\"OPENCODE_API_KEY\".to_string()),\n default_model: None,\n }\n );\n assert_eq!(\n config.providers[\"ollama\"],\n ProviderConfig::Ollama {\n base_url: DEFAULT_OLLAMA_BASE_URL.to_string(),\n keep_alive: Some(\"5m\".to_string()),\n }\n );\n assert_eq!(config.memory, MemoryConfig::default());\n assert_eq!(\n config.spotlight,\n SpotlightConfig {\n hotkey: \"CmdOrCtrl+Shift+Space\".to_string(),\n provider: Some(\"ollama\".to_string()),\n model: Some(\"llama3.3:8b\".to_string()),\n ..SpotlightConfig::default()\n }\n );\n }\n\n #[test]\n fn missing_sections_and_fields_fall_back_to_defaults() {\n let config = Config::parse(MINIMAL).expect(\"parse minimal\");\n\n assert_eq!(\n config.providers[\"ollama\"],\n ProviderConfig::Ollama {\n base_url: DEFAULT_OLLAMA_BASE_URL.to_string(),\n keep_alive: None,\n }\n );\n assert_eq!(config.memory.core_budget_tokens, 500);\n assert_eq!(config.memory.episodic_top_k, 6);\n assert_eq!(config.memory.episodic_cap_tokens, 900);\n assert_eq!(config.memory.similarity_edge_threshold, 0.78);\n assert_eq!(config.spotlight, SpotlightConfig::default());\n\n let partial = format!(\"{MINIMAL}[memory]\\nepisodic_top_k = 3\\n\");\n let config = Config::parse(&partial).expect(\"parse partial memory section\");\n assert_eq!(config.memory.episodic_top_k, 3);\n assert_eq!(config.memory.core_budget_tokens, 500);\n }\n\n #[test]\n fn unknown_keys_are_rejected_by_name() {\n let cases = [\n (format!(\"smell = \\\"fishy\\\"\\n{MINIMAL}\"), \"smell\"),\n (\n format!(\"{MINIMAL}[providers.other]\\nkind = \\\"ollama\\\"\\napi_key_env = \\\"NOPE\\\"\\n\"),\n \"api_key_env\",\n ),\n (format!(\"{MINIMAL}[memory]\\nbudget = 5\\n\"), \"budget\"),\n (\n format!(\"{MINIMAL}[spotlight]\\nshortcut = \\\"F1\\\"\\n\"),\n \"shortcut\",\n ),\n ];\n for (text, key) in cases {\n let err = Config::parse(&text)\n .expect_err(\"unknown key must be rejected\")\n .to_string();\n assert!(err.contains(key), \"{err}\");\n }\n }\n\n #[test]\n fn invalid_values_name_their_key() {\n let cases = [\n (\n \"default_provider = \\\"zen\\\"\\n[providers.ollama]\\nkind = \\\"ollama\\\"\\n\".to_string(),\n \"default_provider\",\n ),\n (\n format!(\"{MINIMAL}[spotlight]\\nprovider = \\\"zen\\\"\\n\"),\n \"spotlight.provider\",\n ),\n (\n format!(\"{MINIMAL}[memory]\\nepisodic_top_k = 0\\n\"),\n \"memory.episodic_top_k\",\n ),\n (\n format!(\"{MINIMAL}[memory]\\nsimilarity_edge_threshold = 1.5\\n\"),\n \"memory.similarity_edge_threshold\",\n ),\n (\n format!(\"{MINIMAL}[memory]\\nsimilarity_edge_threshold = 0.0\\n\"),\n \"memory.similarity_edge_threshold\",\n ),\n ];\n for (text, key) in cases {\n let err = Config::parse(&text)\n .expect_err(\"invalid value must be rejected\")\n .to_string();\n assert!(err.contains(key), \"{err}\");\n }\n }\n\n /// The lesson of a bricked app: a provider whose entry is broken fails\n /// when *it* is built, and no sooner — issue #8.\n #[test]\n fn provider_problems_break_only_that_provider() {\n let config = Config::parse(\n \"default_provider = \\\"ollama\\\"\\n\\\n [providers.ollama]\\n\\\n kind = \\\"ollama\\\"\\n\\\n [providers.zen]\\n\\\n kind = \\\"openai_compat\\\"\\n\\\n base_url = \\\"https://example.test/v1\\\"\\n\\\n api_key_env = \\\"sk-a-key-pasted-where-a-name-goes\\\"\\n\\\n [providers.hollow]\\n\\\n kind = \\\"openai_compat\\\"\\n\\\n base_url = \\\"\\\"\\n\",\n )\n .expect(\"a broken provider entry must still parse\");\n let registry = ProviderRegistry::from_config(&config).expect(\"build registry\");\n\n assert!(registry.provider(\"ollama\").is_ok());\n\n let err = provider_error(®istry, \"zen\");\n assert!(err.contains(\"providers.zen.api_key_env\"), \"{err}\");\n assert!(err.contains(\"`api_key`\"), \"{err}\");\n // The misplaced value is usually the key itself, so no echo.\n assert!(!err.contains(\"sk-a-key\"), \"{err}\");\n\n assert!(provider_error(®istry, \"hollow\").contains(\"hollow\"));\n }\n\n #[test]\n fn a_literal_api_key_builds_without_the_environment() {\n let _env = crate::lock_env();\n const VAR: &str = \"ODYN_TEST_UNSET_TOKEN\";\n std::env::remove_var(VAR);\n let config = Config::parse(&format!(\n \"default_provider = \\\"zen\\\"\\n\\\n [providers.zen]\\n\\\n kind = \\\"openai_compat\\\"\\n\\\n base_url = \\\"https://example.test/v1\\\"\\n\\\n api_key = \\\"sk-literal\\\"\\n\\\n api_key_env = \\\"{VAR}\\\"\\n\"\n ))\n .expect(\"parse\");\n let registry = ProviderRegistry::from_config(&config).expect(\"build registry\");\n\n // The literal wins, so the unset variable is never consulted.\n assert!(registry.provider(\"zen\").is_ok());\n\n // Blank is absence, not an empty key: the env fallback applies.\n let config = Config::parse(&format!(\n \"default_provider = \\\"zen\\\"\\n\\\n [providers.zen]\\n\\\n kind = \\\"openai_compat\\\"\\n\\\n base_url = \\\"https://example.test/v1\\\"\\n\\\n api_key = \\\" \\\"\\n\\\n api_key_env = \\\"{VAR}\\\"\\n\"\n ))\n .expect(\"parse\");\n let registry = ProviderRegistry::from_config(&config).expect(\"build registry\");\n let err = provider_error(®istry, \"zen\");\n assert!(err.contains(VAR), \"{err}\");\n }\n\n #[test]\n fn a_missing_file_is_created_from_the_template() {\n let _env = crate::lock_env();\n let dir = TempDir::new(\"config\");\n let path = dir.config();\n let previous = std::env::var_os(CONFIG_PATH_ENV);\n std::env::set_var(CONFIG_PATH_ENV, &path);\n\n let created = Config::load();\n let reloaded = Config::load();\n\n match previous {\n Some(value) => std::env::set_var(CONFIG_PATH_ENV, value),\n None => std::env::remove_var(CONFIG_PATH_ENV),\n }\n\n let created = created.expect(\"create and parse the template\");\n assert_eq!(created, reloaded.expect(\"reload the written file\"));\n assert_eq!(created.default_provider, \"ollama\");\n assert_eq!(created.memory, MemoryConfig::default());\n assert_eq!(created.spotlight, SpotlightConfig::default());\n\n let written = std::fs::read_to_string(&path).expect(\"template was written\");\n assert_eq!(written, DEFAULT_CONFIG);\n assert!(!written.contains(\"sk-\"), \"{written}\");\n for line in written.lines().filter(|line| line.contains(\"api_key\")) {\n assert!(line.trim_start().starts_with('#'), \"{line}\");\n }\n }\n\n #[test]\n fn default_model_is_declared_only_by_openai_compat_providers() {\n let config = Config::parse(SAMPLE).expect(\"parse sample\");\n\n assert_eq!(config.default_model(\"deepseek\"), Some(\"deepseek-chat\"));\n assert_eq!(config.default_model(\"zen\"), None);\n assert_eq!(config.default_model(\"ollama\"), None);\n assert_eq!(config.default_model(\"nope\"), None);\n }\n\n #[test]\n fn registry_exposes_the_configured_providers() {\n let config = Config::parse(SAMPLE).expect(\"parse sample\");\n let registry = ProviderRegistry::from_config(&config).expect(\"build registry\");\n\n assert_eq!(registry.default_provider_name(), \"deepseek\");\n assert_eq!(registry.names(), vec![\"deepseek\", \"ollama\", \"zen\"]);\n assert_eq!(registry.kind(\"zen\"), Some(\"openai_compat\"));\n assert_eq!(registry.kind(\"ollama\"), Some(\"ollama\"));\n assert_eq!(registry.kind(\"nope\"), None);\n\n // A registry built from a config whose keys are unset still serves the\n // providers that need none.\n assert!(registry.provider(\"ollama\").is_ok());\n let err = provider_error(®istry, \"nope\");\n assert!(err.contains(\"nope\"), \"{err}\");\n }\n\n #[test]\n fn openai_compat_keys_are_read_from_the_environment_on_demand() {\n let _env = crate::lock_env();\n const VAR: &str = \"ODYN_TEST_PROVIDER_TOKEN\";\n let config = Config::parse(&format!(\n \"default_provider = \\\"keyed\\\"\\n\\\n [providers.keyed]\\n\\\n kind = \\\"openai_compat\\\"\\n\\\n base_url = \\\"https://example.test/v1\\\"\\n\\\n api_key_env = \\\"{VAR}\\\"\\n\\\n [providers.keyless]\\n\\\n kind = \\\"openai_compat\\\"\\n\\\n base_url = \\\"https://example.test/local\\\"\\n\"\n ))\n .expect(\"parse\");\n let registry = ProviderRegistry::from_config(&config).expect(\"build registry\");\n\n assert!(registry.provider(\"keyless\").is_ok());\n\n std::env::remove_var(VAR);\n let unset = provider_error(®istry, \"keyed\");\n std::env::set_var(VAR, \"\");\n let empty = provider_error(®istry, \"keyed\");\n std::env::set_var(VAR, \"test-token-value\");\n let built = registry.provider(\"keyed\").is_ok();\n std::env::remove_var(VAR);\n\n for message in [unset, empty] {\n assert!(message.contains(\"providers.keyed.api_key_env\"), \"{message}\");\n assert!(message.contains(VAR), \"{message}\");\n }\n assert!(built, \"a set key must build the provider\");\n }\n\n #[tokio::test]\n async fn built_providers_use_their_configured_base_url() {\n // Bind then drop, so nothing is listening on a port we know is free.\n let listener = TcpListener::bind(\"127.0.0.1:0\").expect(\"bind\");\n let addr = listener.local_addr().expect(\"address\");\n drop(listener);\n\n let config = Config::parse(&format!(\n \"default_provider = \\\"ollama\\\"\\n\\\n [providers.ollama]\\n\\\n kind = \\\"ollama\\\"\\n\\\n base_url = \\\"http://{addr}\\\"\\n\"\n ))\n .expect(\"parse\");\n let registry = ProviderRegistry::from_config(&config).expect(\"build registry\");\n let provider = registry.provider(\"ollama\").expect(\"build ollama provider\");\n\n let messages = vec![Message::new(Role::User, \"hi\")];\n let err = provider\n .chat_collect(ChatRequest::new(&messages, \"llama3.2:3b\"))\n .await\n .expect_err(\"nothing is listening\");\n match err {\n ChatError::Network(message) => assert!(\n message.contains(&format!(\"ollama not reachable at http://{addr}\")),\n \"{message}\"\n ),\n other => panic!(\"expected a network error, got {other:?}\"),\n }\n }\n #[test]\n fn brevity_levels_parse_and_bad_ones_name_the_key() {\n let config = Config::parse(\n \"default_provider = \\\"x\\\"\\n[providers.x]\\nkind = \\\"ollama\\\"\\n\\\n [style]\\nbrevity = \\\"ultra\\\"\\n[spotlight]\\nbrevity = \\\"lite\\\"\\n\",\n )\n .expect(\"parse\");\n assert_eq!(config.style.brevity, crate::brevity::Brevity::Ultra);\n assert_eq!(config.spotlight.brevity, crate::brevity::Brevity::Lite);\n\n // Absent sections fall back: off for chat, full for spotlight.\n let config = Config::parse(\"default_provider = \\\"x\\\"\\n[providers.x]\\nkind = \\\"ollama\\\"\\n\")\n .expect(\"parse defaults\");\n assert_eq!(config.style.brevity, crate::brevity::Brevity::Off);\n assert_eq!(config.spotlight.brevity, crate::brevity::Brevity::Full);\n\n let error = Config::parse(\n \"default_provider = \\\"x\\\"\\n[providers.x]\\nkind = \\\"ollama\\\"\\n\\\n [style]\\nbrevity = \\\"caveman\\\"\\n\",\n )\n .expect_err(\"a made-up level must fail\");\n let message = error.to_string();\n assert!(message.contains(\"brevity\"), \"{message}\");\n for level in [\"off\", \"lite\", \"full\", \"ultra\"] {\n assert!(message.contains(level), \"{message} must offer {level}\");\n }\n }\n}\n"}}}} |