1use std::collections::BTreeMap;
5
6use super::*;
7
8use harn_glob::match_name as glob_match;
9
10pub fn provider_config(name: &str) -> Option<ProviderDef> {
12 effective_config().providers.get(name).cloned()
13}
14
15pub fn provider_protocol(name: &str) -> Option<String> {
16 provider_config(name).and_then(|def| def.protocol)
17}
18
19pub fn provider_uses_acp(name: &str) -> bool {
20 provider_protocol(name)
21 .as_deref()
22 .is_some_and(|protocol| protocol.eq_ignore_ascii_case("acp"))
23}
24
25pub fn model_params(model_id: &str) -> BTreeMap<String, toml::Value> {
28 let config = effective_config();
29 let mut params = BTreeMap::new();
30 for (pattern, defaults) in &config.model_defaults {
31 if glob_match(pattern, model_id) {
32 for (k, v) in defaults {
33 params.insert(k.clone(), v.clone());
34 }
35 }
36 }
37 params
38}
39
40pub fn model_role_defaults(role: &str) -> BTreeMap<String, toml::Value> {
54 let normalized = normalize_model_role_name(role);
55 if normalized.is_empty() {
56 return BTreeMap::new();
57 }
58 let config = effective_config();
59 let mut params = BTreeMap::new();
60 for key in role_lookup_keys(&normalized) {
61 extend_model_role_defaults(&config, &key, &mut params);
62 }
63 apply_model_role_env_overrides(&normalized, &mut params);
64 params
65}
66
67fn extend_model_role_defaults(
68 config: &ProvidersConfig,
69 role: &str,
70 params: &mut BTreeMap<String, toml::Value>,
71) {
72 for (configured_role, defaults) in &config.model_roles {
73 if normalize_model_role_name(configured_role) == role {
74 params.extend(defaults.clone());
75 }
76 }
77 if let Some(defaults) = config.model_roles.get(role) {
78 params.extend(defaults.clone());
79 }
80}
81
82fn normalize_model_role_name(role: &str) -> String {
83 role.trim().to_ascii_lowercase().replace('-', "_")
84}
85
86fn role_lookup_keys(role: &str) -> Vec<String> {
87 if role == "merge" {
88 vec!["fast_apply".to_string(), "merge".to_string()]
89 } else if role == "fast_apply" {
90 vec!["merge".to_string(), "fast_apply".to_string()]
91 } else {
92 vec![role.to_string()]
93 }
94}
95
96fn role_env_token(role: &str) -> String {
97 role.chars()
98 .map(|ch| {
99 if ch.is_ascii_alphanumeric() {
100 ch.to_ascii_uppercase()
101 } else {
102 '_'
103 }
104 })
105 .collect::<String>()
106 .split('_')
107 .filter(|part| !part.is_empty())
108 .collect::<Vec<_>>()
109 .join("_")
110}
111
112fn apply_model_role_env_overrides(role: &str, params: &mut BTreeMap<String, toml::Value>) {
113 for alias in role_env_aliases(role) {
114 apply_model_role_env_var(&format!("HARN_LLM_{alias}_PROVIDER"), "provider", params);
115 apply_model_role_env_var(&format!("HARN_LLM_{alias}_MODEL"), "model", params);
116 apply_model_role_env_var(
117 &format!("HARN_LLM_{alias}_ROUTE_POLICY"),
118 "route_policy",
119 params,
120 );
121 apply_model_role_env_var(
122 &format!("HARN_LLM_ROLE_{alias}_PROVIDER"),
123 "provider",
124 params,
125 );
126 apply_model_role_env_var(&format!("HARN_LLM_ROLE_{alias}_MODEL"), "model", params);
127 apply_model_role_env_var(
128 &format!("HARN_LLM_ROLE_{alias}_ROUTE_POLICY"),
129 "route_policy",
130 params,
131 );
132 }
133}
134
135fn role_env_aliases(role: &str) -> Vec<String> {
136 let token = role_env_token(role);
137 if token.is_empty() {
138 return Vec::new();
139 }
140 if token == "MERGE" {
141 vec!["FAST_APPLY".to_string(), "MERGE".to_string()]
142 } else if token == "FAST_APPLY" {
143 vec!["MERGE".to_string(), "FAST_APPLY".to_string()]
144 } else {
145 vec![token]
146 }
147}
148
149fn apply_model_role_env_var(
150 env_name: &str,
151 option_name: &str,
152 params: &mut BTreeMap<String, toml::Value>,
153) {
154 let Ok(value) = std::env::var(env_name) else {
155 return;
156 };
157 let trimmed = value.trim();
158 if trimmed.is_empty() {
159 return;
160 }
161 params.insert(
162 option_name.to_string(),
163 toml::Value::String(trimmed.to_string()),
164 );
165}
166
167pub fn provider_names() -> Vec<String> {
169 effective_config().providers.keys().cloned().collect()
170}
171
172pub fn known_model_names() -> Vec<String> {
174 effective_config().aliases.keys().cloned().collect()
175}
176
177pub fn alias_entries() -> Vec<(String, AliasDef)> {
178 effective_config().aliases.into_iter().collect()
179}
180
181pub fn alias_tool_calling_entry(alias: &str) -> Option<AliasToolCallingDef> {
182 effective_config().alias_tool_calling.get(alias).cloned()
183}
184
185pub fn model_catalog_entries() -> Vec<(String, ModelDef)> {
187 let config = effective_config();
188 model_catalog_entries_with_config(&config)
189}
190
191pub(crate) fn model_catalog_entries_with_config(
192 config: &ProvidersConfig,
193) -> Vec<(String, ModelDef)> {
194 sorted_model_entries_with_config(config)
195 .into_iter()
196 .map(|(id, model)| {
197 let provider = model.provider.clone();
198 (
199 id.clone(),
200 with_effective_capability_tags(id, provider, model),
201 )
202 })
203 .collect()
204}
205
206pub(crate) fn sorted_model_entries_with_config(
207 config: &ProvidersConfig,
208) -> Vec<(String, ModelDef)> {
209 let mut entries: Vec<_> = config
210 .models
211 .iter()
212 .map(|(id, model)| (id.clone(), model.clone()))
213 .collect();
214 entries.sort_by(|(id_a, model_a), (id_b, model_b)| {
215 model_a
216 .provider
217 .cmp(&model_b.provider)
218 .then_with(|| id_a.cmp(id_b))
219 });
220 entries
221}
222
223pub fn model_catalog_entry(model_id: &str) -> Option<ModelDef> {
224 effective_config()
225 .models
226 .get(model_id)
227 .cloned()
228 .map(|model| {
229 let provider = model.provider.clone();
230 with_effective_capability_tags(model_id.to_string(), provider, model)
231 })
232}
233
234pub fn model_rate_limits(model_id: &str) -> Option<RateLimitsDef> {
235 model_catalog_entry(model_id).and_then(|model| model.rate_limits)
236}
237
238pub fn model_ladder(name: &str) -> Option<ModelLadderDef> {
242 effective_config().model_ladders.get(name).cloned()
243}
244
245pub fn model_ladder_names() -> Vec<String> {
248 effective_config().model_ladders.keys().cloned().collect()
249}
250
251pub fn wire_model_id(model_id: &str) -> String {
252 model_catalog_entry(model_id)
253 .and_then(|model| model.wire_model)
254 .unwrap_or_else(|| model_id.to_string())
255}
256
257pub fn provider_rate_limits(provider: &str) -> Option<RateLimitsDef> {
258 provider_config(provider).and_then(|provider| {
259 provider
260 .rate_limits
261 .unwrap_or_default()
262 .with_rpm_fallback(provider.rpm)
263 })
264}
265
266pub fn model_equivalence_group(model_id: &str) -> Option<String> {
267 model_catalog_entry(model_id).and_then(|model| {
268 model
269 .equivalence_group
270 .or(model.logical_model)
271 .filter(|group| !group.trim().is_empty())
272 })
273}
274
275#[derive(Clone, Debug, Default, PartialEq, Eq)]
276pub struct EquivalentModelRequirements {
277 pub context_tokens: Option<u64>,
278 pub native_tools: bool,
279 pub text_tool_wire_format: bool,
280 pub provider_tool_types: Vec<String>,
281 pub vision: bool,
282 pub url_images: bool,
283 pub audio: bool,
284 pub pdf: bool,
285 pub video: bool,
286 pub files_api: bool,
287 pub thinking: bool,
288 pub reasoning_effort: bool,
289 pub structured_output: bool,
290 pub structured_output_mode: Option<String>,
291}
292
293impl EquivalentModelRequirements {
294 fn from_source_context(
295 context_tokens: u64,
296 caps: &crate::llm::capabilities::Capabilities,
297 ) -> Self {
298 Self {
299 context_tokens: Some(context_tokens),
300 native_tools: caps.native_tools,
301 text_tool_wire_format: caps.text_tool_wire_format_supported,
302 provider_tool_types: equivalent_provider_tool_types_for_capabilities(caps),
303 vision: caps.vision_supported,
304 url_images: caps.image_url_input_supported,
305 audio: caps.audio,
306 pdf: caps.pdf,
307 video: caps.video,
308 files_api: caps.files_api_supported,
309 thinking: !caps.thinking_modes.is_empty(),
310 reasoning_effort: caps.reasoning_effort_supported,
311 structured_output: caps.structured_output.is_some(),
312 structured_output_mode: Some(caps.structured_output_mode.clone()),
313 }
314 }
315}
316
317fn equivalent_provider_tool_types_for_capabilities(
318 caps: &crate::llm::capabilities::Capabilities,
319) -> Vec<String> {
320 let mut kinds = caps.hosted_tools.clone();
321 if caps.computer_use_style.is_some() {
322 kinds.push("computer_use".to_string());
323 }
324 kinds.sort();
325 kinds.dedup();
326 kinds
327}
328
329fn provider_tool_type_matches(
330 caps: &crate::llm::capabilities::Capabilities,
331 required: &str,
332) -> bool {
333 if required == "computer_use" && caps.computer_use_style.is_some() {
334 return true;
335 }
336 caps.hosted_tools
337 .iter()
338 .any(|kind| kind == required || (required == "computer_use" && kind == "computer"))
339}
340
341pub fn equivalent_model_catalog_entries_for_requirements(
345 selector: &str,
346 requirements: EquivalentModelRequirements,
347) -> Vec<(String, ModelDef)> {
348 let resolved = resolve_model_info(selector);
349 let Some(group) = model_equivalence_group(&resolved.id) else {
350 return Vec::new();
351 };
352 let config = effective_config();
353 let Some(source) = config.models.get(&resolved.id) else {
354 return Vec::new();
355 };
356 let source_context = source
357 .runtime_context_window
358 .unwrap_or(source.context_window);
359 let minimum_context = requirements.context_tokens.unwrap_or(source_context);
360
361 sorted_model_entries_with_config(&config)
362 .into_iter()
363 .filter(|(id, model)| !(id == &resolved.id && model.provider == resolved.provider))
364 .filter(|(_, model)| !model.deprecated)
365 .filter(|(_, model)| model.availability != ModelAvailability::Dedicated)
366 .filter(|(_, model)| {
367 model.equivalence_group.as_deref() == Some(group.as_str())
368 || model.logical_model.as_deref() == Some(group.as_str())
369 })
370 .filter(|(id, model)| {
371 let caps = crate::llm::capabilities::lookup(&model.provider, id);
372 let candidate_context = model.runtime_context_window.unwrap_or(model.context_window);
373 let context_matches = candidate_context >= minimum_context;
374 let native_tools_match = !requirements.native_tools || caps.native_tools;
375 let text_tool_format_match =
376 !requirements.text_tool_wire_format || caps.text_tool_wire_format_supported;
377 let provider_tools_match = requirements
378 .provider_tool_types
379 .iter()
380 .all(|required| provider_tool_type_matches(&caps, required));
381 let vision_match = !requirements.vision || caps.vision_supported;
382 let url_images_match = !requirements.url_images
383 || crate::llm::provider::provider_supports_image_urls(&model.provider, id);
384 let audio_match = !requirements.audio || caps.audio;
385 let pdf_match = !requirements.pdf || caps.pdf;
386 let video_match = !requirements.video || caps.video;
387 let files_api_match = !requirements.files_api || caps.files_api_supported;
388 let thinking_match = !requirements.thinking || !caps.thinking_modes.is_empty();
389 let reasoning_effort_match =
390 !requirements.reasoning_effort || caps.reasoning_effort_supported;
391 let structured_output_match =
392 !requirements.structured_output || caps.structured_output.is_some();
393 let structured_output_mode_match = requirements
394 .structured_output_mode
395 .as_ref()
396 .is_none_or(|mode| mode == &caps.structured_output_mode);
397 context_matches
398 && native_tools_match
399 && text_tool_format_match
400 && provider_tools_match
401 && vision_match
402 && url_images_match
403 && audio_match
404 && pdf_match
405 && video_match
406 && files_api_match
407 && thinking_match
408 && reasoning_effort_match
409 && structured_output_match
410 && structured_output_mode_match
411 })
412 .map(|(id, model)| {
413 let provider = model.provider.clone();
414 (
415 id.clone(),
416 with_effective_capability_tags(id, provider, model),
417 )
418 })
419 .collect()
420}
421
422pub fn equivalent_model_catalog_entries_for_context(
425 selector: &str,
426 required_context_tokens: Option<u64>,
427) -> Vec<(String, ModelDef)> {
428 equivalent_model_catalog_entries_for_requirements(
429 selector,
430 EquivalentModelRequirements {
431 context_tokens: required_context_tokens,
432 ..EquivalentModelRequirements::default()
433 },
434 )
435}
436
437pub fn equivalent_model_catalog_entries(selector: &str) -> Vec<(String, ModelDef)> {
438 let resolved = resolve_model_info(selector);
439 let config = effective_config();
440 let Some(source) = config.models.get(&resolved.id) else {
441 return Vec::new();
442 };
443 let source_caps = crate::llm::capabilities::lookup(&source.provider, &resolved.id);
444 let source_context = source
445 .runtime_context_window
446 .unwrap_or(source.context_window);
447 equivalent_model_catalog_entries_for_requirements(
448 selector,
449 EquivalentModelRequirements::from_source_context(source_context, &source_caps),
450 )
451}
452
453pub fn qc_default_model(provider: &str) -> Option<String> {
454 std::env::var("BURIN_QC_MODEL")
455 .ok()
456 .filter(|value| !value.trim().is_empty())
457 .or_else(|| {
458 effective_config()
459 .qc_defaults
460 .get(&provider.to_lowercase())
461 .cloned()
462 })
463}
464
465pub fn default_model_for_provider(provider: &str) -> String {
466 if provider_uses_acp(provider) {
467 return "default".to_string();
468 }
469 match provider {
470 "local" => std::env::var("LOCAL_LLM_MODEL")
471 .or_else(|_| std::env::var("HARN_LLM_MODEL"))
472 .unwrap_or_else(|_| "gemma-4-26b-a4b-it".to_string()),
473 "mlx" => std::env::var("MLX_MODEL_ID")
474 .unwrap_or_else(|_| "unsloth/Qwen3.6-35B-A3B-UD-MLX-4bit".to_string()),
475 "openai" => "gpt-4o-mini".to_string(),
476 "ollama" => "llama3.2".to_string(),
477 "openrouter" => "anthropic/claude-sonnet-4.6".to_string(),
478 _ => "claude-sonnet-4-6".to_string(),
479 }
480}
481
482pub fn qc_defaults() -> BTreeMap<String, String> {
483 effective_config().qc_defaults
484}
485
486pub fn model_pricing_per_mtok(model_id: &str) -> Option<ModelPricing> {
487 effective_config()
488 .models
489 .get(model_id)
490 .and_then(|model| model.pricing.clone())
491}
492
493pub fn model_fast_pricing_per_mtok(model_id: &str) -> Option<ModelPricing> {
498 effective_config()
499 .models
500 .get(model_id)
501 .and_then(|model| model.fast_mode.as_ref())
502 .and_then(|fast_mode| fast_mode.pricing.clone())
503}
504
505pub fn pricing_per_1k_for(provider: &str, model_id: &str) -> Option<(f64, f64)> {
506 model_pricing_per_mtok(model_id)
507 .map(|pricing| {
508 (
509 pricing.input_per_mtok / 1000.0,
510 pricing.output_per_mtok / 1000.0,
511 )
512 })
513 .or_else(|| {
514 let (input, output, _) = provider_economics(provider);
515 match (input, output) {
516 (Some(input), Some(output)) => Some((input, output)),
517 _ => None,
518 }
519 })
520}
521
522pub fn auth_env_names(auth_env: &AuthEnv) -> Vec<String> {
523 match auth_env {
524 AuthEnv::None => Vec::new(),
525 AuthEnv::Single(name) => vec![name.clone()],
526 AuthEnv::Multiple(names) => names.clone(),
527 }
528}
529
530pub fn provider_key_available(provider: &str) -> bool {
531 let Some(pdef) = provider_config(provider) else {
532 return provider == "ollama";
533 };
534 if pdef.auth_style == "none" || matches!(pdef.auth_env, AuthEnv::None) {
535 return true;
536 }
537 auth_env_names(&pdef.auth_env).into_iter().any(|env_name| {
538 std::env::var(env_name)
539 .ok()
540 .is_some_and(|value| !value.trim().is_empty())
541 })
542}
543
544pub fn available_provider_names() -> Vec<String> {
545 provider_names()
546 .into_iter()
547 .filter(|provider| provider_key_available(provider))
548 .collect()
549}
550
551pub fn provider_has_feature(provider: &str, feature: &str) -> bool {
553 provider_config(provider)
554 .map(|p| p.features.iter().any(|f| f == feature))
555 .unwrap_or(false)
556}
557
558pub fn provider_economics(provider: &str) -> (Option<f64>, Option<f64>, Option<u64>) {
562 provider_config(provider)
563 .map(|p| (p.cost_per_1k_in, p.cost_per_1k_out, p.latency_p50_ms))
564 .unwrap_or((None, None, None))
565}
566
567#[derive(Debug, Clone, Copy, PartialEq, Eq)]
576pub enum ToolFormatChannel {
577 Native,
579 Text,
581}
582
583pub fn tool_format_channel(format: &str) -> Option<ToolFormatChannel> {
591 match format {
592 "native" => Some(ToolFormatChannel::Native),
593 "text" | "json" => Some(ToolFormatChannel::Text),
594 _ => None,
595 }
596}
597
598pub fn is_known_tool_format(format: &str) -> bool {
603 tool_format_channel(format).is_some()
604}
605
606pub fn default_tool_format(model: &str, provider: &str) -> String {
612 let config = effective_config();
613 default_tool_format_with_config(&config, model, provider)
614}
615
616pub(crate) fn default_tool_format_with_config(
617 config: &ProvidersConfig,
618 model: &str,
619 provider: &str,
620) -> String {
621 for (name, alias) in &config.aliases {
623 let matches = (alias.id == model && alias.provider == provider) || name == model;
624 if matches {
625 if let Some(ref fmt) = alias.tool_format {
626 return fmt.clone();
627 }
628 }
629 }
630 let capabilities = crate::llm::capabilities::lookup(provider, model);
631 if let Some(format) = capabilities.preferred_tool_format.as_deref() {
632 if is_known_tool_format(format) {
639 return format.to_string();
640 }
641 }
642 let capability_matrix_native = capabilities.native_tools;
643 let legacy_provider_native = config
644 .providers
645 .get(provider)
646 .map(|p| p.features.iter().any(|f| f == "native_tools"))
647 .unwrap_or(false);
648 if capability_matrix_native || legacy_provider_native {
649 "native".to_string()
650 } else {
651 "json".to_string()
662 }
663}
664
665fn with_effective_capability_tags(
666 model_id: String,
667 provider: String,
668 mut model: ModelDef,
669) -> ModelDef {
670 model.capabilities = effective_model_capability_tags(&provider, &model_id);
671 model
672}
673
674pub fn effective_model_capability_tags(provider: &str, model_id: &str) -> Vec<String> {
678 let caps = crate::llm::capabilities::lookup(provider, model_id);
679 let mut tags = capability_tags_from_capabilities(&caps);
680 if effective_batch_api_supported(provider, &caps) && !tags.iter().any(|tag| tag == "batch") {
681 tags.push("batch".to_string());
682 }
683 tags
684}
685
686pub fn effective_batch_api_supported(
687 provider: &str,
688 caps: &crate::llm::capabilities::Capabilities,
689) -> bool {
690 caps.batch_api || provider_has_feature(provider, "batch")
691}
692
693pub(crate) fn capability_tags_from_capabilities(
694 caps: &crate::llm::capabilities::Capabilities,
695) -> Vec<String> {
696 let mut tags = Vec::new();
697 tags.push("streaming".to_string());
700 if caps.native_tools || caps.text_tool_wire_format_supported {
701 tags.push("tools".to_string());
702 }
703 if !caps.tool_search.is_empty() {
704 tags.push("tool_search".to_string());
705 }
706 if caps.vision || caps.vision_supported {
707 tags.push("vision".to_string());
708 }
709 if caps.audio {
710 tags.push("audio".to_string());
711 }
712 if caps.pdf {
713 tags.push("pdf".to_string());
714 }
715 if caps.video {
716 tags.push("video".to_string());
717 }
718 if caps.files_api_supported {
719 tags.push("files".to_string());
720 }
721 if caps.batch_api {
722 tags.push("batch".to_string());
723 }
724 if caps.prompt_caching {
725 tags.push("prompt_caching".to_string());
726 }
727 if !caps.thinking_modes.is_empty() {
728 tags.push("thinking".to_string());
729 }
730 if caps.interleaved_thinking_supported
731 || caps
732 .thinking_modes
733 .iter()
734 .any(|mode| mode == "adaptive" || mode == "effort")
735 {
736 tags.push("extended_thinking".to_string());
737 }
738 if caps.structured_output.is_some() || caps.json_schema.is_some() {
739 tags.push("structured_output".to_string());
740 }
741 tags
742}
743
744pub fn resolve_tier_model(
746 target: &str,
747 preferred_provider: Option<&str>,
748) -> Option<(String, String)> {
749 let config = effective_config();
750
751 let candidate_aliases = if let Some(provider) = preferred_provider {
752 vec![
753 format!("{provider}/{target}"),
754 format!("{provider}:{target}"),
755 format!("tier/{target}"),
756 target.to_string(),
757 ]
758 } else {
759 vec![format!("tier/{target}"), target.to_string()]
760 };
761
762 for alias_name in candidate_aliases {
763 if let Some(alias) = config.aliases.get(&alias_name) {
764 return Some((alias.id.clone(), alias.provider.clone()));
765 }
766 }
767
768 None
769}
770
771pub fn tier_candidates(target: &str) -> Vec<(String, String)> {
775 let config = effective_config();
776 let mut seen = std::collections::BTreeSet::new();
777 let mut candidates = Vec::new();
778
779 for alias in config.aliases.values() {
780 let pair = (alias.id.clone(), alias.provider.clone());
781 if seen.contains(&pair) {
782 continue;
783 }
784 if model_tier(&alias.id) == target {
785 seen.insert(pair.clone());
786 candidates.push(pair);
787 }
788 }
789
790 candidates.sort_by(|(model_a, provider_a), (model_b, provider_b)| {
791 provider_a
792 .cmp(provider_b)
793 .then_with(|| model_a.cmp(model_b))
794 });
795 candidates
796}
797
798pub fn all_model_candidates() -> Vec<(String, String)> {
801 let config = effective_config();
802 let mut seen = std::collections::BTreeSet::new();
803 let mut candidates = Vec::new();
804
805 for alias in config.aliases.values() {
806 let pair = (alias.id.clone(), alias.provider.clone());
807 if seen.insert(pair.clone()) {
808 candidates.push(pair);
809 }
810 }
811
812 candidates.sort_by(|(model_a, provider_a), (model_b, provider_b)| {
813 provider_a
814 .cmp(provider_b)
815 .then_with(|| model_a.cmp(model_b))
816 });
817 candidates
818}