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}