supercode-harness 0.4.4

The optional native Supercode agent and tool harness
Documentation
//! §2 module 26 `model.catalog` (`docs/composable-harness/
//! COMPOSABLE-HARNESS-DESIGN.md` §3.1 `[capabilities.model_catalog]`) — P4
//! of the composable-harness migration (design §5.2 phase **P4**: "aliases +
//! fallback chains (userconfig.rs:386-411) promoted into core" + the
//! `small_model` knob).
//!
//! **What moved here.** The CLI's `alias_table`/`resolve_model_alias`
//! (`crates/cli/src/userconfig.rs`) were CLI-only (design §1.10: "CLI model
//! aliases ✓ … `resolve_model_alias`, userconfig.rs:386-411"). This module is
//! the single source of truth now — [`DEFAULT_ALIASES`] is the exact same
//! eleven built-in aliases, byte-identical, so moving them here changes no
//! resolved slug for any existing caller. The CLI crate re-exports through
//! `userconfig::alias_table`/`resolve_model_alias` (zero call-site churn,
//! zero behavior change — see that module).
//!
//! **What's NEW (P4).** [`resolve_alias`] additionally accepts an
//! `extra` table (`[capabilities.model_catalog].aliases`, §3.1/§3.2:
//! "`capabilities.model_catalog.*` | CLI `alias_table` … + NEW
//! small-model/fallback") so a user's own config can add or override an
//! alias without recompiling — `extra` is checked BEFORE
//! [`DEFAULT_ALIASES`], so a user override always wins. [`resolve_fallback_chain`]
//! resolves a `[capabilities.model_catalog].fallback` list of aliases/slugs
//! into a plain slug list the SAME way, for the D-9-adjacent "failure
//! fallback chain" knob (catalog §4a: "Model aliases + failure fallback
//! chain (resolution table before request build)").
//!
//! **Scope note (S-sized, per design §5.2 P4).** This module lands the
//! RESOLUTION TABLE only — `Config::small_model`/`Config::model_fallback`
//! are knobs a caller can read, not a retry/failover LOOP that automatically
//! re-sends a failed request against the next model in the chain. Building
//! that loop is a distinct, larger change (closer to §1.10's "mid-session
//! model switch," itself called out in design §5.2 as its own M-sized item,
//! separate from this S-sized catalog item) and is out of scope here.

/// The built-in alias → full-slug table (byte-identical to the CLI's
/// original `userconfig::alias_table`, moved here as the single source of
/// truth — see the module doc).
pub const DEFAULT_ALIASES: &[(&str, &str)] = &[
    ("opus", "anthropic/claude-opus-4-8"),
    ("sonnet", "anthropic/claude-sonnet-4-6"),
    ("haiku", "anthropic/claude-haiku-4-5"),
    ("gpt", "openai/gpt-5.5"),
    ("gpt-5.5", "openai/gpt-5.5"),
    ("gpt-5", "openai/gpt-5"),
    ("gemini", "google/gemini-2.5-pro"),
    ("flash", "deepseek/deepseek-v4-flash"),
    ("deepseek-flash", "deepseek/deepseek-v4-flash"),
    ("deepseek", "deepseek/deepseek-v4-pro"),
    ("llama", "meta-llama/llama-4-maverick"),
];

/// Expand a friendly model alias to its full slug, consulting `extra`
/// (config-provided aliases, §3.1 `capabilities.model_catalog.aliases`)
/// BEFORE [`DEFAULT_ALIASES`] — a config-provided alias may override a
/// built-in one (e.g. re-pointing `"opus"` at a different slug), but an
/// unknown value always passes through unchanged so any real slug still
/// works. `extra` is typically empty (no config wired it in), in which case
/// this is exactly the CLI's original `resolve_model_alias` behavior.
pub fn resolve_alias(model: &str, extra: &[(&str, &str)]) -> String {
    extra
        .iter()
        .find(|(alias, _)| *alias == model)
        .or_else(|| DEFAULT_ALIASES.iter().find(|(alias, _)| *alias == model))
        .map(|(_, slug)| (*slug).to_string())
        .unwrap_or_else(|| model.to_string())
}

