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}