1use serde::Deserialize;
2use std::collections::BTreeMap;
3use std::sync::OnceLock;
4
5static CONFIG: OnceLock<ProvidersConfig> = OnceLock::new();
6static CONFIG_PATH: OnceLock<String> = OnceLock::new();
7
8#[derive(Debug, Clone, Deserialize, Default)]
13pub struct ProvidersConfig {
14 #[serde(default)]
15 pub providers: BTreeMap<String, ProviderDef>,
16 #[serde(default)]
17 pub aliases: BTreeMap<String, AliasDef>,
18 #[serde(default)]
19 pub inference_rules: Vec<InferenceRule>,
20 #[serde(default)]
21 pub tier_rules: Vec<TierRule>,
22 #[serde(default)]
23 pub tier_defaults: TierDefaults,
24 #[serde(default)]
25 pub model_defaults: BTreeMap<String, BTreeMap<String, toml::Value>>,
26}
27
28#[derive(Debug, Clone, Deserialize)]
29pub struct ProviderDef {
30 pub base_url: String,
31 #[serde(default)]
32 pub base_url_env: Option<String>,
33 #[serde(default = "default_bearer")]
34 pub auth_style: String,
35 #[serde(default)]
36 pub auth_header: Option<String>,
37 #[serde(default)]
38 pub auth_env: AuthEnv,
39 #[serde(default)]
40 pub extra_headers: BTreeMap<String, String>,
41 #[serde(default)]
42 pub chat_endpoint: String,
43 #[serde(default)]
44 pub completion_endpoint: Option<String>,
45 #[serde(default)]
46 pub healthcheck: Option<HealthcheckDef>,
47 #[serde(default)]
48 pub features: Vec<String>,
49 #[serde(default)]
51 pub fallback: Option<String>,
52 #[serde(default)]
54 pub retry_count: Option<u32>,
55 #[serde(default)]
57 pub retry_delay_ms: Option<u64>,
58 #[serde(default)]
60 pub rpm: Option<u32>,
61}
62
63impl Default for ProviderDef {
64 fn default() -> Self {
65 Self {
66 base_url: String::new(),
67 base_url_env: None,
68 auth_style: default_bearer(),
69 auth_header: None,
70 auth_env: AuthEnv::None,
71 extra_headers: BTreeMap::new(),
72 chat_endpoint: String::new(),
73 completion_endpoint: None,
74 healthcheck: None,
75 features: Vec::new(),
76 fallback: None,
77 retry_count: None,
78 retry_delay_ms: None,
79 rpm: None,
80 }
81 }
82}
83
84fn default_bearer() -> String {
85 "bearer".to_string()
86}
87
88#[derive(Debug, Clone, Deserialize, Default)]
91#[serde(untagged)]
92pub enum AuthEnv {
93 #[default]
94 None,
95 Single(String),
96 Multiple(Vec<String>),
97}
98
99#[derive(Debug, Clone, Deserialize)]
100pub struct HealthcheckDef {
101 pub method: String,
102 #[serde(default)]
103 pub path: Option<String>,
104 #[serde(default)]
105 pub url: Option<String>,
106 #[serde(default)]
107 pub body: Option<String>,
108}
109
110#[derive(Debug, Clone, Deserialize)]
111pub struct AliasDef {
112 pub id: String,
113 pub provider: String,
114 #[serde(default)]
119 pub tool_format: Option<String>,
120}
121
122#[derive(Debug, Clone, Deserialize)]
123pub struct InferenceRule {
124 #[serde(default)]
125 pub pattern: Option<String>,
126 #[serde(default)]
127 pub contains: Option<String>,
128 #[serde(default)]
129 pub exact: Option<String>,
130 pub provider: String,
131}
132
133#[derive(Debug, Clone, Deserialize)]
134pub struct TierRule {
135 #[serde(default)]
136 pub pattern: Option<String>,
137 #[serde(default)]
138 pub contains: Option<String>,
139 #[serde(default)]
140 pub exact: Option<String>,
141 pub tier: String,
142}
143
144#[derive(Debug, Clone, Deserialize)]
145pub struct TierDefaults {
146 #[serde(default = "default_mid")]
147 pub default: String,
148}
149
150impl Default for TierDefaults {
151 fn default() -> Self {
152 Self {
153 default: default_mid(),
154 }
155 }
156}
157
158fn default_mid() -> String {
159 "mid".to_string()
160}
161
162pub fn load_config() -> &'static ProvidersConfig {
168 CONFIG.get_or_init(|| {
169 let verbose_config_logging = matches!(
170 std::env::var("HARN_VERBOSE_CONFIG").ok().as_deref(),
171 Some("1" | "true" | "TRUE" | "yes" | "YES")
172 ) || matches!(
173 std::env::var("HARN_ACP_VERBOSE").ok().as_deref(),
174 Some("1" | "true" | "TRUE" | "yes" | "YES")
175 );
176 if let Ok(path) = std::env::var("HARN_PROVIDERS_CONFIG") {
178 match std::fs::read_to_string(&path) {
179 Ok(content) => match toml::from_str::<ProvidersConfig>(&content) {
180 Ok(config) => {
181 if verbose_config_logging {
182 eprintln!(
183 "[llm_config] Loaded {} providers, {} aliases from {}",
184 config.providers.len(),
185 config.aliases.len(),
186 path
187 );
188 }
189 let _ = CONFIG_PATH.set(path);
190 return config;
191 }
192 Err(e) => eprintln!("[llm_config] TOML parse error in {}: {}", path, e),
193 },
194 Err(e) => eprintln!("[llm_config] Cannot read {}: {}", path, e),
195 }
196 }
197 if let Some(home) = dirs_or_home() {
199 let path = format!("{home}/.config/harn/providers.toml");
200 if let Ok(content) = std::fs::read_to_string(&path) {
201 if let Ok(config) = toml::from_str::<ProvidersConfig>(&content) {
202 let _ = CONFIG_PATH.set(path);
203 return config;
204 }
205 }
206 }
207 default_config()
209 })
210}
211
212pub fn loaded_config_path() -> Option<std::path::PathBuf> {
215 let _ = load_config();
217 CONFIG_PATH.get().map(std::path::PathBuf::from)
218}
219
220pub fn resolve_model(alias: &str) -> (String, Option<String>) {
222 let config = load_config();
223 if let Some(a) = config.aliases.get(alias) {
224 return (a.id.clone(), Some(a.provider.clone()));
225 }
226 (alias.to_string(), None)
227}
228
229pub fn infer_provider(model_id: &str) -> String {
231 let config = load_config();
232 for rule in &config.inference_rules {
233 if let Some(exact) = &rule.exact {
234 if model_id == exact {
235 return rule.provider.clone();
236 }
237 }
238 if let Some(pattern) = &rule.pattern {
239 if glob_match(pattern, model_id) {
240 return rule.provider.clone();
241 }
242 }
243 if let Some(substr) = &rule.contains {
244 if model_id.contains(substr.as_str()) {
245 return rule.provider.clone();
246 }
247 }
248 }
249 if model_id.starts_with("local:") {
254 return "local".to_string();
255 }
256 if model_id.starts_with("claude-") {
257 return "anthropic".to_string();
258 }
259 if model_id.starts_with("gpt-") || model_id.starts_with("o1") || model_id.starts_with("o3") {
260 return "openai".to_string();
261 }
262 if model_id.contains('/') {
263 return "openrouter".to_string();
264 }
265 if model_id.contains(':') {
266 return "ollama".to_string();
267 }
268 "anthropic".to_string()
269}
270
271pub fn model_tier(model_id: &str) -> String {
273 let config = load_config();
274 for rule in &config.tier_rules {
275 if let Some(exact) = &rule.exact {
276 if model_id == exact {
277 return rule.tier.clone();
278 }
279 }
280 if let Some(pattern) = &rule.pattern {
281 if glob_match(pattern, model_id) {
282 return rule.tier.clone();
283 }
284 }
285 if let Some(substr) = &rule.contains {
286 if model_id.contains(substr.as_str()) {
287 return rule.tier.clone();
288 }
289 }
290 }
291 let lower = model_id.to_lowercase();
293 if lower.contains("9b") || lower.contains("a3b") {
294 return "small".to_string();
295 }
296 if lower.starts_with("claude-") || lower == "gpt-4o" {
297 return "frontier".to_string();
298 }
299 config.tier_defaults.default.clone()
300}
301
302pub fn provider_config(name: &str) -> Option<&'static ProviderDef> {
304 load_config().providers.get(name)
305}
306
307pub fn model_params(model_id: &str) -> BTreeMap<String, toml::Value> {
310 let config = load_config();
311 let mut params = BTreeMap::new();
312 for (pattern, defaults) in &config.model_defaults {
313 if glob_match(pattern, model_id) {
314 for (k, v) in defaults {
315 params.insert(k.clone(), v.clone());
316 }
317 }
318 }
319 params
320}
321
322pub fn provider_names() -> Vec<String> {
324 load_config().providers.keys().cloned().collect()
325}
326
327pub fn provider_has_feature(provider: &str, feature: &str) -> bool {
329 provider_config(provider)
330 .map(|p| p.features.iter().any(|f| f == feature))
331 .unwrap_or(false)
332}
333
334pub fn default_tool_format(model: &str, provider: &str) -> String {
337 let config = load_config();
338 for (name, alias) in &config.aliases {
340 let matches = (alias.id == model && alias.provider == provider) || name == model;
341 if matches {
342 if let Some(ref fmt) = alias.tool_format {
343 return fmt.clone();
344 }
345 }
346 }
347 if provider_has_feature(provider, "native_tools") {
349 "native".to_string()
350 } else {
351 "text".to_string()
352 }
353}
354
355pub fn resolve_tier_model(
357 target: &str,
358 preferred_provider: Option<&str>,
359) -> Option<(String, String)> {
360 let config = load_config();
361
362 if let Some(alias) = config.aliases.get(target) {
363 return Some((alias.id.clone(), alias.provider.clone()));
364 }
365
366 let candidate_aliases = if let Some(provider) = preferred_provider {
367 vec![
368 format!("{provider}/{target}"),
369 format!("{provider}:{target}"),
370 format!("tier/{target}"),
371 target.to_string(),
372 ]
373 } else {
374 vec![format!("tier/{target}"), target.to_string()]
375 };
376
377 for alias_name in candidate_aliases {
378 if let Some(alias) = config.aliases.get(&alias_name) {
379 return Some((alias.id.clone(), alias.provider.clone()));
380 }
381 }
382
383 None
384}
385
386pub fn tier_candidates(target: &str) -> Vec<(String, String)> {
390 let config = load_config();
391 let mut seen = std::collections::BTreeSet::new();
392 let mut candidates = Vec::new();
393
394 for alias in config.aliases.values() {
395 let pair = (alias.id.clone(), alias.provider.clone());
396 if seen.contains(&pair) {
397 continue;
398 }
399 if model_tier(&alias.id) == target {
400 seen.insert(pair.clone());
401 candidates.push(pair);
402 }
403 }
404
405 candidates.sort_by(|(model_a, provider_a), (model_b, provider_b)| {
406 provider_a
407 .cmp(provider_b)
408 .then_with(|| model_a.cmp(model_b))
409 });
410 candidates
411}
412
413fn glob_match(pattern: &str, input: &str) -> bool {
419 if let Some(prefix) = pattern.strip_suffix('*') {
420 input.starts_with(prefix)
421 } else if let Some(suffix) = pattern.strip_prefix('*') {
422 input.ends_with(suffix)
423 } else if pattern.contains('*') {
424 let parts: Vec<&str> = pattern.split('*').collect();
425 if parts.len() == 2 {
426 input.starts_with(parts[0]) && input.ends_with(parts[1])
427 } else {
428 input == pattern
429 }
430 } else {
431 input == pattern
432 }
433}
434
435fn dirs_or_home() -> Option<String> {
436 std::env::var("HOME").ok()
437}
438
439pub fn resolve_base_url(pdef: &ProviderDef) -> String {
442 if let Some(env_name) = &pdef.base_url_env {
443 if let Ok(val) = std::env::var(env_name) {
444 let trimmed = val.trim().trim_matches('"').trim_matches('\'');
446 if !trimmed.is_empty() {
447 return trimmed.to_string();
448 }
449 }
450 }
451 pdef.base_url.clone()
452}
453
454fn default_config() -> ProvidersConfig {
459 let mut config = ProvidersConfig::default();
460
461 config.providers.insert(
463 "anthropic".to_string(),
464 ProviderDef {
465 base_url: "https://api.anthropic.com/v1".to_string(),
466 auth_style: "header".to_string(),
467 auth_header: Some("x-api-key".to_string()),
468 auth_env: AuthEnv::Single("ANTHROPIC_API_KEY".to_string()),
469 extra_headers: BTreeMap::from([(
470 "anthropic-version".to_string(),
471 "2023-06-01".to_string(),
472 )]),
473 chat_endpoint: "/messages".to_string(),
474 completion_endpoint: None,
475 healthcheck: Some(HealthcheckDef {
476 method: "POST".to_string(),
477 path: Some("/messages/count_tokens".to_string()),
478 url: None,
479 body: Some(
480 r#"{"model":"claude-sonnet-4-20250514","messages":[{"role":"user","content":"x"}]}"#
481 .to_string(),
482 ),
483 }),
484 features: vec!["prompt_caching".to_string(), "thinking".to_string()],
485 ..Default::default()
486 },
487 );
488
489 config.providers.insert(
491 "openai".to_string(),
492 ProviderDef {
493 base_url: "https://api.openai.com/v1".to_string(),
494 auth_style: "bearer".to_string(),
495 auth_env: AuthEnv::Single("OPENAI_API_KEY".to_string()),
496 chat_endpoint: "/chat/completions".to_string(),
497 completion_endpoint: Some("/completions".to_string()),
498 healthcheck: Some(HealthcheckDef {
499 method: "GET".to_string(),
500 path: Some("/models".to_string()),
501 url: None,
502 body: None,
503 }),
504 ..Default::default()
505 },
506 );
507
508 config.providers.insert(
510 "openrouter".to_string(),
511 ProviderDef {
512 base_url: "https://openrouter.ai/api/v1".to_string(),
513 auth_style: "bearer".to_string(),
514 auth_env: AuthEnv::Single("OPENROUTER_API_KEY".to_string()),
515 chat_endpoint: "/chat/completions".to_string(),
516 completion_endpoint: Some("/completions".to_string()),
517 healthcheck: Some(HealthcheckDef {
518 method: "GET".to_string(),
519 path: Some("/auth/key".to_string()),
520 url: None,
521 body: None,
522 }),
523 ..Default::default()
524 },
525 );
526
527 config.providers.insert(
529 "huggingface".to_string(),
530 ProviderDef {
531 base_url: "https://router.huggingface.co/v1".to_string(),
532 auth_style: "bearer".to_string(),
533 auth_env: AuthEnv::Multiple(vec![
534 "HF_TOKEN".to_string(),
535 "HUGGINGFACE_API_KEY".to_string(),
536 ]),
537 chat_endpoint: "/chat/completions".to_string(),
538 completion_endpoint: Some("/completions".to_string()),
539 healthcheck: Some(HealthcheckDef {
540 method: "GET".to_string(),
541 url: Some("https://huggingface.co/api/whoami-v2".to_string()),
542 path: None,
543 body: None,
544 }),
545 ..Default::default()
546 },
547 );
548
549 config.providers.insert(
558 "ollama".to_string(),
559 ProviderDef {
560 base_url: "http://localhost:11434".to_string(),
561 base_url_env: Some("OLLAMA_HOST".to_string()),
562 auth_style: "none".to_string(),
563 chat_endpoint: "/api/chat".to_string(),
564 completion_endpoint: Some("/api/generate".to_string()),
565 healthcheck: Some(HealthcheckDef {
566 method: "GET".to_string(),
567 path: Some("/api/tags".to_string()),
568 url: None,
569 body: None,
570 }),
571 ..Default::default()
572 },
573 );
574
575 config.providers.insert(
577 "together".to_string(),
578 ProviderDef {
579 base_url: "https://api.together.xyz/v1".to_string(),
580 base_url_env: Some("TOGETHER_AI_BASE_URL".to_string()),
581 auth_style: "bearer".to_string(),
582 auth_env: AuthEnv::Single("TOGETHER_AI_API_KEY".to_string()),
583 chat_endpoint: "/chat/completions".to_string(),
584 completion_endpoint: Some("/completions".to_string()),
585 healthcheck: Some(HealthcheckDef {
586 method: "GET".to_string(),
587 path: Some("/models".to_string()),
588 url: None,
589 body: None,
590 }),
591 ..Default::default()
592 },
593 );
594
595 config.providers.insert(
597 "local".to_string(),
598 ProviderDef {
599 base_url: "http://localhost:8000".to_string(),
600 base_url_env: Some("LOCAL_LLM_BASE_URL".to_string()),
601 auth_style: "none".to_string(),
602 chat_endpoint: "/v1/chat/completions".to_string(),
603 completion_endpoint: Some("/v1/completions".to_string()),
604 healthcheck: Some(HealthcheckDef {
605 method: "GET".to_string(),
606 path: Some("/v1/models".to_string()),
607 url: None,
608 body: None,
609 }),
610 ..Default::default()
611 },
612 );
613
614 config.inference_rules = vec![
616 InferenceRule {
617 pattern: Some("claude-*".to_string()),
618 contains: None,
619 exact: None,
620 provider: "anthropic".to_string(),
621 },
622 InferenceRule {
623 pattern: Some("gpt-*".to_string()),
624 contains: None,
625 exact: None,
626 provider: "openai".to_string(),
627 },
628 InferenceRule {
629 pattern: Some("o1*".to_string()),
630 contains: None,
631 exact: None,
632 provider: "openai".to_string(),
633 },
634 InferenceRule {
635 pattern: Some("o3*".to_string()),
636 contains: None,
637 exact: None,
638 provider: "openai".to_string(),
639 },
640 InferenceRule {
641 pattern: Some("local:*".to_string()),
642 contains: None,
643 exact: None,
644 provider: "local".to_string(),
645 },
646 InferenceRule {
647 pattern: None,
648 contains: Some("/".to_string()),
649 exact: None,
650 provider: "openrouter".to_string(),
651 },
652 InferenceRule {
653 pattern: None,
654 contains: Some(":".to_string()),
655 exact: None,
656 provider: "ollama".to_string(),
657 },
658 ];
659
660 config.tier_rules = vec![
662 TierRule {
663 contains: Some("9b".to_string()),
664 pattern: None,
665 exact: None,
666 tier: "small".to_string(),
667 },
668 TierRule {
669 contains: Some("a3b".to_string()),
670 pattern: None,
671 exact: None,
672 tier: "small".to_string(),
673 },
674 TierRule {
675 contains: Some("gemma-4-e2b".to_string()),
676 pattern: None,
677 exact: None,
678 tier: "small".to_string(),
679 },
680 TierRule {
681 contains: Some("gemma-4-e4b".to_string()),
682 pattern: None,
683 exact: None,
684 tier: "small".to_string(),
685 },
686 TierRule {
687 contains: Some("gemma-4-26b".to_string()),
688 pattern: None,
689 exact: None,
690 tier: "mid".to_string(),
691 },
692 TierRule {
693 contains: Some("gemma-4-31b".to_string()),
694 pattern: None,
695 exact: None,
696 tier: "frontier".to_string(),
697 },
698 TierRule {
699 contains: Some("gemma4:26b".to_string()),
700 pattern: None,
701 exact: None,
702 tier: "mid".to_string(),
703 },
704 TierRule {
705 contains: Some("gemma4:31b".to_string()),
706 pattern: None,
707 exact: None,
708 tier: "frontier".to_string(),
709 },
710 TierRule {
711 pattern: Some("claude-*".to_string()),
712 contains: None,
713 exact: None,
714 tier: "frontier".to_string(),
715 },
716 TierRule {
717 exact: Some("gpt-4o".to_string()),
718 contains: None,
719 pattern: None,
720 tier: "frontier".to_string(),
721 },
722 ];
723
724 config.tier_defaults = TierDefaults {
725 default: "mid".to_string(),
726 };
727
728 config.aliases.insert(
729 "frontier".to_string(),
730 AliasDef {
731 id: "claude-sonnet-4-20250514".to_string(),
732 provider: "anthropic".to_string(),
733 tool_format: None,
734 },
735 );
736 config.aliases.insert(
737 "tier/frontier".to_string(),
738 AliasDef {
739 id: "claude-sonnet-4-20250514".to_string(),
740 provider: "anthropic".to_string(),
741 tool_format: None,
742 },
743 );
744 config.aliases.insert(
745 "mid".to_string(),
746 AliasDef {
747 id: "gpt-4o-mini".to_string(),
748 provider: "openai".to_string(),
749 tool_format: None,
750 },
751 );
752 config.aliases.insert(
753 "tier/mid".to_string(),
754 AliasDef {
755 id: "gpt-4o-mini".to_string(),
756 provider: "openai".to_string(),
757 tool_format: None,
758 },
759 );
760 config.aliases.insert(
761 "small".to_string(),
762 AliasDef {
763 id: "Qwen/Qwen3.5-9B".to_string(),
764 provider: "openrouter".to_string(),
765 tool_format: None,
766 },
767 );
768 config.aliases.insert(
769 "tier/small".to_string(),
770 AliasDef {
771 id: "Qwen/Qwen3.5-9B".to_string(),
772 provider: "openrouter".to_string(),
773 tool_format: None,
774 },
775 );
776 config.aliases.insert(
777 "local-gemma4".to_string(),
778 AliasDef {
779 id: "gemma-4-26b-a4b-it".to_string(),
780 provider: "local".to_string(),
781 tool_format: None,
782 },
783 );
784 config.aliases.insert(
785 "local-gemma4-26b".to_string(),
786 AliasDef {
787 id: "gemma-4-26b-a4b-it".to_string(),
788 provider: "local".to_string(),
789 tool_format: None,
790 },
791 );
792 config.aliases.insert(
793 "local-gemma4-31b".to_string(),
794 AliasDef {
795 id: "gemma-4-31b-it".to_string(),
796 provider: "local".to_string(),
797 tool_format: None,
798 },
799 );
800 config.aliases.insert(
801 "local-gemma4-e4b".to_string(),
802 AliasDef {
803 id: "gemma-4-e4b-it".to_string(),
804 provider: "local".to_string(),
805 tool_format: None,
806 },
807 );
808 config.aliases.insert(
809 "local-gemma4-e2b".to_string(),
810 AliasDef {
811 id: "gemma-4-e2b-it".to_string(),
812 provider: "local".to_string(),
813 tool_format: None,
814 },
815 );
816
817 config
818}
819
820#[cfg(test)]
825mod tests {
826 use super::*;
827
828 #[test]
829 fn test_glob_match_prefix() {
830 assert!(glob_match("claude-*", "claude-sonnet-4-20250514"));
831 assert!(glob_match("gpt-*", "gpt-4o"));
832 assert!(!glob_match("claude-*", "gpt-4o"));
833 }
834
835 #[test]
836 fn test_glob_match_suffix() {
837 assert!(glob_match("*-latest", "llama3.2-latest"));
838 assert!(!glob_match("*-latest", "llama3.2"));
839 }
840
841 #[test]
842 fn test_glob_match_middle() {
843 assert!(glob_match("claude-*-latest", "claude-sonnet-latest"));
844 assert!(!glob_match("claude-*-latest", "claude-sonnet-beta"));
845 }
846
847 #[test]
848 fn test_glob_match_exact() {
849 assert!(glob_match("gpt-4o", "gpt-4o"));
850 assert!(!glob_match("gpt-4o", "gpt-4o-mini"));
851 }
852
853 #[test]
854 fn test_infer_provider_from_defaults() {
855 assert_eq!(infer_provider("claude-sonnet-4-20250514"), "anthropic");
857 assert_eq!(infer_provider("gpt-4o"), "openai");
858 assert_eq!(infer_provider("o1-preview"), "openai");
859 assert_eq!(infer_provider("o3-mini"), "openai");
860 assert_eq!(infer_provider("qwen/qwen3-coder"), "openrouter");
861 assert_eq!(infer_provider("llama3.2:latest"), "ollama");
862 assert_eq!(infer_provider("unknown-model"), "anthropic");
863 }
864
865 #[test]
866 fn test_infer_provider_local_prefix() {
867 assert_eq!(infer_provider("local:gemma-4-e4b-it"), "local");
870 assert_eq!(infer_provider("local:qwen2.5"), "local");
871 assert_eq!(infer_provider("local:owner/model"), "local");
873 }
874
875 #[test]
876 fn test_model_tier_from_defaults() {
877 assert_eq!(model_tier("claude-sonnet-4-20250514"), "frontier");
878 assert_eq!(model_tier("gpt-4o"), "frontier");
879 assert_eq!(model_tier("Qwen3.5-9B"), "small");
880 assert_eq!(model_tier("deepseek-v3"), "mid");
881 }
882
883 #[test]
884 fn test_resolve_model_unknown_alias() {
885 let (id, provider) = resolve_model("gpt-4o");
886 assert_eq!(id, "gpt-4o");
887 assert!(provider.is_none());
888 }
889
890 #[test]
891 fn test_provider_names() {
892 let names = provider_names();
893 assert!(names.len() >= 7);
894 assert!(names.contains(&"anthropic".to_string()));
895 assert!(names.contains(&"together".to_string()));
896 assert!(names.contains(&"local".to_string()));
897 assert!(names.contains(&"openai".to_string()));
898 assert!(names.contains(&"ollama".to_string()));
899 }
900
901 #[test]
902 fn test_resolve_tier_model_default_aliases() {
903 let (model, provider) = resolve_tier_model("frontier", None).unwrap();
904 assert_eq!(model, "claude-sonnet-4-20250514");
905 assert_eq!(provider, "anthropic");
906
907 let (model, provider) = resolve_tier_model("small", None).unwrap();
908 assert_eq!(model, "Qwen/Qwen3.5-9B");
909 assert_eq!(provider, "openrouter");
910 }
911
912 #[test]
913 fn test_resolve_tier_model_prefers_provider_scoped_aliases() {
914 let (model, provider) = resolve_tier_model("mid", Some("openai")).unwrap();
915 assert_eq!(model, "gpt-4o-mini");
916 assert_eq!(provider, "openai");
917 }
918
919 #[test]
920 fn test_provider_config_anthropic() {
921 let pdef = provider_config("anthropic").unwrap();
922 assert_eq!(pdef.auth_style, "header");
923 assert_eq!(pdef.auth_header.as_deref(), Some("x-api-key"));
924 }
925
926 #[test]
927 fn test_resolve_base_url_no_env() {
928 let pdef = ProviderDef {
929 base_url: "https://example.com".to_string(),
930 ..Default::default()
931 };
932 assert_eq!(resolve_base_url(&pdef), "https://example.com");
933 }
934
935 #[test]
936 fn test_default_config_roundtrip() {
937 let config = default_config();
938 assert!(!config.providers.is_empty());
939 assert!(!config.inference_rules.is_empty());
940 assert!(!config.tier_rules.is_empty());
941 assert_eq!(config.tier_defaults.default, "mid");
942 }
943
944 #[test]
945 fn test_model_params_empty() {
946 let params = model_params("claude-sonnet-4-20250514");
947 assert!(params.is_empty());
949 }
950}