1use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum ModelTier {
15 Speculator,
16 Verifier,
17 Actuator,
18 Architect,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum AgentPhase {
25 Explore,
26 Plan,
27 Implement,
28 Verify,
29 Repair,
30 Review,
31 Research,
32}
33
34impl AgentPhase {
35 pub fn default_tier(self) -> ModelTier {
37 match self {
38 AgentPhase::Explore | AgentPhase::Research => ModelTier::Speculator,
39 AgentPhase::Verify | AgentPhase::Review => ModelTier::Verifier,
40 AgentPhase::Implement | AgentPhase::Repair => ModelTier::Actuator,
41 AgentPhase::Plan => ModelTier::Architect,
42 }
43 }
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
48pub struct ModelBudget {
49 pub max_tokens: u64,
50 pub max_calls: u32,
51 pub max_wall_clock_secs: u64,
52}
53
54impl Default for ModelBudget {
55 fn default() -> Self {
56 Self {
57 max_tokens: 100_000,
58 max_calls: 50,
59 max_wall_clock_secs: 600,
60 }
61 }
62}
63
64#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
66pub struct ModelTierConfig {
67 pub speculator_model: String,
68 pub verifier_model: String,
69 pub actuator_model: String,
70 pub architect_model: String,
71 pub explorer_model: Option<String>,
73 pub explorer_tier: Option<ModelTier>,
75}
76
77impl ModelTierConfig {
78 pub fn uniform(model: impl Into<String>) -> Self {
80 let m = model.into();
81 Self {
82 speculator_model: m.clone(),
83 verifier_model: m.clone(),
84 actuator_model: m.clone(),
85 architect_model: m,
86 explorer_model: None,
87 explorer_tier: None,
88 }
89 }
90
91 pub fn model_for_tier(&self, tier: ModelTier) -> &str {
92 match tier {
93 ModelTier::Speculator => &self.speculator_model,
94 ModelTier::Verifier => &self.verifier_model,
95 ModelTier::Actuator => &self.actuator_model,
96 ModelTier::Architect => &self.architect_model,
97 }
98 }
99}
100
101#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
103pub struct ModelRoute {
104 pub phase: AgentPhase,
105 pub requested_tier: Option<ModelTier>,
106 pub resolved_tier: ModelTier,
107 pub model: String,
108 pub fallback_model: Option<String>,
109 pub budget: ModelBudget,
110 pub reason: String,
111}
112
113pub fn resolve_route(
121 phase: AgentPhase,
122 config: &ModelTierConfig,
123 requested_tier: Option<ModelTier>,
124 budget: ModelBudget,
125) -> ModelRoute {
126 if let Some(tier) = requested_tier {
128 return ModelRoute {
129 phase,
130 requested_tier,
131 resolved_tier: tier,
132 model: config.model_for_tier(tier).to_string(),
133 fallback_model: Some(config.speculator_model.clone()),
134 budget,
135 reason: "explicit tier override".into(),
136 };
137 }
138
139 if phase == AgentPhase::Explore {
141 if let Some(model) = &config.explorer_model {
142 let tier = config.explorer_tier.unwrap_or(ModelTier::Speculator);
143 return ModelRoute {
144 phase,
145 requested_tier: None,
146 resolved_tier: tier,
147 model: model.clone(),
148 fallback_model: Some(config.speculator_model.clone()),
149 budget,
150 reason: "explorer_model override".into(),
151 };
152 }
153 if let Some(tier) = config.explorer_tier {
154 return ModelRoute {
155 phase,
156 requested_tier: None,
157 resolved_tier: tier,
158 model: config.model_for_tier(tier).to_string(),
159 fallback_model: Some(config.speculator_model.clone()),
160 budget,
161 reason: "explorer_tier override".into(),
162 };
163 }
164 }
165
166 let tier = phase.default_tier();
167 ModelRoute {
168 phase,
169 requested_tier: None,
170 resolved_tier: tier,
171 model: config.model_for_tier(tier).to_string(),
172 fallback_model: Some(config.speculator_model.clone()),
173 budget,
174 reason: "phase default tier".into(),
175 }
176}
177
178use crate::model::{ModelFamily, ModelId, ProviderCapabilities, ProviderCapabilityMask};
183
184#[derive(Debug, Clone, Default, PartialEq)]
186pub struct RouteObjective {
187 pub require: ProviderCapabilityMask,
189 pub decorrelate_from: Option<ModelId>,
192}
193
194#[derive(Debug, Clone, Default, PartialEq)]
196pub struct PortfolioModels {
197 pub architect: Option<ModelId>,
198 pub actuator: Option<ModelId>,
199 pub verifier: Option<ModelId>,
200 pub speculator: Option<ModelId>,
201 pub adjudicator: Option<ModelId>,
202}
203
204impl PortfolioModels {
205 fn for_tier(&self, tier: ModelTier) -> Option<&ModelId> {
206 match tier {
207 ModelTier::Speculator => self.speculator.as_ref(),
208 ModelTier::Verifier => self.verifier.as_ref(),
209 ModelTier::Actuator => self.actuator.as_ref(),
210 ModelTier::Architect => self.architect.as_ref(),
211 }
212 }
213
214 fn all(&self) -> Vec<&ModelId> {
215 [
216 self.speculator.as_ref(),
217 self.verifier.as_ref(),
218 self.actuator.as_ref(),
219 self.architect.as_ref(),
220 ]
221 .into_iter()
222 .flatten()
223 .collect()
224 }
225}
226
227#[derive(Debug, Clone, PartialEq)]
234pub struct PortfolioRoute {
235 pub phase: AgentPhase,
236 pub resolved_tier: ModelTier,
237 pub model: ModelId,
238 pub fallback: Vec<ModelId>,
240 pub budget: ModelBudget,
241 pub reason: String,
242}
243
244pub fn resolve_portfolio_route(
252 phase: AgentPhase,
253 models: &PortfolioModels,
254 objective: &RouteObjective,
255 budget: ModelBudget,
256 caps_of: &dyn Fn(&ModelId) -> ProviderCapabilities,
257 family_of: &dyn Fn(&ModelId) -> ModelFamily,
258) -> Option<PortfolioRoute> {
259 let tier = phase.default_tier();
260 let capable = |m: &&ModelId| caps_of(m).satisfies(&objective.require);
261
262 let primary: &ModelId = match phase {
263 AgentPhase::Verify | AgentPhase::Review => {
264 let preferred = objective.decorrelate_from.as_ref().map(family_of);
266 let candidates: Vec<&ModelId> = models.all().into_iter().filter(capable).collect();
267 let decorrelated = preferred.as_ref().and_then(|from| {
268 candidates
269 .iter()
270 .find(|m| family_of(m).is_distinct_from(from))
271 .copied()
272 });
273 decorrelated
274 .or_else(|| models.for_tier(tier).filter(|m| capable(m)))
275 .or_else(|| candidates.first().copied())?
276 }
277 _ => models
278 .for_tier(tier)
279 .filter(|m| capable(m))
280 .or_else(|| models.all().into_iter().find(capable))?,
281 };
282
283 let mut fallback: Vec<ModelId> = models
285 .all()
286 .into_iter()
287 .filter(capable)
288 .filter(|m| *m != primary)
289 .cloned()
290 .collect();
291 fallback.sort_by_key(|m| (m.provider == primary.provider, m.to_string()));
292 fallback.dedup();
293
294 Some(PortfolioRoute {
295 phase,
296 resolved_tier: tier,
297 model: primary.clone(),
298 fallback,
299 budget,
300 reason: format!("{phase:?} default tier {tier:?} under objective"),
301 })
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307
308 fn config() -> ModelTierConfig {
309 ModelTierConfig {
310 speculator_model: "spec-1".into(),
311 verifier_model: "verif-1".into(),
312 actuator_model: "act-1".into(),
313 architect_model: "arch-1".into(),
314 explorer_model: None,
315 explorer_tier: None,
316 }
317 }
318
319 #[test]
320 fn explore_defaults_to_speculator() {
321 let route = resolve_route(AgentPhase::Explore, &config(), None, ModelBudget::default());
322 assert_eq!(route.resolved_tier, ModelTier::Speculator);
323 assert_eq!(route.model, "spec-1");
324 }
325
326 #[test]
327 fn explorer_model_wins_over_speculator() {
328 let mut config = config();
329 config.explorer_model = Some("cheap-explorer".into());
330 let route = resolve_route(AgentPhase::Explore, &config, None, ModelBudget::default());
331 assert_eq!(route.model, "cheap-explorer");
332 assert_eq!(route.reason, "explorer_model override");
333 }
334
335 #[test]
336 fn explorer_tier_reuses_existing_tier_model() {
337 let mut config = config();
338 config.explorer_tier = Some(ModelTier::Verifier);
339 let route = resolve_route(AgentPhase::Explore, &config, None, ModelBudget::default());
340 assert_eq!(route.resolved_tier, ModelTier::Verifier);
341 assert_eq!(route.model, "verif-1");
342 }
343
344 #[test]
345 fn plan_routes_to_architect() {
346 let route = resolve_route(AgentPhase::Plan, &config(), None, ModelBudget::default());
347 assert_eq!(route.resolved_tier, ModelTier::Architect);
348 assert_eq!(route.model, "arch-1");
349 }
350
351 #[test]
352 fn explicit_tier_override_beats_phase_default() {
353 let route = resolve_route(
354 AgentPhase::Implement,
355 &config(),
356 Some(ModelTier::Speculator),
357 ModelBudget::default(),
358 );
359 assert_eq!(route.resolved_tier, ModelTier::Speculator);
360 }
361
362 #[test]
363 fn uniform_sets_all_tiers() {
364 let config = ModelTierConfig::uniform("one-model");
365 for phase in [
366 AgentPhase::Explore,
367 AgentPhase::Plan,
368 AgentPhase::Implement,
369 AgentPhase::Verify,
370 ] {
371 let route = resolve_route(phase, &config, None, ModelBudget::default());
372 assert_eq!(route.model, "one-model");
373 }
374 }
375
376 fn qualified(models: &[(&str, &str)]) -> PortfolioModels {
377 let id = |i: usize| ModelId::new(models[i].0, models[i].1);
378 PortfolioModels {
379 architect: Some(id(0)),
380 actuator: Some(id(1)),
381 verifier: Some(id(2)),
382 speculator: Some(id(3)),
383 adjudicator: None,
384 }
385 }
386
387 fn full_caps(_m: &ModelId) -> crate::model::ProviderCapabilities {
388 crate::model::ProviderCapabilities {
389 tool_calling: true,
390 strict_schema: false,
391 parallel_tool_calls: true,
392 streaming_tool_calls: true,
393 prompt_caching: false,
394 structured_output: true,
395 max_context_tokens: 200_000,
396 }
397 }
398
399 fn by_name(m: &ModelId) -> crate::model::ModelFamily {
400 crate::model::ModelFamily::from_model_name(&m.model)
401 }
402
403 #[test]
404 fn review_prefers_a_decorrelated_family_when_the_portfolio_permits() {
405 let models = qualified(&[
406 ("anthropic", "claude-opus-5"),
407 ("openai", "gpt-5.5"),
408 ("anthropic", "claude-sonnet-5"),
409 ("local", "qwen2.5-coder"),
410 ]);
411 let objective = RouteObjective {
412 decorrelate_from: Some(ModelId::new("openai", "gpt-5.5")),
413 ..RouteObjective::default()
414 };
415 let route = resolve_portfolio_route(
416 AgentPhase::Review,
417 &models,
418 &objective,
419 ModelBudget::default(),
420 &full_caps,
421 &by_name,
422 )
423 .unwrap();
424 assert!(
425 by_name(&route.model).is_distinct_from(&crate::model::ModelFamily::OpenAiGpt),
426 "review route {} shares the actuator family",
427 route.model
428 );
429 }
430
431 #[test]
432 fn failover_chain_prefers_a_different_provider_first() {
433 let models = qualified(&[
434 ("anthropic", "claude-opus-5"),
435 ("openai", "gpt-5.5"),
436 ("anthropic", "claude-sonnet-5"),
437 ("local", "qwen2.5-coder"),
438 ]);
439 let route = resolve_portfolio_route(
440 AgentPhase::Implement,
441 &models,
442 &RouteObjective::default(),
443 ModelBudget::default(),
444 &full_caps,
445 &by_name,
446 )
447 .unwrap();
448 assert_eq!(route.model, ModelId::new("openai", "gpt-5.5"));
449 let first = route.fallback.first().expect("has fallback");
450 assert_ne!(
451 first.provider, route.model.provider,
452 "failover crosses providers first"
453 );
454 }
455
456 #[test]
457 fn a_capability_requirement_excludes_incapable_routes() {
458 let models = qualified(&[
459 ("anthropic", "claude-opus-5"),
460 ("openai", "gpt-5.5"),
461 ("anthropic", "claude-sonnet-5"),
462 ("local", "tiny-text-model"),
463 ]);
464 let text_only = |m: &ModelId| {
465 if m.provider == "local" {
466 crate::model::ProviderCapabilities::text_only(8_192)
467 } else {
468 full_caps(m)
469 }
470 };
471 let objective = RouteObjective {
472 require: crate::model::ProviderCapabilityMask::tool_loop(),
473 ..RouteObjective::default()
474 };
475 let route = resolve_portfolio_route(
478 AgentPhase::Explore,
479 &models,
480 &objective,
481 ModelBudget::default(),
482 &text_only,
483 &by_name,
484 )
485 .unwrap();
486 assert_ne!(route.model.provider, "local");
487 }
488}