Skip to main content

synapse/routing/
jev_router.rs

1//! Jev router: pure decision logic for `strategy = "jev"` routes. Jev rates
2//! each request's difficulty against the route's tiers; this module turns the
3//! answers into an ordered, effort-stamped leg plan and a client-facing report.
4//! Spec: `docs/superpowers/specs/2026-09-28-jev-router-design.md`.
5
6use 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
12/// Character budget for the whole Jev `state` (ā‰ˆ 6k tokens).
13pub const STATE_BUDGET: usize = 24_000;
14/// Cap on `latest_user_message`; longer messages keep their head and tail.
15pub const LATEST_CAP: usize = 16_000;
16/// Cap on `system_prompt`.
17pub const SYSTEM_CAP: usize = 2_000;
18/// Cap on each `recent_history` entry.
19pub const HISTORY_ENTRY_CAP: usize = 2_000;
20
21/// How a request's legs were planned.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
23pub enum RoutingMode {
24    /// Jev chose the tier.
25    Jev,
26    /// A plain `legs` route.
27    #[default]
28    Static,
29    /// A `jev` route the client asked to serve without Jev.
30    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
43/// The mode for one request: the route's strategy unless `routing_strategy`
44/// overrides it. `Err` carries the 400 message.
45pub 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
57/// The bounded `state` Jev rates: the latest user message, recent history,
58/// system prompt, and tool/image flags. Never logged (customer data).
59pub 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
81/// Non-system messages with text, other than the latest user message, filled
82/// newest-first until `budget` characters, returned in chronological order.
83fn 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
158/// `s` unchanged when within `cap` characters; otherwise its first and last
159/// `cap / 2` characters joined by an ellipsis line.
160fn 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
177/// The two questions every decision asks. `difficulty`'s levels are the tier
178/// descriptions, so its score indexes `tiers`.
179pub 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/// The parts of a Jev response the router acts on.
200#[derive(Debug, Clone, Copy, PartialEq)]
201pub struct Answers {
202    /// Probability-weighted tier index.
203    pub difficulty: f64,
204    pub confidence: f64,
205    pub needs_reasoning: Option<f64>,
206}
207
208/// `None` when `difficulty` is missing, not a score answer, or lacks its
209/// score or confidence.
210pub 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/// How a decision was reached.
227#[derive(Debug, Clone, Copy, PartialEq, Eq)]
228pub enum DecisionOutcome {
229    Decided,
230    LowConfidence,
231    Timeout,
232    /// Transport error, non-2xx, or an unparseable answer.
233    Error,
234    /// No Jev provider configured on this gateway.
235    Unavailable,
236    StaticOverride,
237}
238
239impl DecisionOutcome {
240    /// `outcome` label of `synapse_routing_decisions_total`.
241    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    /// `x-synapse-routing-degraded` value when the decision fell back.
252    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/// The tier a decision picked (before eligibility), and whether effort bumps.
264#[derive(Debug, Clone, Copy, PartialEq, Eq)]
265pub struct Selection {
266    pub tier: usize,
267    pub outcome: DecisionOutcome,
268    pub bump: bool,
269}
270
271/// Nearest tier to a probability-weighted score (half rounds up), clamped.
272/// `NaN` maps to tier 0.
273pub 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
279/// Turn a decision result into a tier and effort bump.
280pub 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/// How planned legs get their reasoning effort.
305#[derive(Debug, Clone, Copy, PartialEq, Eq)]
306pub enum EffortPolicy {
307    /// Each tier's configured effort, one step harder when `bump`.
308    Tier { bump: bool },
309    /// The client set its own effort; the planner sets none.
310    Client,
311}
312
313/// One leg of a plan and the index of the tier it came from.
314#[derive(Debug, Clone, PartialEq, Eq)]
315pub struct PlannedLeg {
316    pub leg: ChainLeg,
317    pub tier: usize,
318}
319
320/// Legs in fallback order from tier `start` (see [`escalation_order`]), each
321/// stamped with an effort per `policy`. Tiers with no legs contribute nothing.
322pub 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
342/// `tiers` with legs the request cannot use removed (native-Vertex features
343/// only run on `vertex` legs). Empty tiers are kept so indices still match
344/// Jev's score levels.
345pub 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
360/// `wanted` if it has legs, else the nearest harder tier with legs, else the
361/// nearest easier one; `None` when no tier has legs.
362pub 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/// A request's execution order plus what is needed to report on it.
370#[derive(Debug, Clone, PartialEq)]
371pub struct RoutePlan {
372    pub mode: RoutingMode,
373    pub legs: Vec<PlannedLeg>,
374    /// Tier names by index; empty for static routes.
375    pub tier_names: Vec<String>,
376    /// Tier Jev picked (or `default_tier`), before eligibility.
377    pub decided: Option<usize>,
378    pub outcome: Option<DecisionOutcome>,
379    /// The client's own effort replaces the effort this plan would apply.
380    pub client_effort: bool,
381}
382
383impl RoutePlan {
384    /// A static plan, legs untouched. `client_effort` sticks only when a leg
385    /// carries an effort for it to override (a `jev` route downgraded to
386    /// static); plain routes plan no effort and report none.
387    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    /// The legs in execution order, for the lane executors.
405    pub fn chain(&self) -> Vec<ChainLeg> {
406        self.legs.iter().map(|p| p.leg.clone()).collect()
407    }
408
409    /// Effort the first leg will run with, or `client`; `None` when the plan
410    /// sets no effort (static routes).
411    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    /// What to tell the client once `served` (`provider`, `model`) answered;
423    /// `None` when no single leg served (e.g. hybrid extraction). The first
424    /// matching leg in plan order wins.
425    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/// Client-facing summary of how a request was routed.
449#[derive(Debug, Clone, Default, PartialEq, Eq)]
450pub struct RoutingReport {
451    pub mode: RoutingMode,
452    /// Tier that served.
453    pub tier: Option<String>,
454    /// Tier Jev picked, only when a different tier served.
455    pub tier_decided: Option<String>,
456    /// Effort applied by the serving leg, or `client`.
457    pub effort: Option<String>,
458    pub degraded: Option<&'static str>,
459}
460
461impl RoutingReport {
462    /// `x-synapse-*` response headers, in a stable order.
463    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}