Skip to main content

supercode_harness/
model_catalog.rs

1//! §2 module 26 `model.catalog` (`docs/composable-harness/
2//! COMPOSABLE-HARNESS-DESIGN.md` §3.1 `[capabilities.model_catalog]`) — P4
3//! of the composable-harness migration (design §5.2 phase **P4**: "aliases +
4//! fallback chains (userconfig.rs:386-411) promoted into core" + the
5//! `small_model` knob).
6//!
7//! **What moved here.** The CLI's `alias_table`/`resolve_model_alias`
8//! (`crates/cli/src/userconfig.rs`) were CLI-only (design §1.10: "CLI model
9//! aliases ✓ … `resolve_model_alias`, userconfig.rs:386-411"). This module is
10//! the single source of truth now — [`DEFAULT_ALIASES`] is the exact same
11//! eleven built-in aliases, byte-identical, so moving them here changes no
12//! resolved slug for any existing caller. The CLI crate re-exports through
13//! `userconfig::alias_table`/`resolve_model_alias` (zero call-site churn,
14//! zero behavior change — see that module).
15//!
16//! **What's NEW (P4).** [`resolve_alias`] additionally accepts an
17//! `extra` table (`[capabilities.model_catalog].aliases`, §3.1/§3.2:
18//! "`capabilities.model_catalog.*` | CLI `alias_table` … + NEW
19//! small-model/fallback") so a user's own config can add or override an
20//! alias without recompiling — `extra` is checked BEFORE
21//! [`DEFAULT_ALIASES`], so a user override always wins. [`resolve_fallback_chain`]
22//! resolves a `[capabilities.model_catalog].fallback` list of aliases/slugs
23//! into a plain slug list the SAME way, for the D-9-adjacent "failure
24//! fallback chain" knob (catalog §4a: "Model aliases + failure fallback
25//! chain (resolution table before request build)").
26//!
27//! **Scope note (S-sized, per design §5.2 P4).** This module lands the
28//! RESOLUTION TABLE only — `Config::small_model`/`Config::model_fallback`
29//! are knobs a caller can read, not a retry/failover LOOP that automatically
30//! re-sends a failed request against the next model in the chain. Building
31//! that loop is a distinct, larger change (closer to §1.10's "mid-session
32//! model switch," itself called out in design §5.2 as its own M-sized item,
33//! separate from this S-sized catalog item) and is out of scope here.
34
35/// The built-in alias → full-slug table (byte-identical to the CLI's
36/// original `userconfig::alias_table`, moved here as the single source of
37/// truth — see the module doc).
38pub const DEFAULT_ALIASES: &[(&str, &str)] = &[
39    ("opus", "anthropic/claude-opus-4-8"),
40    ("sonnet", "anthropic/claude-sonnet-4-6"),
41    ("haiku", "anthropic/claude-haiku-4-5"),
42    ("gpt", "openai/gpt-5.5"),
43    ("gpt-5.5", "openai/gpt-5.5"),
44    ("gpt-5", "openai/gpt-5"),
45    ("gemini", "google/gemini-2.5-pro"),
46    ("flash", "deepseek/deepseek-v4-flash"),
47    ("deepseek-flash", "deepseek/deepseek-v4-flash"),
48    ("deepseek", "deepseek/deepseek-v4-pro"),
49    ("llama", "meta-llama/llama-4-maverick"),
50];
51
52/// Expand a friendly model alias to its full slug through the built-in
53/// table alone — the no-config path, for callers that have no resolved
54/// [`Routing`] in hand (an imported foreign session's recorded model name, a
55/// help listing). Unknown values pass through unchanged so any real slug
56/// still works. A caller that DOES have a config resolves through
57/// [`Routing::resolve_alias`] instead, which additionally honours the
58/// config's own alias table, its patterns, and its provider/account scopes.
59pub fn resolve_alias(model: &str) -> String {
60    Routing::default().resolve_alias(model)
61}
62
63/// Everything `[capabilities.model_catalog]` resolves into, alias-resolved:
64/// the effective `core.model`, the `small_model` (if set), and the
65/// `fallback` chain (if set). Shared by both resolution paths that carry a
66/// `capabilities.<name>` table shaped like [`crate::configfile::CapabilityConfig`]
67/// — the SDK's [`crate::configfile::HarnessConfig`] resolver
68/// (`materialize_config`) and the CLI's own `FileConfig`-driven
69/// `build_config` — so the alias/small-model/fallback resolution logic
70/// lives in exactly one place.
71#[derive(Debug, Clone, Default, PartialEq, Eq)]
72pub struct Resolution {
73    /// `base_model`, alias-resolved.
74    pub model: String,
75    /// `capabilities.model_catalog.small_model`, alias-resolved, if set to
76    /// a non-empty string.
77    pub small_model: Option<String>,
78    /// `capabilities.model_catalog.fallback`, alias-resolved in order, if
79    /// non-empty.
80    pub fallback: Vec<String>,
81    /// BP-5 (`capabilities.model_catalog.base_prompts`, catalog D2
82    /// "Per-model-family base-prompt selection"): model-id glob → that
83    /// family's base system prompt. Empty unless the config sets the table.
84    pub base_prompts: std::collections::BTreeMap<String, String>,
85    /// BP-13: the whole routing table (aliases, per-model rules, allow/deny
86    /// lists) — carried forward onto [`crate::Config::model_routing`] so
87    /// every later routing decision asks THIS value rather than re-reading
88    /// the capability table behind this module's back.
89    pub routing: Routing,
90    /// BP-13: why `model` is refused by the config-layer allow/deny lists,
91    /// or `None`. Reported by the resolver, never silently applied.
92    pub refusal: Option<String>,
93}
94
95/// Resolve `[capabilities.model_catalog]` against `base_model` (typically
96/// the already-computed `core.model` / CLI `--model`/config value).
97/// Consulted regardless of `capabilities.model_catalog.enabled` — matching
98/// the resolver's existing D-9 check (`configfile::validate_modules`),
99/// which already reads `model_catalog.small_model` unconditionally: these
100/// are data a caller resolves against, not an activation switch.
101pub fn resolve(
102    capabilities: &std::collections::BTreeMap<String, crate::configfile::CapabilityConfig>,
103    base_model: &str,
104) -> Resolution {
105    let routing = Routing::from_capabilities(capabilities);
106
107    let mut out = Resolution {
108        model: if base_model.is_empty() {
109            String::new()
110        } else {
111            routing.resolve_alias(base_model)
112        },
113        small_model: None,
114        fallback: Vec::new(),
115        base_prompts: std::collections::BTreeMap::new(),
116        refusal: None,
117        routing,
118    };
119
120    if let Some(cap) = capabilities.get("model_catalog") {
121        if let Some(sm) = cap.settings.get("small_model").and_then(|v| v.as_str()) {
122            if !sm.is_empty() {
123                out.small_model = Some(out.routing.resolve_alias(sm));
124            }
125        }
126        // BP-5: the per-family base-prompt table. Read here, next to
127        // `small_model`/`fallback`, for the same reason those are: it is
128        // DATA a caller resolves against, not an activation switch.
129        if let Some(table) = cap.settings.get("base_prompts").and_then(|v| v.as_object()) {
130            out.base_prompts = table
131                .iter()
132                .filter_map(|(pattern, body)| {
133                    body.as_str()
134                        .filter(|text| !text.is_empty())
135                        .map(|text| (pattern.clone(), text.to_string()))
136                })
137                .collect();
138        }
139        if let Some(fb) = cap.settings.get("fallback").and_then(|v| v.as_array()) {
140            out.fallback = fb
141                .iter()
142                .filter_map(|v| v.as_str())
143                .map(|m| out.routing.resolve_alias(m))
144                .collect();
145        }
146    }
147    // The allow/deny lists bind every model this table hands out, not just
148    // `core.model` — a fallback entry or a small model the org forbids is
149    // exactly as forbidden as a main model it forbids.
150    out.refusal = [Some(&out.model)]
151        .into_iter()
152        .flatten()
153        .chain(out.small_model.iter())
154        .chain(out.fallback.iter())
155        .filter(|m| !m.is_empty())
156        .find_map(|m| out.routing.refusal(m));
157    out
158}
159
160/// BP-5 (catalog D2 "Per-model-family base-prompt selection", cx§2
161/// "Per-model base instructions": "the system prompt is selected per model
162/// family from bundled markdown"): the base prompt `model` should run
163/// under, or `None` when no family pattern matches.
164///
165/// `prompts` is keyed by a [`crate::config::glob_match`] pattern over the
166/// model id (`"*codex*"`, `"openai/gpt-5.*"`). The MOST SPECIFIC match wins,
167/// specificity being the pattern's own length — a TOML table has no order to
168/// rely on, so selection must not depend on one. Ties (equal-length patterns
169/// both matching) break lexicographically, so the answer is total and
170/// reproducible.
171pub fn base_prompt_for<'a>(
172    prompts: &'a std::collections::BTreeMap<String, String>,
173    model: &str,
174) -> Option<&'a str> {
175    prompts
176        .iter()
177        .filter(|(pattern, _)| crate::config::glob_match(pattern, model))
178        .max_by(|(a, _), (b, _)| a.len().cmp(&b.len()).then_with(|| b.cmp(a)))
179        .map(|(_, body)| body.as_str())
180}
181
182// ---------------------------------------------------------------------------
183// BP-13 — the ONE model-resolution path.
184// ---------------------------------------------------------------------------
185//
186// Everything routing decides about a model — which slug a friendly name means,
187// which reasoning effort and thinking budget the request carries, which service
188// tier it asks for, which tool-shape capability bits the registry reads, and
189// whether the model is allowed at all — resolves HERE, out of the same
190// `[capabilities.model_catalog]` table the presets already set. There is no
191// second table, no parallel registry, and no consumer that reads a raw setting
192// behind this module's back: `configfile::materialize_config` folds this into
193// `Config::model_routing`, and every consumer (`Agent`'s request build, its
194// fallback pass, `ToolRegistry::from_config`, the CLI's `--model`/`/model`)
195// asks the SAME `Routing` value.
196
197/// The reasoning-effort ladder, weakest first. Spans both harnesses' own
198/// vocabularies: Claude Code's `low|medium|high` and Codex's `none…ultra`
199/// (cc§9, cx§6/§9). A level outside the ladder is not an error — it is
200/// forwarded verbatim to the provider — but it cannot be RANKED, so an
201/// effort cap can neither clamp it nor be fooled by it (see [`cap_effort`]).
202pub const EFFORT_LADDER: &[&str] = &["none", "minimal", "low", "medium", "high", "xhigh", "ultra"];
203
204/// Position of `level` on [`EFFORT_LADDER`], or `None` for a level this
205/// build does not rank.
206pub fn effort_rank(level: &str) -> Option<usize> {
207    EFFORT_LADDER.iter().position(|l| *l == level)
208}
209
210/// Clamp `level` down to `cap`. Returns `level` unchanged when there is no
211/// cap, when the cap does not bind, or when EITHER side is unrankable (an
212/// unknown spelling must not be silently rewritten into a ladder value the
213/// user never asked for).
214pub fn cap_effort(level: Option<&str>, cap: Option<&str>) -> Option<String> {
215    let level = level?;
216    let Some(cap) = cap else {
217        return Some(level.to_string());
218    };
219    match (effort_rank(level), effort_rank(cap)) {
220        (Some(l), Some(c)) if l > c => Some(cap.to_string()),
221        _ => Some(level.to_string()),
222    }
223}
224
225/// Match `value` against a model PATTERN and return what the wildcard
226/// captured.
227///
228/// A pattern is either an exact slug (matches by equality; the capture is
229/// the whole value) or a single-`*` glob (`opus*`, `*[1m]`, `openai/gpt-5*`),
230/// where `*` stands for any run of characters including the empty one. A
231/// second `*` is not a pattern this build understands and never matches, so
232/// a typo fails closed rather than matching everything.
233pub fn pattern_capture(pattern: &str, value: &str) -> Option<String> {
234    match pattern.split_once('*') {
235        None => (pattern == value).then(|| value.to_string()),
236        Some((prefix, suffix)) => {
237            if suffix.contains('*') {
238                return None;
239            }
240            if value.len() < prefix.len() + suffix.len() {
241                return None;
242            }
243            if !value.starts_with(prefix) || !value.ends_with(suffix) {
244                return None;
245            }
246            Some(value[prefix.len()..value.len() - suffix.len()].to_string())
247        }
248    }
249}
250
251/// How specific a pattern is: its literal (non-wildcard) length, with an
252/// exact pattern always beating a glob of the same literal length. Used to
253/// order overlays so the most specific rule wins.
254fn pattern_specificity(pattern: &str) -> (usize, u8) {
255    let literal = pattern.chars().filter(|c| *c != '*').count();
256    (literal, u8::from(!pattern.contains('*')))
257}
258
259/// Per-model routing rules: what `[capabilities.model_catalog.models.<pattern>]`
260/// (and the table's own top-level defaults) say about one model.
261///
262/// Every field is `Option` so "unset" and "set to the default value" stay
263/// distinguishable — an unset field inherits, a set one overrides.
264#[derive(Debug, Clone, Default, PartialEq, Eq)]
265pub struct ModelRules {
266    /// Reasoning-effort level for this model — the per-model override of
267    /// `[core] effort` (cc§9 effort levels, cx§9 tiers).
268    pub effort: Option<String>,
269    /// Ceiling on the effort level for this model. Applied AFTER `effort`
270    /// and after `[core] effort`, so a cap binds whatever the level came
271    /// from — the per-model half of the org effort cap.
272    pub max_effort: Option<String>,
273    /// Thinking-token budget (Claude Code's `MAX_THINKING_TOKENS`).
274    pub thinking_budget: Option<u32>,
275    /// Service tier / fast-mode variant asked for on the request
276    /// (cc `/fast`, cx `model_service_tier`).
277    pub service_tier: Option<String>,
278    /// Capability bit: this model takes the freeform `apply_patch`
279    /// envelope. `Some(false)` swaps the write surface to `edit_file` /
280    /// `write_file` instead (cx§9 `apply_patch_tool_type`).
281    pub apply_patch: Option<bool>,
282    /// Capability bit: this model takes the dedicated search tool
283    /// (cx§9 `supports_search_tool`).
284    pub search_tool: Option<bool>,
285}
286
287impl ModelRules {
288    /// Overlay `other`'s SET fields onto `self`; unset fields inherit.
289    pub fn overlay(&mut self, other: &ModelRules) {
290        if other.effort.is_some() {
291            self.effort.clone_from(&other.effort);
292        }
293        if other.max_effort.is_some() {
294            self.max_effort.clone_from(&other.max_effort);
295        }
296        if other.thinking_budget.is_some() {
297            self.thinking_budget = other.thinking_budget;
298        }
299        if other.service_tier.is_some() {
300            self.service_tier.clone_from(&other.service_tier);
301        }
302        if other.apply_patch.is_some() {
303            self.apply_patch = other.apply_patch;
304        }
305        if other.search_tool.is_some() {
306            self.search_tool = other.search_tool;
307        }
308    }
309
310    fn from_settings(obj: &serde_json::Map<String, serde_json::Value>) -> ModelRules {
311        let text = |key: &str| {
312            obj.get(key)
313                .and_then(|v| v.as_str())
314                .filter(|s| !s.is_empty())
315                .map(str::to_string)
316        };
317        ModelRules {
318            effort: text("effort"),
319            max_effort: text("max_effort"),
320            thinking_budget: obj
321                .get("thinking_budget")
322                .and_then(|v| v.as_u64())
323                .filter(|n| *n > 0)
324                .map(|n| n as u32),
325            service_tier: text("service_tier"),
326            apply_patch: obj.get("apply_patch").and_then(|v| v.as_bool()),
327            search_tool: obj.get("search_tool").and_then(|v| v.as_bool()),
328        }
329    }
330}
331
332/// The whole resolved routing table, carried on
333/// [`crate::Config::model_routing`] and consulted by every routing decision.
334///
335/// It keeps the PATTERNS rather than one pre-resolved answer on purpose: a
336/// mid-session model switch has to re-decide effort, budget, tier and tool
337/// bits for the NEW model, and it does that by asking this same value again
338/// ([`Routing::rules_for`]) rather than by re-reading config elsewhere.
339#[derive(Debug, Clone, Default, PartialEq, Eq)]
340pub struct Routing {
341    /// Alias → slug entries from the config, in precedence order: the
342    /// declared account's scope, then the declared provider's, then the flat
343    /// table. Consulted before [`DEFAULT_ALIASES`]. A key may be a `*`
344    /// pattern, in which case the value may contain `{}` — replaced by the
345    /// captured stem after that stem is itself alias-resolved (Claude Code's
346    /// `sonnet[1m]` shape).
347    pub aliases: Vec<(String, String)>,
348    /// `[capabilities.model_catalog.models.<pattern>]`, least specific
349    /// first, so overlaying in order leaves the most specific rule winning.
350    pub models: Vec<(String, ModelRules)>,
351    /// The table's own top-level rules — the baseline every model inherits.
352    pub defaults: ModelRules,
353    /// `allowed_models`: when non-empty, a model matching NO entry is
354    /// refused outright.
355    pub allowed: Vec<String>,
356    /// `denied_models`: a model matching any entry is refused outright.
357    pub denied: Vec<String>,
358    /// The declared provider whose alias scope is in force (`provider`).
359    pub provider: Option<String>,
360    /// The declared account/plan tier whose alias scope is in force
361    /// (`account`) — Claude Code's account-type defaults (cc§9).
362    pub account: Option<String>,
363}
364
365impl Routing {
366    /// Expand a friendly model name to a full slug through this table, then
367    /// [`DEFAULT_ALIASES`], then unchanged pass-through.
368    ///
369    /// Exact entries always beat patterns, and among patterns the most
370    /// specific wins. A pattern's `{}` placeholder receives the captured
371    /// stem RE-RESOLVED through this same function (depth-capped), which is
372    /// what makes `sonnet[1m]` land on `anthropic/claude-sonnet-4-6[1m]`
373    /// rather than on the literal text `sonnet[1m]`.
374    pub fn resolve_alias(&self, model: &str) -> String {
375        self.resolve_alias_depth(model, 0)
376    }
377
378    fn resolve_alias_depth(&self, model: &str, depth: usize) -> String {
379        if depth > 4 {
380            return model.to_string();
381        }
382        for (alias, slug) in &self.aliases {
383            if !alias.contains('*') && alias == model {
384                return slug.clone();
385            }
386        }
387        if let Some((_, slug)) = DEFAULT_ALIASES.iter().find(|(a, _)| *a == model) {
388            return (*slug).to_string();
389        }
390        let mut best: Option<(usize, u8, &str, String)> = None;
391        for (alias, slug) in &self.aliases {
392            if !alias.contains('*') {
393                continue;
394            }
395            let Some(stem) = pattern_capture(alias, model) else {
396                continue;
397            };
398            let (literal, exact) = pattern_specificity(alias);
399            if best
400                .as_ref()
401                .is_none_or(|(l, e, _, _)| (literal, exact) > (*l, *e))
402            {
403                best = Some((literal, exact, slug.as_str(), stem));
404            }
405        }
406        match best {
407            Some((_, _, template, stem)) => {
408                let expanded = self.resolve_alias_depth(&stem, depth + 1);
409                template.replace("{}", &expanded)
410            }
411            None => model.to_string(),
412        }
413    }
414
415    /// The rules in force for `model`: the table defaults with every
416    /// matching `models.<pattern>` overlaid, least specific first.
417    pub fn rules_for(&self, model: &str) -> ModelRules {
418        let mut rules = self.defaults.clone();
419        for (pattern, r) in &self.models {
420            if pattern_capture(pattern, model).is_some() {
421                rules.overlay(r);
422            }
423        }
424        rules
425    }
426
427    /// The effort level to send for `model`, given the session's
428    /// `[core] effort`: the per-model level if one is set, else the session
429    /// level, clamped by whichever effort cap applies.
430    pub fn effective_effort(&self, model: &str, session_effort: Option<&str>) -> Option<String> {
431        let rules = self.rules_for(model);
432        let level = rules.effort.as_deref().or(session_effort);
433        cap_effort(level, rules.max_effort.as_deref())
434    }
435
436    /// Why `model` is refused, or `None` when it is allowed — the
437    /// CONFIG-layer half of the org model allowlist. A denied model can
438    /// never be selected, and with an allowlist set nothing outside it can.
439    pub fn refusal(&self, model: &str) -> Option<String> {
440        if let Some(pattern) = self
441            .denied
442            .iter()
443            .find(|p| pattern_capture(p, model).is_some())
444        {
445            return Some(format!(
446                "model `{model}` matches capabilities.model_catalog.denied_models entry `{pattern}`"
447            ));
448        }
449        if !self.allowed.is_empty()
450            && !self
451                .allowed
452                .iter()
453                .any(|p| pattern_capture(p, model).is_some())
454        {
455            return Some(format!(
456                "model `{model}` is not in capabilities.model_catalog.allowed_models ({})",
457                self.allowed.join(", ")
458            ));
459        }
460        None
461    }
462
463    /// Whether anything in this table restricts model choice at all.
464    pub fn restricts_models(&self) -> bool {
465        !self.allowed.is_empty() || !self.denied.is_empty()
466    }
467
468    /// Build the routing table from `[capabilities.model_catalog]`.
469    ///
470    /// Consulted regardless of the module's `enabled` flag, for the same
471    /// reason [`resolve`] is: these are data a caller resolves against, not
472    /// an activation switch (and the D-9 resolver check already reads
473    /// `small_model` unconditionally).
474    pub fn from_capabilities(
475        capabilities: &std::collections::BTreeMap<String, crate::configfile::CapabilityConfig>,
476    ) -> Routing {
477        let Some(cap) = capabilities.get("model_catalog") else {
478            return Routing::default();
479        };
480        let s = &cap.settings;
481        let scope = |key: &str| {
482            s.get(key)
483                .and_then(|v| v.as_str())
484                .filter(|p| !p.is_empty())
485                .map(str::to_string)
486        };
487        let provider = scope("provider");
488        let account = scope("account");
489
490        // Alias scopes, most specific first: the declared account's table,
491        // then the declared provider's, then the flat table.
492        let mut aliases: Vec<(String, String)> = Vec::new();
493        let provider_table = provider
494            .as_deref()
495            .and_then(|p| s.get("providers")?.as_object()?.get(p))
496            .and_then(|v| v.as_object());
497        if let (Some(pt), Some(account)) = (provider_table, account.as_deref()) {
498            if let Some(at) = pt
499                .get("accounts")
500                .and_then(|v| v.as_object())
501                .and_then(|a| a.get(account))
502                .and_then(|v| v.as_object())
503            {
504                push_alias_table(at.get("aliases"), &mut aliases);
505            }
506        }
507        if let Some(pt) = provider_table {
508            push_alias_table(pt.get("aliases"), &mut aliases);
509        }
510        push_alias_table(s.get("aliases"), &mut aliases);
511
512        let mut models: Vec<(String, ModelRules)> = s
513            .get("models")
514            .and_then(|v| v.as_object())
515            .map(|o| {
516                o.iter()
517                    .filter_map(|(pattern, v)| {
518                        Some((pattern.clone(), ModelRules::from_settings(v.as_object()?)))
519                    })
520                    .collect()
521            })
522            .unwrap_or_default();
523        models.sort_by_key(|(pattern, _)| pattern_specificity(pattern));
524
525        Routing {
526            aliases,
527            models,
528            defaults: ModelRules::from_settings(s),
529            allowed: string_list(s.get("allowed_models")),
530            denied: string_list(s.get("denied_models")),
531            provider,
532            account,
533        }
534    }
535}
536
537fn push_alias_table(value: Option<&serde_json::Value>, out: &mut Vec<(String, String)>) {
538    let Some(obj) = value.and_then(|v| v.as_object()) else {
539        return;
540    };
541    let mut entries: Vec<(String, String)> = obj
542        .iter()
543        .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
544        .collect();
545    entries.sort();
546    out.extend(entries);
547}
548
549fn string_list(value: Option<&serde_json::Value>) -> Vec<String> {
550    value
551        .and_then(|v| v.as_array())
552        .map(|a| {
553            a.iter()
554                .filter_map(|v| v.as_str().map(str::to_string))
555                .collect()
556        })
557        .unwrap_or_default()
558}
559
560#[cfg(test)]
561mod tests {
562    use super::*;
563
564    #[test]
565    fn resolve_alias_with_no_config_matches_every_built_in() {
566        for (alias, slug) in DEFAULT_ALIASES {
567            assert_eq!(resolve_alias(alias), *slug, "alias {alias}");
568        }
569        // A non-alias passes through unchanged.
570        assert_eq!(resolve_alias("vendor/some-model"), "vendor/some-model");
571    }
572
573    fn routing(settings: serde_json::Value) -> Routing {
574        let mut capabilities = std::collections::BTreeMap::new();
575        capabilities.insert("model_catalog".to_string(), cap(settings));
576        Routing::from_capabilities(&capabilities)
577    }
578
579    #[test]
580    fn config_alias_table_overrides_a_built_in_and_adds_new_names() {
581        let r = routing(serde_json::json!({
582            "aliases": {"opus": "vendor/my-custom-opus", "fast": "vendor/fast-model"},
583        }));
584        assert_eq!(r.resolve_alias("opus"), "vendor/my-custom-opus");
585        assert_eq!(r.resolve_alias("fast"), "vendor/fast-model");
586        // Untouched aliases still resolve to the built-in, and an unknown
587        // name still passes through.
588        assert_eq!(r.resolve_alias("sonnet"), "anthropic/claude-sonnet-4-6");
589        assert_eq!(r.resolve_alias("unknown-thing"), "unknown-thing");
590    }
591
592    /// The PATTERN half (cc§9's `[1m]` shape): the wildcard's capture is
593    /// itself alias-resolved before it is substituted, so a suffix modifier
594    /// composes with the alias table instead of shadowing it.
595    #[test]
596    fn a_pattern_alias_resolves_its_captured_stem_before_substituting() {
597        let r = routing(serde_json::json!({"aliases": {"*[1m]": "{}[1m]"}}));
598        assert_eq!(
599            r.resolve_alias("sonnet[1m]"),
600            "anthropic/claude-sonnet-4-6[1m]"
601        );
602        // A full slug carrying the same suffix works too.
603        assert_eq!(r.resolve_alias("vendor/m[1m]"), "vendor/m[1m]");
604        // An exact entry always beats a pattern.
605        let r = routing(serde_json::json!({
606            "aliases": {"*[1m]": "{}[1m]", "sonnet[1m]": "vendor/pinned"},
607        }));
608        assert_eq!(r.resolve_alias("sonnet[1m]"), "vendor/pinned");
609    }
610
611    /// Per-provider and per-account scopes: the SAME friendly name resolves
612    /// to different slugs depending on which provider/plan the session
613    /// declares (cc§9 "Account-type defaults").
614    #[test]
615    fn aliases_resolve_per_provider_and_per_account() {
616        let table = |provider: &str, account: &str| {
617            serde_json::json!({
618                "provider": provider,
619                "account": account,
620                "aliases": {"default": "vendor/generic"},
621                "providers": {
622                    "anthropic": {
623                        "aliases": {"default": "anthropic/claude-sonnet-4-6"},
624                        "accounts": {
625                            "max": {"aliases": {"default": "anthropic/claude-opus-4-8"}},
626                        },
627                    },
628                    "openai": {"aliases": {"default": "openai/gpt-5.5"}},
629                },
630            })
631        };
632        assert_eq!(
633            routing(table("anthropic", "max")).resolve_alias("default"),
634            "anthropic/claude-opus-4-8"
635        );
636        // Same provider, a plan with no table of its own: the provider scope.
637        assert_eq!(
638            routing(table("anthropic", "pro")).resolve_alias("default"),
639            "anthropic/claude-sonnet-4-6"
640        );
641        // Different provider entirely.
642        assert_eq!(
643            routing(table("openai", "max")).resolve_alias("default"),
644            "openai/gpt-5.5"
645        );
646        // No scope declared at all: the flat table.
647        assert_eq!(
648            routing(serde_json::json!({"aliases": {"default": "vendor/generic"}}))
649                .resolve_alias("default"),
650            "vendor/generic"
651        );
652    }
653
654    #[test]
655    fn pattern_capture_is_exact_or_single_wildcard() {
656        assert_eq!(pattern_capture("a/b", "a/b").as_deref(), Some("a/b"));
657        assert_eq!(pattern_capture("a/b", "a/c"), None);
658        assert_eq!(
659            pattern_capture("openai/*", "openai/gpt-5").as_deref(),
660            Some("gpt-5")
661        );
662        assert_eq!(
663            pattern_capture("*[1m]", "opus[1m]").as_deref(),
664            Some("opus")
665        );
666        assert_eq!(
667            pattern_capture("*", "anything").as_deref(),
668            Some("anything")
669        );
670        // A two-wildcard pattern is not understood, and fails closed.
671        assert_eq!(pattern_capture("a*b*c", "axbyc"), None);
672        // The wildcard may capture nothing.
673        assert_eq!(pattern_capture("opus*", "opus").as_deref(), Some(""));
674    }
675
676    /// Per-model rules: the most specific matching pattern wins, and unset
677    /// fields inherit from the table's own top-level defaults.
678    #[test]
679    fn per_model_rules_overlay_defaults_most_specific_last() {
680        let r = routing(serde_json::json!({
681            "effort": "medium",
682            "thinking_budget": 4096,
683            "models": {
684                "anthropic/*": {"thinking_budget": 16384},
685                "anthropic/claude-opus-4-8": {"effort": "high", "apply_patch": false},
686                "openai/*": {"apply_patch": true},
687            },
688        }));
689        let opus = r.rules_for("anthropic/claude-opus-4-8");
690        assert_eq!(opus.effort.as_deref(), Some("high"));
691        assert_eq!(opus.thinking_budget, Some(16384));
692        assert_eq!(opus.apply_patch, Some(false));
693        let haiku = r.rules_for("anthropic/claude-haiku-4-5");
694        assert_eq!(haiku.effort.as_deref(), Some("medium"));
695        assert_eq!(haiku.thinking_budget, Some(16384));
696        assert_eq!(haiku.apply_patch, None);
697        let gpt = r.rules_for("openai/gpt-5.5");
698        assert_eq!(gpt.thinking_budget, Some(4096));
699        assert_eq!(gpt.apply_patch, Some(true));
700    }
701
702    #[test]
703    fn effective_effort_prefers_the_model_rule_then_the_session_then_clamps() {
704        let r = routing(serde_json::json!({
705            "models": {
706                "anthropic/*": {"effort": "high"},
707                "cheap/*": {"max_effort": "low"},
708            },
709        }));
710        // Per-model level beats the session level.
711        assert_eq!(
712            r.effective_effort("anthropic/claude-opus-4-8", Some("medium"))
713                .as_deref(),
714            Some("high")
715        );
716        // No rule: the session level survives.
717        assert_eq!(
718            r.effective_effort("vendor/m", Some("medium")).as_deref(),
719            Some("medium")
720        );
721        // A cap binds whatever the level came from.
722        assert_eq!(
723            r.effective_effort("cheap/m", Some("ultra")).as_deref(),
724            Some("low")
725        );
726        // A cap that does not bind changes nothing.
727        assert_eq!(
728            r.effective_effort("cheap/m", Some("none")).as_deref(),
729            Some("none")
730        );
731        // An unrankable spelling is forwarded verbatim, never rewritten.
732        assert_eq!(
733            r.effective_effort("cheap/m", Some("turbo")).as_deref(),
734            Some("turbo")
735        );
736        // Nothing set at all stays unset.
737        assert_eq!(r.effective_effort("vendor/m", None), None);
738    }
739
740    #[test]
741    fn allow_and_deny_lists_refuse_by_pattern() {
742        let r = routing(serde_json::json!({
743            "allowed_models": ["anthropic/*"],
744            "denied_models": ["anthropic/claude-opus-*"],
745        }));
746        assert!(r.restricts_models());
747        assert!(r.refusal("anthropic/claude-sonnet-4-6").is_none());
748        assert!(r
749            .refusal("anthropic/claude-opus-4-8")
750            .is_some_and(|d| d.contains("denied_models")));
751        assert!(r
752            .refusal("openai/gpt-5.5")
753            .is_some_and(|d| d.contains("allowed_models")));
754        // Nothing configured restricts nothing.
755        assert!(!routing(serde_json::json!({})).restricts_models());
756        assert!(routing(serde_json::json!({})).refusal("anything").is_none());
757    }
758
759    fn cap(settings: serde_json::Value) -> crate::configfile::CapabilityConfig {
760        crate::configfile::CapabilityConfig {
761            enabled: None,
762            settings: settings.as_object().cloned().unwrap_or_default(),
763        }
764    }
765
766    /// Default-off: no `[capabilities.model_catalog]` table at all resolves
767    /// to the base model alias-resolved against the built-ins only, with no
768    /// small_model/fallback — unchanged behavior for every config that
769    /// doesn't set this table.
770    #[test]
771    fn resolve_with_no_model_catalog_table_is_alias_only() {
772        let capabilities = std::collections::BTreeMap::new();
773        let r = resolve(&capabilities, "opus");
774        assert_eq!(r.model, "anthropic/claude-opus-4-8");
775        assert_eq!(r.small_model, None);
776        assert!(r.fallback.is_empty());
777    }
778
779    /// Happy path: small_model + fallback + a custom alias all resolve
780    /// together through the one path, and `enabled` is irrelevant (matches
781    /// the D-9 check's own precedent of reading `small_model`
782    /// unconditionally).
783    #[test]
784    fn resolve_happy_path_reads_small_model_fallback_and_aliases_regardless_of_enabled() {
785        let mut capabilities = std::collections::BTreeMap::new();
786        capabilities.insert(
787            "model_catalog".to_string(),
788            cap(serde_json::json!({
789                "small_model": "haiku",
790                "fallback": ["sonnet", "vendor/already-a-slug"],
791                "aliases": {"cheap": "vendor/cheap-model"},
792            })),
793        );
794        let r = resolve(&capabilities, "cheap");
795        assert_eq!(r.model, "vendor/cheap-model");
796        assert_eq!(r.small_model.as_deref(), Some("anthropic/claude-haiku-4-5"));
797        assert_eq!(
798            r.fallback,
799            vec![
800                "anthropic/claude-sonnet-4-6".to_string(),
801                "vendor/already-a-slug".to_string(),
802            ]
803        );
804    }
805
806    /// A resolved-slug `small_model` (already a full id, not an alias)
807    /// passes through unchanged.
808    #[test]
809    fn resolve_small_model_already_a_slug_passes_through() {
810        let mut capabilities = std::collections::BTreeMap::new();
811        capabilities.insert(
812            "model_catalog".to_string(),
813            cap(serde_json::json!({"small_model": "anthropic/claude-haiku-4-5"})),
814        );
815        let r = resolve(&capabilities, "anthropic/claude-opus-4-8");
816        assert_eq!(r.model, "anthropic/claude-opus-4-8");
817        assert_eq!(r.small_model.as_deref(), Some("anthropic/claude-haiku-4-5"));
818    }
819}