Skip to main content

yapi_core/
model_resolver.rs

1//! Choosing a model from `--model`, settings and available credentials.
2//!
3//! Port of `packages/coding-agent/src/core/model-resolver.ts` in pi `v1.0.0`.
4
5use yapi_ai::registry::ModelRegistry;
6use yapi_types::message::ThinkingLevel;
7use yapi_types::model::Model;
8
9/// The model pi picks for a provider when nothing else decides.
10pub 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
57/// pi's default thinking level.
58pub 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
77/// A model by exact reference: `provider/id`, or a bare id that is unique.
78pub 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        // pi sorts with `localeCompare`, which orders punctuation such as
129        // OpenRouter's `~` before letters.
130        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/// A pattern match, with a thinking level from a `:level` suffix.
141#[derive(Clone, Debug, Default)]
142pub struct PatternMatch {
143    /// The model.
144    pub model: Option<Model>,
145    /// The suffix level.
146    pub thinking_level: Option<ThinkingLevel>,
147    /// A problem worth reporting.
148    pub warning: Option<String>,
149}
150
151/// Matches `pattern` (optionally `pattern:level`) against `models`.
152pub 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/// A model in the session's scope, with the level its pattern named.
192#[derive(Clone, Debug, PartialEq)]
193pub struct ScopedModel {
194    /// The model.
195    pub model: Model,
196    /// The level from a `:level` suffix. `None` keeps the session's level.
197    pub thinking_level: Option<ThinkingLevel>,
198}
199
200/// pi's `resolveModelScopeFromModels`: the models `patterns` select from
201/// `models`, in pattern order without duplicates, and a warning for each
202/// pattern that selects nothing or names an invalid level. A pattern is a
203/// glob over `provider/id` or the id, ignoring case, or a model pattern as
204/// `--model` takes. Either may end in `:level`.
205pub 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/// The result of `--model` resolution.
263#[derive(Clone, Debug, Default)]
264pub struct CliModel {
265    /// The model.
266    pub model: Option<Model>,
267    /// A level from the pattern's suffix.
268    pub thinking_level: Option<ThinkingLevel>,
269    /// Shown on stderr.
270    pub warning: Option<String>,
271    /// Fatal.
272    pub error: Option<String>,
273}
274
275/// Resolves `--provider` and `--model` against every model, authenticated or not.
276pub 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
473/// The starting model when `--model` is absent: the saved default if it has
474/// credentials, else the first authenticated provider's default model, else the
475/// first authenticated model.
476pub 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, &registry)
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, &registry);
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, &registry);
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, &registry)
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        // pi's `localeCompare` puts punctuation such as `~` before letters,
532        // so the descending sort prefers `us.` over `~`.
533        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        // As with credentials for Anthropic only.
546        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        // A repeated model keeps its first entry.
571        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}