Skip to main content

warble_claude_code/
models.rs

1//! Tier → concrete model binding, resolved at **dispatch** (the runtime-injected mapping).
2//!
3//! Tiers travel in the IR as names (`strong`/`cheap`, or custom); which model each name becomes is
4//! decided here, not in the profile — so the same compiled IR can run against different models
5//! (exactly the axis the eval loop ablates). Authored either inline (`--strong/--cheap`) or as a
6//! deployment-scoped YAML config (`--models-config`, never committed with a profile).
7//!
8//! For the claude-code CLI target a tier maps to a **model alias** only; connection/auth are owned
9//! by the Claude Code runtime, not by the emitted files. The richer per-tier fields
10//! (`provider`/`endpoint`, see [`capability-model.md`][spec-cap] §7.2) are *parsed* here so the one
11//! `--models-config` format is shared across back-ends, but this file target only consumes the
12//! `model` — connection/provider selection for the headless run is the session's
13//! (`ANTHROPIC_BASE_URL` whole-session redirect), not per-step. Per-step provider routing is
14//! realized by the direct-driving Agent SDK back-end (`dispatcher/claude-agent-sdk`), which reads
15//! [`TierBinding::provider`]/`endpoint`.
16//!
17//! **This is one of two implementations of the single, versioned binding spec** documented in
18//! [`binding-spec.md`][spec-bind] (the authoritative source; the TS sibling is
19//! `dispatcher/claude-agent-sdk/src/models.ts`). [`BINDING_SPEC_VERSION`] must match the version
20//! declared in that doc and in the TS file — bump all three together.
21//!
22//! [spec-cap]: https://github.com/Canner/Warble/blob/v0.1.0/docs/spec/capability-model.md
23//! [spec-bind]: https://github.com/Canner/Warble/blob/v0.1.0/docs/spec/binding-spec.md
24
25use crate::error::DispatchError;
26use crate::ir::WarbleIr;
27use std::collections::BTreeSet;
28
29/// The binding spec version this module implements — see [`binding-spec.md`][spec-bind], the
30/// authoritative, versioned source both back-ends conform to (kept in lockstep to avoid the IR's
31/// own version-drift history).
32///
33/// [spec-bind]: https://github.com/Canner/Warble/blob/v0.1.0/docs/spec/binding-spec.md
34pub const BINDING_SPEC_VERSION: &str = "1.0";
35
36/// The standard-core authoring tiers — components declare these on steps to stay portable.
37const STRONG_TIER: &str = "strong";
38const CHEAP_TIER: &str = "cheap";
39
40/// Reserved core tier used by the per-step-tier split's driver/routing loop. It is a *dispatch
41/// role*, not an authoring tier — components never declare `tier: orchestrator` on a step — but it
42/// lives in the same `tiers` map so the config has a single concept.
43const ORCHESTRATOR_TIER: &str = "orchestrator";
44
45/// Well-known provider name: rides the Claude runtime (the default when `provider:` is absent).
46pub const ANTHROPIC_PROVIDER: &str = "anthropic";
47/// Well-known provider name: an OpenAI-compatible endpoint (e.g. ollama's `/v1`); requires `endpoint`.
48pub const OPENAI_COMPAT_PROVIDER: &str = "openai_compat";
49
50/// Which provider serves a tier's model — an **open string**, opaque to warble (mirrors how the IR
51/// treats `tier`; see [`binding-spec.md`][spec-bind]). Two well-known values get behavior baked
52/// into [`TierBinding`] parsing below ([`ANTHROPIC_PROVIDER`], the default;
53/// [`OPENAI_COMPAT_PROVIDER`], which requires `endpoint`), but warble does **not** validate this
54/// field against a fixed provider list — any other string is a valid, warble-unrecognized provider
55/// that passes through unchanged. Rejecting a genuinely unsupported provider is the consuming
56/// harness/back-end's job (its per-provider adapter registry), never warble's — warble stays
57/// opaque pass-through.
58///
59/// [spec-bind]: https://github.com/Canner/Warble/blob/v0.1.0/docs/spec/binding-spec.md
60pub type Provider = String;
61
62/// A tier's full runtime binding: which `provider` serves it, at what `endpoint` (for
63/// OpenAI-compatible providers), running which `model`. The shorthand YAML form `tier: <model>` is a
64/// `{ provider: anthropic, endpoint: none, model: <model> }` binding — so existing configs are
65/// unchanged, and the file target (which only reads `model`) behaves exactly as before.
66#[derive(Debug, Clone)]
67pub struct TierBinding {
68    pub provider: Provider,
69    pub endpoint: Option<String>,
70    pub model: String,
71}
72
73impl TierBinding {
74    /// An Anthropic-provider binding from a bare model alias (the shorthand / inline-flag form).
75    fn anthropic(model: String) -> Self {
76        TierBinding {
77            provider: ANTHROPIC_PROVIDER.to_string(),
78            endpoint: None,
79            model,
80        }
81    }
82}
83
84/// An ordered tier→binding map. Declaration order is priority: earlier tiers are "stronger" — used to
85/// pick the single model when a multi-tier component collapses to one agent. Alongside the authoring
86/// tiers (`strong`/`cheap`/custom, which components declare on steps) it carries the reserved
87/// `ORCHESTRATOR_TIER` used by the split driver.
88#[derive(Debug, Clone)]
89pub struct ModelConfig {
90    /// `(tier name, binding)` in declaration order (earliest = strongest).
91    tiers: Vec<(String, TierBinding)>,
92}
93
94impl Default for ModelConfig {
95    /// The Claude Code defaults: `strong→opus`, `cheap→haiku`, `orchestrator→sonnet` (all Anthropic).
96    fn default() -> Self {
97        ModelConfig {
98            tiers: vec![
99                (
100                    STRONG_TIER.to_string(),
101                    TierBinding::anthropic("opus".into()),
102                ),
103                (
104                    CHEAP_TIER.to_string(),
105                    TierBinding::anthropic("haiku".into()),
106                ),
107                (
108                    ORCHESTRATOR_TIER.to_string(),
109                    TierBinding::anthropic("sonnet".into()),
110                ),
111            ],
112        }
113    }
114}
115
116impl ModelConfig {
117    /// Build from the inline `--strong/--cheap/--orchestrator` flags (the standard-core convenience).
118    /// Inline flags are always Anthropic-provider aliases; provider/endpoint routing is `--models-config`
119    /// only (so a non-alias inline flag still loud-fails on the SDK split path, as before).
120    pub fn from_flags(strong: String, cheap: String, orchestrator: String) -> Self {
121        ModelConfig {
122            tiers: vec![
123                (STRONG_TIER.to_string(), TierBinding::anthropic(strong)),
124                (CHEAP_TIER.to_string(), TierBinding::anthropic(cheap)),
125                (
126                    ORCHESTRATOR_TIER.to_string(),
127                    TierBinding::anthropic(orchestrator),
128                ),
129            ],
130        }
131    }
132
133    /// Parse a `--models-config` YAML document. A tier value is EITHER a bare model-alias string
134    /// (Anthropic shorthand) OR a `{ provider, endpoint?, model }` map — the structured binding
135    /// documented in [`capability-model.md`][spec-cap] §7.2. Declaration order is priority;
136    /// `orchestrator` is a reserved tier the split driver uses:
137    ///
138    /// ```yaml
139    /// tiers:
140    ///   strong: opus                          # shorthand ⇒ provider: anthropic
141    ///   cheap:                                # structured binding (capability model §7.2)
142    ///     provider: openai_compat
143    ///     endpoint: http://localhost:11434/v1
144    ///     model: qwen2.5
145    ///   orchestrator: sonnet                  # reserved: the per-step-tier split driver
146    /// ```
147    ///
148    /// [spec-cap]: https://github.com/Canner/Warble/blob/v0.1.0/docs/spec/capability-model.md
149    pub fn from_yaml(text: &str) -> Result<Self, DispatchError> {
150        #[derive(serde::Deserialize)]
151        struct Raw {
152            #[serde(default)]
153            tiers: serde_yaml::Mapping,
154        }
155        let raw: Raw = serde_yaml::from_str(text)
156            .map_err(|e| DispatchError(format!("invalid models config: {e}")))?;
157
158        let mut tiers = Vec::new();
159        for (key, value) in raw.tiers {
160            let name = key
161                .as_str()
162                .ok_or_else(|| DispatchError::new("models config: tier name must be a string"))?
163                .to_string();
164            let binding = Self::parse_tier_value(&name, &value)?;
165            tiers.push((name, binding));
166        }
167        if tiers.is_empty() {
168            return Err(DispatchError::new(
169                "models config: `tiers` must not be empty",
170            ));
171        }
172        Ok(ModelConfig { tiers })
173    }
174
175    /// A tier value: a bare model string (Anthropic shorthand) or a `{provider, endpoint?, model}` map.
176    fn parse_tier_value(
177        name: &str,
178        value: &serde_yaml::Value,
179    ) -> Result<TierBinding, DispatchError> {
180        if let Some(model) = value.as_str() {
181            return Ok(TierBinding::anthropic(model.to_string()));
182        }
183        let map = value.as_mapping().ok_or_else(|| {
184            DispatchError(format!(
185                "models config: tier '{name}' must be a model-alias string or a \
186{{provider, endpoint?, model}} map"
187            ))
188        })?;
189        let get = |k: &str| map.get(serde_yaml::Value::from(k)).and_then(|v| v.as_str());
190        let model = get("model")
191            .ok_or_else(|| {
192                DispatchError(format!(
193                    "models config: tier '{name}' map is missing `model`"
194                ))
195            })?
196            .to_string();
197        // `provider` is an open string (opaque pass-through) — any value parses; only the two
198        // well-known names get special handling (default / endpoint requirement) below.
199        let provider = get("provider")
200            .map(str::to_string)
201            .unwrap_or_else(|| ANTHROPIC_PROVIDER.to_string());
202        let endpoint = get("endpoint").map(str::to_string);
203        if provider == OPENAI_COMPAT_PROVIDER && endpoint.is_none() {
204            return Err(DispatchError(format!(
205                "models config: tier '{name}' uses provider openai_compat but has no `endpoint`"
206            )));
207        }
208        Ok(TierBinding {
209            provider,
210            endpoint,
211            model,
212        })
213    }
214
215    /// The model for the reserved `orchestrator` tier (the split driver), or a loud-fail if a
216    /// `--models-config` omitted it while a component needs a per-step-tier split.
217    pub fn orchestrator(&self) -> Result<&str, DispatchError> {
218        self.require(ORCHESTRATOR_TIER)
219    }
220
221    fn binding_for(&self, tier: &str) -> Option<&TierBinding> {
222        self.tiers
223            .iter()
224            .find(|(name, _)| name == tier)
225            .map(|(_, binding)| binding)
226    }
227
228    fn model_for(&self, tier: &str) -> Option<&str> {
229        self.binding_for(tier).map(|b| b.model.as_str())
230    }
231
232    fn rank(&self, tier: &str) -> usize {
233        self.tiers
234            .iter()
235            .position(|(name, _)| name == tier)
236            .unwrap_or(usize::MAX)
237    }
238
239    fn tier_names(&self) -> String {
240        self.tiers
241            .iter()
242            .map(|(name, _)| name.as_str())
243            .collect::<Vec<_>>()
244            .join(", ")
245    }
246
247    /// The model a tier maps to, or a loud-fail naming the undefined tier.
248    pub fn require(&self, tier: &str) -> Result<&str, DispatchError> {
249        self.model_for(tier).ok_or_else(|| {
250            DispatchError(format!(
251                "tier '{tier}' has no model binding — define it in --models-config or via \
252--strong/--cheap (known tiers: {})",
253                self.tier_names()
254            ))
255        })
256    }
257
258    /// The full `{provider, endpoint, model}` binding a tier maps to (see
259    /// [`capability-model.md`][spec-cap] §7.2), or a loud-fail. Direct-driving back-ends read this
260    /// to route a step cloud-vs-local; the file target uses only [`ModelConfig::require`] (the
261    /// model) because its provider is the session's, not per-step.
262    ///
263    /// [spec-cap]: https://github.com/Canner/Warble/blob/v0.1.0/docs/spec/capability-model.md
264    pub fn binding(&self, tier: &str) -> Result<&TierBinding, DispatchError> {
265        self.binding_for(tier).ok_or_else(|| {
266            DispatchError(format!(
267                "tier '{tier}' has no model binding — define it in --models-config or via \
268--strong/--cheap (known tiers: {})",
269                self.tier_names()
270            ))
271        })
272    }
273
274    /// The model for a single collapsed agent: the strongest (lowest-rank) tier among the calls.
275    pub fn collapsed_model<'a>(
276        &'a self,
277        calls: &[crate::ir::LlmCall],
278    ) -> Result<&'a str, DispatchError> {
279        let strongest = calls
280            .iter()
281            .min_by_key(|c| self.rank(&c.tier))
282            .ok_or_else(|| {
283                DispatchError::new("component has no llm_calls; cannot select a model")
284            })?;
285        self.require(&strongest.tier)
286    }
287
288    /// Validate every step tier in the IR maps to a model (front-loaded so emission is infallible).
289    pub fn validate(&self, ir: &WarbleIr) -> Result<(), DispatchError> {
290        let mut checked = BTreeSet::new();
291        for node in &ir.components {
292            for call in &node.llm_calls {
293                if checked.insert(call.tier.as_str()) {
294                    self.require(&call.tier)?;
295                }
296            }
297        }
298        Ok(())
299    }
300}