/// Resolve a `[capabilities.model_catalog].fallback` list (each entry an
/// alias or an already-full slug) into full slugs, in order, via
/// [`resolve_alias`]. An empty `chain` resolves to an empty `Vec` — the
/// default, no-fallback-configured shape.
pub fn resolve_fallback_chain(chain: &[String], extra: &[(&str, &str)]) -> Vec<String> {
    chain.iter().map(|m| resolve_alias(m, extra)).collect()
}

/// Everything `[capabilities.model_catalog]` resolves into, alias-resolved:
/// the effective `core.model`, the `small_model` (if set), and the
/// `fallback` chain (if set). Shared by both resolution paths that carry a
/// `capabilities.<name>` table shaped like [`crate::configfile::CapabilityConfig`]
/// — the SDK's [`crate::configfile::HarnessConfig`] resolver
/// (`materialize_config`) and the CLI's own `FileConfig`-driven
/// `build_config` — so the alias/small-model/fallback resolution logic
/// lives in exactly one place.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Resolution {
    /// `base_model`, alias-resolved.
    pub model: String,
    /// `capabilities.model_catalog.small_model`, alias-resolved, if set to
    /// a non-empty string.
    pub small_model: Option<String>,
    /// `capabilities.model_catalog.fallback`, alias-resolved in order, if
    /// non-empty.
    pub fallback: Vec<String>,
}

/// Resolve `[capabilities.model_catalog]` against `base_model` (typically
/// the already-computed `core.model` / CLI `--model`/config value).
/// Consulted regardless of `capabilities.model_catalog.enabled` — matching
/// the resolver's existing D-9 check (`configfile::validate_modules`),
/// which already reads `model_catalog.small_model` unconditionally: these
/// are data a caller resolves against, not an activation switch.
pub fn resolve(
    capabilities: &std::collections::BTreeMap<String, crate::configfile::CapabilityConfig>,
    base_model: &str,
) -> Resolution {
    let extra = extra_aliases(capabilities);
    let extra_ref: Vec<(&str, &str)> = extra
        .iter()
        .map(|(a, b)| (a.as_str(), b.as_str()))
        .collect();

    let mut out = Resolution {
        model: if base_model.is_empty() {
            String::new()
        } else {
            resolve_alias(base_model, &extra_ref)
        },
        small_model: None,
        fallback: Vec::new(),
    };

    let Some(cap) = capabilities.get("model_catalog") else {
        return out;
    };
    if let Some(sm) = cap.settings.get("small_model").and_then(|v| v.as_str()) {
        if !sm.is_empty() {
            out.small_model = Some(resolve_alias(sm, &extra_ref));
        }
    }
    if let Some(fb) = cap.settings.get("fallback").and_then(|v| v.as_array()) {
        let chain: Vec<String> = fb
            .iter()
            .filter_map(|v| v.as_str().map(String::from))
            .collect();
        if !chain.is_empty() {
            out.fallback = resolve_fallback_chain(&chain, &extra_ref);
        }
    }
    out
}

