1use serde_json::{json, Map, Value};
7
8use crate::routing::effort::Effort;
9use crate::routing::request::{ChatRequest, Message};
10use crate::routing::table::{escalation_order, ChainLeg, JevRoute, Tier};
11
12pub const STATE_BUDGET: usize = 24_000;
14pub const LATEST_CAP: usize = 16_000;
16pub const SYSTEM_CAP: usize = 2_000;
18pub const HISTORY_ENTRY_CAP: usize = 2_000;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
23pub enum RoutingMode {
24 Jev,
26 #[default]
28 Static,
29 StaticOverride,
31}
32
33impl RoutingMode {
34 pub fn as_str(self) -> &'static str {
35 match self {
36 Self::Jev => "jev",
37 Self::Static => "static",
38 Self::StaticOverride => "static-override",
39 }
40 }
41}
42
43pub fn resolve_mode(is_jev_route: bool, requested: Option<&str>) -> Result<RoutingMode, String> {
46 match (is_jev_route, requested) {
47 (true, None | Some("jev")) => Ok(RoutingMode::Jev),
48 (true, Some("static")) => Ok(RoutingMode::StaticOverride),
49 (false, None | Some("static")) => Ok(RoutingMode::Static),
50 (false, Some("jev")) => Err("routing_strategy \"jev\" requires a route with tiers".into()),
51 (_, Some(other)) => Err(format!(
52 "unknown routing_strategy '{other}' (expected \"static\" or \"jev\")"
53 )),
54 }
55}
56
57pub fn build_state(req: &ChatRequest) -> Value {
60 let latest = req.messages.iter().rposition(|m| m.role == "user");
61 let latest_text = latest
62 .map(|i| head_tail(&message_text(&req.messages[i].content), LATEST_CAP))
63 .unwrap_or_default();
64 let system = req
65 .messages
66 .iter()
67 .find(|m| m.role == "system")
68 .map(|m| truncate(&message_text(&m.content), SYSTEM_CAP))
69 .unwrap_or_default();
70 let remaining =
71 STATE_BUDGET.saturating_sub(latest_text.chars().count() + system.chars().count());
72 json!({
73 "latest_user_message": latest_text,
74 "recent_history": recent_history(&req.messages, latest, remaining),
75 "system_prompt": system,
76 "has_tools": req.tools.as_ref().is_some_and(|t| !t.is_empty()),
77 "has_images": has_images(req),
78 })
79}
80
81fn recent_history(messages: &[Message], latest: Option<usize>, budget: usize) -> Vec<Value> {
84 messages
85 .iter()
86 .enumerate()
87 .rev()
88 .filter(|(i, m)| m.role != "system" && Some(*i) != latest)
89 .map(|(_, m)| {
90 (
91 m.role.as_str(),
92 truncate(&message_text(&m.content), HISTORY_ENTRY_CAP),
93 )
94 })
95 .filter(|(_, text)| !text.is_empty())
96 .scan(0usize, |used, (role, text)| {
97 *used += text.chars().count();
98 (*used <= budget).then_some((role, text))
99 })
100 .collect::<Vec<_>>()
101 .into_iter()
102 .rev()
103 .map(|(role, content)| json!({ "role": role, "content": content }))
104 .collect()
105}
106
107fn message_text(content: &Value) -> String {
108 match content {
109 Value::String(s) => s.clone(),
110 Value::Array(parts) => parts
111 .iter()
112 .map(part_text)
113 .filter(|t| !t.is_empty())
114 .collect::<Vec<_>>()
115 .join("\n"),
116 Value::Null => String::new(),
117 other => other.to_string(),
118 }
119}
120
121fn part_text(part: &Value) -> String {
122 match part.get("type").and_then(Value::as_str) {
123 Some("text") => part
124 .get("text")
125 .and_then(Value::as_str)
126 .unwrap_or_default()
127 .to_string(),
128 Some("image_url" | "image") => "[image]".into(),
129 Some("input_audio" | "audio") => "[audio]".into(),
130 Some("file") => "[file]".into(),
131 _ => String::new(),
132 }
133}
134
135fn is_image_part(part: &Value) -> bool {
136 matches!(
137 part.get("type").and_then(Value::as_str),
138 Some("image_url" | "image")
139 )
140}
141
142fn has_images(req: &ChatRequest) -> bool {
143 req.messages.iter().any(|m| {
144 m.content
145 .as_array()
146 .is_some_and(|parts| parts.iter().any(is_image_part))
147 }) || req
148 .vertex
149 .as_ref()
150 .and_then(|v| v.media_uris.as_ref())
151 .is_some_and(|uris| !uris.is_empty())
152}
153
154fn truncate(s: &str, cap: usize) -> String {
155 s.chars().take(cap).collect()
156}
157
158fn head_tail(s: &str, cap: usize) -> String {
161 let n = s.chars().count();
162 match n <= cap {
163 true => s.to_string(),
164 false => format!(
165 "{}\nā¦\n{}",
166 s.chars().take(cap / 2).collect::<String>(),
167 s.chars().skip(n - cap / 2).collect::<String>()
168 ),
169 }
170}
171
172const DIFFICULTY_INSTRUCTIONS: &str = "How demanding is it to produce a high-quality reply to \
173`latest_user_message`, given `recent_history` and `system_prompt`?";
174const REASONING_INSTRUCTIONS: &str = "Does replying well to `latest_user_message` require careful \
175step-by-step reasoning such as maths, logic, planning, or debugging?";
176
177pub fn build_questions(tiers: &[Tier]) -> Map<String, Value> {
180 Map::from_iter([
181 (
182 "difficulty".to_string(),
183 json!({
184 "type": "score",
185 "instructions": DIFFICULTY_INSTRUCTIONS,
186 "criteria": tiers.iter().map(|t| t.description.as_str()).collect::<Vec<_>>(),
187 }),
188 ),
189 (
190 "needs_reasoning".to_string(),
191 json!({
192 "type": "noul",
193 "instructions": REASONING_INSTRUCTIONS,
194 }),
195 ),
196 ])
197}
198
199#[derive(Debug, Clone, Copy, PartialEq)]
201pub struct Answers {
202 pub difficulty: f64,
204 pub confidence: f64,
205 pub needs_reasoning: Option<f64>,
206}
207
208pub fn parse_answers(answers: &Value) -> Option<Answers> {
211 answers
212 .get("difficulty")
213 .filter(|d| d.get("type").and_then(Value::as_str) == Some("score"))
214 .and_then(|d| {
215 Some(Answers {
216 difficulty: d.get("score")?.as_f64()?,
217 confidence: d.get("confidence")?.as_f64()?,
218 needs_reasoning: answers
219 .get("needs_reasoning")
220 .and_then(|n| n.get("noul"))
221 .and_then(Value::as_f64),
222 })
223 })
224}
225
226#[derive(Debug, Clone, Copy, PartialEq, Eq)]
228pub enum DecisionOutcome {
229 Decided,
230 LowConfidence,
231 Timeout,
232 Error,
234 Unavailable,
236 StaticOverride,
237}
238
239impl DecisionOutcome {
240 pub fn metric_label(self) -> &'static str {
242 match self {
243 Self::Decided => "decided",
244 Self::LowConfidence => "low_confidence",
245 Self::Timeout => "timeout",
246 Self::Error | Self::Unavailable => "error",
247 Self::StaticOverride => "static_override",
248 }
249 }
250
251 pub fn degraded_reason(self) -> Option<&'static str> {
253 match self {
254 Self::Decided | Self::StaticOverride => None,
255 Self::LowConfidence => Some("low_confidence"),
256 Self::Timeout => Some("timeout"),
257 Self::Error => Some("error"),
258 Self::Unavailable => Some("jev_unavailable"),
259 }
260 }
261}
262
263#[derive(Debug, Clone, Copy, PartialEq, Eq)]
265pub struct Selection {
266 pub tier: usize,
267 pub outcome: DecisionOutcome,
268 pub bump: bool,
269}
270
271pub fn score_to_tier(score: f64, tiers: usize) -> usize {
274 (score + 0.5)
275 .floor()
276 .clamp(0.0, tiers.saturating_sub(1) as f64) as usize
277}
278
279pub fn select(result: Result<Answers, DecisionOutcome>, route: &JevRoute) -> Selection {
281 let bump = |a: &Answers| {
282 a.needs_reasoning
283 .is_some_and(|p| p >= route.router.reasoning_threshold)
284 };
285 match result {
286 Ok(a) if a.confidence < route.router.min_confidence => Selection {
287 tier: route.default_index(),
288 outcome: DecisionOutcome::LowConfidence,
289 bump: bump(&a),
290 },
291 Ok(a) => Selection {
292 tier: score_to_tier(a.difficulty, route.tiers.len()),
293 outcome: DecisionOutcome::Decided,
294 bump: bump(&a),
295 },
296 Err(outcome) => Selection {
297 tier: route.default_index(),
298 outcome,
299 bump: false,
300 },
301 }
302}
303
304#[derive(Debug, Clone, Copy, PartialEq, Eq)]
306pub enum EffortPolicy {
307 Tier { bump: bool },
309 Client,
311}
312
313#[derive(Debug, Clone, PartialEq, Eq)]
315pub struct PlannedLeg {
316 pub leg: ChainLeg,
317 pub tier: usize,
318}
319
320pub fn order_legs(tiers: &[Tier], start: usize, policy: EffortPolicy) -> Vec<PlannedLeg> {
323 escalation_order(tiers.len(), start)
324 .into_iter()
325 .flat_map(|i| {
326 let effort = match policy {
327 EffortPolicy::Client => None,
328 EffortPolicy::Tier { bump: true } => Some(tiers[i].effort.bump()),
329 EffortPolicy::Tier { bump: false } => Some(tiers[i].effort),
330 };
331 tiers[i].legs.iter().map(move |l| PlannedLeg {
332 leg: ChainLeg {
333 effort,
334 ..l.clone()
335 },
336 tier: i,
337 })
338 })
339 .collect()
340}
341
342pub fn eligible_tiers(tiers: &[Tier], vertex_only: bool) -> Vec<Tier> {
346 tiers
347 .iter()
348 .map(|t| Tier {
349 legs: t
350 .legs
351 .iter()
352 .filter(|l| !vertex_only || l.provider == "vertex")
353 .cloned()
354 .collect(),
355 ..t.clone()
356 })
357 .collect()
358}
359
360pub fn nearest_serving(tiers: &[Tier], wanted: usize) -> Option<usize> {
363 let serving = |i: &usize| !tiers[*i].legs.is_empty();
364 (wanted..tiers.len())
365 .find(serving)
366 .or_else(|| (0..wanted.min(tiers.len())).rev().find(serving))
367}
368
369#[derive(Debug, Clone, PartialEq)]
371pub struct RoutePlan {
372 pub mode: RoutingMode,
373 pub legs: Vec<PlannedLeg>,
374 pub tier_names: Vec<String>,
376 pub decided: Option<usize>,
378 pub outcome: Option<DecisionOutcome>,
379 pub client_effort: bool,
381}
382
383impl RoutePlan {
384 pub fn static_legs(legs: &[ChainLeg], client_effort: bool) -> Self {
388 Self {
389 mode: RoutingMode::Static,
390 legs: legs
391 .iter()
392 .map(|l| PlannedLeg {
393 leg: l.clone(),
394 tier: 0,
395 })
396 .collect(),
397 tier_names: Vec::new(),
398 decided: None,
399 outcome: None,
400 client_effort: client_effort && legs.iter().any(|l| l.effort.is_some()),
401 }
402 }
403
404 pub fn chain(&self) -> Vec<ChainLeg> {
406 self.legs.iter().map(|p| p.leg.clone()).collect()
407 }
408
409 pub fn planned_effort(&self) -> Option<&'static str> {
412 match self.client_effort {
413 true => Some("client"),
414 false => self
415 .legs
416 .first()
417 .and_then(|p| p.leg.effort)
418 .map(Effort::as_str),
419 }
420 }
421
422 pub fn report_for(&self, served: Option<(&str, &str)>) -> RoutingReport {
426 let served = served.and_then(|(provider, model)| {
427 self.legs
428 .iter()
429 .find(|p| p.leg.provider == provider && p.leg.model == model)
430 });
431 let tier = served.and_then(|p| self.tier_names.get(p.tier)).cloned();
432 let decided = self.decided.and_then(|d| self.tier_names.get(d)).cloned();
433 RoutingReport {
434 mode: self.mode,
435 tier_decided: decided.filter(|d| tier.as_ref().is_some_and(|t| t != d)),
436 effort: match self.client_effort {
437 true => Some("client".to_string()),
438 false => served
439 .and_then(|p| p.leg.effort)
440 .map(|e| e.as_str().to_string()),
441 },
442 degraded: self.outcome.and_then(DecisionOutcome::degraded_reason),
443 tier,
444 }
445 }
446}
447
448#[derive(Debug, Clone, Default, PartialEq, Eq)]
450pub struct RoutingReport {
451 pub mode: RoutingMode,
452 pub tier: Option<String>,
454 pub tier_decided: Option<String>,
456 pub effort: Option<String>,
458 pub degraded: Option<&'static str>,
459}
460
461impl RoutingReport {
462 pub fn headers(&self) -> Vec<(&'static str, String)> {
464 std::iter::once(("x-synapse-routing", self.mode.as_str().to_string()))
465 .chain(self.tier.clone().map(|t| ("x-synapse-tier", t)))
466 .chain(
467 self.tier_decided
468 .clone()
469 .map(|t| ("x-synapse-tier-decided", t)),
470 )
471 .chain(
472 self.effort
473 .clone()
474 .map(|e| ("x-synapse-reasoning-effort", e)),
475 )
476 .chain(
477 self.degraded
478 .map(|d| ("x-synapse-routing-degraded", d.to_string())),
479 )
480 .collect()
481 }
482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487 use crate::routing::request::ChatRequest;
488 use serde_json::json;
489
490 fn req(body: serde_json::Value) -> ChatRequest {
491 serde_json::from_value(body).unwrap()
492 }
493
494 #[test]
495 fn resolve_mode_follows_route_unless_overridden() {
496 assert_eq!(resolve_mode(true, None), Ok(RoutingMode::Jev));
497 assert_eq!(resolve_mode(true, Some("jev")), Ok(RoutingMode::Jev));
498 assert_eq!(
499 resolve_mode(true, Some("static")),
500 Ok(RoutingMode::StaticOverride)
501 );
502 assert_eq!(resolve_mode(false, None), Ok(RoutingMode::Static));
503 assert_eq!(resolve_mode(false, Some("static")), Ok(RoutingMode::Static));
504 assert!(resolve_mode(false, Some("jev"))
505 .unwrap_err()
506 .contains("requires a route with tiers"));
507 assert!(resolve_mode(true, Some("fastest"))
508 .unwrap_err()
509 .contains("unknown routing_strategy 'fastest'"));
510 }
511
512 #[test]
513 fn routing_mode_labels() {
514 assert_eq!(RoutingMode::Jev.as_str(), "jev");
515 assert_eq!(RoutingMode::Static.as_str(), "static");
516 assert_eq!(RoutingMode::StaticOverride.as_str(), "static-override");
517 assert_eq!(RoutingMode::default(), RoutingMode::Static);
518 }
519
520 #[test]
521 fn state_carries_latest_user_message_system_prompt_and_flags() {
522 let s = build_state(&req(json!({
523 "model": "auto",
524 "messages": [
525 {"role": "system", "content": "You are terse."},
526 {"role": "user", "content": "first"},
527 {"role": "assistant", "content": "answer"},
528 {"role": "user", "content": [
529 {"type": "text", "text": "what is in"},
530 {"type": "image_url", "image_url": {"url": "https://x/y.png"}}
531 ]}
532 ],
533 "tools": [{"type": "function", "function": {"name": "f"}}]
534 })));
535 assert_eq!(s["latest_user_message"], "what is in\n[image]");
536 assert_eq!(s["system_prompt"], "You are terse.");
537 assert_eq!(s["has_tools"], true);
538 assert_eq!(s["has_images"], true);
539 assert_eq!(
540 s["recent_history"],
541 json!([
542 {"role": "user", "content": "first"},
543 {"role": "assistant", "content": "answer"}
544 ])
545 );
546 }
547
548 #[test]
549 fn non_text_parts_become_placeholders_and_system_prompt_is_capped() {
550 let s = build_state(&req(json!({
551 "model": "auto",
552 "messages": [
553 {"role": "system", "content": "s".repeat(SYSTEM_CAP + 500)},
554 {"role": "user", "content": [
555 {"type": "input_audio", "input_audio": {"data": "..."}},
556 {"type": "file", "file": {"file_id": "f"}},
557 {"type": "unknown"}
558 ]}
559 ],
560 "tools": []
561 })));
562 assert_eq!(s["latest_user_message"], "[audio]\n[file]");
563 assert_eq!(
564 s["system_prompt"].as_str().unwrap().chars().count(),
565 SYSTEM_CAP
566 );
567 assert_eq!(s["has_tools"], false);
568 assert_eq!(s["has_images"], false);
569 }
570
571 #[test]
572 fn long_latest_message_keeps_head_and_tail() {
573 let long = format!("{}{}", "a".repeat(10_000), "z".repeat(10_000));
574 let s = build_state(&req(json!({
575 "model": "auto",
576 "messages": [{"role": "user", "content": long}]
577 })));
578 let latest = s["latest_user_message"].as_str().unwrap();
579 assert!(latest.starts_with(&"a".repeat(8_000)));
580 assert!(latest.ends_with(&"z".repeat(8_000)));
581 assert!(latest.contains("\nā¦\n"));
582 assert_eq!(latest.chars().count(), LATEST_CAP + "\nā¦\n".chars().count());
583 }
584
585 #[test]
586 fn history_fills_the_budget_newest_first_in_chronological_order() {
587 let messages: Vec<serde_json::Value> = (0..30)
588 .map(|i| {
589 json!({
590 "role": if i % 2 == 0 { "user" } else { "assistant" },
591 "content": format!("{i}:{}", "x".repeat(1_990))
592 })
593 })
594 .chain(std::iter::once(
595 json!({"role": "user", "content": "latest"}),
596 ))
597 .collect();
598 let s = build_state(&req(json!({"model": "auto", "messages": messages})));
599 let history = s["recent_history"].as_array().unwrap();
600 let total: usize = history
601 .iter()
602 .map(|h| h["content"].as_str().unwrap().chars().count())
603 .sum();
604 assert!(total <= STATE_BUDGET, "history {total} exceeds budget");
605 assert!(history.len() < 30, "older messages were dropped");
606 assert!(history.last().unwrap()["content"]
607 .as_str()
608 .unwrap()
609 .starts_with("29:"));
610 let first_idx: usize = history[0]["content"]
611 .as_str()
612 .unwrap()
613 .split(':')
614 .next()
615 .unwrap()
616 .parse()
617 .unwrap();
618 assert_eq!(first_idx, 30 - history.len());
619 }
620
621 #[test]
622 fn vertex_media_uris_count_as_images_and_no_user_message_is_empty_string() {
623 let s = build_state(&req(json!({
624 "model": "auto",
625 "messages": [{"role": "system", "content": "sys"}],
626 "vertex": {"media_uris": ["gs://b/v.mp4"]}
627 })));
628 assert_eq!(s["has_images"], true);
629 assert_eq!(s["latest_user_message"], "");
630 assert_eq!(s["has_tools"], false);
631 }
632
633 use crate::routing::table::RouteTable;
634
635 fn route() -> JevRoute {
636 RouteTable::from_toml_str(
637 r#"
638 [routes."auto"]
639 strategy = "jev"
640 [routes."auto".jev_router]
641 default_tier = "moderate"
642 [[routes."auto".tiers]]
643 name = "trivial"
644 description = "Greetings"
645 effort = "none"
646 legs = [{ provider = "qwen", model = "qwen-flash" }]
647 [[routes."auto".tiers]]
648 name = "moderate"
649 description = "Everyday questions"
650 effort = "low"
651 legs = [{ provider = "vertex", model = "gemini-2.5-flash" }]
652 [[routes."auto".tiers]]
653 name = "hard"
654 description = "Multi-step analysis"
655 effort = "medium"
656 legs = [{ provider = "vertex", model = "gemini-2.5-pro" }]
657 [[routes."auto".tiers]]
658 name = "expert"
659 description = "Proofs and deep debugging"
660 effort = "max"
661 legs = [{ provider = "vertex", model = "gemini-3.1-pro-preview" }]
662 "#,
663 )
664 .unwrap()
665 .jev_route("auto")
666 .unwrap()
667 .clone()
668 }
669
670 fn answers(difficulty: f64, confidence: f64, needs_reasoning: Option<f64>) -> Answers {
671 Answers {
672 difficulty,
673 confidence,
674 needs_reasoning,
675 }
676 }
677
678 #[test]
679 fn questions_use_tier_descriptions_in_order() {
680 let q = build_questions(&route().tiers);
681 assert_eq!(q["difficulty"]["type"], "score");
682 assert_eq!(
683 q["difficulty"]["criteria"],
684 json!([
685 "Greetings",
686 "Everyday questions",
687 "Multi-step analysis",
688 "Proofs and deep debugging"
689 ])
690 );
691 assert_eq!(q["needs_reasoning"]["type"], "noul");
692 assert!(q["difficulty"]["instructions"]
693 .as_str()
694 .unwrap()
695 .contains("`latest_user_message`"));
696 assert_eq!(q.len(), 2);
697 }
698
699 #[test]
700 fn questions_match_the_spec_contract_exactly() {
701 assert_eq!(
702 Value::Object(build_questions(&route().tiers)),
703 json!({
704 "difficulty": {
705 "type": "score",
706 "instructions": "How demanding is it to produce a high-quality reply to \
707 `latest_user_message`, given `recent_history` and `system_prompt`?",
708 "criteria": [
709 "Greetings",
710 "Everyday questions",
711 "Multi-step analysis",
712 "Proofs and deep debugging"
713 ]
714 },
715 "needs_reasoning": {
716 "type": "noul",
717 "instructions": "Does replying well to `latest_user_message` require \
718 careful step-by-step reasoning such as maths, logic, planning, or debugging?"
719 }
720 })
721 );
722 }
723
724 #[test]
725 fn truncation_counts_characters_not_bytes() {
726 let crabs = "š¦".repeat(LATEST_CAP + 101);
727 let separator = "\nā¦\n";
728 let kept = head_tail(&crabs, LATEST_CAP);
729 assert_eq!(kept.chars().filter(|c| *c == 'š¦').count(), LATEST_CAP);
730 assert_eq!(kept.chars().count(), LATEST_CAP + separator.chars().count());
731 assert_eq!(kept.replacen(separator, "", 1), "š¦".repeat(LATEST_CAP));
732 let cut = truncate(&crabs, SYSTEM_CAP);
733 assert_eq!(cut, "š¦".repeat(SYSTEM_CAP));
734 }
735
736 #[test]
737 fn history_entries_are_capped_individually() {
738 let s = build_state(&req(json!({
739 "model": "auto",
740 "messages": [
741 {"role": "user", "content": "q".repeat(HISTORY_ENTRY_CAP + 700)},
742 {"role": "assistant", "content": "short"},
743 {"role": "user", "content": "latest"}
744 ]
745 })));
746 assert_eq!(
747 s["recent_history"][0]["content"]
748 .as_str()
749 .unwrap()
750 .chars()
751 .count(),
752 HISTORY_ENTRY_CAP
753 );
754 assert_eq!(s["recent_history"][1]["content"], "short");
755 }
756
757 #[test]
758 fn history_skips_entries_without_text() {
759 let s = build_state(&req(json!({
760 "model": "auto",
761 "messages": [
762 {"role": "user", "content": "weather in Lisbon?"},
763 {"role": "assistant", "content": null, "tool_calls": [
764 {"id": "c1", "type": "function",
765 "function": {"name": "weather", "arguments": "{}"}}
766 ]},
767 {"role": "tool", "tool_call_id": "c1", "content": "sunny"},
768 {"role": "assistant", "content": ""},
769 {"role": "assistant", "content": [{"type": "unknown"}]},
770 {"role": "user", "content": "and tomorrow?"}
771 ]
772 })));
773 assert_eq!(
774 s["recent_history"],
775 json!([
776 {"role": "user", "content": "weather in Lisbon?"},
777 {"role": "tool", "content": "sunny"}
778 ])
779 );
780 }
781
782 #[test]
783 fn parses_score_and_noul_answers() {
784 let a = parse_answers(&json!({
785 "difficulty": {"type": "score", "score": 1.15, "confidence": 0.77, "probabilities": {}},
786 "needs_reasoning": {"type": "noul", "noul": 0.82}
787 }))
788 .unwrap();
789 assert_eq!(a, answers(1.15, 0.77, Some(0.82)));
790 assert_eq!(
791 parse_answers(
792 &json!({"difficulty": {"type": "score", "score": 2.0, "confidence": 1.0}})
793 ),
794 Some(answers(2.0, 1.0, None))
795 );
796 assert_eq!(
797 parse_answers(&json!({"difficulty": {"type": "noul", "noul": 0.5}})),
798 None
799 );
800 assert_eq!(
801 parse_answers(&json!({"difficulty": {"type": "score", "score": 2.0}})),
802 None
803 );
804 assert_eq!(parse_answers(&json!({})), None);
805 }
806
807 #[test]
808 fn score_rounds_half_up_and_clamps() {
809 assert_eq!(score_to_tier(0.0, 4), 0);
810 assert_eq!(score_to_tier(0.49, 4), 0);
811 assert_eq!(score_to_tier(0.5, 4), 1);
812 assert_eq!(score_to_tier(2.3, 4), 2);
813 assert_eq!(score_to_tier(9.0, 4), 3);
814 assert_eq!(score_to_tier(-1.0, 4), 0);
815 assert_eq!(score_to_tier(f64::NAN, 4), 0);
816 }
817
818 #[test]
819 fn score_maps_onto_one_and_ten_tiers() {
820 [0.0, 0.5, 0.99, 7.0, -3.0]
821 .into_iter()
822 .for_each(|s| assert_eq!(score_to_tier(s, 1), 0, "score {s}"));
823 assert_eq!(score_to_tier(0.49, 10), 0);
824 assert_eq!(score_to_tier(4.5, 10), 5);
825 assert_eq!(score_to_tier(8.49, 10), 8);
826 assert_eq!(score_to_tier(9.0, 10), 9);
827 assert_eq!(score_to_tier(12.0, 10), 9);
828 }
829
830 #[test]
831 fn confident_answer_picks_scored_tier_and_bumps_on_reasoning() {
832 let r = route();
833 assert_eq!(
834 select(Ok(answers(2.3, 0.9, Some(0.8))), &r),
835 Selection {
836 tier: 2,
837 outcome: DecisionOutcome::Decided,
838 bump: true
839 }
840 );
841 assert_eq!(
842 select(Ok(answers(2.3, 0.9, Some(0.69))), &r),
843 Selection {
844 tier: 2,
845 outcome: DecisionOutcome::Decided,
846 bump: false
847 }
848 );
849 assert_eq!(
850 select(Ok(answers(2.3, 0.9, None)), &r),
851 Selection {
852 tier: 2,
853 outcome: DecisionOutcome::Decided,
854 bump: false
855 }
856 );
857 }
858
859 #[test]
860 fn thresholds_are_inclusive() {
861 assert_eq!(
862 select(Ok(answers(0.2, 0.5, Some(0.7))), &route()),
863 Selection {
864 tier: 0,
865 outcome: DecisionOutcome::Decided,
866 bump: true
867 }
868 );
869 }
870
871 #[test]
872 fn low_confidence_uses_default_tier() {
873 assert_eq!(
874 select(Ok(answers(3.0, 0.49, Some(0.9))), &route()),
875 Selection {
876 tier: 1,
877 outcome: DecisionOutcome::LowConfidence,
878 bump: true
879 }
880 );
881 }
882
883 #[test]
884 fn failures_use_default_tier_without_bump() {
885 [
886 DecisionOutcome::Timeout,
887 DecisionOutcome::Error,
888 DecisionOutcome::Unavailable,
889 ]
890 .into_iter()
891 .for_each(|o| {
892 assert_eq!(
893 select(Err(o), &route()),
894 Selection {
895 tier: 1,
896 outcome: o,
897 bump: false
898 }
899 )
900 });
901 }
902
903 #[test]
904 fn outcome_labels_and_degraded_reasons() {
905 use DecisionOutcome::*;
906 assert_eq!(Decided.metric_label(), "decided");
907 assert_eq!(LowConfidence.metric_label(), "low_confidence");
908 assert_eq!(Timeout.metric_label(), "timeout");
909 assert_eq!(Error.metric_label(), "error");
910 assert_eq!(Unavailable.metric_label(), "error");
911 assert_eq!(StaticOverride.metric_label(), "static_override");
912 assert_eq!(Decided.degraded_reason(), None);
913 assert_eq!(StaticOverride.degraded_reason(), None);
914 assert_eq!(Timeout.degraded_reason(), Some("timeout"));
915 assert_eq!(LowConfidence.degraded_reason(), Some("low_confidence"));
916 assert_eq!(Unavailable.degraded_reason(), Some("jev_unavailable"));
917 assert_eq!(Error.degraded_reason(), Some("error"));
918 }
919
920 use crate::routing::effort::Effort;
921
922 fn models(plan: &[PlannedLeg]) -> Vec<(&str, usize, Option<Effort>)> {
923 plan.iter()
924 .map(|p| (p.leg.model.as_str(), p.tier, p.leg.effort))
925 .collect()
926 }
927
928 #[test]
929 fn order_escalates_then_descends_with_tier_effort() {
930 let r = route();
931 assert_eq!(
932 models(&order_legs(&r.tiers, 1, EffortPolicy::Tier { bump: false })),
933 vec![
934 ("gemini-2.5-flash", 1, Some(Effort::Low)),
935 ("gemini-2.5-pro", 2, Some(Effort::Medium)),
936 ("gemini-3.1-pro-preview", 3, Some(Effort::Max)),
937 ("qwen-flash", 0, Some(Effort::None)),
938 ]
939 );
940 }
941
942 #[test]
943 fn order_from_last_tier_only_descends_and_bump_saturates() {
944 let r = route();
945 assert_eq!(
946 models(&order_legs(&r.tiers, 3, EffortPolicy::Tier { bump: true })),
947 vec![
948 ("gemini-3.1-pro-preview", 3, Some(Effort::Max)),
949 ("gemini-2.5-pro", 2, Some(Effort::High)),
950 ("gemini-2.5-flash", 1, Some(Effort::Medium)),
951 ("qwen-flash", 0, Some(Effort::Minimal)),
952 ]
953 );
954 }
955
956 #[test]
957 fn client_policy_stamps_no_effort() {
958 assert!(order_legs(&route().tiers, 0, EffortPolicy::Client)
959 .iter()
960 .all(|p| p.leg.effort.is_none()));
961 }
962
963 #[test]
964 fn vertex_only_eligibility_empties_other_tiers_and_keeps_indices() {
965 let tiers = eligible_tiers(&route().tiers, true);
966 assert_eq!(tiers.len(), 4);
967 assert!(tiers[0].legs.is_empty());
968 assert_eq!(nearest_serving(&tiers, 0), Some(1));
969 assert_eq!(nearest_serving(&tiers, 2), Some(2));
970 assert_eq!(
971 models(&order_legs(&tiers, 1, EffortPolicy::Tier { bump: false }))
972 .iter()
973 .map(|(m, _, _)| *m)
974 .collect::<Vec<_>>(),
975 vec![
976 "gemini-2.5-flash",
977 "gemini-2.5-pro",
978 "gemini-3.1-pro-preview"
979 ]
980 );
981 assert_eq!(eligible_tiers(&route().tiers, false), route().tiers);
982 }
983
984 #[test]
985 fn nearest_serving_prefers_harder_then_easier_and_none_when_empty() {
986 let mut tiers = route().tiers;
987 tiers[2].legs.clear();
988 tiers[3].legs.clear();
989 assert_eq!(nearest_serving(&tiers, 2), Some(1));
990 tiers.iter_mut().for_each(|t| t.legs.clear());
991 assert_eq!(nearest_serving(&tiers, 1), None);
992 }
993
994 fn tiered_plan(outcome: DecisionOutcome, client_effort: bool) -> RoutePlan {
995 let r = route();
996 let policy = match client_effort {
997 true => EffortPolicy::Client,
998 false => EffortPolicy::Tier { bump: false },
999 };
1000 RoutePlan {
1001 mode: RoutingMode::Jev,
1002 legs: order_legs(&r.tiers, 2, policy),
1003 tier_names: r.tiers.iter().map(|t| t.name.clone()).collect(),
1004 decided: Some(2),
1005 outcome: Some(outcome),
1006 client_effort,
1007 }
1008 }
1009
1010 #[test]
1011 fn report_names_served_tier_and_effort() {
1012 let report = tiered_plan(DecisionOutcome::Decided, false)
1013 .report_for(Some(("vertex", "gemini-2.5-pro")));
1014 assert_eq!(
1015 report,
1016 RoutingReport {
1017 mode: RoutingMode::Jev,
1018 tier: Some("hard".into()),
1019 tier_decided: None,
1020 effort: Some("medium".into()),
1021 degraded: None,
1022 }
1023 );
1024 assert_eq!(
1025 report.headers(),
1026 vec![
1027 ("x-synapse-routing", "jev".to_string()),
1028 ("x-synapse-tier", "hard".to_string()),
1029 ("x-synapse-reasoning-effort", "medium".to_string()),
1030 ]
1031 );
1032 }
1033
1034 #[test]
1035 fn report_flags_fallback_tier_client_effort_and_degradation() {
1036 let report = tiered_plan(DecisionOutcome::Timeout, true)
1037 .report_for(Some(("vertex", "gemini-3.1-pro-preview")));
1038 assert_eq!(report.tier.as_deref(), Some("expert"));
1039 assert_eq!(report.tier_decided.as_deref(), Some("hard"));
1040 assert_eq!(report.effort.as_deref(), Some("client"));
1041 assert_eq!(report.degraded, Some("timeout"));
1042 assert!(report
1043 .headers()
1044 .contains(&("x-synapse-routing-degraded", "timeout".to_string())));
1045 }
1046
1047 #[test]
1048 fn planned_effort_is_the_first_legs_effort_or_client() {
1049 assert_eq!(
1050 tiered_plan(DecisionOutcome::Decided, false).planned_effort(),
1051 Some("medium")
1052 );
1053 let bumped = RoutePlan {
1054 legs: order_legs(&route().tiers, 2, EffortPolicy::Tier { bump: true }),
1055 ..tiered_plan(DecisionOutcome::Decided, false)
1056 };
1057 assert_eq!(bumped.planned_effort(), Some("high"));
1058 assert_eq!(
1059 tiered_plan(DecisionOutcome::Decided, true).planned_effort(),
1060 Some("client")
1061 );
1062 assert_eq!(RoutePlan::static_legs(&[], false).planned_effort(), None);
1063 }
1064
1065 #[test]
1066 fn unserved_tiered_plan_reports_no_tier_or_effort() {
1067 let report = tiered_plan(DecisionOutcome::Decided, false).report_for(None);
1068 assert_eq!(
1069 report.headers(),
1070 vec![("x-synapse-routing", "jev".to_string())]
1071 );
1072 }
1073
1074 #[test]
1075 fn static_override_reports_tier_and_effort_without_degradation() {
1076 let plan = RoutePlan {
1077 mode: RoutingMode::StaticOverride,
1078 outcome: Some(DecisionOutcome::StaticOverride),
1079 ..tiered_plan(DecisionOutcome::StaticOverride, false)
1080 };
1081 assert_eq!(
1082 plan.report_for(Some(("vertex", "gemini-2.5-pro")))
1083 .headers(),
1084 vec![
1085 ("x-synapse-routing", "static-override".to_string()),
1086 ("x-synapse-tier", "hard".to_string()),
1087 ("x-synapse-reasoning-effort", "medium".to_string()),
1088 ]
1089 );
1090 }
1091
1092 #[test]
1093 fn static_plan_reports_only_the_mode_and_keeps_legs() {
1094 let legs = vec![ChainLeg {
1095 provider: "qwen".into(),
1096 model: "qwen-max".into(),
1097 ..Default::default()
1098 }];
1099 let plan = RoutePlan::static_legs(&legs, false);
1100 assert_eq!(plan.chain(), legs);
1101 assert_eq!(
1102 plan.report_for(Some(("qwen", "qwen-max"))).headers(),
1103 vec![("x-synapse-routing", "static".to_string())]
1104 );
1105 assert_eq!(plan.report_for(None).tier, None);
1106 let client = RoutePlan::static_legs(&legs, true);
1107 assert!(!client.client_effort);
1108 assert_eq!(client.report_for(Some(("qwen", "qwen-max"))).effort, None);
1109 }
1110
1111 #[test]
1112 fn downgraded_jev_route_reports_the_served_legs_effort_or_client() {
1113 let legs = route().static_legs();
1114 let plan = RoutePlan::static_legs(&legs, false);
1115 assert_eq!(
1116 plan.report_for(Some(("vertex", "gemini-2.5-pro")))
1117 .headers(),
1118 vec![
1119 ("x-synapse-routing", "static".to_string()),
1120 ("x-synapse-reasoning-effort", "medium".to_string()),
1121 ]
1122 );
1123 assert_eq!(plan.planned_effort(), Some("low"));
1124 let client = RoutePlan::static_legs(&legs, true);
1125 assert_eq!(
1126 client
1127 .report_for(Some(("vertex", "gemini-2.5-pro")))
1128 .effort
1129 .as_deref(),
1130 Some("client")
1131 );
1132 assert_eq!(client.planned_effort(), Some("client"));
1133 }
1134}