HIGH-1: submodule URL ssh → https + shallow (DNS spoofing surface, both repos)
HIGH-3: docs/DNA-MIGRATION.md — two-format coexistence policy (4-seg legacy
task-class vs 5-seg agent-shell marketplace)
HIGH-5: agent_shell_dna doc — explicit consumer = marketplace, planned ledger
integration; module-doc clarification
MEDIUM: Haiku model id pinned to claude-haiku-4-5-20251001 across
pricing.rs::from_slug + ::name + escalate.rs tests + select_posterior
fixture + kei-registries submodule (pushed c39e528→7aaa6a7)
191 lines
7.1 KiB
Rust
191 lines
7.1 KiB
Rust
//! Retry-ladder bookkeeping for the router.
|
|
//!
|
|
//! Two surfaces:
|
|
//! - `next_model(current_model_id, provider_id, registry)` — registry-backed
|
|
//! escalation: returns the next non-deprecated model in the provider's
|
|
//! cost-output ascending order. Returns None if already at the most
|
|
//! expensive non-deprecated model.
|
|
//! - `next_after_failure(current, depth, failure)` — legacy Claude-only
|
|
//! ladder (kept for backward compatibility with `calibrate.rs`).
|
|
//!
|
|
//! Constructor Pattern: pure-fn cube, no I/O. Side effects (ledger write)
|
|
//! happen in callers.
|
|
|
|
use crate::pricing::Model;
|
|
use crate::registry::Registry;
|
|
|
|
pub const MAX_ESCALATION_DEPTH: u32 = 2;
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum EscalationDecision {
|
|
/// Retry on the next-tier model.
|
|
Retry { next: Model, depth: u32 },
|
|
/// No more tiers above OR depth ceiling reached.
|
|
Surrender,
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
// Registry-backed escalation
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
/// Result of a registry-backed escalation lookup.
|
|
/// Distinguishes "at top of ladder" from "model not found" (e.g. typo).
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum EscalationResult<'r> {
|
|
/// Caller should retry on this model id.
|
|
Next(&'r str),
|
|
/// `current_model_id` is the most expensive non-deprecated model.
|
|
AtTop,
|
|
/// `current_model_id` is not present in this provider's model list.
|
|
NotFound,
|
|
}
|
|
|
|
/// Given `current_model_id` within `provider_id`, return the next
|
|
/// more expensive non-deprecated model from the registry (sorted by
|
|
/// `cost_output_per_mtok_micro` ascending).
|
|
pub fn next_model<'r>(
|
|
current_model_id: &str,
|
|
provider_id: &str,
|
|
registry: &'r Registry,
|
|
) -> EscalationResult<'r> {
|
|
let sorted = registry.models_for_provider(provider_id);
|
|
let mut found_current = false;
|
|
for m in &sorted {
|
|
if found_current {
|
|
return EscalationResult::Next(&m.id);
|
|
}
|
|
if m.id == current_model_id {
|
|
found_current = true;
|
|
}
|
|
}
|
|
if found_current {
|
|
EscalationResult::AtTop
|
|
} else {
|
|
EscalationResult::NotFound
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
// Legacy ladder (Claude-only)
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
pub fn next_after_failure(
|
|
current: Model,
|
|
depth: u32,
|
|
outcome_is_failure: bool,
|
|
) -> EscalationDecision {
|
|
if !outcome_is_failure {
|
|
return EscalationDecision::Surrender;
|
|
}
|
|
if depth >= MAX_ESCALATION_DEPTH {
|
|
return EscalationDecision::Surrender;
|
|
}
|
|
match current.next_tier() {
|
|
Some(next) => EscalationDecision::Retry { next, depth: depth + 1 },
|
|
None => EscalationDecision::Surrender,
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
// Tests
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::path::PathBuf;
|
|
|
|
fn reg() -> Registry {
|
|
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
.parent().unwrap()
|
|
.parent().unwrap()
|
|
.parent().unwrap()
|
|
.join("_blocks/registries");
|
|
Registry::load_from(&dir).expect("registry load failed")
|
|
}
|
|
|
|
// ── next_model() tests ────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn haiku_escalates_to_sonnet_within_anthropic() {
|
|
let r = reg();
|
|
assert_eq!(next_model("claude-haiku-4-5-20251001", "anthropic", &r), EscalationResult::Next("claude-sonnet-4-6"));
|
|
}
|
|
|
|
#[test]
|
|
fn sonnet_escalates_to_opus_within_anthropic() {
|
|
let r = reg();
|
|
assert_eq!(next_model("claude-sonnet-4-6", "anthropic", &r), EscalationResult::Next("claude-opus-4-7"));
|
|
}
|
|
|
|
/// Finding 5: at-top must be `AtTop`, not `NotFound`.
|
|
#[test]
|
|
fn opus_at_top_returns_at_top() {
|
|
let r = reg();
|
|
assert_eq!(next_model("claude-opus-4-7", "anthropic", &r), EscalationResult::AtTop);
|
|
}
|
|
|
|
/// Finding 5: typo / unknown model must be `NotFound`, not `AtTop`.
|
|
#[test]
|
|
fn unknown_model_returns_not_found() {
|
|
let r = reg();
|
|
assert_eq!(next_model("does-not-exist", "anthropic", &r), EscalationResult::NotFound);
|
|
}
|
|
|
|
/// Finding 5: `Next` variant carries the correct model id.
|
|
#[test]
|
|
fn next_variant_carries_model_id() {
|
|
let r = reg();
|
|
assert!(matches!(next_model("claude-haiku-4-5-20251001", "anthropic", &r), EscalationResult::Next("claude-sonnet-4-6")));
|
|
}
|
|
|
|
#[test]
|
|
fn escalation_skips_deprecated_models() {
|
|
// All current Anthropic models have deprecated_at = "" so this
|
|
// verifies the escalation ladder works without deprecated entries.
|
|
let r = reg();
|
|
let ms = r.models_for_provider("anthropic");
|
|
for m in &ms {
|
|
assert!(!m.is_deprecated(), "{} is deprecated but should not be", m.id);
|
|
}
|
|
}
|
|
|
|
// ── legacy next_after_failure() tests ────────────────────────────────
|
|
|
|
#[test]
|
|
fn haiku_failure_escalates_to_sonnet() {
|
|
assert_eq!(
|
|
next_after_failure(Model::Haiku45, 0, true),
|
|
EscalationDecision::Retry { next: Model::Sonnet46, depth: 1 }
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sonnet_failure_escalates_to_opus() {
|
|
assert_eq!(
|
|
next_after_failure(Model::Sonnet46, 1, true),
|
|
EscalationDecision::Retry { next: Model::Opus47, depth: 2 }
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn opus_failure_surrenders() {
|
|
assert_eq!(next_after_failure(Model::Opus47, 1, true), EscalationDecision::Surrender);
|
|
}
|
|
|
|
#[test]
|
|
fn ceiling_reached_surrenders_even_below_top() {
|
|
assert_eq!(
|
|
next_after_failure(Model::Haiku45, MAX_ESCALATION_DEPTH, true),
|
|
EscalationDecision::Surrender
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn success_returns_surrender_defensively() {
|
|
assert_eq!(
|
|
next_after_failure(Model::Haiku45, 0, false),
|
|
EscalationDecision::Surrender
|
|
);
|
|
}
|
|
}
|