1use serde::{Deserialize, Serialize};
41
42#[cfg(any(feature = "openai", feature = "anthropic", feature = "openrouter"))]
43use crate::error;
44use crate::retry::RetryConfig;
45
46pub const OLLAMA_BASE_URL: &str = "http://localhost:11434/v1";
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
78pub struct EndpointCapabilities {
79 pub tool_calling: bool,
81
82 pub structured_output: bool,
85}
86
87impl Default for EndpointCapabilities {
88 fn default() -> Self {
91 Self::all()
92 }
93}
94
95impl EndpointCapabilities {
96 pub fn all() -> Self {
98 Self {
99 tool_calling: true,
100 structured_output: true,
101 }
102 }
103
104 pub fn text_only() -> Self {
106 Self {
107 tool_calling: false,
108 structured_output: false,
109 }
110 }
111
112 pub fn with_tool_calling(mut self, supported: bool) -> Self {
114 self.tool_calling = supported;
115 self
116 }
117
118 pub fn with_structured_output(mut self, supported: bool) -> Self {
120 self.structured_output = supported;
121 self
122 }
123}
124
125#[derive(Debug, Clone, Default, Serialize, Deserialize)]
132pub struct Config {
133 #[serde(skip_serializing_if = "Option::is_none")]
135 pub openai_api_key: Option<String>,
136
137 #[serde(skip_serializing_if = "Option::is_none")]
139 pub openai_base_url: Option<String>,
140
141 #[serde(skip_serializing_if = "Option::is_none")]
143 pub anthropic_api_key: Option<String>,
144
145 #[serde(skip_serializing_if = "Option::is_none")]
147 pub anthropic_base_url: Option<String>,
148
149 #[serde(skip_serializing_if = "Option::is_none")]
151 pub openrouter_api_key: Option<String>,
152
153 #[serde(skip_serializing_if = "Option::is_none")]
155 pub openrouter_base_url: Option<String>,
156
157 #[serde(skip_serializing_if = "Option::is_none")]
167 pub openai_compatible_base_url: Option<String>,
168
169 #[serde(skip_serializing_if = "Option::is_none")]
175 pub openai_compatible_api_key: Option<String>,
176
177 #[serde(skip_serializing_if = "Option::is_none")]
182 pub openai_compatible_capabilities: Option<EndpointCapabilities>,
183
184 #[serde(skip_serializing_if = "Option::is_none")]
186 pub openrouter_http_referer: Option<String>,
187
188 #[serde(skip_serializing_if = "Option::is_none")]
190 pub openrouter_title: Option<String>,
191
192 #[serde(skip_serializing_if = "Option::is_none")]
194 pub openrouter_categories: Option<Vec<String>>,
195
196 #[serde(skip_serializing_if = "Option::is_none")]
198 pub openrouter_app_url: Option<String>,
199
200 #[serde(skip_serializing_if = "Option::is_none")]
202 pub openrouter_app_title: Option<String>,
203
204 #[serde(skip_serializing_if = "Option::is_none")]
206 pub timeout_seconds: Option<u64>,
207
208 #[serde(skip_serializing_if = "Option::is_none")]
210 pub default_max_tokens: Option<i32>,
211
212 #[serde(skip_serializing_if = "Option::is_none")]
214 pub retry_config: Option<RetryConfig>,
215}
216
217impl Config {
218 pub fn new() -> Self {
220 Self::default()
221 }
222
223 pub fn from_env() -> Self {
231 let mut config = Self::new();
232
233 if let Ok(key) = std::env::var("OPENAI_API_KEY") {
234 config.openai_api_key = Some(key);
235 }
236 if let Ok(url) = std::env::var("OPENAI_BASE_URL") {
237 config.openai_base_url = Some(url);
238 }
239 if let Ok(key) = std::env::var("ANTHROPIC_API_KEY") {
240 config.anthropic_api_key = Some(key);
241 }
242 if let Ok(url) = std::env::var("ANTHROPIC_BASE_URL") {
243 config.anthropic_base_url = Some(url);
244 }
245 if let Ok(key) = std::env::var("OPENROUTER_API_KEY") {
246 config.openrouter_api_key = Some(key);
247 }
248 if let Ok(url) = std::env::var("OPENROUTER_BASE_URL") {
249 config.openrouter_base_url = Some(url);
250 }
251 if let Ok(referer) = std::env::var("OPENROUTER_HTTP_REFERER") {
252 config.openrouter_http_referer = Some(referer);
253 } else if let Ok(url) = std::env::var("OPENROUTER_APP_URL") {
254 config.openrouter_app_url = Some(url.clone());
255 config.openrouter_http_referer = Some(url);
256 }
257 if let Ok(title) = std::env::var("OPENROUTER_TITLE") {
258 config.openrouter_title = Some(title);
259 } else if let Ok(title) = std::env::var("OPENROUTER_APP_TITLE") {
260 config.openrouter_app_title = Some(title.clone());
261 config.openrouter_title = Some(title);
262 }
263 if let Ok(categories) = std::env::var("OPENROUTER_CATEGORIES") {
264 let categories = parse_openrouter_categories(&categories);
265 if !categories.is_empty() {
266 config.openrouter_categories = Some(categories);
267 }
268 }
269 if let Ok(timeout) = std::env::var("AI_TIMEOUT_SECONDS") {
270 if let Ok(secs) = timeout.parse() {
271 config.timeout_seconds = Some(secs);
272 }
273 }
274
275 let mut retry = RetryConfig::default();
276 let mut retry_customized = false;
277
278 if let Ok(value) = std::env::var("AI_MAX_RETRIES") {
279 if let Ok(max_retries) = value.parse() {
280 retry.max_retries = max_retries;
281 retry_customized = true;
282 }
283 }
284 if let Ok(value) = std::env::var("AI_RETRY_INITIAL_DELAY_MS") {
285 if let Ok(milliseconds) = value.parse() {
286 retry.initial_delay = std::time::Duration::from_millis(milliseconds);
287 retry_customized = true;
288 }
289 }
290 if let Ok(value) = std::env::var("AI_RETRY_MAX_DELAY_MS") {
291 if let Ok(milliseconds) = value.parse() {
292 retry.max_delay = std::time::Duration::from_millis(milliseconds);
293 retry_customized = true;
294 }
295 }
296 if let Ok(value) = std::env::var("AI_RETRY_BACKOFF_MULTIPLIER") {
297 if let Ok(multiplier) = value.parse() {
298 retry.backoff_multiplier = multiplier;
299 retry_customized = true;
300 }
301 }
302 if let Ok(value) = std::env::var("AI_RETRY_JITTER") {
303 if let Some(jitter) = parse_bool(&value) {
304 retry.jitter = jitter;
305 retry_customized = true;
306 }
307 }
308
309 if retry_customized {
310 config.retry_config = Some(retry);
311 }
312
313 config
314 }
315
316 pub fn with_openai_key(mut self, key: impl Into<String>) -> Self {
320 self.openai_api_key = Some(key.into());
321 self
322 }
323
324 pub fn with_openai_base_url(mut self, url: impl Into<String>) -> Self {
326 self.openai_base_url = Some(url.into());
327 self
328 }
329
330 pub fn with_anthropic_key(mut self, key: impl Into<String>) -> Self {
332 self.anthropic_api_key = Some(key.into());
333 self
334 }
335
336 pub fn with_anthropic_base_url(mut self, url: impl Into<String>) -> Self {
338 self.anthropic_base_url = Some(url.into());
339 self
340 }
341
342 pub fn with_openrouter_key(mut self, key: impl Into<String>) -> Self {
344 self.openrouter_api_key = Some(key.into());
345 self
346 }
347
348 pub fn with_openrouter_base_url(mut self, url: impl Into<String>) -> Self {
350 self.openrouter_base_url = Some(url.into());
351 self
352 }
353
354 pub fn with_openai_compatible_base_url(mut self, url: impl Into<String>) -> Self {
359 self.openai_compatible_base_url = Some(url.into());
360 self
361 }
362
363 pub fn with_openai_compatible_key(mut self, key: impl Into<String>) -> Self {
368 self.openai_compatible_api_key = Some(key.into());
369 self
370 }
371
372 pub fn with_openai_compatible_capabilities(
374 mut self,
375 capabilities: EndpointCapabilities,
376 ) -> Self {
377 self.openai_compatible_capabilities = Some(capabilities);
378 self
379 }
380
381 pub fn with_ollama(self) -> Self {
386 self.with_openai_compatible_base_url(OLLAMA_BASE_URL)
387 }
388
389 pub fn with_openrouter_http_referer(mut self, referer: impl Into<String>) -> Self {
391 self.openrouter_http_referer = Some(referer.into());
392 self
393 }
394
395 pub fn with_openrouter_title(mut self, title: impl Into<String>) -> Self {
397 self.openrouter_title = Some(title.into());
398 self
399 }
400
401 pub fn with_openrouter_categories(mut self, categories: Vec<String>) -> Self {
403 self.openrouter_categories = Some(categories);
404 self
405 }
406
407 pub fn with_openrouter_app_url(mut self, url: impl Into<String>) -> Self {
410 let url = url.into();
411 self.openrouter_app_url = Some(url.clone());
412 self.openrouter_http_referer = Some(url);
413 self
414 }
415
416 pub fn with_openrouter_app_title(mut self, title: impl Into<String>) -> Self {
419 let title = title.into();
420 self.openrouter_app_title = Some(title.clone());
421 self.openrouter_title = Some(title);
422 self
423 }
424
425 pub fn with_timeout(mut self, seconds: u64) -> Self {
427 self.timeout_seconds = Some(seconds);
428 self
429 }
430
431 pub fn with_default_max_tokens(mut self, max_tokens: i32) -> Self {
433 self.default_max_tokens = Some(max_tokens);
434 self
435 }
436
437 pub fn with_retry_config(mut self, retry_config: RetryConfig) -> Self {
439 self.retry_config = Some(retry_config);
440 self
441 }
442
443 pub fn openai_key(&self) -> Option<String> {
447 self.openai_api_key
448 .clone()
449 .or_else(|| std::env::var("OPENAI_API_KEY").ok())
450 }
451
452 pub fn anthropic_key(&self) -> Option<String> {
454 self.anthropic_api_key
455 .clone()
456 .or_else(|| std::env::var("ANTHROPIC_API_KEY").ok())
457 }
458
459 pub fn openrouter_key(&self) -> Option<String> {
461 self.openrouter_api_key
462 .clone()
463 .or_else(|| std::env::var("OPENROUTER_API_KEY").ok())
464 }
465
466 pub fn openrouter_base_url(&self) -> Option<String> {
470 self.openrouter_base_url
471 .clone()
472 .or_else(|| std::env::var("OPENROUTER_BASE_URL").ok())
473 }
474
475 pub fn openai_compatible_base_url(&self) -> Option<String> {
489 self.openai_compatible_base_url.clone()
490 }
491
492 pub fn openai_compatible_key(&self) -> Option<String> {
497 self.openai_compatible_api_key.clone()
498 }
499
500 pub fn openai_compatible_capabilities(&self) -> EndpointCapabilities {
503 self.openai_compatible_capabilities.unwrap_or_default()
504 }
505
506 pub fn openrouter_http_referer(&self) -> Option<String> {
511 self.openrouter_http_referer
512 .clone()
513 .or_else(|| self.openrouter_app_url.clone())
514 .or_else(|| std::env::var("OPENROUTER_HTTP_REFERER").ok())
515 .or_else(|| std::env::var("OPENROUTER_APP_URL").ok())
516 }
517
518 pub fn openrouter_title(&self) -> Option<String> {
523 self.openrouter_title
524 .clone()
525 .or_else(|| self.openrouter_app_title.clone())
526 .or_else(|| std::env::var("OPENROUTER_TITLE").ok())
527 .or_else(|| std::env::var("OPENROUTER_APP_TITLE").ok())
528 }
529
530 pub fn openrouter_categories(&self) -> Option<Vec<String>> {
533 self.openrouter_categories.clone().or_else(|| {
534 std::env::var("OPENROUTER_CATEGORIES")
535 .ok()
536 .map(|categories| parse_openrouter_categories(&categories))
537 .filter(|categories| !categories.is_empty())
538 })
539 }
540
541 pub fn openrouter_app_url(&self) -> Option<String> {
544 self.openrouter_http_referer()
545 }
546
547 pub fn openrouter_app_title(&self) -> Option<String> {
550 self.openrouter_title()
551 }
552
553 pub fn retry_config(&self) -> RetryConfig {
555 self.retry_config.clone().unwrap_or_default()
556 }
557
558 pub fn timeout(&self) -> u64 {
560 self.timeout_seconds.unwrap_or(120)
561 }
562
563 #[cfg(feature = "openai")]
572 pub fn validate_openai(&self) -> error::Result<()> {
573 if self.openai_key().is_none() {
574 return Err(error::Error::Config(
575 "OpenAI API key not configured. Set OPENAI_API_KEY env var or provide via config."
576 .into(),
577 ));
578 }
579 Ok(())
580 }
581
582 #[cfg(feature = "anthropic")]
589 pub fn validate_anthropic(&self) -> error::Result<()> {
590 if self.anthropic_key().is_none() {
591 return Err(error::Error::Config(
592 "Anthropic API key not configured. Set ANTHROPIC_API_KEY env var or provide via config."
593 .into(),
594 ));
595 }
596 Ok(())
597 }
598
599 #[cfg(feature = "openrouter")]
606 pub fn validate_openrouter(&self) -> error::Result<()> {
607 if self.openrouter_key().is_none() {
608 return Err(error::Error::Config(
609 "OpenRouter API key not configured. Set OPENROUTER_API_KEY env var or provide via config."
610 .into(),
611 ));
612 }
613 Ok(())
614 }
615}
616
617fn parse_openrouter_categories(value: &str) -> Vec<String> {
618 value
619 .split(',')
620 .map(str::trim)
621 .filter(|category| !category.is_empty())
622 .map(ToOwned::to_owned)
623 .collect()
624}
625
626fn parse_bool(value: &str) -> Option<bool> {
627 match value.trim().to_ascii_lowercase().as_str() {
628 "1" | "true" | "yes" | "on" => Some(true),
629 "0" | "false" | "no" | "off" => Some(false),
630 _ => None,
631 }
632}
633
634#[cfg(test)]
635mod tests {
636 use std::time::Duration;
637
638 use super::*;
639
640 #[test]
641 fn openrouter_attribution_builders_set_canonical_fields() {
642 let config = Config::new()
643 .with_openrouter_base_url("https://proxy.example.com/api/v1")
644 .with_openrouter_http_referer("https://app.example.com")
645 .with_openrouter_title("Example App")
646 .with_openrouter_categories(vec!["productivity".to_string(), "agents".to_string()]);
647
648 assert_eq!(
649 config.openrouter_base_url(),
650 Some("https://proxy.example.com/api/v1".to_string())
651 );
652 assert_eq!(
653 config.openrouter_http_referer(),
654 Some("https://app.example.com".to_string())
655 );
656 assert_eq!(config.openrouter_title(), Some("Example App".to_string()));
657 assert_eq!(
658 config.openrouter_categories(),
659 Some(vec!["productivity".to_string(), "agents".to_string()])
660 );
661 }
662
663 #[test]
664 fn retry_config_defaults_when_not_set() {
665 assert_eq!(Config::new().retry_config().max_retries, 3);
666 }
667
668 #[test]
669 fn retry_config_builder_overrides_defaults() {
670 let retry = RetryConfig::new().with_initial_delay(Duration::from_millis(250));
671 let config = Config::new().with_retry_config(retry);
672
673 assert_eq!(
674 config.retry_config().initial_delay,
675 Duration::from_millis(250)
676 );
677 }
678
679 #[test]
680 fn openrouter_category_parser_trims_empty_values() {
681 assert_eq!(
682 parse_openrouter_categories(" agents, , productivity "),
683 vec!["agents".to_string(), "productivity".to_string()]
684 );
685 }
686}