1use std::str::FromStr;
25
26use serde::Deserialize;
27
28use crate::error::{AgentError, ErrorKind};
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32pub enum ProviderKind {
33 #[default]
39 OpenAiCompatible,
40 Anthropic,
42}
43
44impl ProviderKind {
45 #[must_use]
47 pub const fn as_str(self) -> &'static str {
48 match self {
49 Self::OpenAiCompatible => "openai-compatible",
50 Self::Anthropic => "anthropic",
51 }
52 }
53
54 #[must_use]
59 pub const fn default_model(self) -> &'static str {
60 match self {
61 Self::OpenAiCompatible => "gpt-4o-mini",
62 Self::Anthropic => "claude-sonnet-4-5",
63 }
64 }
65
66 #[must_use]
68 pub const fn default_base_url(self) -> &'static str {
69 match self {
70 Self::OpenAiCompatible => "https://api.openai.com/v1",
71 Self::Anthropic => "https://api.anthropic.com",
72 }
73 }
74}
75
76impl FromStr for ProviderKind {
77 type Err = AgentError;
78
79 fn from_str(s: &str) -> Result<Self, Self::Err> {
80 match s.trim().to_ascii_lowercase().as_str() {
81 "openai-compatible" | "openai_compatible" | "openai" => Ok(Self::OpenAiCompatible),
82 "anthropic" => Ok(Self::Anthropic),
83 other => Err(AgentError::new(
84 ErrorKind::Config,
85 format!(
86 r#"unknown agent provider "{other}": expected "openai-compatible" or "anthropic""#
87 ),
88 )),
89 }
90 }
91}
92
93impl std::fmt::Display for ProviderKind {
94 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95 f.write_str(self.as_str())
96 }
97}
98
99impl<'de> Deserialize<'de> for ProviderKind {
100 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
101 where
102 D: serde::Deserializer<'de>,
103 {
104 let raw = String::deserialize(deserializer)?;
105 Self::from_str(&raw).map_err(serde::de::Error::custom)
106 }
107}
108
109#[derive(Debug, Clone)]
114pub struct AgentConfig {
115 pub provider: ProviderKind,
117 pub model: Option<String>,
119 pub base_url: Option<String>,
121 pub request_timeout_secs: u64,
123 pub max_steps: u32,
125 pub max_tokens: u32,
127 pub system_prompt: Option<String>,
129 pub prompt_caching: bool,
132 pub max_run_secs: Option<u64>,
134}
135
136impl Default for AgentConfig {
137 fn default() -> Self {
138 Self {
139 provider: ProviderKind::default(),
140 model: None,
141 base_url: None,
142 request_timeout_secs: 60,
143 max_steps: 10,
144 max_tokens: 32_000,
145 system_prompt: None,
146 prompt_caching: true,
147 max_run_secs: None,
148 }
149 }
150}
151
152#[derive(Debug, Default, Deserialize)]
158#[serde(deny_unknown_fields)]
159struct RawAgentConfig {
160 provider: Option<ProviderKind>,
161 model: Option<String>,
162 base_url: Option<String>,
163 request_timeout_secs: Option<u64>,
164 max_steps: Option<u32>,
165 max_tokens: Option<u32>,
166 system_prompt: Option<String>,
167 prompt_caching: Option<bool>,
168 max_run_secs: Option<u64>,
169}
170
171impl AgentConfig {
172 pub fn load() -> Result<Self, AgentError> {
184 let path = std::env::var("AGENT_CONFIG_FILE").unwrap_or_else(|_| "autumn.toml".to_owned());
185 let mut config = match std::fs::read_to_string(&path) {
186 Ok(text) => Self::from_toml_str(&text)?,
187 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Self::default(),
188 Err(err) => {
189 return Err(AgentError::with_source(
190 ErrorKind::Config,
191 format!("cannot read agent config file {path}"),
192 err,
193 ));
194 }
195 };
196 config.apply_env()?;
197 config.validate()?;
198 Ok(config)
199 }
200
201 pub fn from_toml_str(document: &str) -> Result<Self, AgentError> {
213 let table: toml::Table = document.parse().map_err(|err| {
214 AgentError::with_source(ErrorKind::Config, "invalid TOML in agent config", err)
215 })?;
216 let Some(section) = table.get("agent") else {
217 return Ok(Self::default());
218 };
219 let section_table = section
220 .as_table()
221 .ok_or_else(|| AgentError::new(ErrorKind::Config, "[agent] must be a TOML table"))?;
222 if section_table.contains_key("api_key") || section_table.contains_key("api-key") {
224 return Err(AgentError::new(
225 ErrorKind::Config,
226 "refusing to load: api_key must come from the AGENT_API_KEY environment variable, never from the config file",
227 ));
228 }
229 let raw: RawAgentConfig = section_table.clone().try_into().map_err(|err| {
230 let detail = err.to_string();
231 AgentError::with_source(
232 ErrorKind::Config,
233 format!("invalid [agent] table: {detail}"),
234 err,
235 )
236 })?;
237 let mut config = Self::default();
238 if let Some(provider) = raw.provider {
239 config.provider = provider;
240 }
241 if let Some(model) = raw.model {
242 config.model = Some(model);
243 }
244 if let Some(base_url) = raw.base_url {
245 config.base_url = Some(base_url);
246 }
247 if let Some(secs) = raw.request_timeout_secs {
248 config.request_timeout_secs = secs;
249 }
250 if let Some(steps) = raw.max_steps {
251 config.max_steps = steps;
252 }
253 if let Some(tokens) = raw.max_tokens {
254 config.max_tokens = tokens;
255 }
256 if let Some(prompt) = raw.system_prompt {
257 config.system_prompt = Some(prompt);
258 }
259 if let Some(caching) = raw.prompt_caching {
260 config.prompt_caching = caching;
261 }
262 if let Some(secs) = raw.max_run_secs {
263 config.max_run_secs = Some(secs);
264 }
265 config.validate()?;
266 Ok(config)
267 }
268
269 pub fn apply_env(&mut self) -> Result<(), AgentError> {
282 self.apply_env_with(|name| std::env::var(name).ok())
283 }
284
285 pub(crate) fn apply_env_with(
291 &mut self,
292 get: impl Fn(&str) -> Option<String>,
293 ) -> Result<(), AgentError> {
294 if let Some(raw) = get("AGENT_PROVIDER") {
295 self.provider = ProviderKind::from_str(&raw)?;
296 }
297 if let Some(model) = get("AGENT_MODEL") {
298 self.model = Some(model);
299 }
300 if let Some(base_url) = get("AGENT_BASE_URL") {
301 self.base_url = Some(base_url);
302 }
303 if let Some(raw) = get("AGENT_REQUEST_TIMEOUT_SECS") {
304 self.request_timeout_secs = parse_env_u64("AGENT_REQUEST_TIMEOUT_SECS", &raw)?;
305 }
306 if let Some(raw) = get("AGENT_MAX_STEPS") {
307 let steps = parse_env_u64("AGENT_MAX_STEPS", &raw)?;
308 self.max_steps = u32::try_from(steps).map_err(|_| {
309 AgentError::new(ErrorKind::Config, "AGENT_MAX_STEPS does not fit in a u32")
310 })?;
311 }
312 if let Some(raw) = get("AGENT_MAX_TOKENS") {
313 let tokens = parse_env_u64("AGENT_MAX_TOKENS", &raw)?;
314 self.max_tokens = u32::try_from(tokens).map_err(|_| {
315 AgentError::new(ErrorKind::Config, "AGENT_MAX_TOKENS does not fit in a u32")
316 })?;
317 }
318 if let Some(prompt) = get("AGENT_SYSTEM_PROMPT") {
319 self.system_prompt = Some(prompt);
320 }
321 if let Some(raw) = get("AGENT_PROMPT_CACHING") {
322 self.prompt_caching = parse_env_bool("AGENT_PROMPT_CACHING", &raw)?;
323 }
324 if let Some(raw) = get("AGENT_MAX_RUN_SECS") {
325 self.max_run_secs = Some(parse_env_u64("AGENT_MAX_RUN_SECS", &raw)?);
326 }
327 Ok(())
328 }
329
330 pub fn api_key(&self) -> Result<String, AgentError> {
339 Self::api_key_with(|name| std::env::var(name).ok())
340 }
341
342 pub(crate) fn api_key_with(get: impl Fn(&str) -> Option<String>) -> Result<String, AgentError> {
352 get("AGENT_API_KEY").ok_or_else(|| {
353 AgentError::new(
354 ErrorKind::Config,
355 "AGENT_API_KEY is not set: export it before starting the app",
356 )
357 })
358 }
359
360 pub fn validate(&self) -> Result<(), AgentError> {
368 if self.request_timeout_secs == 0 || self.request_timeout_secs > 3_600 {
369 return Err(AgentError::new(
370 ErrorKind::Config,
371 "request_timeout_secs must be between 1 and 3600",
372 ));
373 }
374 if self.max_steps == 0 || self.max_steps > 1_000 {
375 return Err(AgentError::new(
376 ErrorKind::Config,
377 "max_steps must be between 1 and 1000",
378 ));
379 }
380 if self.max_tokens < 1_000 || self.max_tokens > 1_000_000 {
381 return Err(AgentError::new(
382 ErrorKind::Config,
383 "max_tokens must be between 1000 and 1000000",
384 ));
385 }
386 if let Some(secs) = self.max_run_secs
387 && (secs == 0 || secs > 86_400)
388 {
389 return Err(AgentError::new(
390 ErrorKind::Config,
391 "max_run_secs must be between 1 and 86400",
392 ));
393 }
394 if let Some(model) = self.model.as_deref()
395 && model.trim().is_empty()
396 {
397 return Err(AgentError::new(
398 ErrorKind::Config,
399 "model must not be blank",
400 ));
401 }
402 if let Some(base_url) = self.base_url.as_deref() {
403 let url = reqwest::Url::parse(base_url).map_err(|err| {
404 AgentError::with_source(ErrorKind::Config, "base_url is not a valid URL", err)
405 })?;
406 if url.scheme() != "http" && url.scheme() != "https" {
407 return Err(AgentError::new(
408 ErrorKind::Config,
409 "base_url must use the http or https scheme",
410 ));
411 }
412 }
413 Ok(())
414 }
415
416 #[must_use]
418 pub fn resolved_model(&self) -> &str {
419 self.model
420 .as_deref()
421 .filter(|model| !model.trim().is_empty())
422 .unwrap_or_else(|| self.provider.default_model())
423 }
424
425 #[must_use]
427 pub fn resolved_base_url(&self) -> &str {
428 self.base_url
429 .as_deref()
430 .unwrap_or_else(|| self.provider.default_base_url())
431 }
432}
433
434fn parse_env_u64(name: &str, raw: &str) -> Result<u64, AgentError> {
436 raw.trim().parse::<u64>().map_err(|_| {
437 AgentError::new(
438 ErrorKind::Config,
439 format!("{name} must be a non-negative integer, got {raw:?}"),
440 )
441 })
442}
443
444fn parse_env_bool(name: &str, raw: &str) -> Result<bool, AgentError> {
446 match raw.trim().to_ascii_lowercase().as_str() {
447 "1" | "true" | "yes" | "on" => Ok(true),
448 "0" | "false" | "no" | "off" => Ok(false),
449 _ => Err(AgentError::new(
450 ErrorKind::Config,
451 format!("{name} must be true or false, got {raw:?}"),
452 )),
453 }
454}
455
456#[cfg(test)]
457mod tests;