/// `capabilities.model_catalog.aliases` — extra alias → slug overrides
/// layered over [`DEFAULT_ALIASES`] (judgment call: §3.1's schema dump
/// shows `small_model`/`fallback` under `[capabilities.model_catalog]` but
/// no `aliases` key; §3.2's mapping row — "`capabilities.model_catalog.*` |
/// CLI `alias_table` … + NEW small-model/fallback" — names the CLI alias
/// table as exactly this module's own precedent, so `aliases` here is the
/// natural, minimal extension point rather than a new top-level schema key).
fn extra_aliases(
    capabilities: &std::collections::BTreeMap<String, crate::configfile::CapabilityConfig>,
) -> Vec<(String, String)> {
    capabilities
        .get("model_catalog")
        .and_then(|cap| cap.settings.get("aliases"))
        .and_then(|v| v.as_object())
        .map(|o| {
            o.iter()
                .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
                .collect()
        })
        .unwrap_or_default()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn resolve_alias_with_no_extra_matches_every_built_in() {
        for (alias, slug) in DEFAULT_ALIASES {
            assert_eq!(resolve_alias(alias, &[]), *slug, "alias {alias}");
        }
        // A non-alias passes through unchanged.
        assert_eq!(resolve_alias("vendor/some-model", &[]), "vendor/some-model");
    }

    #[test]
    fn resolve_alias_extra_table_overrides_a_built_in() {
        let extra = [("opus", "vendor/my-custom-opus")];
        assert_eq!(resolve_alias("opus", &extra), "vendor/my-custom-opus");
        // Untouched aliases still resolve to the built-in.
        assert_eq!(
            resolve_alias("sonnet", &extra),
            "anthropic/claude-sonnet-4-6"
        );
    }

    #[test]
    fn resolve_alias_extra_table_adds_a_brand_new_alias() {
        let extra = [("fast", "vendor/fast-model")];
        assert_eq!(resolve_alias("fast", &extra), "vendor/fast-model");
        // A name that's neither built-in nor extra passes through unchanged.
        assert_eq!(resolve_alias("unknown-thing", &extra), "unknown-thing");
    }

    #[test]
    fn resolve_fallback_chain_resolves_each_entry_and_preserves_order() {
        let chain = vec!["haiku".to_string(), "gpt".to_string()];
        assert_eq!(
            resolve_fallback_chain(&chain, &[]),
            vec![
                "anthropic/claude-haiku-4-5".to_string(),
                "openai/gpt-5.5".to_string(),
            ]
        );
    }

    #[test]
    fn resolve_fallback_chain_empty_input_is_empty_output() {
        assert!(resolve_fallback_chain(&[], &[]).is_empty());
    }

    fn cap(settings: serde_json::Value) -> crate::configfile::CapabilityConfig {
        crate::configfile::CapabilityConfig {
            enabled: None,
            settings: settings.as_object().cloned().unwrap_or_default(),
        }
    }

    /// Default-off: no `[capabilities.model_catalog]` table at all resolves
    /// to the base model alias-resolved against the built-ins only, with no
    /// small_model/fallback — unchanged behavior for every config that
    /// doesn't set this table.
    #[test]
    fn resolve_with_no_model_catalog_table_is_alias_only() {
        let capabilities = std::collections::BTreeMap::new();
        let r = resolve(&capabilities, "opus");
        assert_eq!(r.model, "anthropic/claude-opus-4-8");
        assert_eq!(r.small_model, None);
        assert!(r.fallback.is_empty());
    }

    /// Happy path: small_model + fallback + a custom alias all resolve
    /// together, and `enabled` is irrelevant (matches the D-9 check's own
    /// precedent of reading `small_model` unconditionally).
    #[test]
    fn resolve_happy_path_reads_small_model_fallback_and_aliases_regardless_of_enabled() {
        let mut capabilities = std::collections::BTreeMap::new();
        capabilities.insert(
            "model_catalog".to_string(),
            cap(serde_json::json!({
                "small_model": "haiku",
                "fallback": ["sonnet", "vendor/already-a-slug"],
                "aliases": {"cheap": "vendor/cheap-model"},
            })),
        );
        let r = resolve(&capabilities, "cheap");
        assert_eq!(r.model, "vendor/cheap-model");
        assert_eq!(r.small_model.as_deref(), Some("anthropic/claude-haiku-4-5"));
        assert_eq!(
            r.fallback,
            vec![
                "anthropic/claude-sonnet-4-6".to_string(),
                "vendor/already-a-slug".to_string(),
            ]
        );
    }

    /// A resolved-slug `small_model` (already a full id, not an alias)
    /// passes through unchanged.
    #[test]
    fn resolve_small_model_already_a_slug_passes_through() {
        let mut capabilities = std::collections::BTreeMap::new();
        capabilities.insert(
            "model_catalog".to_string(),
            cap(serde_json::json!({"small_model": "anthropic/claude-haiku-4-5"})),
        );
        let r = resolve(&capabilities, "anthropic/claude-opus-4-8");
        assert_eq!(r.model, "anthropic/claude-opus-4-8");
        assert_eq!(r.small_model.as_deref(), Some("anthropic/claude-haiku-4-5"));
    }
}