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_serving_tier_pricing_per_mtok(model_id: &str, tier_id: &str) -> Option<ModelPricing> {
497 effective_config()
498 .models
499 .get(model_id)
500 .and_then(|model| model.serving_tiers.iter().find(|tier| tier.id == tier_id))
501 .and_then(|tier| tier.pricing.clone())
502}
503
504pub fn pricing_per_1k_for(provider: &str, model_id: &str) -> Option<(f64, f64)> {
505 model_pricing_per_mtok(model_id)
506 .map(|pricing| {
507 (
508 pricing.input_per_mtok / 1000.0,
509 pricing.output_per_mtok / 1000.0,
510 )
511 })
512 .or_else(|| {
513 let (input, output, _) = provider_economics(provider);
514 match (input, output) {
515 (Some(input), Some(output)) => Some((input, output)),
516 _ => None,
517 }
518 })
519}
520
521pub fn auth_env_names(auth_env: &AuthEnv) -> Vec<String> {
522 match auth_env {
523 AuthEnv::None => Vec::new(),
524 AuthEnv::Single(name) => vec![name.clone()],
525 AuthEnv::Multiple(names) => names.clone(),
526 }
527}
528
529pub fn provider_key_available(provider: &str) -> bool {
530 let Some(pdef) = provider_config(provider) else {
531 return provider == "ollama";
532 };
533 if pdef.auth_style == "none" || matches!(pdef.auth_env, AuthEnv::None) {
534 return true;
535 }
536 auth_env_names(&pdef.auth_env).into_iter().any(|env_name| {
537 std::env::var(env_name)
538 .ok()
539 .is_some_and(|value| auth_env_value_available(&value))
540 })
541}
542
543pub(crate) fn auth_env_value_available(value: &str) -> bool {
544 if value.trim().is_empty() {
545 return false;
546 }
547 match crate::secrets::resolve_secret_ref_to_string(value) {
548 Ok(Some(secret)) => !secret.trim().is_empty(),
549 Ok(None) => true,
550 Err(_) => false,
551 }
552}
553
554pub fn available_provider_names() -> Vec<String> {
555 provider_names()
556 .into_iter()
557 .filter(|provider| provider_key_available(provider))
558 .collect()
559}
560
561pub fn provider_has_feature(provider: &str, feature: &str) -> bool {
563 provider_config(provider)
564 .map(|p| p.features.iter().any(|f| f == feature))
565 .unwrap_or(false)
566}
567
568pub fn provider_economics(provider: &str) -> (Option<f64>, Option<f64>, Option<u64>) {
572 provider_config(provider)
573 .map(|p| (p.cost_per_1k_in, p.cost_per_1k_out, p.latency_p50_ms))
574 .unwrap_or((None, None, None))
575}
576
577#[derive(Debug, Clone, Copy, PartialEq, Eq)]
586pub enum ToolFormatChannel {
587 Native,
589 Text,
591}
592
593pub fn tool_format_channel(format: &str) -> Option<ToolFormatChannel> {
601 match format {
602 "native" => Some(ToolFormatChannel::Native),
603 "text" | "json" => Some(ToolFormatChannel::Text),
604 _ => None,
605 }
606}
607
608pub fn is_known_tool_format(format: &str) -> bool {
613 tool_format_channel(format).is_some()
614}
615
616pub fn default_tool_format(model: &str, provider: &str) -> String {
622 let config = effective_config();
623 default_tool_format_with_config(&config, model, provider)
624}
625
626pub(crate) fn default_tool_format_with_config(
627 config: &ProvidersConfig,
628 model: &str,
629 provider: &str,
630) -> String {
631 for (name, alias) in &config.aliases {
633 let matches = (alias.id == model && alias.provider == provider) || name == model;
634 if matches {
635 if let Some(ref fmt) = alias.tool_format {
636 return fmt.clone();
637 }
638 }
639 }
640 let capabilities = crate::llm::capabilities::lookup(provider, model);
641 if let Some(format) = capabilities.preferred_tool_format.as_deref() {
642 if is_known_tool_format(format) {
649 return format.to_string();
650 }
651 }
652 let capability_matrix_native = capabilities.native_tools;
653 let legacy_provider_native = config
654 .providers
655 .get(provider)
656 .map(|p| p.features.iter().any(|f| f == "native_tools"))
657 .unwrap_or(false);
658 if capability_matrix_native || legacy_provider_native {
659 "native".to_string()
660 } else {
661 "json".to_string()
672 }
673}
674
675fn with_effective_capability_tags(
676 model_id: String,
677 provider: String,
678 mut model: ModelDef,
679) -> ModelDef {
680 model.capabilities = effective_model_capability_tags(&provider, &model_id);
681 model
682}
683
684pub fn effective_model_capability_tags(provider: &str, model_id: &str) -> Vec<String> {
688 let caps = crate::llm::capabilities::lookup(provider, model_id);
689 let mut tags = capability_tags_from_capabilities(&caps);
690 if effective_batch_api_supported(provider, &caps) && !tags.iter().any(|tag| tag == "batch") {
691 tags.push("batch".to_string());
692 }
693 tags
694}
695
696pub fn effective_batch_api_supported(
697 provider: &str,
698 caps: &crate::llm::capabilities::Capabilities,
699) -> bool {
700 caps.batch_api || provider_has_feature(provider, "batch")
701}
702
703pub(crate) fn capability_tags_from_capabilities(
704 caps: &crate::llm::capabilities::Capabilities,
705) -> Vec<String> {
706 let mut tags = Vec::new();
707 tags.push("streaming".to_string());
710 if caps.native_tools || caps.text_tool_wire_format_supported {
711 tags.push("tools".to_string());
712 }
713 if !caps.tool_search.is_empty() {
714 tags.push("tool_search".to_string());
715 }
716 if caps.vision || caps.vision_supported {
717 tags.push("vision".to_string());
718 }
719 if caps.audio {
720 tags.push("audio".to_string());
721 }
722 if caps.pdf {
723 tags.push("pdf".to_string());
724 }
725 if caps.video {
726 tags.push("video".to_string());
727 }
728 if caps.files_api_supported {
729 tags.push("files".to_string());
730 }
731 if caps.batch_api {
732 tags.push("batch".to_string());
733 }
734 if caps.prompt_caching {
735 tags.push("prompt_caching".to_string());
736 }
737 if !caps.thinking_modes.is_empty() {
738 tags.push("thinking".to_string());
739 }
740 if caps.interleaved_thinking_supported
741 || caps
742 .thinking_modes
743 .iter()
744 .any(|mode| mode == "adaptive" || mode == "effort")
745 {
746 tags.push("extended_thinking".to_string());
747 }
748 if caps.structured_output.is_some() || caps.json_schema.is_some() {
749 tags.push("structured_output".to_string());
750 }
751 tags
752}
753
754pub fn resolve_tier_model(
756 target: &str,
757 preferred_provider: Option<&str>,
758) -> Option<(String, String)> {
759 let config = effective_config();
760
761 let candidate_aliases = if let Some(provider) = preferred_provider {
762 vec![
763 format!("{provider}/{target}"),
764 format!("{provider}:{target}"),
765 format!("tier/{target}"),
766 target.to_string(),
767 ]
768 } else {
769 vec![format!("tier/{target}"), target.to_string()]
770 };
771
772 for alias_name in candidate_aliases {
773 if let Some(alias) = config.aliases.get(&alias_name) {
774 return Some((alias.id.clone(), alias.provider.clone()));
775 }
776 }
777
778 None
779}
780
781pub fn tier_candidates(target: &str) -> Vec<(String, String)> {
785 let config = effective_config();
786 let mut seen = std::collections::BTreeSet::new();
787 let mut candidates = Vec::new();
788
789 for alias in config.aliases.values() {
790 let pair = (alias.id.clone(), alias.provider.clone());
791 if seen.contains(&pair) {
792 continue;
793 }
794 if model_tier(&alias.id) == target {
795 seen.insert(pair.clone());
796 candidates.push(pair);
797 }
798 }
799
800 candidates.sort_by(|(model_a, provider_a), (model_b, provider_b)| {
801 provider_a
802 .cmp(provider_b)
803 .then_with(|| model_a.cmp(model_b))
804 });
805 candidates
806}
807
808pub fn all_model_candidates() -> Vec<(String, String)> {
811 let config = effective_config();
812 let mut seen = std::collections::BTreeSet::new();
813 let mut candidates = Vec::new();
814
815 for alias in config.aliases.values() {
816 let pair = (alias.id.clone(), alias.provider.clone());
817 if seen.insert(pair.clone()) {
818 candidates.push(pair);
819 }
820 }
821
822 candidates.sort_by(|(model_a, provider_a), (model_b, provider_b)| {
823 provider_a
824 .cmp(provider_b)
825 .then_with(|| model_a.cmp(model_b))
826 });
827 candidates
828}