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 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#[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 pub bash_timeout_seconds: u64,
173 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 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}