1use std::collections::BTreeMap;
5
6use super::*;
7
8use harn_glob::match_name as glob_match;
9
10const LOGICAL_MODEL_DEFAULT_PREFIX: &str = "logical:";
11const MODEL_DEFAULT_UNSET_KEY: &str = "_unset";
12
13pub fn provider_config(name: &str) -> Option<ProviderDef> {
15 effective_config().providers.get(name).cloned()
16}
17
18pub fn provider_protocol(name: &str) -> Option<String> {
19 provider_config(name).and_then(|def| def.protocol)
20}
21
22pub fn provider_uses_acp(name: &str) -> bool {
23 provider_protocol(name)
24 .as_deref()
25 .is_some_and(|protocol| protocol.eq_ignore_ascii_case("acp"))
26}
27
28pub fn model_params(model_id: &str) -> BTreeMap<String, toml::Value> {
31 let config = effective_config();
32 matching_model_params(&config, model_id)
33}
34
35fn matching_model_params(
36 config: &ProvidersConfig,
37 model_id: &str,
38) -> BTreeMap<String, toml::Value> {
39 let mut params = BTreeMap::new();
40 apply_matching_model_params(config, model_id, &mut params);
41 params
42}
43
44fn apply_matching_model_params(
45 config: &ProvidersConfig,
46 model_id: &str,
47 params: &mut BTreeMap<String, toml::Value>,
48) {
49 for (pattern, defaults) in &config.model_defaults {
50 if !pattern.starts_with(LOGICAL_MODEL_DEFAULT_PREFIX) && glob_match(pattern, model_id) {
51 apply_model_param_layer(params, defaults);
52 }
53 }
54}
55
56fn apply_model_param_layer(
57 params: &mut BTreeMap<String, toml::Value>,
58 defaults: &BTreeMap<String, toml::Value>,
59) {
60 for (key, value) in defaults {
61 if key != MODEL_DEFAULT_UNSET_KEY {
62 params.insert(key.clone(), value.clone());
63 }
64 }
65 if let Some(keys) = defaults
66 .get(MODEL_DEFAULT_UNSET_KEY)
67 .and_then(toml::Value::as_array)
68 {
69 for key in keys.iter().filter_map(toml::Value::as_str) {
70 params.remove(key);
71 }
72 }
73}
74
75pub fn model_params_for_route(provider: &str, model_id: &str) -> BTreeMap<String, toml::Value> {
81 let config = effective_config();
82 model_params_for_route_with_config(&config, provider, model_id)
83}
84
85pub(crate) fn model_params_for_route_with_config(
86 config: &ProvidersConfig,
87 provider: &str,
88 model_id: &str,
89) -> BTreeMap<String, toml::Value> {
90 let normalized_id = normalize_model_id(model_id);
91 let route = config
92 .models
93 .get_key_value(model_id)
94 .filter(|(_, model)| model.provider == provider)
95 .or_else(|| {
96 config
97 .models
98 .get_key_value(&normalized_id)
99 .filter(|(_, model)| model.provider == provider)
100 })
101 .or_else(|| {
102 config.models.iter().find(|(_, model)| {
103 model.provider == provider
104 && model
105 .wire_model
106 .as_deref()
107 .is_some_and(|wire| wire == model_id || wire == normalized_id.as_str())
108 })
109 });
110
111 let mut params = route
112 .and_then(|(_, model)| model.logical_model.as_deref())
113 .and_then(|logical_model| {
114 config
115 .model_defaults
116 .get(&format!("{LOGICAL_MODEL_DEFAULT_PREFIX}{logical_model}"))
117 })
118 .map(|defaults| {
119 let mut params = BTreeMap::new();
120 apply_model_param_layer(&mut params, defaults);
121 params
122 })
123 .unwrap_or_default();
124
125 let mut identities = vec![model_id.to_string()];
126 if normalized_id != model_id {
127 identities.push(normalized_id);
128 }
129 if let Some((catalog_id, model)) = route {
130 for identity in [Some(catalog_id.as_str()), model.wire_model.as_deref()]
131 .into_iter()
132 .flatten()
133 {
134 if !identities.iter().any(|known| known == identity) {
135 identities.push(identity.to_string());
136 }
137 }
138 }
139 for identity in &identities {
140 apply_matching_model_params(config, identity, &mut params);
141 }
142 let provider_prefix = format!("{provider}/");
143 for identity in identities {
144 if !identity.starts_with(&provider_prefix) {
145 apply_matching_model_params(
146 config,
147 &format!("{provider_prefix}{identity}"),
148 &mut params,
149 );
150 }
151 }
152 params
153}
154
155pub fn model_default_issues(config: &ProvidersConfig) -> Vec<String> {
160 let mut issues = Vec::new();
161 for (selector, defaults) in &config.model_defaults {
162 if let Some(unset) = defaults.get(MODEL_DEFAULT_UNSET_KEY) {
163 let valid = !selector.starts_with(LOGICAL_MODEL_DEFAULT_PREFIX)
164 && unset.as_array().is_some_and(|keys| {
165 !keys.is_empty()
166 && keys.iter().all(|key| {
167 key.as_str()
168 .is_some_and(is_supported_generation_default_key)
169 })
170 });
171 if !valid {
172 issues.push(format!(
173 "model_defaults.{selector}.{MODEL_DEFAULT_UNSET_KEY} must be a non-empty list of supported route-default keys"
174 ));
175 }
176 }
177 let Some(logical_model) = selector.strip_prefix(LOGICAL_MODEL_DEFAULT_PREFIX) else {
178 continue;
179 };
180 if logical_model.is_empty()
181 || logical_model.contains('*')
182 || logical_model.contains('?')
183 || logical_model.contains('[')
184 {
185 issues.push(format!(
186 "model_defaults.{selector} must name one exact logical model"
187 ));
188 continue;
189 }
190 let routes: Vec<_> = config
191 .models
192 .iter()
193 .filter(|(_, model)| model.logical_model.as_deref() == Some(logical_model))
194 .collect();
195 if routes.is_empty() {
196 issues.push(format!(
197 "model_defaults.{selector} references an unknown logical model"
198 ));
199 continue;
200 }
201
202 for (key, value) in defaults {
203 if key == MODEL_DEFAULT_UNSET_KEY {
204 continue;
205 }
206 let valid = match key.as_str() {
207 "temperature" => value
208 .as_float()
209 .is_some_and(|value| value.is_finite() && (0.0..=2.0).contains(&value)),
210 "frequency_penalty" | "presence_penalty" => value
211 .as_float()
212 .is_some_and(|value| value.is_finite() && (-2.0..=2.0).contains(&value)),
213 "top_p" => value
214 .as_float()
215 .is_some_and(|value| value.is_finite() && (0.0..=1.0).contains(&value)),
216 "top_k" => value.as_integer().is_some_and(|value| value >= 0),
217 "max_tokens" => value.as_integer().is_some_and(|value| value > 0),
218 "reasoning_effort" => value.as_str().is_some_and(|value| {
219 matches!(
220 value,
221 "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"
222 )
223 }),
224 _ => false,
225 };
226 if !valid {
227 issues.push(format!(
228 "model_defaults.{selector}.{key} is not a supported generation default"
229 ));
230 continue;
231 }
232
233 for (model_id, model) in &routes {
234 let caps = crate::llm::capabilities::lookup_with_user_overrides(
235 &model.provider,
236 model_id,
237 None,
238 );
239 let supported = match key.as_str() {
240 "temperature" => caps.temperature_supported,
241 "top_p" => caps.top_p_supported,
242 "top_k" => caps.top_k_supported,
243 "frequency_penalty" => caps.frequency_penalty_supported,
244 "presence_penalty" => caps.presence_penalty_supported,
245 "reasoning_effort" => {
246 let effort = value.as_str().expect("validated effort string");
247 caps.reasoning_effort_supported
248 && caps.thinking_modes.iter().any(|mode| mode == "effort")
249 && (caps.reasoning_effort_levels.is_empty()
250 || caps
251 .reasoning_effort_levels
252 .iter()
253 .any(|level| level == effort))
254 }
255 "max_tokens" => true,
256 _ => false,
257 };
258 let effective =
259 model_params_for_route_with_config(config, &model.provider, model_id);
260 if !supported && effective.contains_key(key) {
261 issues.push(format!(
262 "model_defaults.{selector}.{key} cannot be represented by route {}:{}",
263 model.provider, model_id
264 ));
265 }
266 }
267 }
268 }
269 issues
270}
271
272fn is_supported_generation_default_key(key: &str) -> bool {
273 matches!(
274 key,
275 "temperature"
276 | "top_p"
277 | "top_k"
278 | "frequency_penalty"
279 | "presence_penalty"
280 | "max_tokens"
281 | "reasoning_effort"
282 )
283}
284
285pub fn model_role_defaults(role: &str) -> BTreeMap<String, toml::Value> {
299 let normalized = normalize_model_role_name(role);
300 if normalized.is_empty() {
301 return BTreeMap::new();
302 }
303 let config = effective_config();
304 let mut params = BTreeMap::new();
305 for key in role_lookup_keys(&normalized) {
306 extend_model_role_defaults(&config, &key, &mut params);
307 }
308 apply_model_role_env_overrides(&normalized, &mut params);
309 params
310}
311
312fn extend_model_role_defaults(
313 config: &ProvidersConfig,
314 role: &str,
315 params: &mut BTreeMap<String, toml::Value>,
316) {
317 for (configured_role, defaults) in &config.model_roles {
318 if normalize_model_role_name(configured_role) == role {
319 params.extend(defaults.clone());
320 }
321 }
322 if let Some(defaults) = config.model_roles.get(role) {
323 params.extend(defaults.clone());
324 }
325}
326
327fn normalize_model_role_name(role: &str) -> String {
328 role.trim().to_ascii_lowercase().replace('-', "_")
329}
330
331fn role_lookup_keys(role: &str) -> Vec<String> {
332 if role == "merge" {
333 vec!["fast_apply".to_string(), "merge".to_string()]
334 } else if role == "fast_apply" {
335 vec!["merge".to_string(), "fast_apply".to_string()]
336 } else {
337 vec![role.to_string()]
338 }
339}
340
341fn role_env_token(role: &str) -> String {
342 role.chars()
343 .map(|ch| {
344 if ch.is_ascii_alphanumeric() {
345 ch.to_ascii_uppercase()
346 } else {
347 '_'
348 }
349 })
350 .collect::<String>()
351 .split('_')
352 .filter(|part| !part.is_empty())
353 .collect::<Vec<_>>()
354 .join("_")
355}
356
357fn apply_model_role_env_overrides(role: &str, params: &mut BTreeMap<String, toml::Value>) {
358 for alias in role_env_aliases(role) {
359 apply_model_role_env_var(&format!("HARN_LLM_{alias}_PROVIDER"), "provider", params);
360 apply_model_role_env_var(&format!("HARN_LLM_{alias}_MODEL"), "model", params);
361 apply_model_role_env_var(
362 &format!("HARN_LLM_{alias}_ROUTE_POLICY"),
363 "route_policy",
364 params,
365 );
366 apply_model_role_env_var(
367 &format!("HARN_LLM_ROLE_{alias}_PROVIDER"),
368 "provider",
369 params,
370 );
371 apply_model_role_env_var(&format!("HARN_LLM_ROLE_{alias}_MODEL"), "model", params);
372 apply_model_role_env_var(
373 &format!("HARN_LLM_ROLE_{alias}_ROUTE_POLICY"),
374 "route_policy",
375 params,
376 );
377 }
378}
379
380fn role_env_aliases(role: &str) -> Vec<String> {
381 let token = role_env_token(role);
382 if token.is_empty() {
383 return Vec::new();
384 }
385 if token == "MERGE" {
386 vec!["FAST_APPLY".to_string(), "MERGE".to_string()]
387 } else if token == "FAST_APPLY" {
388 vec!["MERGE".to_string(), "FAST_APPLY".to_string()]
389 } else {
390 vec![token]
391 }
392}
393
394fn apply_model_role_env_var(
395 env_name: &str,
396 option_name: &str,
397 params: &mut BTreeMap<String, toml::Value>,
398) {
399 let Ok(value) = std::env::var(env_name) else {
400 return;
401 };
402 let trimmed = value.trim();
403 if trimmed.is_empty() {
404 return;
405 }
406 params.insert(
407 option_name.to_string(),
408 toml::Value::String(trimmed.to_string()),
409 );
410}
411
412pub fn provider_names() -> Vec<String> {
414 effective_config().providers.keys().cloned().collect()
415}
416
417pub fn known_model_names() -> Vec<String> {
419 effective_config().aliases.keys().cloned().collect()
420}
421
422pub fn alias_entries() -> Vec<(String, AliasDef)> {
423 effective_config().aliases.into_iter().collect()
424}
425
426pub fn alias_tool_calling_entry(alias: &str) -> Option<AliasToolCallingDef> {
427 effective_config().alias_tool_calling.get(alias).cloned()
428}
429
430pub fn model_catalog_entries() -> Vec<(String, ModelDef)> {
432 let config = effective_config();
433 model_catalog_entries_with_config(&config)
434}
435
436pub(crate) fn model_catalog_entries_with_config(
437 config: &ProvidersConfig,
438) -> Vec<(String, ModelDef)> {
439 sorted_model_entries_with_config(config)
440 .into_iter()
441 .map(|(id, model)| {
442 let provider = model.provider.clone();
443 (
444 id.clone(),
445 with_effective_capability_tags(id, provider, model),
446 )
447 })
448 .collect()
449}
450
451pub(crate) fn sorted_model_entries_with_config(
452 config: &ProvidersConfig,
453) -> Vec<(String, ModelDef)> {
454 let mut entries: Vec<_> = config
455 .models
456 .iter()
457 .map(|(id, model)| (id.clone(), model.clone()))
458 .collect();
459 entries.sort_by(|(id_a, model_a), (id_b, model_b)| {
460 model_a
461 .provider
462 .cmp(&model_b.provider)
463 .then_with(|| id_a.cmp(id_b))
464 });
465 entries
466}
467
468pub fn model_catalog_entry(model_id: &str) -> Option<ModelDef> {
469 effective_config()
470 .models
471 .get(model_id)
472 .cloned()
473 .map(|model| {
474 let provider = model.provider.clone();
475 with_effective_capability_tags(model_id.to_string(), provider, model)
476 })
477}
478
479pub fn model_rate_limits(model_id: &str) -> Option<RateLimitsDef> {
480 model_catalog_entry(model_id).and_then(|model| model.rate_limits)
481}
482
483pub fn model_ladder(name: &str) -> Option<ModelLadderDef> {
487 effective_config().model_ladders.get(name).cloned()
488}
489
490pub fn model_ladder_names() -> Vec<String> {
493 effective_config().model_ladders.keys().cloned().collect()
494}
495
496pub fn wire_model_id(model_id: &str) -> String {
497 model_catalog_entry(model_id)
498 .and_then(|model| model.wire_model)
499 .unwrap_or_else(|| model_id.to_string())
500}
501
502pub fn provider_rate_limits(provider: &str) -> Option<RateLimitsDef> {
503 provider_config(provider).and_then(|provider| {
504 provider
505 .rate_limits
506 .unwrap_or_default()
507 .with_rpm_fallback(provider.rpm)
508 })
509}
510
511pub fn model_equivalence_group(model_id: &str) -> Option<String> {
512 model_catalog_entry(model_id).and_then(|model| {
513 model
514 .equivalence_group
515 .or(model.logical_model)
516 .filter(|group| !group.trim().is_empty())
517 })
518}
519
520#[derive(Clone, Debug, Default, PartialEq, Eq)]
521pub struct EquivalentModelRequirements {
522 pub context_tokens: Option<u64>,
523 pub native_tools: bool,
524 pub text_tool_wire_format: bool,
525 pub provider_tool_types: Vec<String>,
526 pub vision: bool,
527 pub url_images: bool,
528 pub audio: bool,
529 pub pdf: bool,
530 pub video: bool,
531 pub files_api: bool,
532 pub thinking: bool,
533 pub reasoning_effort: bool,
534 pub structured_output: bool,
535 pub structured_output_mode: Option<String>,
536}
537
538impl EquivalentModelRequirements {
539 fn from_source_context(
540 context_tokens: u64,
541 caps: &crate::llm::capabilities::Capabilities,
542 ) -> Self {
543 Self {
544 context_tokens: Some(context_tokens),
545 native_tools: caps.native_tools,
546 text_tool_wire_format: caps.text_tool_wire_format_supported,
547 provider_tool_types: equivalent_provider_tool_types_for_capabilities(caps),
548 vision: caps.vision_supported,
549 url_images: caps.image_url_input_supported,
550 audio: caps.audio,
551 pdf: caps.pdf,
552 video: caps.video,
553 files_api: caps.files_api_supported,
554 thinking: !caps.thinking_modes.is_empty(),
555 reasoning_effort: caps.reasoning_effort_supported,
556 structured_output: caps.structured_output.is_some(),
557 structured_output_mode: Some(caps.structured_output_mode.clone()),
558 }
559 }
560}
561
562fn equivalent_provider_tool_types_for_capabilities(
563 caps: &crate::llm::capabilities::Capabilities,
564) -> Vec<String> {
565 let mut kinds = caps.hosted_tools.clone();
566 if caps.computer_use_style.is_some() {
567 kinds.push("computer_use".to_string());
568 }
569 kinds.sort();
570 kinds.dedup();
571 kinds
572}
573
574fn provider_tool_type_matches(
575 caps: &crate::llm::capabilities::Capabilities,
576 required: &str,
577) -> bool {
578 if required == "computer_use" && caps.computer_use_style.is_some() {
579 return true;
580 }
581 caps.hosted_tools
582 .iter()
583 .any(|kind| kind == required || (required == "computer_use" && kind == "computer"))
584}
585
586pub fn equivalent_model_catalog_entries_for_requirements(
590 selector: &str,
591 requirements: EquivalentModelRequirements,
592) -> Vec<(String, ModelDef)> {
593 let resolved = resolve_model_info(selector);
594 let Some(group) = model_equivalence_group(&resolved.id) else {
595 return Vec::new();
596 };
597 let config = effective_config();
598 let Some(source) = config.models.get(&resolved.id) else {
599 return Vec::new();
600 };
601 let source_context = source
602 .runtime_context_window
603 .unwrap_or(source.context_window);
604 let minimum_context = requirements.context_tokens.unwrap_or(source_context);
605
606 sorted_model_entries_with_config(&config)
607 .into_iter()
608 .filter(|(id, model)| !(id == &resolved.id && model.provider == resolved.provider))
609 .filter(|(_, model)| !model.deprecated)
610 .filter(|(_, model)| model.availability != ModelAvailability::Dedicated)
611 .filter(|(_, model)| {
612 model.equivalence_group.as_deref() == Some(group.as_str())
613 || model.logical_model.as_deref() == Some(group.as_str())
614 })
615 .filter(|(id, model)| {
616 let caps = crate::llm::capabilities::lookup(&model.provider, id);
617 let candidate_context = model.runtime_context_window.unwrap_or(model.context_window);
618 let context_matches = candidate_context >= minimum_context;
619 let native_tools_match = !requirements.native_tools || caps.native_tools;
620 let text_tool_format_match =
621 !requirements.text_tool_wire_format || caps.text_tool_wire_format_supported;
622 let provider_tools_match = requirements
623 .provider_tool_types
624 .iter()
625 .all(|required| provider_tool_type_matches(&caps, required));
626 let vision_match = !requirements.vision || caps.vision_supported;
627 let url_images_match = !requirements.url_images
628 || crate::llm::provider::provider_supports_image_urls(&model.provider, id);
629 let audio_match = !requirements.audio || caps.audio;
630 let pdf_match = !requirements.pdf || caps.pdf;
631 let video_match = !requirements.video || caps.video;
632 let files_api_match = !requirements.files_api || caps.files_api_supported;
633 let thinking_match = !requirements.thinking || !caps.thinking_modes.is_empty();
634 let reasoning_effort_match =
635 !requirements.reasoning_effort || caps.reasoning_effort_supported;
636 let structured_output_match =
637 !requirements.structured_output || caps.structured_output.is_some();
638 let structured_output_mode_match = requirements
639 .structured_output_mode
640 .as_ref()
641 .is_none_or(|mode| mode == &caps.structured_output_mode);
642 context_matches
643 && native_tools_match
644 && text_tool_format_match
645 && provider_tools_match
646 && vision_match
647 && url_images_match
648 && audio_match
649 && pdf_match
650 && video_match
651 && files_api_match
652 && thinking_match
653 && reasoning_effort_match
654 && structured_output_match
655 && structured_output_mode_match
656 })
657 .map(|(id, model)| {
658 let provider = model.provider.clone();
659 (
660 id.clone(),
661 with_effective_capability_tags(id, provider, model),
662 )
663 })
664 .collect()
665}
666
667pub fn equivalent_model_catalog_entries_for_context(
670 selector: &str,
671 required_context_tokens: Option<u64>,
672) -> Vec<(String, ModelDef)> {
673 equivalent_model_catalog_entries_for_requirements(
674 selector,
675 EquivalentModelRequirements {
676 context_tokens: required_context_tokens,
677 ..EquivalentModelRequirements::default()
678 },
679 )
680}
681
682pub fn equivalent_model_catalog_entries(selector: &str) -> Vec<(String, ModelDef)> {
683 let resolved = resolve_model_info(selector);
684 let config = effective_config();
685 let Some(source) = config.models.get(&resolved.id) else {
686 return Vec::new();
687 };
688 let source_caps = crate::llm::capabilities::lookup(&source.provider, &resolved.id);
689 let source_context = source
690 .runtime_context_window
691 .unwrap_or(source.context_window);
692 equivalent_model_catalog_entries_for_requirements(
693 selector,
694 EquivalentModelRequirements::from_source_context(source_context, &source_caps),
695 )
696}
697
698pub fn qc_default_model(provider: &str) -> Option<String> {
699 std::env::var("BURIN_QC_MODEL")
700 .ok()
701 .filter(|value| !value.trim().is_empty())
702 .or_else(|| {
703 effective_config()
704 .qc_defaults
705 .get(&provider.to_lowercase())
706 .cloned()
707 })
708}
709
710pub fn default_model_for_provider(provider: &str) -> String {
711 if provider_uses_acp(provider) {
712 return "default".to_string();
713 }
714 match provider {
715 "local" => std::env::var("LOCAL_LLM_MODEL")
716 .or_else(|_| std::env::var("HARN_LLM_MODEL"))
717 .unwrap_or_else(|_| "gemma-4-26b-a4b-it".to_string()),
718 "mlx" => std::env::var("MLX_MODEL_ID")
719 .unwrap_or_else(|_| "unsloth/Qwen3.6-35B-A3B-UD-MLX-4bit".to_string()),
720 "openai" => "gpt-4o-mini".to_string(),
721 "ollama" => "llama3.2".to_string(),
722 "openrouter" => "anthropic/claude-sonnet-4.6".to_string(),
723 _ => "claude-sonnet-4-6".to_string(),
724 }
725}
726
727pub fn qc_defaults() -> BTreeMap<String, String> {
728 effective_config().qc_defaults
729}
730
731pub fn model_pricing_per_mtok(model_id: &str) -> Option<ModelPricing> {
732 effective_config()
733 .models
734 .get(model_id)
735 .and_then(|model| model.pricing.clone())
736}
737
738pub fn model_pricing_for_input_tokens(model_id: &str, input_tokens: i64) -> Option<ModelPricing> {
741 model_pricing_per_mtok(model_id).map(|pricing| pricing.for_input_tokens(input_tokens))
742}
743
744pub fn model_serving_tier_pricing_per_mtok(model_id: &str, tier_id: &str) -> Option<ModelPricing> {
748 effective_config()
749 .models
750 .get(model_id)
751 .and_then(|model| model.serving_tiers.iter().find(|tier| tier.id == tier_id))
752 .and_then(|tier| tier.pricing.clone())
753}
754
755pub fn pricing_per_1k_for(provider: &str, model_id: &str) -> Option<(f64, f64)> {
756 model_pricing_per_mtok(model_id)
757 .map(|pricing| {
758 (
759 pricing.input_per_mtok / 1000.0,
760 pricing.output_per_mtok / 1000.0,
761 )
762 })
763 .or_else(|| {
764 let (input, output, _) = provider_economics(provider);
765 match (input, output) {
766 (Some(input), Some(output)) => Some((input, output)),
767 _ => None,
768 }
769 })
770}
771
772pub fn auth_env_names(auth_env: &AuthEnv) -> Vec<String> {
773 match auth_env {
774 AuthEnv::None => Vec::new(),
775 AuthEnv::Single(name) => vec![name.clone()],
776 AuthEnv::Multiple(names) => names.clone(),
777 }
778}
779
780pub fn provider_has_feature(provider: &str, feature: &str) -> bool {
782 provider_config(provider)
783 .map(|p| p.features.iter().any(|f| f == feature))
784 .unwrap_or(false)
785}
786
787pub fn provider_economics(provider: &str) -> (Option<f64>, Option<f64>, Option<u64>) {
791 provider_config(provider)
792 .map(|p| (p.cost_per_1k_in, p.cost_per_1k_out, p.latency_p50_ms))
793 .unwrap_or((None, None, None))
794}
795
796#[derive(Debug, Clone, Copy, PartialEq, Eq)]
805pub enum ToolFormatChannel {
806 Native,
808 Text,
810}
811
812pub fn tool_format_channel(format: &str) -> Option<ToolFormatChannel> {
820 match format {
821 "native" => Some(ToolFormatChannel::Native),
822 "text" | "json" => Some(ToolFormatChannel::Text),
823 _ => None,
824 }
825}
826
827pub fn is_known_tool_format(format: &str) -> bool {
832 tool_format_channel(format).is_some()
833}
834
835pub fn default_tool_format(model: &str, provider: &str) -> String {
841 let config = effective_config();
842 default_tool_format_with_config(&config, model, provider)
843}
844
845pub(crate) fn default_tool_format_with_config(
846 config: &ProvidersConfig,
847 model: &str,
848 provider: &str,
849) -> String {
850 for (name, alias) in &config.aliases {
852 let matches = (alias.id == model && alias.provider == provider) || name == model;
853 if matches {
854 if let Some(ref fmt) = alias.tool_format {
855 return fmt.clone();
856 }
857 }
858 }
859 let capabilities = crate::llm::capabilities::lookup(provider, model);
860 if let Some(format) = capabilities.preferred_tool_format.as_deref() {
861 if is_known_tool_format(format) {
868 return format.to_string();
869 }
870 }
871 let capability_matrix_native = capabilities.native_tools;
872 let legacy_provider_native = config
873 .providers
874 .get(provider)
875 .map(|p| p.features.iter().any(|f| f == "native_tools"))
876 .unwrap_or(false);
877 if capability_matrix_native || legacy_provider_native {
878 "native".to_string()
879 } else {
880 "json".to_string()
891 }
892}
893
894fn with_effective_capability_tags(
895 model_id: String,
896 provider: String,
897 mut model: ModelDef,
898) -> ModelDef {
899 model.capabilities = effective_model_capability_tags(&provider, &model_id);
900 model
901}
902
903pub fn effective_model_capability_tags(provider: &str, model_id: &str) -> Vec<String> {
907 let caps = crate::llm::capabilities::lookup(provider, model_id);
908 let mut tags = capability_tags_from_capabilities(&caps);
909 if effective_batch_api_supported(provider, &caps) && !tags.iter().any(|tag| tag == "batch") {
910 tags.push("batch".to_string());
911 }
912 tags
913}
914
915pub fn effective_batch_api_supported(
916 provider: &str,
917 caps: &crate::llm::capabilities::Capabilities,
918) -> bool {
919 caps.batch_api || provider_has_feature(provider, "batch")
920}
921
922pub(crate) fn capability_tags_from_capabilities(
923 caps: &crate::llm::capabilities::Capabilities,
924) -> Vec<String> {
925 let mut tags = Vec::new();
926 tags.push("streaming".to_string());
929 if caps.native_tools || caps.text_tool_wire_format_supported {
930 tags.push("tools".to_string());
931 }
932 if !caps.tool_search.is_empty() {
933 tags.push("tool_search".to_string());
934 }
935 if caps.vision || caps.vision_supported {
936 tags.push("vision".to_string());
937 }
938 if caps.audio {
939 tags.push("audio".to_string());
940 }
941 if caps.pdf {
942 tags.push("pdf".to_string());
943 }
944 if caps.video {
945 tags.push("video".to_string());
946 }
947 if caps.files_api_supported {
948 tags.push("files".to_string());
949 }
950 if caps.batch_api {
951 tags.push("batch".to_string());
952 }
953 if caps.prompt_caching {
954 tags.push("prompt_caching".to_string());
955 }
956 if !caps.thinking_modes.is_empty() {
957 tags.push("thinking".to_string());
958 }
959 if caps.interleaved_thinking_supported
960 || caps
961 .thinking_modes
962 .iter()
963 .any(|mode| mode == "adaptive" || mode == "effort")
964 {
965 tags.push("extended_thinking".to_string());
966 }
967 if caps.structured_output.is_some() || caps.json_schema.is_some() {
968 tags.push("structured_output".to_string());
969 }
970 tags
971}
972
973pub fn resolve_tier_model(
975 target: &str,
976 preferred_provider: Option<&str>,
977) -> Option<(String, String)> {
978 let config = effective_config();
979
980 let candidate_aliases = if let Some(provider) = preferred_provider {
981 vec![
982 format!("{provider}/{target}"),
983 format!("{provider}:{target}"),
984 format!("tier/{target}"),
985 target.to_string(),
986 ]
987 } else {
988 vec![format!("tier/{target}"), target.to_string()]
989 };
990
991 for alias_name in candidate_aliases {
992 if let Some(alias) = config.aliases.get(&alias_name) {
993 return Some((alias.id.clone(), alias.provider.clone()));
994 }
995 }
996
997 None
998}
999
1000pub fn tier_candidates(target: &str) -> Vec<(String, String)> {
1004 let config = effective_config();
1005 let mut seen = std::collections::BTreeSet::new();
1006 let mut candidates = Vec::new();
1007
1008 for alias in config.aliases.values() {
1009 let pair = (alias.id.clone(), alias.provider.clone());
1010 if seen.contains(&pair) {
1011 continue;
1012 }
1013 if model_tier(&alias.id) == target {
1014 seen.insert(pair.clone());
1015 candidates.push(pair);
1016 }
1017 }
1018
1019 candidates.sort_by(|(model_a, provider_a), (model_b, provider_b)| {
1020 provider_a
1021 .cmp(provider_b)
1022 .then_with(|| model_a.cmp(model_b))
1023 });
1024 candidates
1025}
1026
1027pub fn all_model_candidates() -> Vec<(String, String)> {
1030 let config = effective_config();
1031 let mut seen = std::collections::BTreeSet::new();
1032 let mut candidates = Vec::new();
1033
1034 for alias in config.aliases.values() {
1035 let pair = (alias.id.clone(), alias.provider.clone());
1036 if seen.insert(pair.clone()) {
1037 candidates.push(pair);
1038 }
1039 }
1040
1041 candidates.sort_by(|(model_a, provider_a), (model_b, provider_b)| {
1042 provider_a
1043 .cmp(provider_b)
1044 .then_with(|| model_a.cmp(model_b))
1045 });
1046 candidates
1047}