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}