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, consulting `extra`
53/// (config-provided aliases, §3.1 `capabilities.model_catalog.aliases`)
54/// BEFORE [`DEFAULT_ALIASES`] — a config-provided alias may override a
55/// built-in one (e.g. re-pointing `"opus"` at a different slug), but an
56/// unknown value always passes through unchanged so any real slug still
57/// works. `extra` is typically empty (no config wired it in), in which case
58/// this is exactly the CLI's original `resolve_model_alias` behavior.
59pub fn resolve_alias(model: &str, extra: &[(&str, &str)]) -> String {
60 extra
61 .iter()
62 .find(|(alias, _)| *alias == model)
63 .or_else(|| DEFAULT_ALIASES.iter().find(|(alias, _)| *alias == model))
64 .map(|(_, slug)| (*slug).to_string())
65 .unwrap_or_else(|| model.to_string())
66}
67
68/// Resolve a `[capabilities.model_catalog].fallback` list (each entry an
69/// alias or an already-full slug) into full slugs, in order, via
70/// [`resolve_alias`]. An empty `chain` resolves to an empty `Vec` — the
71/// default, no-fallback-configured shape.
72pub fn resolve_fallback_chain(chain: &[String], extra: &[(&str, &str)]) -> Vec<String> {
73 chain.iter().map(|m| resolve_alias(m, extra)).collect()
74}
75
76/// Everything `[capabilities.model_catalog]` resolves into, alias-resolved:
77/// the effective `core.model`, the `small_model` (if set), and the
78/// `fallback` chain (if set). Shared by both resolution paths that carry a
79/// `capabilities.<name>` table shaped like [`crate::configfile::CapabilityConfig`]
80/// — the SDK's [`crate::configfile::HarnessConfig`] resolver
81/// (`materialize_config`) and the CLI's own `FileConfig`-driven
82/// `build_config` — so the alias/small-model/fallback resolution logic
83/// lives in exactly one place.
84#[derive(Debug, Clone, Default, PartialEq, Eq)]
85pub struct Resolution {
86 /// `base_model`, alias-resolved.
87 pub model: String,
88 /// `capabilities.model_catalog.small_model`, alias-resolved, if set to
89 /// a non-empty string.
90 pub small_model: Option<String>,
91 /// `capabilities.model_catalog.fallback`, alias-resolved in order, if
92 /// non-empty.
93 pub fallback: Vec<String>,
94}
95
96/// Resolve `[capabilities.model_catalog]` against `base_model` (typically
97/// the already-computed `core.model` / CLI `--model`/config value).
98/// Consulted regardless of `capabilities.model_catalog.enabled` — matching
99/// the resolver's existing D-9 check (`configfile::validate_modules`),
100/// which already reads `model_catalog.small_model` unconditionally: these
101/// are data a caller resolves against, not an activation switch.
102pub fn resolve(
103 capabilities: &std::collections::BTreeMap<String, crate::configfile::CapabilityConfig>,
104 base_model: &str,
105) -> Resolution {
106 let extra = extra_aliases(capabilities);
107 let extra_ref: Vec<(&str, &str)> = extra
108 .iter()
109 .map(|(a, b)| (a.as_str(), b.as_str()))
110 .collect();
111
112 let mut out = Resolution {
113 model: if base_model.is_empty() {
114 String::new()
115 } else {
116 resolve_alias(base_model, &extra_ref)
117 },
118 small_model: None,
119 fallback: Vec::new(),
120 };
121
122 let Some(cap) = capabilities.get("model_catalog") else {
123 return out;
124 };
125 if let Some(sm) = cap.settings.get("small_model").and_then(|v| v.as_str()) {
126 if !sm.is_empty() {
127 out.small_model = Some(resolve_alias(sm, &extra_ref));
128 }
129 }
130 if let Some(fb) = cap.settings.get("fallback").and_then(|v| v.as_array()) {
131 let chain: Vec<String> = fb
132 .iter()
133 .filter_map(|v| v.as_str().map(String::from))
134 .collect();
135 if !chain.is_empty() {
136 out.fallback = resolve_fallback_chain(&chain, &extra_ref);
137 }
138 }
139 out
140}
141
142/// `capabilities.model_catalog.aliases` — extra alias → slug overrides
143/// layered over [`DEFAULT_ALIASES`] (judgment call: §3.1's schema dump
144/// shows `small_model`/`fallback` under `[capabilities.model_catalog]` but
145/// no `aliases` key; §3.2's mapping row — "`capabilities.model_catalog.*` |
146/// CLI `alias_table` … + NEW small-model/fallback" — names the CLI alias
147/// table as exactly this module's own precedent, so `aliases` here is the
148/// natural, minimal extension point rather than a new top-level schema key).
149fn extra_aliases(
150 capabilities: &std::collections::BTreeMap<String, crate::configfile::CapabilityConfig>,
151) -> Vec<(String, String)> {
152 capabilities
153 .get("model_catalog")
154 .and_then(|cap| cap.settings.get("aliases"))
155 .and_then(|v| v.as_object())
156 .map(|o| {
157 o.iter()
158 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
159 .collect()
160 })
161 .unwrap_or_default()
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167
168 #[test]
169 fn resolve_alias_with_no_extra_matches_every_built_in() {
170 for (alias, slug) in DEFAULT_ALIASES {
171 assert_eq!(resolve_alias(alias, &[]), *slug, "alias {alias}");
172 }
173 // A non-alias passes through unchanged.
174 assert_eq!(resolve_alias("vendor/some-model", &[]), "vendor/some-model");
175 }
176
177 #[test]
178 fn resolve_alias_extra_table_overrides_a_built_in() {
179 let extra = [("opus", "vendor/my-custom-opus")];
180 assert_eq!(resolve_alias("opus", &extra), "vendor/my-custom-opus");
181 // Untouched aliases still resolve to the built-in.
182 assert_eq!(
183 resolve_alias("sonnet", &extra),
184 "anthropic/claude-sonnet-4-6"
185 );
186 }
187
188 #[test]
189 fn resolve_alias_extra_table_adds_a_brand_new_alias() {
190 let extra = [("fast", "vendor/fast-model")];
191 assert_eq!(resolve_alias("fast", &extra), "vendor/fast-model");
192 // A name that's neither built-in nor extra passes through unchanged.
193 assert_eq!(resolve_alias("unknown-thing", &extra), "unknown-thing");
194 }
195
196 #[test]
197 fn resolve_fallback_chain_resolves_each_entry_and_preserves_order() {
198 let chain = vec!["haiku".to_string(), "gpt".to_string()];
199 assert_eq!(
200 resolve_fallback_chain(&chain, &[]),
201 vec![
202 "anthropic/claude-haiku-4-5".to_string(),
203 "openai/gpt-5.5".to_string(),
204 ]
205 );
206 }
207
208 #[test]
209 fn resolve_fallback_chain_empty_input_is_empty_output() {
210 assert!(resolve_fallback_chain(&[], &[]).is_empty());
211 }
212
213 fn cap(settings: serde_json::Value) -> crate::configfile::CapabilityConfig {
214 crate::configfile::CapabilityConfig {
215 enabled: None,
216 settings: settings.as_object().cloned().unwrap_or_default(),
217 }
218 }
219
220 /// Default-off: no `[capabilities.model_catalog]` table at all resolves
221 /// to the base model alias-resolved against the built-ins only, with no
222 /// small_model/fallback — unchanged behavior for every config that
223 /// doesn't set this table.
224 #[test]
225 fn resolve_with_no_model_catalog_table_is_alias_only() {
226 let capabilities = std::collections::BTreeMap::new();
227 let r = resolve(&capabilities, "opus");
228 assert_eq!(r.model, "anthropic/claude-opus-4-8");
229 assert_eq!(r.small_model, None);
230 assert!(r.fallback.is_empty());
231 }
232
233 /// Happy path: small_model + fallback + a custom alias all resolve
234 /// together, and `enabled` is irrelevant (matches the D-9 check's own
235 /// precedent of reading `small_model` unconditionally).
236 #[test]
237 fn resolve_happy_path_reads_small_model_fallback_and_aliases_regardless_of_enabled() {
238 let mut capabilities = std::collections::BTreeMap::new();
239 capabilities.insert(
240 "model_catalog".to_string(),
241 cap(serde_json::json!({
242 "small_model": "haiku",
243 "fallback": ["sonnet", "vendor/already-a-slug"],
244 "aliases": {"cheap": "vendor/cheap-model"},
245 })),
246 );
247 let r = resolve(&capabilities, "cheap");
248 assert_eq!(r.model, "vendor/cheap-model");
249 assert_eq!(r.small_model.as_deref(), Some("anthropic/claude-haiku-4-5"));
250 assert_eq!(
251 r.fallback,
252 vec![
253 "anthropic/claude-sonnet-4-6".to_string(),
254 "vendor/already-a-slug".to_string(),
255 ]
256 );
257 }
258
259 /// A resolved-slug `small_model` (already a full id, not an alias)
260 /// passes through unchanged.
261 #[test]
262 fn resolve_small_model_already_a_slug_passes_through() {
263 let mut capabilities = std::collections::BTreeMap::new();
264 capabilities.insert(
265 "model_catalog".to_string(),
266 cap(serde_json::json!({"small_model": "anthropic/claude-haiku-4-5"})),
267 );
268 let r = resolve(&capabilities, "anthropic/claude-opus-4-8");
269 assert_eq!(r.model, "anthropic/claude-opus-4-8");
270 assert_eq!(r.small_model.as_deref(), Some("anthropic/claude-haiku-4-5"));
271 }
272}