Skip to main content

supercode_harness/
model_catalog.rs

1//! §2 module 26 `model.catalog` (`docs/composable-harness/
2//! COMPOSABLE-HARNESS-DESIGN.md` §3.1 `[capabilities.model_catalog]`) — P4
3//! of the composable-harness migration (design §5.2 phase **P4**: "aliases +
4//! fallback chains (userconfig.rs:386-411) promoted into core" + the
5//! `small_model` knob).
6//!
7//! **What moved here.** The CLI's `alias_table`/`resolve_model_alias`
8//! (`crates/cli/src/userconfig.rs`) were CLI-only (design §1.10: "CLI model
9//! aliases ✓ … `resolve_model_alias`, userconfig.rs:386-411"). This module is
10//! the single source of truth now — [`DEFAULT_ALIASES`] is the exact same
11//! eleven built-in aliases, byte-identical, so moving them here changes no
12//! resolved slug for any existing caller. The CLI crate re-exports through
13//! `userconfig::alias_table`/`resolve_model_alias` (zero call-site churn,
14//! zero behavior change — see that module).
15//!
16//! **What's NEW (P4).** [`resolve_alias`] additionally accepts an
17//! `extra` table (`[capabilities.model_catalog].aliases`, §3.1/§3.2:
18//! "`capabilities.model_catalog.*` | CLI `alias_table` … + NEW
19//! small-model/fallback") so a user's own config can add or override an
20//! alias without recompiling — `extra` is checked BEFORE
21//! [`DEFAULT_ALIASES`], so a user override always wins. [`resolve_fallback_chain`]
22//! resolves a `[capabilities.model_catalog].fallback` list of aliases/slugs
23//! into a plain slug list the SAME way, for the D-9-adjacent "failure
24//! fallback chain" knob (catalog §4a: "Model aliases + failure fallback
25//! chain (resolution table before request build)").
26//!
27//! **Scope note (S-sized, per design §5.2 P4).** This module lands the
28//! RESOLUTION TABLE only — `Config::small_model`/`Config::model_fallback`
29//! are knobs a caller can read, not a retry/failover LOOP that automatically
30//! re-sends a failed request against the next model in the chain. Building
31//! that loop is a distinct, larger change (closer to §1.10's "mid-session
32//! model switch," itself called out in design §5.2 as its own M-sized item,
33//! separate from this S-sized catalog item) and is out of scope here.
34
35/// The built-in alias → full-slug table (byte-identical to the CLI's
36/// original `userconfig::alias_table`, moved here as the single source of
37/// truth — see the module doc).
38pub const DEFAULT_ALIASES: &[(&str, &str)] = &[
39    ("opus", "anthropic/claude-opus-4-8"),
40    ("sonnet", "anthropic/claude-sonnet-4-6"),
41    ("haiku", "anthropic/claude-haiku-4-5"),
42    ("gpt", "openai/gpt-5.5"),
43    ("gpt-5.5", "openai/gpt-5.5"),
44    ("gpt-5", "openai/gpt-5"),
45    ("gemini", "google/gemini-2.5-pro"),
46    ("flash", "deepseek/deepseek-v4-flash"),
47    ("deepseek-flash", "deepseek/deepseek-v4-flash"),
48    ("deepseek", "deepseek/deepseek-v4-pro"),
49    ("llama", "meta-llama/llama-4-maverick"),
50];
51
52/// Expand a friendly model alias to its full slug, 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}