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    ("gpt", "openai/gpt-5.5"),
42    ("gpt-5.5", "openai/gpt-5.5"),
43    ("gpt-5", "openai/gpt-5"),
44    ("gemini", "google/gemini-2.5-pro"),
45    ("flash", "deepseek/deepseek-v4-flash"),
46    ("deepseek-flash", "deepseek/deepseek-v4-flash"),
47    ("deepseek", "deepseek/deepseek-v4-pro"),
48    ("llama", "meta-llama/llama-4-maverick"),
49];
50
51/// Expand a friendly model alias to its full slug through the built-in
52/// table alone — the no-config path, for callers that have no resolved
53/// [`Routing`] in hand (an imported foreign session's recorded model name, a
54/// help listing). Unknown values pass through unchanged so any real slug
55/// still works. A caller that DOES have a config resolves through
56/// [`Routing::resolve_alias`] instead, which additionally honours the
57/// config's own alias table, its patterns, and its provider/account scopes.
58pub fn resolve_alias(model: &str) -> String {
59    Routing::default().resolve_alias(model)
60}
61
62/// Everything `[capabilities.model_catalog]` resolves into, alias-resolved:
63/// the effective `core.model`, the `small_model` (if set), and the
64/// `fallback` chain (if set). Shared by both resolution paths that carry a
65/// `capabilities.<name>` table shaped like [`crate::configfile::CapabilityConfig`]
66/// — the SDK's [`crate::configfile::HarnessConfig`] resolver
67/// (`materialize_config`) and the CLI's own `FileConfig`-driven
68/// `build_config` — so the alias/small-model/fallback resolution logic
69/// lives in exactly one place.
70#[derive(Debug, Clone, Default, PartialEq, Eq)]
71pub struct Resolution {
72    /// `base_model`, alias-resolved.
73    pub model: String,
74    /// `capabilities.model_catalog.small_model`, alias-resolved, if set to
75    /// a non-empty string.
76    pub small_model: Option<String>,
77    /// `capabilities.model_catalog.fallback`, alias-resolved in order, if
78    /// non-empty.
79    pub fallback: Vec<String>,
80    /// BP-5 (`capabilities.model_catalog.base_prompts`, catalog D2
81    /// "Per-model-family base-prompt selection"): model-id glob → that
82    /// family's base system prompt. Empty unless the config sets the table.
83    pub base_prompts: std::collections::BTreeMap<String, String>,
84    /// BP-13: the whole routing table (aliases, per-model rules, allow/deny
85    /// lists) — carried forward onto [`crate::Config::model_routing`] so
86    /// every later routing decision asks THIS value rather than re-reading
87    /// the capability table behind this module's back.
88    pub routing: Routing,
89    /// BP-13: why `model` is refused by the config-layer allow/deny lists,
90    /// or `None`. Reported by the resolver, never silently applied.
91    pub refusal: Option<String>,
92}
93
94/// Resolve `[capabilities.model_catalog]` against `base_model` (typically
95/// the already-computed `core.model` / CLI `--model`/config value).
96/// Consulted regardless of `capabilities.model_catalog.enabled` — matching
97/// the resolver's existing D-9 check (`configfile::validate_modules`),
98/// which already reads `model_catalog.small_model` unconditionally: these
99/// are data a caller resolves against, not an activation switch.
100pub fn resolve(
101    capabilities: &std::collections::BTreeMap<String, crate::configfile::CapabilityConfig>,
102    base_model: &str,
103) -> Resolution {
104    let routing = Routing::from_capabilities(capabilities);
105
106    let mut out = Resolution {
107        model: if base_model.is_empty() {
108            String::new()
109        } else {
110            routing.resolve_alias(base_model)
111        },
112        small_model: None,
113        fallback: Vec::new(),
114        base_prompts: std::collections::BTreeMap::new(),
115        refusal: None,
116        routing,
117    };
118
119    if let Some(cap) = capabilities.get("model_catalog") {
120        if let Some(sm) = cap.settings.get("small_model").and_then(|v| v.as_str()) {
121            if !sm.is_empty() {
122                out.small_model = Some(out.routing.resolve_alias(sm));
123            }
124        }
125        // BP-5: the per-family base-prompt table. Read here, next to
126        // `small_model`/`fallback`, for the same reason those are: it is
127        // DATA a caller resolves against, not an activation switch.
128        if let Some(table) = cap.settings.get("base_prompts").and_then(|v| v.as_object()) {
129            out.base_prompts = table
130                .iter()
131                .filter_map(|(pattern, body)| {
132                    body.as_str()
133                        .filter(|text| !text.is_empty())
134                        .map(|text| (pattern.clone(), text.to_string()))
135                })
136                .collect();
137        }
138        if let Some(fb) = cap.settings.get("fallback").and_then(|v| v.as_array()) {
139            out.fallback = fb
140                .iter()
141                .filter_map(|v| v.as_str())
142                .map(|m| out.routing.resolve_alias(m))
143                .collect();
144        }
145    }
146    // The allow/deny lists bind every model this table hands out, not just
147    // `core.model` — a fallback entry or a small model the org forbids is
148    // exactly as forbidden as a main model it forbids.
149    out.refusal = [Some(&out.model)]
150        .into_iter()
151        .flatten()
152        .chain(out.small_model.iter())
153        .chain(out.fallback.iter())
154        .filter(|m| !m.is_empty())
155        .find_map(|m| out.routing.refusal(m));
156    out
157}
158
159/// BP-5 (catalog D2 "Per-model-family base-prompt selection", cx§2
160/// "Per-model base instructions": "the system prompt is selected per model
161/// family from bundled markdown"): the base prompt `model` should run
162/// under, or `None` when no family pattern matches.
163///
164/// `prompts` is keyed by a [`crate::config::glob_match`] pattern over the
165/// model id (`"*codex*"`, `"openai/gpt-5.*"`). The MOST SPECIFIC match wins,
166/// specificity being the pattern's own length — a TOML table has no order to
167/// rely on, so selection must not depend on one. Ties (equal-length patterns
168/// both matching) break lexicographically, so the answer is total and
169/// reproducible.
170pub fn base_prompt_for<'a>(
171    prompts: &'a std::collections::BTreeMap<String, String>,
172    model: &str,
173) -> Option<&'a str> {
174    prompts
175        .iter()
176        .filter(|(pattern, _)| crate::config::glob_match(pattern, model))
177        .max_by(|(a, _), (b, _)| a.len().cmp(&b.len()).then_with(|| b.cmp(a)))
178        .map(|(_, body)| body.as_str())
179}
180
181// ---------------------------------------------------------------------------
182// BP-13 — the ONE model-resolution path.
183// ---------------------------------------------------------------------------
184//
185// Everything routing decides about a model — which slug a friendly name means,
186// which reasoning effort and thinking budget the request carries, which service
187// tier it asks for, which tool-shape capability bits the registry reads, and
188// whether the model is allowed at all — resolves HERE, out of the same
189// `[capabilities.model_catalog]` table the presets already set. There is no
190// second table, no parallel registry, and no consumer that reads a raw setting
191// behind this module's back: `configfile::materialize_config` folds this into
192// `Config::model_routing`, and every consumer (`Agent`'s request build, its
193// fallback pass, `ToolRegistry::from_config`, the CLI's `--model`/`/model`)
194// asks the SAME `Routing` value.
195
196/// The reasoning-effort ladder, weakest first. Spans both harnesses' own
197/// vocabularies: Claude Code's `low|medium|high` and Codex's `none…ultra`
198/// (cc§9, cx§6/§9). A level outside the ladder is not an error — it is
199/// forwarded verbatim to the provider — but it cannot be RANKED, so an
200/// effort cap can neither clamp it nor be fooled by it (see [`cap_effort`]).
201pub const EFFORT_LADDER: &[&str] = &["none", "minimal", "low", "medium", "high", "xhigh", "ultra"];
202
203/// Position of `level` on [`EFFORT_LADDER`], or `None` for a level this
204/// build does not rank.
205pub fn effort_rank(level: &str) -> Option<usize> {
206    EFFORT_LADDER.iter().position(|l| *l == level)
207}
208
209/// Clamp `level` down to `cap`. Returns `level` unchanged when there is no
210/// cap, when the cap does not bind, or when EITHER side is unrankable (an
211/// unknown spelling must not be silently rewritten into a ladder value the
212/// user never asked for).
213pub fn cap_effort(level: Option<&str>, cap: Option<&str>) -> Option<String> {
214    let level = level?;
215    let Some(cap) = cap else {
216        return Some(level.to_string());
217    };
218    match (effort_rank(level), effort_rank(cap)) {
219        (Some(l), Some(c)) if l > c => Some(cap.to_string()),
220        _ => Some(level.to_string()),
221    }
222}
223
224/// Match `value` against a model PATTERN and return what the wildcard
225/// captured.
226///
227/// A pattern is either an exact slug (matches by equality; the capture is
228/// the whole value) or a single-`*` glob (`opus*`, `*[1m]`, `openai/gpt-5*`),
229/// where `*` stands for any run of characters including the empty one. A
230/// second `*` is not a pattern this build understands and never matches, so
231/// a typo fails closed rather than matching everything.
232pub fn pattern_capture(pattern: &str, value: &str) -> Option<String> {
233    match pattern.split_once('*') {
234        None => (pattern == value).then(|| value.to_string()),
235        Some((prefix, suffix)) => {
236            if suffix.contains('*') {
237                return None;
238            }
239            if value.len() < prefix.len() + suffix.len() {
240                return None;
241            }
242            if !value.starts_with(prefix) || !value.ends_with(suffix) {
243                return None;
244            }
245            Some(value[prefix.len()..value.len() - suffix.len()].to_string())
246        }
247    }
248}
249
250/// How specific a pattern is: its literal (non-wildcard) length, with an
251/// exact pattern always beating a glob of the same literal length. Used to
252/// order overlays so the most specific rule wins.
253fn pattern_specificity(pattern: &str) -> (usize, u8) {
254    let literal = pattern.chars().filter(|c| *c != '*').count();
255    (literal, u8::from(!pattern.contains('*')))
256}
257
258/// Per-model routing rules: what `[capabilities.model_catalog.models.<pattern>]`
259/// (and the table's own top-level defaults) say about one model.
260///
261/// Every field is `Option` so "unset" and "set to the default value" stay
262/// distinguishable — an unset field inherits, a set one overrides.
263#[derive(Debug, Clone, Default, PartialEq, Eq)]
264pub struct ModelRules {
265    /// Reasoning-effort level for this model — the per-model override of
266    /// `[core] effort` (cc§9 effort levels, cx§9 tiers).
267    pub effort: Option<String>,
268    /// Ceiling on the effort level for this model. Applied AFTER `effort`
269    /// and after `[core] effort`, so a cap binds whatever the level came
270    /// from — the per-model half of the org effort cap.
271    pub max_effort: Option<String>,
272    /// Thinking-token budget (Claude Code's `MAX_THINKING_TOKENS`).
273    pub thinking_budget: Option<u32>,
274    /// Service tier / fast-mode variant asked for on the request
275    /// (cc `/fast`, cx `model_service_tier`).
276    pub service_tier: Option<String>,
277    /// Capability bit: this model takes the freeform `apply_patch`
278    /// envelope. `Some(false)` swaps the write surface to `edit_file` /
279    /// `write_file` instead (cx§9 `apply_patch_tool_type`).
280    pub apply_patch: Option<bool>,
281    /// Capability bit: this model takes the dedicated search tool
282    /// (cx§9 `supports_search_tool`).
283    pub search_tool: Option<bool>,
284}
285
286impl ModelRules {
287    /// Overlay `other`'s SET fields onto `self`; unset fields inherit.
288    pub fn overlay(&mut self, other: &ModelRules) {
289        if other.effort.is_some() {
290            self.effort.clone_from(&other.effort);
291        }
292        if other.max_effort.is_some() {
293            self.max_effort.clone_from(&other.max_effort);
294        }
295        if other.thinking_budget.is_some() {
296            self.thinking_budget = other.thinking_budget;
297        }
298        if other.service_tier.is_some() {
299            self.service_tier.clone_from(&other.service_tier);
300        }
301        if other.apply_patch.is_some() {
302            self.apply_patch = other.apply_patch;
303        }
304        if other.search_tool.is_some() {
305            self.search_tool = other.search_tool;
306        }
307    }
308
309    fn from_settings(obj: &serde_json::Map<String, serde_json::Value>) -> ModelRules {
310        let text = |key: &str| {
311            obj.get(key)
312                .and_then(|v| v.as_str())
313                .filter(|s| !s.is_empty())
314                .map(str::to_string)
315        };
316        ModelRules {
317            effort: text("effort"),
318            max_effort: text("max_effort"),
319            thinking_budget: obj
320                .get("thinking_budget")
321                .and_then(|v| v.as_u64())
322                .filter(|n| *n > 0)
323                .map(|n| n as u32),
324            service_tier: text("service_tier"),
325            apply_patch: obj.get("apply_patch").and_then(|v| v.as_bool()),
326            search_tool: obj.get("search_tool").and_then(|v| v.as_bool()),
327        }
328    }
329}
330
331/// The whole resolved routing table, carried on
332/// [`crate::Config::model_routing`] and consulted by every routing decision.
333///
334/// It keeps the PATTERNS rather than one pre-resolved answer on purpose: a
335/// mid-session model switch has to re-decide effort, budget, tier and tool
336/// bits for the NEW model, and it does that by asking this same value again
337/// ([`Routing::rules_for`]) rather than by re-reading config elsewhere.
338#[derive(Debug, Clone, Default, PartialEq, Eq)]
339pub struct Routing {
340    /// Alias → slug entries from the config, in precedence order: the
341    /// declared account's scope, then the declared provider's, then the flat
342    /// table. Consulted before [`DEFAULT_ALIASES`]. A key may be a `*`
343    /// pattern, in which case the value may contain `{}` — replaced by the
344    /// captured stem after that stem is itself alias-resolved (Claude Code's
345    /// `sonnet[1m]` shape).
346    pub aliases: Vec<(String, String)>,
347    /// `[capabilities.model_catalog.models.<pattern>]`, least specific
348    /// first, so overlaying in order leaves the most specific rule winning.
349    pub models: Vec<(String, ModelRules)>,
350    /// The table's own top-level rules — the baseline every model inherits.
351    pub defaults: ModelRules,
352    /// `allowed_models`: when non-empty, a model matching NO entry is
353    /// refused outright.
354    pub allowed: Vec<String>,
355    /// `denied_models`: a model matching any entry is refused outright.
356    pub denied: Vec<String>,
357    /// The declared provider whose alias scope is in force (`provider`).
358    pub provider: Option<String>,
359    /// The declared account/plan tier whose alias scope is in force
360    /// (`account`) — Claude Code's account-type defaults (cc§9).
361    pub account: Option<String>,
362}
363
364impl Routing {
365    /// Expand a friendly model name to a full slug through this table, then
366    /// [`DEFAULT_ALIASES`], then unchanged pass-through.
367    ///
368    /// Exact entries always beat patterns, and among patterns the most
369    /// specific wins. A pattern's `{}` placeholder receives the captured
370    /// stem RE-RESOLVED through this same function (depth-capped), which is
371    /// what makes `sonnet[1m]` land on `anthropic/claude-sonnet-4-6[1m]`
372    /// rather than on the literal text `sonnet[1m]`.
373    pub fn resolve_alias(&self, model: &str) -> String {
374        self.resolve_alias_depth(model, 0)
375    }
376
377    fn resolve_alias_depth(&self, model: &str, depth: usize) -> String {
378        if depth > 4 {
379            return model.to_string();
380        }
381        for (alias, slug) in &self.aliases {
382            if !alias.contains('*') && alias == model {
383                return slug.clone();
384            }
385        }
386        if let Some((_, slug)) = DEFAULT_ALIASES.iter().find(|(a, _)| *a == model) {
387            return (*slug).to_string();
388        }
389        let mut best: Option<(usize, u8, &str, String)> = None;
390        for (alias, slug) in &self.aliases {
391            if !alias.contains('*') {
392                continue;
393            }
394            let Some(stem) = pattern_capture(alias, model) else {
395                continue;
396            };
397            let (literal, exact) = pattern_specificity(alias);
398            if best
399                .as_ref()
400                .is_none_or(|(l, e, _, _)| (literal, exact) > (*l, *e))
401            {
402                best = Some((literal, exact, slug.as_str(), stem));
403            }
404        }
405        match best {
406            Some((_, _, template, stem)) => {
407                let expanded = self.resolve_alias_depth(&stem, depth + 1);
408                template.replace("{}", &expanded)
409            }
410            None => model.to_string(),
411        }
412    }
413
414    /// The rules in force for `model`: the table defaults with every
415    /// matching `models.<pattern>` overlaid, least specific first.
416    pub fn rules_for(&self, model: &str) -> ModelRules {
417        let mut rules = self.defaults.clone();
418        for (pattern, r) in &self.models {
419            if pattern_capture(pattern, model).is_some() {
420                rules.overlay(r);
421            }
422        }
423        rules
424    }
425
426    /// The effort level to send for `model`, given the session's
427    /// `[core] effort`: the per-model level if one is set, else the session
428    /// level, clamped by whichever effort cap applies.
429    pub fn effective_effort(&self, model: &str, session_effort: Option<&str>) -> Option<String> {
430        let rules = self.rules_for(model);
431        let level = rules.effort.as_deref().or(session_effort);
432        cap_effort(level, rules.max_effort.as_deref())
433    }
434
435    /// Why `model` is refused, or `None` when it is allowed — the
436    /// CONFIG-layer half of the org model allowlist. A denied model can
437    /// never be selected, and with an allowlist set nothing outside it can.
438    pub fn refusal(&self, model: &str) -> Option<String> {
439        if let Some(pattern) = self
440            .denied
441            .iter()
442            .find(|p| pattern_capture(p, model).is_some())
443        {
444            return Some(format!(
445                "model `{model}` matches capabilities.model_catalog.denied_models entry `{pattern}`"
446            ));
447        }
448        if !self.allowed.is_empty()
449            && !self
450                .allowed
451                .iter()
452                .any(|p| pattern_capture(p, model).is_some())
453        {
454            return Some(format!(
455                "model `{model}` is not in capabilities.model_catalog.allowed_models ({})",
456                self.allowed.join(", ")
457            ));
458        }
459        None
460    }
461
462    /// Whether anything in this table restricts model choice at all.
463    pub fn restricts_models(&self) -> bool {
464        !self.allowed.is_empty() || !self.denied.is_empty()
465    }
466
467    /// Build the routing table from `[capabilities.model_catalog]`.
468    ///
469    /// Consulted regardless of the module's `enabled` flag, for the same
470    /// reason [`resolve`] is: these are data a caller resolves against, not
471    /// an activation switch (and the D-9 resolver check already reads
472    /// `small_model` unconditionally).
473    pub fn from_capabilities(
474        capabilities: &std::collections::BTreeMap<String, crate::configfile::CapabilityConfig>,
475    ) -> Routing {
476        let Some(cap) = capabilities.get("model_catalog") else {
477            return Routing::default();
478        };
479        let s = &cap.settings;
480        let scope = |key: &str| {
481            s.get(key)
482                .and_then(|v| v.as_str())
483                .filter(|p| !p.is_empty())
484                .map(str::to_string)
485        };
486        let provider = scope("provider");
487        let account = scope("account");
488
489        // Alias scopes, most specific first: the declared account's table,
490        // then the declared provider's, then the flat table.
491        let mut aliases: Vec<(String, String)> = Vec::new();
492        let provider_table = provider
493            .as_deref()
494            .and_then(|p| s.get("providers")?.as_object()?.get(p))
495            .and_then(|v| v.as_object());
496        if let (Some(pt), Some(account)) = (provider_table, account.as_deref()) {
497            if let Some(at) = pt
498                .get("accounts")
499                .and_then(|v| v.as_object())
500                .and_then(|a| a.get(account))
501                .and_then(|v| v.as_object())
502            {
503                push_alias_table(at.get("aliases"), &mut aliases);
504            }
505        }
506        if let Some(pt) = provider_table {
507            push_alias_table(pt.get("aliases"), &mut aliases);
508        }
509        push_alias_table(s.get("aliases"), &mut aliases);
510
511        let mut models: Vec<(String, ModelRules)> = s
512            .get("models")
513            .and_then(|v| v.as_object())
514            .map(|o| {
515                o.iter()
516                    .filter_map(|(pattern, v)| {
517                        Some((pattern.clone(), ModelRules::from_settings(v.as_object()?)))
518                    })
519                    .collect()
520            })
521            .unwrap_or_default();
522        models.sort_by_key(|(pattern, _)| pattern_specificity(pattern));
523
524        Routing {
525            aliases,
526            models,
527            defaults: ModelRules::from_settings(s),
528            allowed: string_list(s.get("allowed_models")),
529            denied: string_list(s.get("denied_models")),
530            provider,
531            account,
532        }
533    }
534}
535
536fn push_alias_table(value: Option<&serde_json::Value>, out: &mut Vec<(String, String)>) {
537    let Some(obj) = value.and_then(|v| v.as_object()) else {
538        return;
539    };
540    let mut entries: Vec<(String, String)> = obj
541        .iter()
542        .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
543        .collect();
544    entries.sort();
545    out.extend(entries);
546}
547
548fn string_list(value: Option<&serde_json::Value>) -> Vec<String> {
549    value
550        .and_then(|v| v.as_array())
551        .map(|a| {
552            a.iter()
553                .filter_map(|v| v.as_str().map(str::to_string))
554                .collect()
555        })
556        .unwrap_or_default()
557}