1use yapi_ai::registry::ModelRegistry;
6use yapi_types::message::ThinkingLevel;
7use yapi_types::model::Model;
8
9pub const DEFAULT_MODEL_PER_PROVIDER: &[(&str, &str)] = &[
11 ("amazon-bedrock", "us.anthropic.claude-opus-4-6-v1"),
12 ("ant-ling", "Ring-2.6-1T"),
13 ("anthropic", "claude-opus-4-8"),
14 ("openai", "gpt-5.5"),
15 ("azure-openai-responses", "gpt-5.4"),
16 ("openai-codex", "gpt-6.1-sol"),
17 ("radius", "balanced"),
18 ("nvidia", "nvidia/nemotron-3-super-120b-a12b"),
19 ("deepseek", "deepseek-v4-pro"),
20 ("google", "gemini-3.1-pro-preview"),
21 ("google-vertex", "gemini-3.1-pro-preview"),
22 ("github-copilot", "gpt-5.4"),
23 ("openrouter", "moonshotai/kimi-k2.6"),
24 ("vercel-ai-gateway", "zai/glm-5.1"),
25 ("xai", "grok-4.7"),
26 ("groq", "openai/gpt-oss-120b"),
27 ("cerebras", "gpt-oss-120b"),
28 ("zai", "glm-5.3"),
29 ("zai-coding-cn", "glm-5.3"),
30 ("mistral", "devstral-medium-latest"),
31 ("minimax", "MiniMax-M2.7"),
32 ("minimax-cn", "MiniMax-M2.7"),
33 ("moonshotai", "kimi-k2.6"),
34 ("moonshotai-cn", "kimi-k2.6"),
35 ("huggingface", "moonshotai/Kimi-K2.6"),
36 ("fireworks", "accounts/fireworks/models/kimi-k3"),
37 ("together", "moonshotai/Kimi-K3"),
38 ("baseten", "zai-org/GLM-5.2"),
39 ("opencode", "kimi-k2.6"),
40 ("opencode-go", "kimi-k3"),
41 ("kimi-coding", "kimi-for-coding"),
42 ("meta", "muse-spark-1.3"),
43 ("cloudflare-workers-ai", "@cf/moonshotai/kimi-k2.6"),
44 (
45 "cloudflare-ai-gateway",
46 "workers-ai/@cf/moonshotai/kimi-k2.6",
47 ),
48 ("qwen-token-plan", "qwen3.7-max"),
49 ("qwen-token-plan-cn", "qwen3.7-max"),
50 ("qwen-token-plan-individual", "qwen3.8-max"),
51 ("xiaomi", "mimo-v2.5-pro"),
52 ("xiaomi-token-plan-cn", "mimo-v2.5-pro"),
53 ("xiaomi-token-plan-ams", "mimo-v2.5-pro"),
54 ("xiaomi-token-plan-sgp", "mimo-v2.5-pro"),
55];
56
57pub const DEFAULT_THINKING_LEVEL: ThinkingLevel = ThinkingLevel::Medium;
59
60fn default_model_id(provider: &str) -> Option<&'static str> {
61 DEFAULT_MODEL_PER_PROVIDER
62 .iter()
63 .find(|(id, _)| *id == provider)
64 .map(|(_, model)| *model)
65}
66
67fn is_alias(id: &str) -> bool {
68 if id.ends_with("-latest") {
69 return true;
70 }
71 let bytes = id.as_bytes();
72 !(bytes.len() >= 9
73 && bytes[bytes.len() - 9] == b'-'
74 && bytes[bytes.len() - 8..].iter().all(u8::is_ascii_digit))
75}
76
77pub fn exact_match<'a>(reference: &str, models: &'a [Model]) -> Option<&'a Model> {
79 let reference = reference.trim();
80 if reference.is_empty() {
81 return None;
82 }
83 let lower = reference.to_lowercase();
84 let unique = |matches: Vec<&'a Model>| (matches.len() == 1).then(|| matches[0]);
85 let canonical: Vec<&Model> = models
86 .iter()
87 .filter(|model| model.reference().to_lowercase() == lower)
88 .collect();
89 if !canonical.is_empty() {
90 return unique(canonical);
91 }
92 if let Some((provider, id)) = reference.split_once('/') {
93 let (provider, id) = (provider.trim().to_lowercase(), id.trim().to_lowercase());
94 if !provider.is_empty() && !id.is_empty() {
95 let matches: Vec<&Model> = models
96 .iter()
97 .filter(|model| {
98 model.provider.to_lowercase() == provider && model.id.to_lowercase() == id
99 })
100 .collect();
101 if !matches.is_empty() {
102 return unique(matches);
103 }
104 }
105 }
106 unique(
107 models
108 .iter()
109 .filter(|model| model.id.to_lowercase() == lower)
110 .collect(),
111 )
112}
113
114fn try_match<'a>(pattern: &str, models: &'a [Model]) -> Option<&'a Model> {
115 if let Some(model) = exact_match(pattern, models) {
116 return Some(model);
117 }
118 let lower = pattern.to_lowercase();
119 let matches: Vec<&Model> = models
120 .iter()
121 .filter(|model| {
122 model.id.to_lowercase().contains(&lower) || model.name.to_lowercase().contains(&lower)
123 })
124 .collect();
125 let (mut aliases, mut dated): (Vec<&Model>, Vec<&Model>) =
126 matches.into_iter().partition(|model| is_alias(&model.id));
127 let pick = |list: &mut Vec<&'a Model>| {
128 list.sort_by(|a, b| yapi_types::collate::locale_compare(&b.id, &a.id));
131 list.first().copied()
132 };
133 if aliases.is_empty() {
134 pick(&mut dated)
135 } else {
136 pick(&mut aliases)
137 }
138}
139
140#[derive(Clone, Debug, Default)]
142pub struct PatternMatch {
143 pub model: Option<Model>,
145 pub thinking_level: Option<ThinkingLevel>,
147 pub warning: Option<String>,
149}
150
151pub fn parse_pattern(pattern: &str, models: &[Model], allow_invalid_level: bool) -> PatternMatch {
153 if let Some(model) = try_match(pattern, models) {
154 return PatternMatch {
155 model: Some(model.clone()),
156 ..PatternMatch::default()
157 };
158 }
159 let Some((prefix, suffix)) = pattern.rsplit_once(':') else {
160 return PatternMatch::default();
161 };
162 match ThinkingLevel::parse(suffix) {
163 Some(level) => {
164 let result = parse_pattern(prefix, models, allow_invalid_level);
165 if result.model.is_some() && result.warning.is_none() {
166 PatternMatch {
167 thinking_level: Some(level),
168 ..result
169 }
170 } else {
171 result
172 }
173 }
174 None if !allow_invalid_level => PatternMatch::default(),
175 None => {
176 let result = parse_pattern(prefix, models, allow_invalid_level);
177 match result.model {
178 Some(model) => PatternMatch {
179 model: Some(model),
180 thinking_level: None,
181 warning: Some(format!(
182 "Invalid thinking level \"{suffix}\" in pattern \"{pattern}\". Using default instead."
183 )),
184 },
185 None => result,
186 }
187 }
188 }
189}
190
191#[derive(Clone, Debug, PartialEq)]
193pub struct ScopedModel {
194 pub model: Model,
196 pub thinking_level: Option<ThinkingLevel>,
198}
199
200pub fn resolve_model_scope(
206 patterns: &[String],
207 models: &[Model],
208) -> (Vec<ScopedModel>, Vec<String>) {
209 let mut scoped: Vec<ScopedModel> = Vec::new();
210 let mut warnings = Vec::new();
211 let mut add = |model: &Model, thinking_level: Option<ThinkingLevel>| {
212 if !scoped
213 .iter()
214 .any(|entry| entry.model.is(&model.provider, &model.id))
215 {
216 scoped.push(ScopedModel {
217 model: model.clone(),
218 thinking_level,
219 });
220 }
221 };
222 for pattern in patterns {
223 if pattern.contains(['*', '?', '[']) {
224 let (glob, thinking_level) = match pattern.rsplit_once(':') {
225 Some((glob, suffix)) => match ThinkingLevel::parse(suffix) {
226 Some(level) => (glob, Some(level)),
227 None => (pattern.as_str(), None),
228 },
229 None => (pattern.as_str(), None),
230 };
231 if let Some(model) = exact_match(glob, models) {
232 add(model, thinking_level);
233 continue;
234 }
235 let matching: Vec<&Model> = models
236 .iter()
237 .filter(|model| {
238 crate::glob::matches_with(glob, &model.reference(), true)
239 || crate::glob::matches_with(glob, &model.id, true)
240 })
241 .collect();
242 if matching.is_empty() {
243 warnings.push(format!("No models match pattern \"{pattern}\""));
244 }
245 for model in matching {
246 add(model, thinking_level);
247 }
248 continue;
249 }
250 let found = parse_pattern(pattern, models, true);
251 if let Some(warning) = found.warning {
252 warnings.push(warning);
253 }
254 match &found.model {
255 Some(model) => add(model, found.thinking_level),
256 None => warnings.push(format!("No models match pattern \"{pattern}\"")),
257 }
258 }
259 (scoped, warnings)
260}
261
262#[derive(Clone, Debug, Default)]
264pub struct CliModel {
265 pub model: Option<Model>,
267 pub thinking_level: Option<ThinkingLevel>,
269 pub warning: Option<String>,
271 pub error: Option<String>,
273}
274
275pub fn resolve_cli_model(
277 provider: Option<&str>,
278 pattern: &str,
279 cli_thinking: Option<ThinkingLevel>,
280 registry: &ModelRegistry,
281) -> CliModel {
282 let models = registry.models();
283 if models.is_empty() {
284 return CliModel {
285 error: Some(
286 "No models available. Check your installation or add models to models.json.".into(),
287 ),
288 ..CliModel::default()
289 };
290 }
291 let canonical = |name: &str| {
292 models
293 .iter()
294 .find(|model| model.provider.eq_ignore_ascii_case(name))
295 .map(|model| model.provider.clone())
296 };
297 let mut resolved_provider = match provider {
298 Some(name) => match canonical(name) {
299 Some(provider) => Some(provider),
300 None => {
301 return CliModel {
302 error: Some(format!(
303 "Unknown provider \"{name}\". Use --list-models to see available providers/models."
304 )),
305 ..CliModel::default()
306 };
307 }
308 },
309 None => None,
310 };
311 let mut pattern_text = pattern.to_owned();
312 let mut inferred = false;
313 if resolved_provider.is_none()
314 && let Some((prefix, rest)) = pattern.split_once('/')
315 && let Some(provider) = canonical(prefix)
316 {
317 resolved_provider = Some(provider);
318 pattern_text = rest.to_owned();
319 inferred = true;
320 }
321 if resolved_provider.is_none() {
322 let lower = pattern.to_lowercase();
323 let exact: Vec<&Model> = models
324 .iter()
325 .filter(|model| {
326 model.id.to_lowercase() == lower || model.reference().to_lowercase() == lower
327 })
328 .collect();
329 if exact.len() == 1 {
330 return CliModel {
331 model: Some(exact[0].clone()),
332 ..CliModel::default()
333 };
334 }
335 if exact.len() > 1 {
336 let authenticated: Vec<&&Model> = exact
337 .iter()
338 .filter(|model| registry.has_auth(&model.provider))
339 .collect();
340 if authenticated.len() == 1 {
341 return CliModel {
342 model: Some((*authenticated[0]).clone()),
343 ..CliModel::default()
344 };
345 }
346 let mut names: Vec<String> = exact.iter().map(|model| model.reference()).collect();
347 names.sort();
348 let hint = if authenticated.is_empty() {
349 "No matching provider is authenticated."
350 } else {
351 "More than one matching provider is authenticated."
352 };
353 return CliModel {
354 error: Some(format!(
355 "Model \"{pattern}\" is ambiguous across providers: {}. {hint} Use --provider or provider/model.",
356 names.join(", ")
357 )),
358 ..CliModel::default()
359 };
360 }
361 }
362 if let (Some(_), Some(resolved)) = (provider, &resolved_provider) {
363 let prefix = format!("{resolved}/");
364 if pattern.to_lowercase().starts_with(&prefix.to_lowercase()) {
365 pattern_text = pattern[prefix.len()..].to_owned();
366 }
367 }
368 let candidates: Vec<Model> = match &resolved_provider {
369 Some(provider) => models
370 .iter()
371 .filter(|m| &m.provider == provider)
372 .cloned()
373 .collect(),
374 None => models.to_vec(),
375 };
376 let found = parse_pattern(&pattern_text, &candidates, false);
377 if let Some(model) = found.model {
378 if inferred && !registry.has_auth(&model.provider) {
379 let others: Vec<&Model> = models
380 .iter()
381 .filter(|m| m.id.eq_ignore_ascii_case(pattern) && !m.is(&model.provider, &model.id))
382 .filter(|m| registry.has_auth(&m.provider))
383 .collect();
384 if others.len() == 1 {
385 return CliModel {
386 model: Some(others[0].clone()),
387 ..CliModel::default()
388 };
389 }
390 }
391 return CliModel {
392 model: Some(model),
393 thinking_level: found.thinking_level,
394 warning: found.warning,
395 error: None,
396 };
397 }
398 if inferred {
399 let lower = pattern.to_lowercase();
400 if let Some(exact) = models
401 .iter()
402 .find(|m| m.id.to_lowercase() == lower || m.reference().to_lowercase() == lower)
403 {
404 return CliModel {
405 model: Some(exact.clone()),
406 ..CliModel::default()
407 };
408 }
409 let fallback = parse_pattern(pattern, models, false);
410 if fallback.model.is_some() {
411 return CliModel {
412 model: fallback.model,
413 thinking_level: fallback.thinking_level,
414 warning: fallback.warning,
415 error: None,
416 };
417 }
418 }
419 if let Some(provider) = &resolved_provider {
420 let mut fallback_pattern = pattern_text.clone();
421 let mut fallback_thinking = None;
422 if cli_thinking.is_none()
423 && let Some((prefix, suffix)) = pattern_text.rsplit_once(':')
424 && let Some(level) = ThinkingLevel::parse(suffix)
425 {
426 fallback_pattern = prefix.to_owned();
427 fallback_thinking = Some(level);
428 }
429 let provider_models: Vec<&Model> =
430 models.iter().filter(|m| &m.provider == provider).collect();
431 if !provider_models.is_empty() {
432 let base = default_model_id(provider)
433 .and_then(|id| provider_models.iter().find(|m| m.id == id))
434 .unwrap_or(&provider_models[0]);
435 let mut model = Model {
436 id: fallback_pattern.clone(),
437 name: fallback_pattern.clone(),
438 ..(*base).clone()
439 };
440 if cli_thinking
441 .or(fallback_thinking)
442 .is_some_and(|level| level != ThinkingLevel::Off)
443 {
444 model.reasoning = true;
445 }
446 let message = format!(
447 "Model \"{fallback_pattern}\" not found for provider \"{provider}\". Using custom model id."
448 );
449 return CliModel {
450 model: Some(model),
451 thinking_level: fallback_thinking,
452 warning: Some(match found.warning {
453 Some(warning) => format!("{warning} {message}"),
454 None => message,
455 }),
456 error: None,
457 };
458 }
459 }
460 let display = match &resolved_provider {
461 Some(provider) => format!("{provider}/{pattern_text}"),
462 None => pattern.to_owned(),
463 };
464 CliModel {
465 warning: found.warning,
466 error: Some(format!(
467 "Model \"{display}\" not found. Use --list-models to see available models."
468 )),
469 ..CliModel::default()
470 }
471}
472
473pub fn initial_model(
477 registry: &ModelRegistry,
478 default_provider: Option<&str>,
479 default_model: Option<&str>,
480) -> Option<Model> {
481 if let (Some(provider), Some(id)) = (default_provider, default_model)
482 && let Some(model) = registry.find(provider, id)
483 && registry.has_auth(provider)
484 {
485 return Some(model.clone());
486 }
487 let available = registry.available();
488 for (provider, id) in DEFAULT_MODEL_PER_PROVIDER {
489 if let Some(model) = available.iter().find(|m| m.is(provider, id)) {
490 return Some((*model).clone());
491 }
492 }
493 available.first().map(|model| (*model).clone())
494}
495
496#[cfg(test)]
497mod tests {
498 use super::*;
499
500 #[test]
501 fn resolves_prefixed_and_fuzzy() {
502 let registry = ModelRegistry::builtin();
503 let model = resolve_cli_model(None, "anthropic/claude-sonnet-4-5", None, ®istry)
504 .model
505 .unwrap();
506 assert_eq!(model.reference(), "anthropic/claude-sonnet-4-5");
507 let result = resolve_cli_model(Some("anthropic"), "sonnet-4-5:high", None, ®istry);
508 assert_eq!(result.model.unwrap().provider, "anthropic");
509 assert_eq!(result.thinking_level, Some(ThinkingLevel::High));
510 let fallback = resolve_cli_model(Some("anthropic"), "claude-unreleased", None, ®istry);
511 assert_eq!(fallback.model.unwrap().id, "claude-unreleased");
512 assert!(fallback.warning.unwrap().contains("Using custom model id"));
513 assert!(
514 resolve_cli_model(Some("nope"), "x", None, ®istry)
515 .error
516 .is_some()
517 );
518 assert!(is_alias("claude-sonnet-4-5"));
519 assert!(!is_alias("claude-sonnet-4-5-20250929"));
520 }
521
522 #[test]
523 fn fuzzy_matches_locale_order() {
524 let base = ModelRegistry::builtin().models()[0].clone();
525 let model = |provider: &str, id: &str| Model {
526 provider: provider.into(),
527 id: id.into(),
528 name: id.into(),
529 ..base.clone()
530 };
531 let models = [
534 model("openrouter", "~anthropic/claude-sonnet-latest"),
535 model("amazon-bedrock", "us.anthropic.claude-sonnet-5"),
536 ];
537 assert_eq!(
538 try_match("sonnet", &models).map(|model| model.provider.as_str()),
539 Some("amazon-bedrock")
540 );
541 }
542
543 #[test]
544 fn scopes_models_by_glob() {
545 let registry = ModelRegistry::builtin();
547 let models: Vec<Model> = registry
548 .models()
549 .iter()
550 .filter(|model| model.provider == "anthropic")
551 .cloned()
552 .collect();
553 let patterns: Vec<String> = [
554 "claude-sonnet-4-5:high",
555 "anthropic/claude-opus-4-*",
556 "claude-sonnet-4-5",
557 "zzz-nothing",
558 "claude-haiku-4-5:bogus",
559 ]
560 .map(String::from)
561 .to_vec();
562 let (scoped, warnings) = resolve_model_scope(&patterns, &models);
563 assert_eq!(scoped[0].model.reference(), "anthropic/claude-sonnet-4-5");
564 assert_eq!(scoped[0].thinking_level, Some(ThinkingLevel::High));
565 assert!(
566 scoped[1..]
567 .iter()
568 .any(|s| s.model.id.starts_with("claude-opus-4-"))
569 );
570 assert_eq!(
572 scoped
573 .iter()
574 .filter(|s| s.model.reference() == "anthropic/claude-sonnet-4-5")
575 .count(),
576 1
577 );
578 assert_eq!(
579 scoped
580 .last()
581 .map(|s| (s.model.id.as_str(), s.thinking_level)),
582 Some(("claude-haiku-4-5", None))
583 );
584 assert_eq!(
585 warnings,
586 [
587 "No models match pattern \"zzz-nothing\"",
588 "Invalid thinking level \"bogus\" in pattern \"claude-haiku-4-5:bogus\". Using default instead.",
589 ]
590 );
591 }
592}