1use serde::{Deserialize, Serialize};
8use tokenmiser_providers::{response_visible_content_empty, ChatResponse};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct CascadeConfig {
12 pub min_avg_logprob: f32,
15 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
28pub fn should_escalate(resp: &ChatResponse, cfg: &CascadeConfig) -> EscalateDecision {
30 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 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 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 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 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}