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 headers: BTreeMap<String, String>,
113 pub request: BTreeMap<String, serde_json::Value>,
114}
115
116#[derive(Debug, Clone)]
117pub struct ModelSelection {
118 pub name: String,
119 pub provider_name: String,
120 pub provider: ProviderConfig,
121 pub effort: Option<String>,
122 pub efforts: Vec<String>,
123 pub context_window: Option<u64>,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
127#[serde(default)]
128pub struct UiConfig {
129 pub show_reasoning: bool,
130 pub reasoning_toggle: String,
131 pub tool_input_max_bytes: usize,
132 pub tool_output_max_bytes: usize,
133 pub tool_output_max_lines: usize,
134 pub tool_live_output_lines: usize,
135}
136
137impl Default for UiConfig {
138 fn default() -> Self {
139 Self {
140 show_reasoning: false,
141 reasoning_toggle: "ctrl-o".into(),
142 tool_input_max_bytes: 2048,
143 tool_output_max_bytes: 8192,
144 tool_output_max_lines: 16,
145 tool_live_output_lines: 6,
146 }
147 }
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
151#[serde(default)]
152pub struct ToolsConfig {
153 pub bash_timeout_seconds: u64,
154 pub max_parallel: usize,
155 pub max_output_bytes: usize,
156}
157
158impl Default for ToolsConfig {
159 fn default() -> Self {
160 Self {
161 bash_timeout_seconds: 120,
162 max_parallel: 8,
163 max_output_bytes: 65_536,
164 }
165 }
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
169#[serde(default)]
170pub struct ContextConfig {
171 pub shell_history_count: usize,
172 pub stdin_max_bytes: usize,
173 pub read_max_lines: usize,
174}
175
176impl Default for ContextConfig {
177 fn default() -> Self {
178 Self {
179 shell_history_count: 5,
180 stdin_max_bytes: 131_072,
181 read_max_lines: 1000,
182 }
183 }
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
187#[serde(default)]
188pub struct SessionConfig {
189 pub max_agent_cycles: usize,
190 pub shell_history_limit: usize,
191 pub input_history_limit: usize,
192}
193
194impl Default for SessionConfig {
195 fn default() -> Self {
196 Self {
197 max_agent_cycles: 50,
198 shell_history_limit: 5000,
199 input_history_limit: 1000,
200 }
201 }
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize)]
205#[serde(default)]
206pub struct Config {
207 pub default_model: String,
208 pub providers: BTreeMap<String, ProviderConfig>,
209 pub models: BTreeMap<String, ModelProfile>,
210 pub ui: UiConfig,
211 pub tools: ToolsConfig,
212 pub context: ContextConfig,
213 pub session: SessionConfig,
214}
215
216impl Default for Config {
217 fn default() -> Self {
218 let default_model = ModelProfile {
219 provider: "openai".into(),
220 model: "gpt-5.6".into(),
221 effort: Some("medium".into()),
222 efforts: canonical_efforts(),
223 context_window: Some(1_050_000),
224 ..ModelProfile::default()
225 };
226 Self {
227 default_model: "default".into(),
228 providers: BTreeMap::from([("openai".into(), ProviderConfig::default())]),
229 models: BTreeMap::from([("default".into(), default_model)]),
230 ui: UiConfig::default(),
231 tools: ToolsConfig::default(),
232 context: ContextConfig::default(),
233 session: SessionConfig::default(),
234 }
235 }
236}
237
238impl Config {
239 pub fn ensure_user_config(home: &Path) -> Result<Option<PathBuf>> {
240 let path = home.join(".config/a/config.toml");
241 if path.exists() {
242 return Ok(None);
243 }
244 let directory = path.parent().context("config path has no parent")?;
245 fs::create_dir_all(directory)
246 .with_context(|| format!("create config directory {}", directory.display()))?;
247 let mut temporary = tempfile::NamedTempFile::new_in(directory)?;
248 temporary.write_all(include_bytes!("../config.example.toml"))?;
249 temporary.as_file().sync_all()?;
250 match temporary.persist_noclobber(&path) {
251 Ok(_) => Ok(Some(path)),
252 Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => Ok(None),
253 Err(error) => Err(error.error)
254 .with_context(|| format!("create initial config {}", path.display())),
255 }
256 }
257
258 pub fn load_from(cwd: &Path, home: &Path) -> Result<Self> {
259 let global = home.join(".config/a/config.toml");
260 let project = cwd.join(".a/config.toml");
261 let mut merged = toml::Value::Table(Default::default());
262 for path in [global, project] {
263 if path.is_file() {
264 let source = fs::read_to_string(&path)
265 .with_context(|| format!("read config {}", path.display()))?;
266 let value = toml::from_str::<toml::Value>(&source)
267 .with_context(|| format!("parse config {}", path.display()))?;
268 merge_toml(&mut merged, value);
269 }
270 }
271
272 if merged.get("provider").is_some() {
273 anyhow::bail!(
274 "legacy [provider] configuration is no longer supported; define [providers.<name>], [models.<name>], and default_model"
275 );
276 }
277 let explicit_api_key_envs = merged
278 .get("providers")
279 .and_then(toml::Value::as_table)
280 .into_iter()
281 .flat_map(|providers| providers.iter())
282 .filter_map(|(name, value)| value.get("api_key_env").map(|_| name.clone()))
283 .collect::<BTreeSet<_>>();
284 let mut config: Self = merged.try_into().context("decode merged configuration")?;
285 for (name, provider) in &mut config.providers {
286 if !explicit_api_key_envs.contains(name) && provider.kind == ProviderKind::Anthropic {
287 provider.api_key_env = "ANTHROPIC_API_KEY".into();
288 }
289 }
290 if config.tools.max_parallel == 0 {
291 anyhow::bail!("tools.max_parallel must be greater than zero");
292 }
293 config.validate_models()?;
294 Ok(config)
295 }
296
297 pub fn load(cwd: &Path) -> Result<Self> {
298 let home = std::env::var_os("HOME")
299 .map(PathBuf::from)
300 .context("HOME is not set")?;
301 Self::load_from(cwd, &home)
302 }
303
304 pub fn model_names(&self) -> Vec<&str> {
305 self.models.keys().map(String::as_str).collect()
306 }
307
308 pub fn resolve_model(
309 &self,
310 name: Option<&str>,
311 effort_override: Option<&str>,
312 ) -> Result<ModelSelection> {
313 let name = name.unwrap_or(&self.default_model);
314 let profile = self
315 .models
316 .get(name)
317 .with_context(|| format!("model profile not found: {name}"))?;
318 let mut provider = self
319 .providers
320 .get(&profile.provider)
321 .cloned()
322 .with_context(|| {
323 format!(
324 "provider '{}' referenced by model '{name}' was not found",
325 profile.provider
326 )
327 })?;
328 provider.model = profile.model.clone();
329 if let Some(max_tokens) = profile.max_tokens {
330 provider.max_tokens = max_tokens;
331 }
332 provider.headers.extend(profile.headers.clone());
333 provider.request.extend(profile.request.clone());
334
335 let effort = effort_override.or(profile.effort.as_deref());
336 if let Some(effort) = effort {
337 validate_effort(effort)?;
338 if !profile.efforts.iter().any(|candidate| candidate == effort) {
339 anyhow::bail!("effort '{effort}' is not configured for model '{name}'");
340 }
341 apply_effort(&mut provider, effort)?;
342 }
343 Ok(ModelSelection {
344 name: name.into(),
345 provider_name: profile.provider.clone(),
346 provider,
347 effort: effort.map(str::to_owned),
348 efforts: profile.efforts.clone(),
349 context_window: profile.context_window,
350 })
351 }
352
353 pub fn resolve_session_model(
354 &self,
355 profile: Option<&str>,
356 provider_type: &str,
357 model: &str,
358 effort: Option<&str>,
359 ) -> Result<ModelSelection> {
360 if let Some(profile) = profile {
361 return self.resolve_model(Some(profile), effort);
362 }
363 let kind = ProviderKind::parse(provider_type)?;
364 for name in self.models.keys() {
365 let selection = self.resolve_model(Some(name), None)?;
366 if selection.provider.kind == kind && selection.provider.model == model {
367 return self.resolve_model(Some(name), effort);
368 }
369 }
370 anyhow::bail!(
371 "session model {provider_type}/{model} does not match a configured model profile"
372 )
373 }
374
375 fn validate_models(&self) -> Result<()> {
376 if self.models.is_empty() {
377 anyhow::bail!("at least one [models.<name>] profile is required");
378 }
379 if !self.models.contains_key(&self.default_model) {
380 anyhow::bail!("default_model '{}' was not found", self.default_model);
381 }
382 for (name, profile) in &self.models {
383 if profile.provider.is_empty() || profile.model.is_empty() {
384 anyhow::bail!("model '{name}' requires provider and model");
385 }
386 let provider = self.providers.get(&profile.provider).with_context(|| {
387 format!(
388 "provider '{}' referenced by model '{name}' was not found",
389 profile.provider
390 )
391 })?;
392 let max_tokens = profile.max_tokens.unwrap_or(provider.max_tokens);
393 if max_tokens == 0 {
394 anyhow::bail!("max_tokens must be greater than zero for model '{name}'");
395 }
396 if let Some(context_window) = profile.context_window
397 && context_window <= u64::from(max_tokens)
398 {
399 anyhow::bail!(
400 "context_window ({context_window}) must be greater than max_tokens ({max_tokens}) for model '{name}'"
401 );
402 }
403 for effort in profile.efforts.iter().chain(profile.effort.iter()) {
404 validate_effort(effort)?;
405 }
406 if let Some(effort) = &profile.effort
407 && !profile.efforts.iter().any(|candidate| candidate == effort)
408 {
409 anyhow::bail!(
410 "default effort '{effort}' is not listed in efforts for model '{name}'"
411 );
412 }
413 }
414 Ok(())
415 }
416}
417
418fn canonical_efforts() -> Vec<String> {
419 ["none", "minimal", "low", "medium", "high", "xhigh", "max"]
420 .into_iter()
421 .map(str::to_owned)
422 .collect()
423}
424
425fn validate_effort(effort: &str) -> Result<()> {
426 if canonical_efforts()
427 .iter()
428 .any(|candidate| candidate == effort)
429 {
430 Ok(())
431 } else {
432 anyhow::bail!("unknown effort '{effort}'")
433 }
434}
435
436fn apply_effort(provider: &mut ProviderConfig, effort: &str) -> Result<()> {
437 match provider.kind {
438 ProviderKind::Responses => {
439 insert_nested_request_value(&mut provider.request, "reasoning", "effort", effort)
440 }
441 ProviderKind::Chatcompletion => {
442 provider.request.insert(
443 "reasoning_effort".into(),
444 serde_json::Value::String(effort.into()),
445 );
446 Ok(())
447 }
448 ProviderKind::Anthropic => {
449 insert_nested_request_value(&mut provider.request, "output_config", "effort", effort)
450 }
451 }
452}
453
454fn insert_nested_request_value(
455 request: &mut BTreeMap<String, serde_json::Value>,
456 object_key: &str,
457 field: &str,
458 value: &str,
459) -> Result<()> {
460 let object = request
461 .entry(object_key.into())
462 .or_insert_with(|| serde_json::json!({}));
463 let object = object
464 .as_object_mut()
465 .with_context(|| format!("request.{object_key} must be an object"))?;
466 object.insert(field.into(), serde_json::Value::String(value.into()));
467 Ok(())
468}
469
470fn merge_toml(base: &mut toml::Value, overlay: toml::Value) {
471 match (base, overlay) {
472 (toml::Value::Table(base), toml::Value::Table(overlay)) => {
473 for (key, value) in overlay {
474 match base.get_mut(&key) {
475 Some(current) => merge_toml(current, value),
476 None => {
477 base.insert(key, value);
478 }
479 }
480 }
481 }
482 (base, overlay) => *base = overlay,
483 }
484}