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(crate) fn is_logical_model_selector(selector: &str) -> bool {
18 selector.starts_with(LOGICAL_MODEL_DEFAULT_PREFIX)
19}
20
21pub fn provider_config(name: &str) -> Option<ProviderDef> {
23 let mut provider = effective_config().providers.get(name).cloned()?;
24 if let Some(base_url) = runtime_provider_endpoint(name) {
25 provider.base_url = base_url;
29 provider.base_url_env = None;
30 provider.region_env = None;
31 }
32 Some(provider)
33}
34
35pub fn provider_protocol(name: &str) -> Option<String> {
36 provider_config(name).and_then(|def| def.protocol)
37}
38
39pub fn provider_uses_acp(name: &str) -> bool {
40 provider_protocol(name)
41 .as_deref()
42 .is_some_and(|protocol| protocol.eq_ignore_ascii_case("acp"))
43}
44
45pub fn model_params(model_id: &str) -> BTreeMap<String, toml::Value> {
48 let config = effective_config();
49 matching_model_params(&config, model_id)
50}
51
52fn matching_model_params(
53 config: &ProvidersConfig,
54 model_id: &str,
55) -> BTreeMap<String, toml::Value> {
56 let mut params = BTreeMap::new();
57 apply_matching_model_params(config, model_id, &mut params);
58 params
59}
60
61fn apply_matching_model_params(
62 config: &ProvidersConfig,
63 model_id: &str,
64 params: &mut BTreeMap<String, toml::Value>,
65) {
66 for (pattern, defaults) in &config.model_defaults {
67 if !is_logical_model_selector(pattern) && glob_match(pattern, model_id) {
68 apply_model_param_layer(params, defaults);
69 }
70 }
71}
72
73fn apply_model_param_layer(
74 params: &mut BTreeMap<String, toml::Value>,
75 defaults: &BTreeMap<String, toml::Value>,
76) {
77 for (key, value) in defaults {
78 if key != MODEL_DEFAULT_UNSET_KEY {
79 params.insert(key.clone(), value.clone());
80 }
81 }
82 if let Some(keys) = defaults
83 .get(MODEL_DEFAULT_UNSET_KEY)
84 .and_then(toml::Value::as_array)
85 {
86 for key in keys.iter().filter_map(toml::Value::as_str) {
87 params.remove(key);
88 }
89 }
90}
91
92pub fn model_params_for_route(provider: &str, model_id: &str) -> BTreeMap<String, toml::Value> {
98 let config = effective_config();
99 model_params_for_route_with_config(&config, provider, model_id)
100}
101
102pub fn generation_defaults_for_route(
111 provider: &str,
112 model_id: &str,
113) -> BTreeMap<String, toml::Value> {
114 model_params_for_route(provider, model_id)
115 .into_iter()
116 .filter(|(key, value)| is_valid_generation_default(key, value))
117 .collect()
118}
119
120pub(crate) fn model_params_for_route_with_config(
121 config: &ProvidersConfig,
122 provider: &str,
123 model_id: &str,
124) -> BTreeMap<String, toml::Value> {
125 let normalized_id = normalize_model_id(model_id);
126 let route = config
127 .models
128 .get_key_value(model_id)
129 .filter(|(_, model)| model.provider == provider)
130 .or_else(|| {
131 config
132 .models
133 .get_key_value(&normalized_id)
134 .filter(|(_, model)| model.provider == provider)
135 })
136 .or_else(|| {
137 config.models.iter().find(|(_, model)| {
138 model.provider == provider
139 && model
140 .wire_model
141 .as_deref()
142 .is_some_and(|wire| wire == model_id || wire == normalized_id.as_str())
143 })
144 });
145
146 let mut params = route
147 .and_then(|(_, model)| model.logical_model.as_deref())
148 .and_then(|logical_model| {
149 config
150 .model_defaults
151 .get(&format!("{LOGICAL_MODEL_DEFAULT_PREFIX}{logical_model}"))
152 })
153 .map(|defaults| {
154 let mut params = BTreeMap::new();
155 apply_model_param_layer(&mut params, defaults);
156 params
157 })
158 .unwrap_or_default();
159
160 let mut identities = vec![model_id.to_string()];
161 if normalized_id != model_id {
162 identities.push(normalized_id);
163 }
164 if let Some((catalog_id, model)) = route {
165 for identity in [Some(catalog_id.as_str()), model.wire_model.as_deref()]
166 .into_iter()
167 .flatten()
168 {
169 if !identities.iter().any(|known| known == identity) {
170 identities.push(identity.to_string());
171 }
172 }
173 }
174 for identity in &identities {
175 apply_matching_model_params(config, identity, &mut params);
176 }
177 let provider_prefix = format!("{provider}/");
178 for identity in identities {
179 if !identity.starts_with(&provider_prefix) {
180 apply_matching_model_params(
181 config,
182 &format!("{provider_prefix}{identity}"),
183 &mut params,
184 );
185 }
186 }
187 params
188}
189
190pub fn model_default_issues(config: &ProvidersConfig) -> Vec<String> {
195 let mut issues = Vec::new();
196 for (selector, defaults) in &config.model_defaults {
197 if let Some(unset) = defaults.get(MODEL_DEFAULT_UNSET_KEY) {
198 let valid = !is_logical_model_selector(selector)
199 && unset.as_array().is_some_and(|keys| {
200 !keys.is_empty()
201 && keys.iter().all(|key| {
202 key.as_str()
203 .is_some_and(is_supported_generation_default_key)
204 })
205 });
206 if !valid {
207 issues.push(format!(
208 "model_defaults.{selector}.{MODEL_DEFAULT_UNSET_KEY} must be a non-empty list of supported route-default keys"
209 ));
210 }
211 }
212 let Some(logical_model) = selector.strip_prefix(LOGICAL_MODEL_DEFAULT_PREFIX) else {
213 continue;
214 };
215 if logical_model.is_empty()
216 || logical_model.contains('*')
217 || logical_model.contains('?')
218 || logical_model.contains('[')
219 {
220 issues.push(format!(
221 "model_defaults.{selector} must name one exact logical model"
222 ));
223 continue;
224 }
225 let routes: Vec<_> = config
226 .models
227 .iter()
228 .filter(|(_, model)| model.logical_model.as_deref() == Some(logical_model))
229 .collect();
230 if routes.is_empty() {
231 issues.push(format!(
232 "model_defaults.{selector} references an unknown logical model"
233 ));
234 continue;
235 }
236
237 for (key, value) in defaults {
238 if key == MODEL_DEFAULT_UNSET_KEY {
239 continue;
240 }
241 if !is_valid_generation_default(key, value) {
242 issues.push(format!(
243 "model_defaults.{selector}.{key} is not a supported generation default"
244 ));
245 continue;
246 }
247
248 for (model_id, model) in &routes {
249 let caps = crate::llm::capabilities::lookup_with_user_overrides(
250 &model.provider,
251 model_id,
252 None,
253 );
254 let supported = match key.as_str() {
255 "temperature" => caps.temperature_supported,
256 "top_p" => caps.top_p_supported,
257 "top_k" => caps.top_k_supported,
258 "frequency_penalty" => caps.frequency_penalty_supported,
259 "presence_penalty" => caps.presence_penalty_supported,
260 "reasoning_effort" => {
261 let effort = value.as_str().expect("validated effort string");
262 caps.reasoning_effort_supported
263 && caps.thinking_modes.iter().any(|mode| mode == "effort")
264 && (caps.reasoning_effort_levels.is_empty()
265 || caps
266 .reasoning_effort_levels
267 .iter()
268 .any(|level| level == effort))
269 }
270 "max_tokens" => true,
271 _ => false,
272 };
273 let effective =
274 model_params_for_route_with_config(config, &model.provider, model_id);
275 if !supported && effective.contains_key(key) {
276 issues.push(format!(
277 "model_defaults.{selector}.{key} cannot be represented by route {}:{}",
278 model.provider, model_id
279 ));
280 }
281 }
282 }
283 }
284 issues
285}
286
287fn is_supported_generation_default_key(key: &str) -> bool {
288 matches!(
289 key,
290 "temperature"
291 | "top_p"
292 | "top_k"
293 | "frequency_penalty"
294 | "presence_penalty"
295 | "max_tokens"
296 | "reasoning_effort"
297 )
298}
299
300fn is_valid_generation_default(key: &str, value: &toml::Value) -> bool {
301 match key {
302 "temperature" => value
303 .as_float()
304 .is_some_and(|value| value.is_finite() && (0.0..=2.0).contains(&value)),
305 "frequency_penalty" | "presence_penalty" => value
306 .as_float()
307 .is_some_and(|value| value.is_finite() && (-2.0..=2.0).contains(&value)),
308 "top_p" => value
309 .as_float()
310 .is_some_and(|value| value.is_finite() && (0.0..=1.0).contains(&value)),
311 "top_k" => value.as_integer().is_some_and(|value| value >= 0),
312 "max_tokens" => value.as_integer().is_some_and(|value| value > 0),
313 "reasoning_effort" => value.as_str().is_some_and(|value| {
314 matches!(
315 value,
316 "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"
317 )
318 }),
319 _ => false,
320 }
321}
322
323pub fn model_role_defaults(role: &str) -> BTreeMap<String, toml::Value> {
337 let normalized = normalize_model_role_name(role);
338 if normalized.is_empty() {
339 return BTreeMap::new();
340 }
341 let config = effective_config();
342 let mut params = BTreeMap::new();
343 for key in role_lookup_keys(&normalized) {
344 extend_model_role_defaults(&config, &key, &mut params);
345 }
346 apply_model_role_env_overrides(&normalized, &mut params);
347 params
348}
349
350fn extend_model_role_defaults(
351 config: &ProvidersConfig,
352 role: &str,
353 params: &mut BTreeMap<String, toml::Value>,
354) {
355 for (configured_role, defaults) in &config.model_roles {
356 if normalize_model_role_name(configured_role) == role {
357 params.extend(defaults.clone());
358 }
359 }
360 if let Some(defaults) = config.model_roles.get(role) {
361 params.extend(defaults.clone());
362 }
363}
364
365fn normalize_model_role_name(role: &str) -> String {
366 role.trim().to_ascii_lowercase().replace('-', "_")
367}
368
369fn role_lookup_keys(role: &str) -> Vec<String> {
370 if role == "merge" {
371 vec!["fast_apply".to_string(), "merge".to_string()]
372 } else if role == "fast_apply" {
373 vec!["merge".to_string(), "fast_apply".to_string()]
374 } else {
375 vec![role.to_string()]
376 }
377}
378
379fn role_env_token(role: &str) -> String {
380 role.chars()
381 .map(|ch| {
382 if ch.is_ascii_alphanumeric() {
383 ch.to_ascii_uppercase()
384 } else {
385 '_'
386 }
387 })
388 .collect::<String>()
389 .split('_')
390 .filter(|part| !part.is_empty())
391 .collect::<Vec<_>>()
392 .join("_")
393}
394
395fn apply_model_role_env_overrides(role: &str, params: &mut BTreeMap<String, toml::Value>) {
396 for alias in role_env_aliases(role) {
397 apply_model_role_env_var(&format!("HARN_LLM_{alias}_PROVIDER"), "provider", params);
398 apply_model_role_env_var(&format!("HARN_LLM_{alias}_MODEL"), "model", params);
399 apply_model_role_env_var(
400 &format!("HARN_LLM_{alias}_ROUTE_POLICY"),
401 "route_policy",
402 params,
403 );
404 apply_model_role_env_var(
405 &format!("HARN_LLM_ROLE_{alias}_PROVIDER"),
406 "provider",
407 params,
408 );
409 apply_model_role_env_var(&format!("HARN_LLM_ROLE_{alias}_MODEL"), "model", params);
410 apply_model_role_env_var(
411 &format!("HARN_LLM_ROLE_{alias}_ROUTE_POLICY"),
412 "route_policy",
413 params,
414 );
415 }
416}
417
418fn role_env_aliases(role: &str) -> Vec<String> {
419 let token = role_env_token(role);
420 if token.is_empty() {
421 return Vec::new();
422 }
423 if token == "MERGE" {
424 vec!["FAST_APPLY".to_string(), "MERGE".to_string()]
425 } else if token == "FAST_APPLY" {
426 vec!["MERGE".to_string(), "FAST_APPLY".to_string()]
427 } else {
428 vec![token]
429 }
430}
431
432fn apply_model_role_env_var(
433 env_name: &str,
434 option_name: &str,
435 params: &mut BTreeMap<String, toml::Value>,
436) {
437 let Ok(Some(value)) = crate::stdlib::process::session_env_var(env_name) else {
438 return;
439 };
440 let trimmed = value.trim();
441 if trimmed.is_empty() {
442 return;
443 }
444 params.insert(
445 option_name.to_string(),
446 toml::Value::String(trimmed.to_string()),
447 );
448}
449
450pub fn provider_names() -> Vec<String> {
452 effective_config().providers.keys().cloned().collect()
453}
454
455pub fn known_model_names() -> Vec<String> {
457 effective_config().aliases.keys().cloned().collect()
458}
459
460pub fn alias_entries() -> Vec<(String, AliasDef)> {
461 effective_config()
462 .aliases
463 .iter()
464 .map(|(name, alias)| (name.clone(), alias.clone()))
465 .collect()
466}
467
468pub fn alias_tool_calling_entry(alias: &str) -> Option<AliasToolCallingDef> {
469 effective_config().alias_tool_calling.get(alias).cloned()
470}
471
472pub fn model_catalog_entries() -> Vec<(String, ModelDef)> {
474 let config = effective_config();
475 model_catalog_entries_with_config(&config)
476}
477
478pub(crate) fn model_catalog_entries_with_config(
479 config: &ProvidersConfig,
480) -> Vec<(String, ModelDef)> {
481 sorted_model_entries_with_config(config)
482 .into_iter()
483 .map(|(id, model)| {
484 let provider = model.provider.clone();
485 (
486 id.clone(),
487 with_effective_capability_tags(id, provider, model),
488 )
489 })
490 .collect()
491}
492
493pub(crate) fn sorted_model_entries_with_config(
494 config: &ProvidersConfig,
495) -> Vec<(String, ModelDef)> {
496 let mut entries: Vec<_> = config
497 .models
498 .iter()
499 .map(|(id, model)| (id.clone(), model.clone()))
500 .collect();
501 entries.sort_by(|(id_a, model_a), (id_b, model_b)| {
502 model_a
503 .provider
504 .cmp(&model_b.provider)
505 .then_with(|| id_a.cmp(id_b))
506 });
507 entries
508}
509
510pub fn model_catalog_entry(model_id: &str) -> Option<ModelDef> {
511 effective_config()
512 .models
513 .get(model_id)
514 .cloned()
515 .map(|model| {
516 let provider = model.provider.clone();
517 with_effective_capability_tags(model_id.to_string(), provider, model)
518 })
519}
520
521pub fn model_catalog_id_for_route(provider: &str, model_id: &str) -> Option<String> {
527 let config = effective_config();
528 let normalized_id = normalize_model_id(model_id);
529 config
530 .models
531 .get_key_value(model_id)
532 .filter(|(_, model)| model.provider == provider)
533 .or_else(|| {
534 config
535 .models
536 .get_key_value(&normalized_id)
537 .filter(|(_, model)| model.provider == provider)
538 })
539 .or_else(|| {
540 config.models.iter().find(|(_, model)| {
541 model.provider == provider
542 && model
543 .wire_model
544 .as_deref()
545 .is_some_and(|wire| wire == model_id || wire == normalized_id.as_str())
546 })
547 })
548 .map(|(id, _)| id.clone())
549}
550
551pub fn model_rate_limits(model_id: &str) -> Option<RateLimitsDef> {
552 model_catalog_entry(model_id).and_then(|model| model.rate_limits)
553}
554
555pub fn model_ladder(name: &str) -> Option<ModelLadderDef> {
559 effective_config().model_ladders.get(name).cloned()
560}
561
562pub fn model_ladder_names() -> Vec<String> {
565 effective_config().model_ladders.keys().cloned().collect()
566}
567
568pub fn wire_model_id(model_id: &str) -> String {
569 model_catalog_entry(model_id)
570 .and_then(|model| model.wire_model)
571 .unwrap_or_else(|| model_id.to_string())
572}
573
574pub(crate) fn capability_model_id(provider: &str, model_id: &str) -> String {
579 if !provider_has_feature(provider, "wire_model_capabilities") {
580 return model_id.to_string();
581 }
582 effective_config()
583 .models
584 .get(model_id)
585 .filter(|model| model.provider == provider)
586 .and_then(|model| model.wire_model.clone())
587 .unwrap_or_else(|| model_id.to_string())
588}
589
590pub fn provider_rate_limits(provider: &str) -> Option<RateLimitsDef> {
591 provider_config(provider).and_then(|provider| {
592 provider
593 .rate_limits
594 .unwrap_or_default()
595 .with_rpm_fallback(provider.rpm)
596 })
597}
598
599pub fn model_equivalence_group(model_id: &str) -> Option<String> {
600 model_catalog_entry(model_id).and_then(|model| {
601 model
602 .equivalence_group
603 .or(model.logical_model)
604 .filter(|group| !group.trim().is_empty())
605 })
606}
607
608#[derive(Clone, Debug, Default, PartialEq, Eq)]
609pub struct EquivalentModelRequirements {
610 pub context_tokens: Option<u64>,
611 pub responses_api: bool,
612 pub native_tools: bool,
613 pub text_tool_wire_format: bool,
614 pub provider_tool_types: Vec<String>,
615 pub vision: bool,
616 pub url_images: bool,
617 pub audio: bool,
618 pub pdf: bool,
619 pub video: bool,
620 pub files_api: bool,
621 pub thinking: bool,
622 pub reasoning_effort: bool,
623 pub structured_output: bool,
624 pub structured_output_mode: Option<String>,
625}
626
627impl EquivalentModelRequirements {
628 fn from_source_context(
629 context_tokens: u64,
630 caps: &crate::llm::capabilities::Capabilities,
631 ) -> Self {
632 Self {
633 context_tokens: Some(context_tokens),
634 responses_api: caps.responses_api,
635 native_tools: caps.native_tools,
636 text_tool_wire_format: caps.text_tool_wire_format_supported,
637 provider_tool_types: equivalent_provider_tool_types_for_capabilities(caps),
638 vision: caps.vision_supported,
639 url_images: caps.image_url_input_supported,
640 audio: caps.audio,
641 pdf: caps.pdf,
642 video: caps.video,
643 files_api: caps.files_api_supported,
644 thinking: !caps.thinking_modes.is_empty(),
645 reasoning_effort: caps.reasoning_effort_supported,
646 structured_output: caps.structured_output.is_some(),
647 structured_output_mode: Some(caps.structured_output_mode.clone()),
648 }
649 }
650}
651
652fn equivalent_provider_tool_types_for_capabilities(
653 caps: &crate::llm::capabilities::Capabilities,
654) -> Vec<String> {
655 let mut kinds = caps.hosted_tools.clone();
656 if caps.computer_use_style.is_some() {
657 kinds.push("computer_use".to_string());
658 }
659 kinds.sort();
660 kinds.dedup();
661 kinds
662}
663
664fn provider_tool_type_matches(
665 caps: &crate::llm::capabilities::Capabilities,
666 required: &str,
667) -> bool {
668 if required == "computer_use" && caps.computer_use_style.is_some() {
669 return true;
670 }
671 caps.hosted_tools
672 .iter()
673 .any(|kind| kind == required || (required == "computer_use" && kind == "computer"))
674}
675
676pub fn equivalent_model_catalog_entries_for_requirements(
680 selector: &str,
681 requirements: EquivalentModelRequirements,
682) -> Vec<(String, ModelDef)> {
683 let resolved = resolve_model_info(selector);
684 let Some(group) = model_equivalence_group(&resolved.id) else {
685 return Vec::new();
686 };
687 let config = effective_config();
688 let Some(source) = config.models.get(&resolved.id) else {
689 return Vec::new();
690 };
691 let source_context = source
692 .runtime_context_window
693 .unwrap_or(source.context_window);
694 let minimum_context = requirements.context_tokens.unwrap_or(source_context);
695
696 sorted_model_entries_with_config(&config)
697 .into_iter()
698 .filter(|(id, model)| !(id == &resolved.id && model.provider == resolved.provider))
699 .filter(|(_, model)| !model.deprecated)
700 .filter(|(_, model)| model.availability != ModelAvailability::Dedicated)
701 .filter(|(_, model)| {
702 model.equivalence_group.as_deref() == Some(group.as_str())
703 || model.logical_model.as_deref() == Some(group.as_str())
704 })
705 .filter(|(id, model)| {
706 let caps = crate::llm::capabilities::lookup(&model.provider, id);
707 let candidate_context = model.runtime_context_window.unwrap_or(model.context_window);
708 let context_matches = candidate_context >= minimum_context;
709 let responses_api_match = !requirements.responses_api || caps.responses_api;
710 let native_tools_match = !requirements.native_tools || caps.native_tools;
711 let text_tool_format_match =
712 !requirements.text_tool_wire_format || caps.text_tool_wire_format_supported;
713 let provider_tools_match = requirements
714 .provider_tool_types
715 .iter()
716 .all(|required| provider_tool_type_matches(&caps, required));
717 let vision_match = !requirements.vision || caps.vision_supported;
718 let url_images_match = !requirements.url_images
719 || crate::llm::provider::provider_supports_image_urls(&model.provider, id);
720 let audio_match = !requirements.audio || caps.audio;
721 let pdf_match = !requirements.pdf || caps.pdf;
722 let video_match = !requirements.video || caps.video;
723 let files_api_match = !requirements.files_api || caps.files_api_supported;
724 let thinking_match = !requirements.thinking || !caps.thinking_modes.is_empty();
725 let reasoning_effort_match =
726 !requirements.reasoning_effort || caps.reasoning_effort_supported;
727 let structured_output_match =
728 !requirements.structured_output || caps.structured_output.is_some();
729 let structured_output_mode_match = requirements
730 .structured_output_mode
731 .as_ref()
732 .is_none_or(|mode| mode == &caps.structured_output_mode);
733 context_matches
734 && responses_api_match
735 && native_tools_match
736 && text_tool_format_match
737 && provider_tools_match
738 && vision_match
739 && url_images_match
740 && audio_match
741 && pdf_match
742 && video_match
743 && files_api_match
744 && thinking_match
745 && reasoning_effort_match
746 && structured_output_match
747 && structured_output_mode_match
748 })
749 .map(|(id, model)| {
750 let provider = model.provider.clone();
751 (
752 id.clone(),
753 with_effective_capability_tags(id, provider, model),
754 )
755 })
756 .collect()
757}
758
759pub fn equivalent_model_catalog_entries_for_context(
762 selector: &str,
763 required_context_tokens: Option<u64>,
764) -> Vec<(String, ModelDef)> {
765 equivalent_model_catalog_entries_for_requirements(
766 selector,
767 EquivalentModelRequirements {
768 context_tokens: required_context_tokens,
769 ..EquivalentModelRequirements::default()
770 },
771 )
772}
773
774pub fn equivalent_model_catalog_entries(selector: &str) -> Vec<(String, ModelDef)> {
775 let resolved = resolve_model_info(selector);
776 let config = effective_config();
777 let Some(source) = config.models.get(&resolved.id) else {
778 return Vec::new();
779 };
780 let source_caps = crate::llm::capabilities::lookup(&source.provider, &resolved.id);
781 let source_context = source
782 .runtime_context_window
783 .unwrap_or(source.context_window);
784 equivalent_model_catalog_entries_for_requirements(
785 selector,
786 EquivalentModelRequirements::from_source_context(source_context, &source_caps),
787 )
788}
789
790pub fn qc_default_model(provider: &str) -> Option<String> {
791 crate::stdlib::process::session_env_var("BURIN_QC_MODEL")
792 .ok()
793 .flatten()
794 .filter(|value| !value.trim().is_empty())
795 .or_else(|| {
796 effective_config()
797 .qc_defaults
798 .get(&provider.to_lowercase())
799 .cloned()
800 })
801}
802
803pub fn default_model_for_provider(provider: &str) -> String {
804 if provider_uses_acp(provider) {
805 return "default".to_string();
806 }
807 match provider {
808 "local" => crate::stdlib::process::session_env_value("LOCAL_LLM_MODEL")
809 .or_else(|| crate::stdlib::process::session_env_value("HARN_LLM_MODEL"))
810 .unwrap_or_else(|| "gemma-4-26b-a4b-it".to_string()),
811 "mlx" => crate::stdlib::process::session_env_var("MLX_MODEL_ID")
812 .ok()
813 .flatten()
814 .unwrap_or_else(|| "unsloth/Qwen3.6-35B-A3B-UD-MLX-4bit".to_string()),
815 "openai" => "gpt-4o-mini".to_string(),
816 "ollama" => "llama3.2".to_string(),
817 "openrouter" => "anthropic/claude-sonnet-4.6".to_string(),
818 _ => "claude-sonnet-4-6".to_string(),
819 }
820}
821
822pub fn qc_defaults() -> BTreeMap<String, String> {
823 effective_config().qc_defaults.clone()
824}
825
826pub fn model_pricing_per_mtok(model_id: &str) -> Option<ModelPricing> {
827 effective_config()
828 .models
829 .get(model_id)
830 .and_then(|model| model.pricing.clone())
831}
832
833pub fn model_pricing_per_mtok_for_route(provider: &str, model_id: &str) -> Option<ModelPricing> {
834 let catalog_id = model_catalog_id_for_route(provider, model_id)?;
835 model_pricing_per_mtok(&catalog_id)
836}
837
838pub fn model_pricing_for_input_tokens(model_id: &str, input_tokens: i64) -> Option<ModelPricing> {
841 model_pricing_per_mtok(model_id).map(|pricing| pricing.for_input_tokens(input_tokens))
842}
843
844pub fn model_pricing_for_route_input_tokens(
845 provider: &str,
846 model_id: &str,
847 input_tokens: i64,
848) -> Option<ModelPricing> {
849 model_pricing_per_mtok_for_route(provider, model_id)
850 .map(|pricing| pricing.for_input_tokens(input_tokens))
851}
852
853pub fn model_serving_tier_pricing_per_mtok(model_id: &str, tier_id: &str) -> Option<ModelPricing> {
857 effective_config()
858 .models
859 .get(model_id)
860 .and_then(|model| model.serving_tiers.iter().find(|tier| tier.id == tier_id))
861 .and_then(|tier| tier.pricing.clone())
862}
863
864pub fn model_serving_tier_pricing_per_mtok_for_route(
865 provider: &str,
866 model_id: &str,
867 tier_id: &str,
868) -> Option<ModelPricing> {
869 let catalog_id = model_catalog_id_for_route(provider, model_id)?;
870 model_serving_tier_pricing_per_mtok(&catalog_id, tier_id)
871}
872
873pub fn pricing_per_1k_for(provider: &str, model_id: &str) -> Option<(f64, f64)> {
874 model_pricing_per_mtok_for_route(provider, model_id)
875 .map(|pricing| {
876 (
877 pricing.input_per_mtok / 1000.0,
878 pricing.output_per_mtok / 1000.0,
879 )
880 })
881 .or_else(|| {
882 let (input, output, _) = provider_economics(provider);
883 match (input, output) {
884 (Some(input), Some(output)) => Some((input, output)),
885 _ => None,
886 }
887 })
888}
889
890pub fn auth_env_names(auth_env: &AuthEnv) -> Vec<String> {
891 match auth_env {
892 AuthEnv::None => Vec::new(),
893 AuthEnv::Single(name) => vec![name.clone()],
894 AuthEnv::Multiple(names) => names.clone(),
895 }
896}
897
898pub fn provider_has_feature(provider: &str, feature: &str) -> bool {
900 provider_config(provider)
901 .map(|p| p.features.iter().any(|f| f == feature))
902 .unwrap_or(false)
903}
904
905pub fn provider_economics(provider: &str) -> (Option<f64>, Option<f64>, Option<u64>) {
909 provider_config(provider)
910 .map(|p| (p.cost_per_1k_in, p.cost_per_1k_out, p.latency_p50_ms))
911 .unwrap_or((None, None, None))
912}
913
914#[derive(Debug, Clone, Copy, PartialEq, Eq)]
923pub enum ToolFormatChannel {
924 Native,
926 Text,
928}
929
930pub fn tool_format_channel(format: &str) -> Option<ToolFormatChannel> {
938 match format {
939 "native" => Some(ToolFormatChannel::Native),
940 "text" | "json" => Some(ToolFormatChannel::Text),
944 _ => None,
945 }
946}
947
948pub fn is_known_tool_format(format: &str) -> bool {
953 tool_format_channel(format).is_some()
954}
955
956pub fn default_tool_format(model: &str, provider: &str) -> String {
962 let config = effective_config();
963 default_tool_format_with_config(&config, model, provider)
964}
965
966pub(crate) fn default_tool_format_with_config(
967 config: &ProvidersConfig,
968 model: &str,
969 provider: &str,
970) -> String {
971 default_tool_format_with_config_and_fitness(
972 config,
973 model,
974 provider,
975 crate::llm::tool_scorecard::pinned_tool_format(provider, model),
976 )
977}
978
979pub(crate) fn default_tool_format_with_config_and_fitness(
980 config: &ProvidersConfig,
981 model: &str,
982 provider: &str,
983 measured_format: Option<String>,
984) -> String {
985 for (name, alias) in &config.aliases {
987 let matches = (alias.id == model && alias.provider == provider) || name == model;
988 if matches {
989 if let Some(ref fmt) = alias.tool_format {
990 return fmt.clone();
991 }
992 }
993 }
994 if let Some(format) =
995 measured_format.filter(|format| matches!(format.as_str(), "native" | "json" | "text"))
996 {
997 return format;
998 }
999 let capabilities = crate::llm::capabilities::lookup(provider, model);
1000 if let Some(format) = capabilities.preferred_tool_format.as_deref() {
1001 if is_known_tool_format(format) {
1008 return format.to_string();
1009 }
1010 }
1011 let capability_matrix_native = capabilities.native_tools;
1012 let legacy_provider_native = config
1013 .providers
1014 .get(provider)
1015 .map(|p| p.features.iter().any(|f| f == "native_tools"))
1016 .unwrap_or(false);
1017 if capability_matrix_native || legacy_provider_native {
1018 "native".to_string()
1019 } else {
1020 "json".to_string()
1031 }
1032}
1033
1034fn with_effective_capability_tags(
1035 model_id: String,
1036 provider: String,
1037 mut model: ModelDef,
1038) -> ModelDef {
1039 model.capabilities = effective_model_capability_tags(&provider, &model_id);
1040 model
1041}
1042
1043pub fn effective_model_capability_tags(provider: &str, model_id: &str) -> Vec<String> {
1047 let caps = crate::llm::capabilities::lookup(provider, model_id);
1048 let mut tags = capability_tags_from_capabilities(&caps);
1049 if effective_batch_api_supported(provider, &caps) && !tags.iter().any(|tag| tag == "batch") {
1050 tags.push("batch".to_string());
1051 }
1052 tags
1053}
1054
1055pub fn effective_batch_api_supported(
1056 provider: &str,
1057 caps: &crate::llm::capabilities::Capabilities,
1058) -> bool {
1059 caps.batch_api || provider_has_feature(provider, "batch")
1060}
1061
1062pub(crate) fn capability_tags_from_capabilities(
1063 caps: &crate::llm::capabilities::Capabilities,
1064) -> Vec<String> {
1065 let mut tags = Vec::new();
1066 tags.push("streaming".to_string());
1069 if caps.native_tools || caps.text_tool_wire_format_supported {
1070 tags.push("tools".to_string());
1071 }
1072 if !caps.tool_search.is_empty() {
1073 tags.push("tool_search".to_string());
1074 }
1075 if caps.vision || caps.vision_supported {
1076 tags.push("vision".to_string());
1077 }
1078 if caps.audio {
1079 tags.push("audio".to_string());
1080 }
1081 if caps.pdf {
1082 tags.push("pdf".to_string());
1083 }
1084 if caps.video {
1085 tags.push("video".to_string());
1086 }
1087 if caps.files_api_supported {
1088 tags.push("files".to_string());
1089 }
1090 if caps.batch_api {
1091 tags.push("batch".to_string());
1092 }
1093 if caps.prompt_caching {
1094 tags.push("prompt_caching".to_string());
1095 }
1096 if !caps.thinking_modes.is_empty() {
1097 tags.push("thinking".to_string());
1098 }
1099 if caps.interleaved_thinking_supported
1100 || caps
1101 .thinking_modes
1102 .iter()
1103 .any(|mode| mode == "adaptive" || mode == "effort")
1104 {
1105 tags.push("extended_thinking".to_string());
1106 }
1107 if caps.structured_output.is_some() || caps.json_schema.is_some() {
1108 tags.push("structured_output".to_string());
1109 }
1110 tags
1111}
1112
1113pub fn resolve_tier_model(
1115 target: &str,
1116 preferred_provider: Option<&str>,
1117) -> Option<(String, String)> {
1118 let config = effective_config();
1119
1120 let candidate_aliases = if let Some(provider) = preferred_provider {
1121 vec![
1122 format!("{provider}/{target}"),
1123 format!("{provider}:{target}"),
1124 format!("tier/{target}"),
1125 target.to_string(),
1126 ]
1127 } else {
1128 vec![format!("tier/{target}"), target.to_string()]
1129 };
1130
1131 for alias_name in candidate_aliases {
1132 if let Some(alias) = config.aliases.get(&alias_name) {
1133 return Some((alias.id.clone(), alias.provider.clone()));
1134 }
1135 }
1136
1137 None
1138}
1139
1140pub fn tier_candidates(target: &str) -> Vec<(String, String)> {
1144 let config = effective_config();
1145 let mut seen = std::collections::BTreeSet::new();
1146 let mut candidates = Vec::new();
1147
1148 for alias in config.aliases.values() {
1149 let pair = (alias.id.clone(), alias.provider.clone());
1150 if seen.contains(&pair) {
1151 continue;
1152 }
1153 if model_tier(&alias.id) == target {
1154 seen.insert(pair.clone());
1155 candidates.push(pair);
1156 }
1157 }
1158
1159 candidates.sort_by(|(model_a, provider_a), (model_b, provider_b)| {
1160 provider_a
1161 .cmp(provider_b)
1162 .then_with(|| model_a.cmp(model_b))
1163 });
1164 candidates
1165}
1166
1167pub fn all_model_candidates() -> Vec<(String, String)> {
1170 let config = effective_config();
1171 let mut seen = std::collections::BTreeSet::new();
1172 let mut candidates = Vec::new();
1173
1174 for alias in config.aliases.values() {
1175 let pair = (alias.id.clone(), alias.provider.clone());
1176 if seen.insert(pair.clone()) {
1177 candidates.push(pair);
1178 }
1179 }
1180
1181 candidates.sort_by(|(model_a, provider_a), (model_b, provider_b)| {
1182 provider_a
1183 .cmp(provider_b)
1184 .then_with(|| model_a.cmp(model_b))
1185 });
1186 candidates
1187}