Skip to main content

capo_agent/settings/
mod.rs

1//! Structured user settings (`~/.capo/agent/settings.json`).
2//!
3//! JSON to match pi's `~/.pi/agent/settings.json`. Sections beyond the
4//! Phase 0 baseline (e.g. `ui.theme` variants, `logging` rotation) are
5//! reserved for M4+ and intentionally minimal here.
6
7mod cli;
8mod load;
9
10pub use cli::CliOverrides;
11pub use load::{load, load_with};
12
13use serde::{Deserialize, Serialize};
14
15#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
16pub struct Settings {
17    #[serde(default)]
18    pub model: ModelSettings,
19    #[serde(default)]
20    pub anthropic: AnthropicSettings,
21    #[serde(default)]
22    pub ui: UiSettings,
23    #[serde(default)]
24    pub session: SessionSettings,
25    #[serde(default)]
26    pub logging: LoggingSettings,
27}
28
29impl Settings {
30    pub fn load(cli: &CliOverrides) -> crate::Result<Self> {
31        load::load(cli)
32    }
33
34    pub fn load_with<F>(
35        agent_dir: &std::path::Path,
36        cli: &CliOverrides,
37        env_lookup: F,
38    ) -> crate::Result<Self>
39    where
40        F: Fn(&str) -> Option<String>,
41    {
42        load::load_with(agent_dir, cli, env_lookup)
43    }
44}
45
46/// Supported LLM providers. `Settings::model.provider` is a string for
47/// JSON-friendliness; this enum is the parsed form.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum LlmProviderKind {
50    /// Direct HTTPS call to `api.anthropic.com`. Requires an API key
51    /// (env `ANTHROPIC_API_KEY` or `auth.json::anthropic.key`).
52    Anthropic,
53    /// Shells out to the locally-installed `claude` binary
54    /// (Claude Code CLI). The CLI handles its own authentication;
55    /// capo's `Auth` is not consulted. Requires `claude` on `$PATH`.
56    ClaudeCode,
57    /// Shells out to the locally-installed `codex` binary
58    /// (OpenAI Codex CLI). The CLI handles its own authentication;
59    /// capo's `Auth` is not consulted. Requires `codex` on `$PATH`.
60    CodexCli,
61    /// Direct HTTPS call to OpenAI's API. Requires an API key
62    /// (env `OPENAI_API_KEY` or `auth.json::openai.key`).
63    Openai,
64    /// Direct HTTPS call to Google's Gemini REST API. Requires an API key
65    /// (env `GEMINI_API_KEY` or `auth.json::gemini.key`).
66    Gemini,
67    /// Shells out to the locally-installed `gemini` binary (Google's
68    /// Gemini CLI). The CLI handles its own authentication; capo's `Auth`
69    /// is not consulted. Requires `gemini` on `$PATH`.
70    GeminiCli,
71}
72
73impl LlmProviderKind {
74    /// Parse a `Settings::model.provider` string. Case-insensitive.
75    pub fn parse(s: &str) -> Result<Self, String> {
76        match s.trim().to_ascii_lowercase().as_str() {
77            "anthropic" => Ok(Self::Anthropic),
78            "claude-code" | "claude_code" => Ok(Self::ClaudeCode),
79            "codex-cli" | "codex_cli" => Ok(Self::CodexCli),
80            "openai" => Ok(Self::Openai),
81            "gemini" => Ok(Self::Gemini),
82            "gemini-cli" | "gemini_cli" => Ok(Self::GeminiCli),
83            other => Err(format!(
84                "unknown LLM provider {other:?}; expected one of: anthropic, claude-code, codex-cli, openai, gemini, gemini-cli"
85            )),
86        }
87    }
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
91pub struct ModelSettings {
92    /// Provider tag. One of: `"anthropic"`, `"claude-code"`, `"codex-cli"`,
93    /// `"openai"`, `"gemini"`, `"gemini-cli"`.
94    /// See [`LlmProviderKind`] for semantics. Validated at LLM construction
95    /// time (not at settings deserialize) so a forgotten provider only
96    /// blocks the user when they actually launch capo.
97    pub provider: String,
98    pub name: String,
99    pub max_tokens: u32,
100}
101
102impl Default for ModelSettings {
103    fn default() -> Self {
104        Self {
105            provider: "anthropic".to_string(),
106            name: "claude-sonnet-4-6".to_string(),
107            max_tokens: 8192,
108        }
109    }
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
113pub struct AnthropicSettings {
114    /// Base URL for the Anthropic HTTP API. Defaults to the public endpoint;
115    /// override via `CAPO_ANTHROPIC_BASE_URL` env or `anthropic.base_url` in
116    /// `settings.json` to route through a corporate proxy or test server.
117    /// Only consulted when `model.provider == "anthropic"` (CLI providers
118    /// shell out to their own binaries and ignore this).
119    pub base_url: String,
120}
121
122impl Default for AnthropicSettings {
123    fn default() -> Self {
124        Self {
125            base_url: "https://api.anthropic.com".to_string(),
126        }
127    }
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
131pub struct UiSettings {
132    pub theme: String,
133    pub streaming_throttle_ms: u32,
134    pub footer_show_cost: bool,
135}
136
137impl Default for UiSettings {
138    fn default() -> Self {
139        Self {
140            theme: "dark".to_string(),
141            streaming_throttle_ms: 50,
142            footer_show_cost: true,
143        }
144    }
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
148pub struct SessionSettings {
149    pub autosave: bool,
150    /// Fraction of context window (0.0..=1.0) at which autocompact triggers.
151    /// Name keeps `_pct` for spec/JSON-config compatibility, but the value is
152    /// always interpreted as a 0.0–1.0 fraction. `0.0` disables autocompact.
153    pub compact_at_context_pct: f32,
154    /// Total LLM context window in tokens. Used by `AutocompactExtension`
155    /// to compute the absolute compaction threshold. Default 200_000
156    /// matches motosan-agent-loop's `AutocompactConfig::default`. **Override
157    /// for newer models** (Sonnet 4.5+ supports 1_000_000).
158    pub max_context_tokens: usize,
159    /// Number of recent user-turn boundaries kept verbatim during compaction.
160    pub keep_turns: usize,
161}
162
163impl Default for SessionSettings {
164    fn default() -> Self {
165        Self {
166            autosave: true,
167            compact_at_context_pct: 0.85,
168            max_context_tokens: 1_000_000,
169            keep_turns: 3,
170        }
171    }
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
175pub struct LoggingSettings {
176    pub level: String,
177    pub file: String,
178}
179
180impl Default for LoggingSettings {
181    fn default() -> Self {
182        Self {
183            level: "info".to_string(),
184            file: "~/.capo/agent/capo.log".to_string(),
185        }
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn settings_default_matches_documented_baseline() {
195        let s = Settings::default();
196        assert_eq!(s.model.provider, "anthropic");
197        assert_eq!(s.model.name, "claude-sonnet-4-6");
198        assert_eq!(s.model.max_tokens, 8192);
199        assert_eq!(s.anthropic.base_url, "https://api.anthropic.com");
200        assert_eq!(s.ui.theme, "dark");
201        assert_eq!(s.ui.streaming_throttle_ms, 50);
202        assert!(s.ui.footer_show_cost);
203        assert!(s.session.autosave);
204        assert!((s.session.compact_at_context_pct - 0.85).abs() < f32::EPSILON);
205        assert_eq!(s.session.max_context_tokens, 1_000_000);
206        assert_eq!(s.session.keep_turns, 3);
207        assert_eq!(s.logging.level, "info");
208        assert_eq!(s.logging.file, "~/.capo/agent/capo.log");
209    }
210
211    #[test]
212    fn settings_serde_round_trips() {
213        let original = Settings::default();
214        let json = match serde_json::to_string_pretty(&original) {
215            Ok(json) => json,
216            Err(err) => panic!("serialize failed: {err}"),
217        };
218        let back: Settings = match serde_json::from_str(&json) {
219            Ok(settings) => settings,
220            Err(err) => panic!("deserialize failed: {err}"),
221        };
222        assert_eq!(original, back);
223    }
224
225    #[test]
226    fn settings_loads_partial_json_with_defaults_for_missing_sections() {
227        let json = r#"{ "model": { "provider": "anthropic", "name": "x", "max_tokens": 4096 } }"#;
228        let s: Settings = match serde_json::from_str(json) {
229            Ok(settings) => settings,
230            Err(err) => panic!("deserialize failed: {err}"),
231        };
232        assert_eq!(s.model.name, "x");
233        // Missing sections fall back to defaults.
234        assert_eq!(s.ui.theme, "dark");
235        assert!((s.session.compact_at_context_pct - 0.85).abs() < f32::EPSILON);
236    }
237
238    fn parse_ok(input: &str) -> LlmProviderKind {
239        match LlmProviderKind::parse(input) {
240            Ok(kind) => kind,
241            Err(err) => panic!("parse failed for {input}: {err}"),
242        }
243    }
244
245    fn parse_err(input: &str) -> String {
246        match LlmProviderKind::parse(input) {
247            Ok(kind) => panic!("parse should have failed for {input}: {kind:?}"),
248            Err(err) => err,
249        }
250    }
251
252    #[test]
253    fn llm_provider_kind_parses_all_three() {
254        assert_eq!(parse_ok("anthropic"), LlmProviderKind::Anthropic);
255        assert_eq!(parse_ok("claude-code"), LlmProviderKind::ClaudeCode);
256        assert_eq!(parse_ok("claude_code"), LlmProviderKind::ClaudeCode);
257        assert_eq!(parse_ok("codex-cli"), LlmProviderKind::CodexCli);
258        assert_eq!(parse_ok("CODEX-CLI"), LlmProviderKind::CodexCli);
259    }
260
261    #[test]
262    fn llm_provider_kind_rejects_unknown() {
263        let err = parse_err("gpt-4");
264        assert!(err.contains("unknown LLM provider"));
265        assert!(err.contains("gpt-4"));
266        assert!(err.contains("anthropic"));
267    }
268
269    #[test]
270    fn parse_recognizes_v0_5_providers() {
271        assert!(matches!(parse_ok("openai"), LlmProviderKind::Openai));
272        assert!(matches!(parse_ok("gemini"), LlmProviderKind::Gemini));
273        assert!(matches!(parse_ok("gemini-cli"), LlmProviderKind::GeminiCli));
274        assert!(matches!(parse_ok("gemini_cli"), LlmProviderKind::GeminiCli));
275    }
276
277    #[test]
278    fn parse_unknown_provider_lists_all_six_supported_names() {
279        let err = parse_err("nope");
280        for name in [
281            "anthropic",
282            "claude-code",
283            "codex-cli",
284            "openai",
285            "gemini",
286            "gemini-cli",
287        ] {
288            assert!(err.contains(name), "{name} missing from: {err}");
289        }
290    }
291}