Skip to main content

tokenmiser_router/
tier2.rs

1//! Speculative cascade: a runtime mode, not a classifier. The cheap model
2//! always runs first and only low confidence escalates to the frontier.
3//!
4//! Confidence comes from mean token logprobs where the provider supplies them
5//! (not Anthropic), falling back to a response-length heuristic.
6
7use serde::{Deserialize, Serialize};
8use tokenmiser_providers::{response_visible_content_empty, ChatResponse};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct CascadeConfig {
12    /// Minimum mean log-probability across generated tokens; more negative
13    /// escalates less often.
14    pub min_avg_logprob: f32,
15    /// Responses shorter than this escalate under the length fallback.
16    pub min_completion_tokens: u32,
17}
18
19impl Default for CascadeConfig {
20    fn default() -> Self {
21        Self {
22            min_avg_logprob: -1.5,
23            min_completion_tokens: 5,
24        }
25    }
26}
27
28/// Decide whether the cheap response suffices or the frontier is needed.
29pub fn should_escalate(resp: &ChatResponse, cfg: &CascadeConfig) -> EscalateDecision {
30    // Empty visible content always escalates: reasoning-mode models emit
31    // high-confidence logprobs over thinking tokens while leaving
32    // `message.content` blank, so the logprob signal is meaningless there.
33    if response_visible_content_empty(resp) {
34        return EscalateDecision::Yes {
35            reason: "empty visible content (reasoning-mode model)".into(),
36            signal: Signal::Length(0),
37        };
38    }
39
40    // OpenAI-shaped `choices[0].logprobs.content`.
41    if let Some(avg) = avg_logprob(resp) {
42        if avg < cfg.min_avg_logprob {
43            return EscalateDecision::Yes {
44                reason: format!(
45                    "avg_logprob {:.3} < threshold {:.3}",
46                    avg, cfg.min_avg_logprob
47                ),
48                signal: Signal::Logprob(avg),
49            };
50        }
51        return EscalateDecision::No {
52            signal: Signal::Logprob(avg),
53        };
54    }
55
56    // Length heuristic fallback.
57    let completion_tokens = resp.usage.completion_tokens as u32;
58    let finish = resp
59        .choices
60        .first()
61        .and_then(|c| c.finish_reason.as_deref())
62        .unwrap_or("");
63
64    if completion_tokens < cfg.min_completion_tokens {
65        return EscalateDecision::Yes {
66            reason: format!(
67                "completion_tokens {} < min {}",
68                completion_tokens, cfg.min_completion_tokens
69            ),
70            signal: Signal::Length(completion_tokens),
71        };
72    }
73    if finish == "length" {
74        // Hit the token cap before stopping naturally.
75        return EscalateDecision::Yes {
76            reason: "finish_reason=length".into(),
77            signal: Signal::Length(completion_tokens),
78        };
79    }
80
81    EscalateDecision::No {
82        signal: Signal::Length(completion_tokens),
83    }
84}
85
86fn avg_logprob(resp: &ChatResponse) -> Option<f32> {
87    let choice = resp.choices.first()?;
88    let logprobs = choice.logprobs.as_ref()?;
89    let content = logprobs.get("content")?.as_array()?;
90    if content.is_empty() {
91        return None;
92    }
93    let mut sum = 0.0_f64;
94    let mut n = 0usize;
95    for tok in content {
96        if let Some(lp) = tok.get("logprob").and_then(|v| v.as_f64()) {
97            sum += lp;
98            n += 1;
99        }
100    }
101    if n == 0 {
102        return None;
103    }
104    Some((sum / n as f64) as f32)
105}
106
107#[derive(Debug, Clone)]
108pub enum EscalateDecision {
109    Yes { reason: String, signal: Signal },
110    No { signal: Signal },
111}
112
113#[derive(Debug, Clone, Copy)]
114pub enum Signal {
115    Logprob(f32),
116    Length(u32),
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use serde_json::json;
123    use tokenmiser_providers::{ChatChoice, ChatMessage, Usage};
124
125    fn resp_with_logprobs(logprobs: Vec<f64>, completion_tokens: u64) -> ChatResponse {
126        let content = logprobs
127            .into_iter()
128            .map(|lp| json!({ "logprob": lp }))
129            .collect::<Vec<_>>();
130        ChatResponse {
131            id: "t".into(),
132            object: "chat.completion".into(),
133            created: 0,
134            model: "m".into(),
135            choices: vec![ChatChoice {
136                index: 0,
137                message: ChatMessage {
138                    role: "assistant".into(),
139                    content: serde_json::Value::String("hi".into()),
140                    extra: Default::default(),
141                },
142                finish_reason: Some("stop".into()),
143                logprobs: Some(json!({"content": content})),
144            }],
145            usage: Usage {
146                prompt_tokens: 10,
147                completion_tokens,
148                total_tokens: 10 + completion_tokens,
149            },
150            extra: Default::default(),
151        }
152    }
153
154    #[test]
155    fn high_confidence_does_not_escalate() {
156        let r = resp_with_logprobs(vec![-0.1, -0.2, -0.05], 3);
157        match should_escalate(&r, &CascadeConfig::default()) {
158            EscalateDecision::No { .. } => {}
159            d => panic!("expected No, got {:?}", d),
160        }
161    }
162
163    #[test]
164    fn low_confidence_escalates() {
165        let r = resp_with_logprobs(vec![-3.0, -4.0, -2.5], 3);
166        match should_escalate(&r, &CascadeConfig::default()) {
167            EscalateDecision::Yes { .. } => {}
168            d => panic!("expected Yes, got {:?}", d),
169        }
170    }
171
172    #[test]
173    fn empty_content_always_escalates_even_with_high_logprobs() {
174        // Reasoning mode: high-confidence logprobs over thinking tokens with
175        // empty message.content.
176        let mut r = resp_with_logprobs(vec![-0.1, -0.05, -0.08], 5);
177        r.choices[0].message.content = serde_json::Value::String("".into());
178        match should_escalate(&r, &CascadeConfig::default()) {
179            EscalateDecision::Yes { reason, .. } => {
180                assert!(
181                    reason.contains("empty"),
182                    "expected empty-content reason, got: {reason}"
183                );
184            }
185            d => panic!("expected escalate on empty content, got {:?}", d),
186        }
187    }
188
189    #[test]
190    fn short_response_falls_back_to_length_and_escalates() {
191        let r = ChatResponse {
192            id: "t".into(),
193            object: "chat.completion".into(),
194            created: 0,
195            model: "m".into(),
196            choices: vec![ChatChoice {
197                index: 0,
198                message: ChatMessage {
199                    role: "assistant".into(),
200                    content: serde_json::Value::String("ok".into()),
201                    extra: Default::default(),
202                },
203                finish_reason: Some("stop".into()),
204                logprobs: None,
205            }],
206            usage: Usage {
207                prompt_tokens: 10,
208                completion_tokens: 1,
209                total_tokens: 11,
210            },
211            extra: Default::default(),
212        };
213        match should_escalate(&r, &CascadeConfig::default()) {
214            EscalateDecision::Yes { .. } => {}
215            d => panic!("expected Yes via length, got {:?}", d),
216        }
217    }
218}