Skip to main content

a_agent/
config.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::fmt;
3use std::fs;
4use std::io::Write;
5use std::path::{Path, PathBuf};
6
7use anyhow::{Context, Result};
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "lowercase")]
12pub enum ProviderKind {
13    Anthropic,
14    Responses,
15    Chatcompletion,
16}
17
18impl ProviderKind {
19    pub fn as_str(self) -> &'static str {
20        match self {
21            Self::Anthropic => "anthropic",
22            Self::Responses => "responses",
23            Self::Chatcompletion => "chatcompletion",
24        }
25    }
26
27    pub fn parse(value: &str) -> anyhow::Result<Self> {
28        match value {
29            "anthropic" => Ok(Self::Anthropic),
30            "responses" => Ok(Self::Responses),
31            "chatcompletion" => Ok(Self::Chatcompletion),
32            _ => anyhow::bail!("unknown provider type in session: {value}"),
33        }
34    }
35}
36
37#[derive(Clone, Serialize, Deserialize)]
38#[serde(default)]
39pub struct ProviderConfig {
40    #[serde(rename = "type")]
41    pub kind: ProviderKind,
42    pub base_url: Option<String>,
43    pub model: String,
44    pub api_key_env: String,
45    pub api_key: Option<String>,
46    pub headers: BTreeMap<String, String>,
47    pub max_tokens: u32,
48    pub request: BTreeMap<String, serde_json::Value>,
49}
50
51impl Default for ProviderConfig {
52    fn default() -> Self {
53        Self {
54            kind: ProviderKind::Responses,
55            base_url: None,
56            model: "gpt-5.6".into(),
57            api_key_env: "OPENAI_API_KEY".into(),
58            api_key: None,
59            headers: BTreeMap::new(),
60            max_tokens: 8192,
61            request: BTreeMap::new(),
62        }
63    }
64}
65
66impl ProviderConfig {
67    pub fn resolve_api_key(&self) -> Result<String> {
68        self.resolve_api_key_with(|name| std::env::var(name).ok())
69    }
70
71    pub fn resolve_api_key_with(
72        &self,
73        get_env: impl FnOnce(&str) -> Option<String>,
74    ) -> Result<String> {
75        if let Some(api_key) = self.api_key.as_ref().filter(|key| !key.is_empty()) {
76            return Ok(api_key.clone());
77        }
78        get_env(&self.api_key_env).ok_or_else(|| {
79            anyhow::anyhow!(
80                "provider authentication is not configured; set api_key in the selected provider, set {}, or update ~/.config/a/config.toml",
81                self.api_key_env
82            )
83        })
84    }
85}
86
87impl fmt::Debug for ProviderConfig {
88    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
89        formatter
90            .debug_struct("ProviderConfig")
91            .field("kind", &self.kind)
92            .field("base_url", &self.base_url)
93            .field("model", &self.model)
94            .field("api_key_env", &self.api_key_env)
95            .field("api_key", &self.api_key.as_ref().map(|_| "[REDACTED]"))
96            .field("headers", &self.headers)
97            .field("max_tokens", &self.max_tokens)
98            .field("request", &self.request)
99            .finish()
100    }
101}
102
103#[derive(Debug, Clone, Default, Serialize, Deserialize)]
104#[serde(default)]
105pub struct ModelProfile {
106    pub provider: String,
107    pub model: String,
108    pub effort: Option<String>,
109    pub efforts: Vec<String>,
110    pub context_window: Option<u64>,
111    pub max_tokens: Option<u32>,
112    /// A models.dev `provider/model` key, needed when the model id alone is
113    /// ambiguous across providers that price it differently.
114    pub pricing: Option<String>,
115    pub cost: Option<Rates>,
116    pub headers: BTreeMap<String, String>,
117    pub request: BTreeMap<String, serde_json::Value>,
118}
119
120/// Token prices in USD per million tokens, matching how models.dev states them.
121#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
122#[serde(default)]
123pub struct Rates {
124    pub input: f64,
125    pub output: f64,
126    pub cache_read: f64,
127    pub cache_write: f64,
128}
129
130#[derive(Debug, Clone)]
131pub struct ModelSelection {
132    pub name: String,
133    pub provider_name: String,
134    pub provider: ProviderConfig,
135    pub effort: Option<String>,
136    pub efforts: Vec<String>,
137    pub context_window: Option<u64>,
138    pub pricing: Option<String>,
139    pub cost: Option<Rates>,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
143#[serde(default)]
144pub struct UiConfig {
145    pub show_reasoning: bool,
146    pub reasoning_toggle: String,
147    pub tool_input_max_bytes: usize,
148    pub tool_output_max_bytes: usize,
149    pub tool_output_max_lines: usize,
150    pub tool_live_output_lines: usize,
151    pub patch_diff_max_lines: usize,
152}
153
154impl Default for UiConfig {
155    fn default() -> Self {
156        Self {
157            show_reasoning: false,
158            reasoning_toggle: "ctrl-o".into(),
159            tool_input_max_bytes: 2048,
160            tool_output_max_bytes: 8192,
161            tool_output_max_lines: 16,
162            tool_live_output_lines: 6,
163            patch_diff_max_lines: 24,
164        }
165    }
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
169#[serde(default)]
170pub struct ToolsConfig {
171    /// Applied when a bash call does not ask for a timeout of its own.
172    pub bash_timeout_seconds: u64,
173    /// Most a bash call may ask for. Commands the model expects to be slow can
174    /// raise their own limit up to this, so the default stays short without
175    /// making long builds impossible.
176    pub bash_max_timeout_seconds: u64,
177    pub max_parallel: usize,
178    pub max_output_bytes: usize,
179}
180
181impl Default for ToolsConfig {
182    fn default() -> Self {
183        Self {
184            bash_timeout_seconds: 120,
185            bash_max_timeout_seconds: 1800,
186            max_parallel: 8,
187            max_output_bytes: 65_536,
188        }
189    }
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize)]
193#[serde(default)]
194pub struct ContextConfig {
195    pub shell_history_count: usize,
196    pub stdin_max_bytes: usize,
197    pub read_max_lines: usize,
198}
199
200impl Default for ContextConfig {
201    fn default() -> Self {
202        Self {
203            shell_history_count: 5,
204            stdin_max_bytes: 131_072,
205            read_max_lines: 1000,
206        }
207    }
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize)]
211#[serde(default)]
212pub struct SessionConfig {
213    pub max_agent_cycles: usize,
214    pub shell_history_limit: usize,
215    pub input_history_limit: usize,
216    /// How much of a file's previous contents to keep so a rewind can restore
217    /// it. Larger files are recorded as unrestorable rather than truncated.
218    pub snapshot_max_bytes: usize,
219}
220
221impl Default for SessionConfig {
222    fn default() -> Self {
223        Self {
224            max_agent_cycles: 50,
225            shell_history_limit: 5000,
226            input_history_limit: 1000,
227            snapshot_max_bytes: 1024 * 1024,
228        }
229    }
230}
231
232#[derive(Debug, Clone, Serialize, Deserialize)]
233#[serde(default)]
234pub struct Config {
235    pub default_model: String,
236    pub providers: BTreeMap<String, ProviderConfig>,
237    pub models: BTreeMap<String, ModelProfile>,
238    pub ui: UiConfig,
239    pub tools: ToolsConfig,
240    pub context: ContextConfig,
241    pub session: SessionConfig,
242}
243
244impl Default for Config {
245    fn default() -> Self {
246        let default_model = ModelProfile {
247            provider: "openai".into(),
248            model: "gpt-5.6".into(),
249            effort: Some("medium".into()),
250            efforts: canonical_efforts(),
251            context_window: Some(1_050_000),
252            ..ModelProfile::default()
253        };
254        Self {
255            default_model: "default".into(),
256            providers: BTreeMap::from([("openai".into(), ProviderConfig::default())]),
257            models: BTreeMap::from([("default".into(), default_model)]),
258            ui: UiConfig::default(),
259            tools: ToolsConfig::default(),
260            context: ContextConfig::default(),
261            session: SessionConfig::default(),
262        }
263    }
264}
265
266impl Config {
267    pub fn ensure_user_config(home: &Path) -> Result<Option<PathBuf>> {
268        let path = home.join(".config/a/config.toml");
269        if path.exists() {
270            return Ok(None);
271        }
272        let directory = path.parent().context("config path has no parent")?;
273        fs::create_dir_all(directory)
274            .with_context(|| format!("create config directory {}", directory.display()))?;
275        let mut temporary = tempfile::NamedTempFile::new_in(directory)?;
276        temporary.write_all(include_bytes!("../config.example.toml"))?;
277        temporary.as_file().sync_all()?;
278        match temporary.persist_noclobber(&path) {
279            Ok(_) => Ok(Some(path)),
280            Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => Ok(None),
281            Err(error) => Err(error.error)
282                .with_context(|| format!("create initial config {}", path.display())),
283        }
284    }
285
286    pub fn load_from(cwd: &Path, home: &Path) -> Result<Self> {
287        let global = home.join(".config/a/config.toml");
288        let project = cwd.join(".a/config.toml");
289        let mut merged = toml::Value::Table(Default::default());
290        for path in [global, project] {
291            if path.is_file() {
292                let source = fs::read_to_string(&path)
293                    .with_context(|| format!("read config {}", path.display()))?;
294                let value = toml::from_str::<toml::Value>(&source)
295                    .with_context(|| format!("parse config {}", path.display()))?;
296                merge_toml(&mut merged, value);
297            }
298        }
299
300        if merged.get("provider").is_some() {
301            anyhow::bail!(
302                "legacy [provider] configuration is no longer supported; define [providers.<name>], [models.<name>], and default_model"
303            );
304        }
305        let explicit_api_key_envs = merged
306            .get("providers")
307            .and_then(toml::Value::as_table)
308            .into_iter()
309            .flat_map(|providers| providers.iter())
310            .filter_map(|(name, value)| value.get("api_key_env").map(|_| name.clone()))
311            .collect::<BTreeSet<_>>();
312        let mut config: Self = merged.try_into().context("decode merged configuration")?;
313        for (name, provider) in &mut config.providers {
314            if !explicit_api_key_envs.contains(name) && provider.kind == ProviderKind::Anthropic {
315                provider.api_key_env = "ANTHROPIC_API_KEY".into();
316            }
317        }
318        if config.tools.max_parallel == 0 {
319            anyhow::bail!("tools.max_parallel must be greater than zero");
320        }
321        if config.tools.bash_max_timeout_seconds < config.tools.bash_timeout_seconds {
322            anyhow::bail!(
323                "tools.bash_max_timeout_seconds ({}) must be at least tools.bash_timeout_seconds ({})",
324                config.tools.bash_max_timeout_seconds,
325                config.tools.bash_timeout_seconds
326            );
327        }
328        config.validate_models()?;
329        Ok(config)
330    }
331
332    pub fn load(cwd: &Path) -> Result<Self> {
333        let home = std::env::var_os("HOME")
334            .map(PathBuf::from)
335            .context("HOME is not set")?;
336        Self::load_from(cwd, &home)
337    }
338
339    pub fn model_names(&self) -> Vec<&str> {
340        self.models.keys().map(String::as_str).collect()
341    }
342
343    pub fn resolve_model(
344        &self,
345        name: Option<&str>,
346        effort_override: Option<&str>,
347    ) -> Result<ModelSelection> {
348        let name = name.unwrap_or(&self.default_model);
349        let profile = self
350            .models
351            .get(name)
352            .with_context(|| format!("model profile not found: {name}"))?;
353        let mut provider = self
354            .providers
355            .get(&profile.provider)
356            .cloned()
357            .with_context(|| {
358                format!(
359                    "provider '{}' referenced by model '{name}' was not found",
360                    profile.provider
361                )
362            })?;
363        provider.model = profile.model.clone();
364        if let Some(max_tokens) = profile.max_tokens {
365            provider.max_tokens = max_tokens;
366        }
367        provider.headers.extend(profile.headers.clone());
368        provider.request.extend(profile.request.clone());
369
370        let effort = effort_override.or(profile.effort.as_deref());
371        if let Some(effort) = effort {
372            validate_effort(effort)?;
373            if !profile.efforts.iter().any(|candidate| candidate == effort) {
374                anyhow::bail!("effort '{effort}' is not configured for model '{name}'");
375            }
376            apply_effort(&mut provider, effort)?;
377        }
378        Ok(ModelSelection {
379            name: name.into(),
380            provider_name: profile.provider.clone(),
381            provider,
382            effort: effort.map(str::to_owned),
383            efforts: profile.efforts.clone(),
384            context_window: profile.context_window,
385            pricing: profile.pricing.clone(),
386            cost: profile.cost,
387        })
388    }
389
390    pub fn resolve_session_model(
391        &self,
392        profile: Option<&str>,
393        provider_type: &str,
394        model: &str,
395        effort: Option<&str>,
396    ) -> Result<ModelSelection> {
397        if let Some(profile) = profile {
398            return self.resolve_model(Some(profile), effort);
399        }
400        let kind = ProviderKind::parse(provider_type)?;
401        for name in self.models.keys() {
402            let selection = self.resolve_model(Some(name), None)?;
403            if selection.provider.kind == kind && selection.provider.model == model {
404                return self.resolve_model(Some(name), effort);
405            }
406        }
407        anyhow::bail!(
408            "session model {provider_type}/{model} does not match a configured model profile"
409        )
410    }
411
412    fn validate_models(&self) -> Result<()> {
413        if self.models.is_empty() {
414            anyhow::bail!("at least one [models.<name>] profile is required");
415        }
416        if !self.models.contains_key(&self.default_model) {
417            anyhow::bail!("default_model '{}' was not found", self.default_model);
418        }
419        for (name, profile) in &self.models {
420            if profile.provider.is_empty() || profile.model.is_empty() {
421                anyhow::bail!("model '{name}' requires provider and model");
422            }
423            let provider = self.providers.get(&profile.provider).with_context(|| {
424                format!(
425                    "provider '{}' referenced by model '{name}' was not found",
426                    profile.provider
427                )
428            })?;
429            let max_tokens = profile.max_tokens.unwrap_or(provider.max_tokens);
430            if max_tokens == 0 {
431                anyhow::bail!("max_tokens must be greater than zero for model '{name}'");
432            }
433            if let Some(context_window) = profile.context_window
434                && context_window <= u64::from(max_tokens)
435            {
436                anyhow::bail!(
437                    "context_window ({context_window}) must be greater than max_tokens ({max_tokens}) for model '{name}'"
438                );
439            }
440            for effort in profile.efforts.iter().chain(profile.effort.iter()) {
441                validate_effort(effort)?;
442            }
443            if let Some(effort) = &profile.effort
444                && !profile.efforts.iter().any(|candidate| candidate == effort)
445            {
446                anyhow::bail!(
447                    "default effort '{effort}' is not listed in efforts for model '{name}'"
448                );
449            }
450        }
451        Ok(())
452    }
453}
454
455fn canonical_efforts() -> Vec<String> {
456    ["none", "minimal", "low", "medium", "high", "xhigh", "max"]
457        .into_iter()
458        .map(str::to_owned)
459        .collect()
460}
461
462fn validate_effort(effort: &str) -> Result<()> {
463    if canonical_efforts()
464        .iter()
465        .any(|candidate| candidate == effort)
466    {
467        Ok(())
468    } else {
469        anyhow::bail!("unknown effort '{effort}'")
470    }
471}
472
473fn apply_effort(provider: &mut ProviderConfig, effort: &str) -> Result<()> {
474    match provider.kind {
475        ProviderKind::Responses => {
476            insert_nested_request_value(&mut provider.request, "reasoning", "effort", effort)
477        }
478        ProviderKind::Chatcompletion => {
479            provider.request.insert(
480                "reasoning_effort".into(),
481                serde_json::Value::String(effort.into()),
482            );
483            Ok(())
484        }
485        ProviderKind::Anthropic => {
486            insert_nested_request_value(&mut provider.request, "output_config", "effort", effort)
487        }
488    }
489}
490
491fn insert_nested_request_value(
492    request: &mut BTreeMap<String, serde_json::Value>,
493    object_key: &str,
494    field: &str,
495    value: &str,
496) -> Result<()> {
497    let object = request
498        .entry(object_key.into())
499        .or_insert_with(|| serde_json::json!({}));
500    let object = object
501        .as_object_mut()
502        .with_context(|| format!("request.{object_key} must be an object"))?;
503    object.insert(field.into(), serde_json::Value::String(value.into()));
504    Ok(())
505}
506
507fn merge_toml(base: &mut toml::Value, overlay: toml::Value) {
508    match (base, overlay) {
509        (toml::Value::Table(base), toml::Value::Table(overlay)) => {
510            for (key, value) in overlay {
511                match base.get_mut(&key) {
512                    Some(current) => merge_toml(current, value),
513                    None => {
514                        base.insert(key, value);
515                    }
516                }
517            }
518        }
519        (base, overlay) => *base = overlay,
520    }
521}