Skip to main content

lean_ctx/proxy/
routing.rs

1//! Active request router (enterprise#13) — alias + intent-tier model rewrite
2//! in the forward path, **fail-open by construction**.
3//!
4//! Runs between body parse and body compression: it may replace the `model`
5//! field and re-target the request to another upstream of the **same wire
6//! shape** — or, with the `shape-xlat` feature (enterprise#16), route an
7//! Anthropic `/v1/messages` request onto an OpenAI-shape upstream with the
8//! translation flag set. The decision is recorded as `routed_from` on the
9//! usage record, so savings attribution can prove what the router did
10//! (enterprise#15/#19).
11//!
12//! Two rule sources (`[proxy.routing]`, [`RoutingRules`]):
13//!
14//! 1. **Aliases** — exact requested-model match. `"acme/fast" = "foundry:gpt-4o-mini"`
15//!    gives clients a stable org-level name; `"claude-opus-4-5" = "claude-sonnet-4-5"`
16//!    transparently downgrades a concrete model.
17//! 2. **Tiers** — intent classification of the request's last user message
18//!    (`intent_engine::classify` → `route_intent` → `fast|standard|premium`)
19//!    picks the target from the `tiers` table. Unset/empty tier = keep the
20//!    requested model.
21//!
22//! Every failure mode — no rules, no model field, unknown target provider,
23//! shape mismatch, unextractable query — routes nothing: the request forwards
24//! unchanged. A routing bug can cost savings, never availability.
25
26use super::routing_feedback::RoutingFeedback;
27use crate::core::config::{
28    ResolvedProvider, RoutingRules, Upstreams, WireShape, parse_route_target,
29};
30use crate::core::ocla::registry::OclaRegistry;
31use crate::core::ocla::types::{ModelRouteRequest, OclaRequestContext};
32use std::sync::OnceLock;
33
34static ROUTING_FEEDBACK: OnceLock<RoutingFeedback> = OnceLock::new();
35
36#[cfg(test)]
37use crate::core::ocla::builtin::model_router::BuiltinModelRouter;
38#[cfg(test)]
39use crate::core::ocla::registry::with_test_registry;
40#[cfg(test)]
41use std::sync::Arc;
42
43/// What the router decided for one request. Applied by the forward path:
44/// `model` already swapped in the body by [`route_request`]; the caller
45/// re-targets the upstream and injects the registry credential if set.
46#[derive(Debug, Clone, PartialEq)]
47pub struct RouteDecision {
48    /// Model now in the body.
49    pub model: String,
50    /// Originally requested model (usage record `routed_from`).
51    pub routed_from: String,
52    /// Registry/builtin provider id serving the request after routing
53    /// (usage attribution); `None` = upstream unchanged.
54    pub provider_id: Option<String>,
55    /// Override for the upstream base URL; `None` = keep the handler's.
56    pub upstream_base: Option<String>,
57    /// Registry entry whose `api_key_env` credential must be injected before
58    /// the request leaves (gateway-held keys, enterprise#7).
59    pub credential: Option<ResolvedProvider>,
60    /// Target's local-inference flag (shadow-rate billing): `Some` for
61    /// registry targets, `None` for built-ins (URL heuristic applies).
62    pub local: Option<bool>,
63    /// Cross-shape route (enterprise#16, feature `shape-xlat`): the Anthropic
64    /// request body must be translated to OpenAI Chat Completions before it
65    /// leaves, and the response translated back. Always `false` within-shape.
66    pub xlat: bool,
67}
68
69/// Maximum user-message prefix fed to the intent classifier. Classification is
70/// keyword/structure based; a bounded prefix keeps it O(1) per request.
71const CLASSIFY_QUERY_CAP: usize = 2000;
72
73/// Applies the routing rules to a parsed request body. On a routing decision
74/// the body's `model` field is rewritten in place and the full decision is
75/// returned; on any miss/failure the body is untouched and `None` is returned
76/// (fail-open passthrough).
77///
78/// `xlat_ok` — the caller vouches that this request may be shape-translated
79/// (exact messages-create path, `shape-xlat` compiled in). Subpaths like
80/// `count_tokens`/`batches` have no OpenAI equivalent and must stay
81/// within-shape.
82pub fn route_request(
83    parsed: &mut serde_json::Value,
84    provider_label: &str,
85    upstreams: &Upstreams,
86    rules: &RoutingRules,
87    xlat_ok: bool,
88) -> Option<RouteDecision> {
89    if ROUTING_FEEDBACK
90        .get_or_init(RoutingFeedback::new)
91        .should_use_fallback()
92    {
93        tracing::warn!("routing quality below threshold, using fallback");
94        return None;
95    }
96    if !rules.is_active() {
97        return None;
98    }
99    // Body-addressed model dialects route. Gemini keys the model in the URL
100    // path and ChatGPT-backend is OAuth'd Codex traffic — both passthrough.
101    let request_shape = match provider_label {
102        "Anthropic" => WireShape::Anthropic,
103        "OpenAI" => WireShape::OpenAi,
104        _ => return None,
105    };
106    let requested = parsed.get("model")?.as_str()?.trim().to_string();
107    if requested.is_empty() {
108        return None;
109    }
110
111    #[cfg(test)]
112    let _registry_guard = {
113        let mut registry = OclaRegistry::with_builtins();
114        registry.model_router = Arc::new(BuiltinModelRouter::with_rules(rules.clone()));
115        Some(with_test_registry(registry))
116    };
117
118    let target = rules.aliases.get(&requested).cloned().or_else(|| {
119        let content_ref = extract_user_query(parsed, request_shape)?;
120        let request_id = format!(
121            "proxy-routing:{}",
122            blake3::hash(&serde_json::to_vec(parsed).ok()?).to_hex()
123        );
124        let request = ModelRouteRequest {
125            context: OclaRequestContext {
126                request_id,
127                session_id: "proxy-routing".into(),
128                agent_id: "proxy-routing".into(),
129                content_ref,
130                tenant_id: None,
131                trace_id: "tr-unit".into(),
132            },
133            candidate_models: vec![requested.clone()],
134            maximum_cost_micros: None,
135            maximum_latency_ms: None,
136        };
137        let decision = OclaRegistry::global()
138            .model_router
139            .route_model(request)
140            .ok()?;
141        if decision.model == requested {
142            None
143        } else if decision.provider.is_empty() {
144            Some(decision.model)
145        } else {
146            Some(format!("{}:{}", decision.provider, decision.model))
147        }
148    })?;
149    let (provider, new_model) = parse_route_target(&target)?;
150    let new_model = new_model.to_string();
151
152    let resolved = match provider {
153        None => ResolvedTarget::default(),
154        Some(p) => resolve_provider(p, request_shape, upstreams, xlat_ok)?,
155    };
156
157    if new_model == requested && resolved.upstream_base.is_none() {
158        return None; // no-op rule
159    }
160
161    parsed["model"] = serde_json::Value::String(new_model.clone());
162    let decision = RouteDecision {
163        model: new_model,
164        routed_from: requested,
165        provider_id: resolved.provider_id,
166        upstream_base: resolved.upstream_base,
167        credential: resolved.credential,
168        local: resolved.local,
169        xlat: resolved.xlat,
170    };
171    Some(decision)
172}
173
174/// A resolved route target. `Default` = model-only rewrite (upstream unchanged).
175#[derive(Default)]
176struct ResolvedTarget {
177    provider_id: Option<String>,
178    upstream_base: Option<String>,
179    credential: Option<ResolvedProvider>,
180    local: Option<bool>,
181    xlat: bool,
182}
183
184/// Resolves a route-target provider name, enforcing the shape rules: same
185/// shape always routes; Anthropic→OpenAI routes with the translation flag when
186/// the `shape-xlat` feature is compiled in and the caller allowed it. Unknown
187/// ids and untranslatable shape pairs are logged and route nothing.
188fn resolve_provider(
189    name: &str,
190    request_shape: WireShape,
191    upstreams: &Upstreams,
192    xlat_ok: bool,
193) -> Option<ResolvedTarget> {
194    let (target_shape, base_url, credential, local) = match name {
195        "anthropic" => (
196            WireShape::Anthropic,
197            upstreams.anthropic.clone(),
198            None,
199            None,
200        ),
201        "openai" => (WireShape::OpenAi, upstreams.openai.clone(), None, None),
202        "gemini" => (WireShape::Gemini, upstreams.gemini.clone(), None, None),
203        id => {
204            let Some(p) = upstreams.provider_by_id(id) else {
205                tracing::warn!(
206                    "[proxy.routing] target provider '{id}' not in [[proxy.providers]] — passthrough"
207                );
208                return None;
209            };
210            (
211                p.shape,
212                p.base_url.clone(),
213                p.api_key_env.is_some().then(|| p.clone()),
214                Some(p.local),
215            )
216        }
217    };
218    let xlat = if target_shape == request_shape {
219        false
220    } else if can_translate(
221        request_shape,
222        target_shape,
223        xlat_ok,
224        credential.as_ref(),
225        local,
226    ) {
227        true
228    } else {
229        tracing::warn!(
230            "[proxy.routing] target '{name}' speaks {} but the request is {} — \
231             not translatable here, passthrough",
232            target_shape.as_str(),
233            request_shape.as_str()
234        );
235        return None;
236    };
237    Some(ResolvedTarget {
238        provider_id: Some(name.to_string()),
239        upstream_base: Some(base_url),
240        credential,
241        local,
242        xlat,
243    })
244}
245
246/// Anthropic→OpenAI is the supported translation pair (enterprise#16). The
247/// upstream must be gateway-authenticated (`api_key_env`) or a local endpoint
248/// (no auth) — the caller's Anthropic credentials mean nothing to an
249/// OpenAI-shape provider.
250#[cfg(feature = "shape-xlat")]
251fn can_translate(
252    request_shape: WireShape,
253    target_shape: WireShape,
254    xlat_ok: bool,
255    credential: Option<&ResolvedProvider>,
256    local: Option<bool>,
257) -> bool {
258    xlat_ok
259        && request_shape == WireShape::Anthropic
260        && target_shape == WireShape::OpenAi
261        && (credential.is_some() || local == Some(true))
262}
263
264#[cfg(not(feature = "shape-xlat"))]
265fn can_translate(
266    _request_shape: WireShape,
267    _target_shape: WireShape,
268    _xlat_ok: bool,
269    _credential: Option<&ResolvedProvider>,
270    _local: Option<bool>,
271) -> bool {
272    false
273}
274
275/// Extracts the newest user-authored text from a request body — the router's
276/// classification input. Handles the two body-addressed dialects:
277///
278/// - Anthropic Messages / OpenAI Chat: `messages[]`, last `role == "user"`,
279///   content as string or text-part array.
280/// - OpenAI Responses: `input` as string, or `input[]` items with
281///   `role == "user"` and `content[]` parts (`input_text`/`text`).
282fn extract_user_query(parsed: &serde_json::Value, shape: WireShape) -> Option<String> {
283    debug_assert!(matches!(shape, WireShape::Anthropic | WireShape::OpenAi));
284    let items = parsed.get("messages").or_else(|| parsed.get("input"))?;
285
286    // OpenAI Responses shorthand: `"input": "plain text"`.
287    if let Some(text) = items.as_str() {
288        return non_empty_prefix(text);
289    }
290    let items = items.as_array()?;
291    let last_user = items.iter().rev().find(|m| {
292        m.get("role").and_then(|r| r.as_str()) == Some("user")
293            || (m.get("type").and_then(|t| t.as_str()) == Some("message")
294                && m.get("role").and_then(|r| r.as_str()) == Some("user"))
295    })?;
296    let content = last_user.get("content")?;
297    if let Some(text) = content.as_str() {
298        return non_empty_prefix(text);
299    }
300    let parts = content.as_array()?;
301    let mut buf = String::new();
302    for part in parts {
303        let is_text = matches!(
304            part.get("type").and_then(|t| t.as_str()),
305            Some("text" | "input_text")
306        );
307        if is_text && let Some(t) = part.get("text").and_then(|t| t.as_str()) {
308            if !buf.is_empty() {
309                buf.push(' ');
310            }
311            buf.push_str(t);
312            if buf.len() >= CLASSIFY_QUERY_CAP {
313                break;
314            }
315        }
316    }
317    non_empty_prefix(&buf)
318}
319fn non_empty_prefix(text: &str) -> Option<String> {
320    let trimmed = text.trim();
321    if trimmed.is_empty() {
322        return None;
323    }
324    let mut end = trimmed.len().min(CLASSIFY_QUERY_CAP);
325    while !trimmed.is_char_boundary(end) {
326        end -= 1;
327    }
328    Some(trimmed[..end].to_string())
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334    use serde_json::json;
335
336    fn upstreams_with_foundry() -> Upstreams {
337        Upstreams {
338            anthropic: "https://api.anthropic.com".into(),
339            openai: "https://api.openai.com".into(),
340            chatgpt: "https://chatgpt.com".into(),
341            gemini: "https://generativelanguage.googleapis.com".into(),
342            providers: vec![
343                ResolvedProvider {
344                    id: "foundry".into(),
345                    shape: WireShape::OpenAi,
346                    base_url: "https://acme.services.ai.azure.com/openai".into(),
347                    api_key_env: Some("FOUNDRY_API_KEY".into()),
348                    aws_region: None,
349                    local: false,
350                },
351                ResolvedProvider {
352                    id: "claudeish".into(),
353                    shape: WireShape::Anthropic,
354                    base_url: "https://anthropic-gw.example.com".into(),
355                    api_key_env: None,
356                    aws_region: None,
357                    local: false,
358                },
359            ],
360        }
361    }
362
363    fn rules(aliases: &[(&str, &str)], tiers: &[(&str, &str)]) -> RoutingRules {
364        RoutingRules {
365            enabled: Some(true),
366            aliases: aliases
367                .iter()
368                .map(|(k, v)| (k.to_string(), v.to_string()))
369                .collect(),
370            tiers: tiers
371                .iter()
372                .map(|(k, v)| (k.to_string(), v.to_string()))
373                .collect(),
374        }
375    }
376
377    #[test]
378    fn route_decision_does_not_record_feedback_before_outcome() {
379        let feedback = ROUTING_FEEDBACK.get_or_init(RoutingFeedback::new);
380        let before = feedback.stats();
381        let mut body = json!({"model":"expensive","messages":[{"role":"user","content":"hi"}]});
382
383        let decision = route_request(
384            &mut body,
385            "OpenAI",
386            &upstreams_with_foundry(),
387            &rules(&[("expensive", "fast")], &[]),
388            false,
389        )
390        .expect("alias should route");
391
392        assert_eq!(decision.routed_from, "expensive");
393        assert_eq!(decision.model, "fast");
394        assert_eq!(feedback.stats(), before);
395    }
396
397    #[test]
398    fn alias_routes_to_registry_provider_and_rewrites_model() {
399        let mut body = json!({"model": "acme/fast", "messages": [{"role":"user","content":"hi"}]});
400        let d = route_request(
401            &mut body,
402            "OpenAI",
403            &upstreams_with_foundry(),
404            &rules(&[("acme/fast", "foundry:gpt-4o-mini")], &[]),
405            false,
406        )
407        .expect("routed");
408        assert_eq!(body["model"], "gpt-4o-mini");
409        assert_eq!(d.routed_from, "acme/fast");
410        assert_eq!(d.provider_id.as_deref(), Some("foundry"));
411        assert_eq!(
412            d.upstream_base.as_deref(),
413            Some("https://acme.services.ai.azure.com/openai")
414        );
415        assert!(
416            d.credential.is_some(),
417            "foundry has api_key_env — credential must be injected"
418        );
419    }
420
421    #[test]
422    fn alias_model_only_keeps_upstream() {
423        let mut body =
424            json!({"model": "claude-opus-4-5", "messages": [{"role":"user","content":"hi"}]});
425        let d = route_request(
426            &mut body,
427            "Anthropic",
428            &upstreams_with_foundry(),
429            &rules(&[("claude-opus-4-5", "claude-sonnet-4-5")], &[]),
430            false,
431        )
432        .expect("routed");
433        assert_eq!(body["model"], "claude-sonnet-4-5");
434        assert_eq!(d.upstream_base, None);
435        assert_eq!(d.provider_id, None);
436        assert_eq!(d.credential, None);
437    }
438
439    #[test]
440    fn cross_shape_target_is_passthrough_when_xlat_not_allowed() {
441        // Anthropic request → OpenAI-shape foundry with xlat_ok=false (wrong
442        // path, e.g. count_tokens): must stay passthrough.
443        let mut body =
444            json!({"model": "claude-opus-4-5", "messages": [{"role":"user","content":"hi"}]});
445        let before = body.clone();
446        let d = route_request(
447            &mut body,
448            "Anthropic",
449            &upstreams_with_foundry(),
450            &rules(&[("claude-opus-4-5", "foundry:gpt-4o-mini")], &[]),
451            false,
452        );
453        assert_eq!(d, None);
454        assert_eq!(body, before, "fail-open must leave the body untouched");
455    }
456
457    #[cfg(feature = "shape-xlat")]
458    #[test]
459    fn cross_shape_target_routes_with_translation_flag() {
460        // enterprise#16: with the feature compiled in and the caller vouching
461        // for the path, Anthropic → OpenAI-shape routes and marks xlat.
462        let mut body =
463            json!({"model": "claude-opus-4-5", "messages": [{"role":"user","content":"hi"}]});
464        let d = route_request(
465            &mut body,
466            "Anthropic",
467            &upstreams_with_foundry(),
468            &rules(&[("claude-opus-4-5", "foundry:gpt-4o-mini")], &[]),
469            true,
470        )
471        .expect("cross-shape route with translation");
472        assert!(d.xlat, "decision must carry the translation flag");
473        assert_eq!(body["model"], "gpt-4o-mini");
474        assert_eq!(d.provider_id.as_deref(), Some("foundry"));
475        assert!(d.credential.is_some());
476
477        // Within-shape decisions never set xlat.
478        let mut body2 = json!({"model": "acme/fast", "messages": [{"role":"user","content":"hi"}]});
479        let d2 = route_request(
480            &mut body2,
481            "OpenAI",
482            &upstreams_with_foundry(),
483            &rules(&[("acme/fast", "foundry:gpt-4o-mini")], &[]),
484            true,
485        )
486        .expect("within-shape route");
487        assert!(!d2.xlat);
488    }
489
490    #[cfg(feature = "shape-xlat")]
491    #[test]
492    fn cross_shape_needs_gateway_credential_or_local_target() {
493        // An OpenAI-shape target without api_key_env and not local cannot be
494        // reached with the caller's Anthropic credentials → passthrough.
495        let mut upstreams = upstreams_with_foundry();
496        upstreams.providers.push(ResolvedProvider {
497            id: "openaiish".into(),
498            shape: WireShape::OpenAi,
499            base_url: "https://oai-compat.example.com".into(),
500            api_key_env: None,
501            aws_region: None,
502            local: false,
503        });
504        let mut body =
505            json!({"model": "claude-opus-4-5", "messages": [{"role":"user","content":"hi"}]});
506        let before = body.clone();
507        let d = route_request(
508            &mut body,
509            "Anthropic",
510            &upstreams,
511            &rules(&[("claude-opus-4-5", "openaiish:gpt-4o-mini")], &[]),
512            true,
513        );
514        assert_eq!(d, None);
515        assert_eq!(body, before);
516
517        // The same target declared local (e.g. Ollama) needs no credential.
518        upstreams.providers.last_mut().unwrap().local = true;
519        let d = route_request(
520            &mut body,
521            "Anthropic",
522            &upstreams,
523            &rules(&[("claude-opus-4-5", "openaiish:llama3.3")], &[]),
524            true,
525        )
526        .expect("local cross-shape target routes");
527        assert!(d.xlat);
528        assert_eq!(d.local, Some(true));
529    }
530
531    #[cfg(feature = "shape-xlat")]
532    #[test]
533    fn openai_to_anthropic_direction_stays_passthrough() {
534        // Only Anthropic→OpenAI is translated; the reverse pair passes through.
535        let mut body = json!({"model": "gpt-5.2", "messages": [{"role":"user","content":"hi"}]});
536        let d = route_request(
537            &mut body,
538            "OpenAI",
539            &upstreams_with_foundry(),
540            &rules(&[("gpt-5.2", "claudeish:claude-sonnet-4-5")], &[]),
541            true,
542        );
543        assert_eq!(d, None);
544    }
545
546    #[test]
547    fn unknown_provider_and_disabled_rules_are_passthrough() {
548        let mut body = json!({"model": "m", "messages": [{"role":"user","content":"hi"}]});
549        let before = body.clone();
550        assert_eq!(
551            route_request(
552                &mut body,
553                "OpenAI",
554                &upstreams_with_foundry(),
555                &rules(&[("m", "nope:x")], &[]),
556                false,
557            ),
558            None
559        );
560        // enabled=false → inactive even with rules present.
561        let mut off = rules(&[("m", "foundry:x")], &[]);
562        off.enabled = Some(false);
563        assert_eq!(
564            route_request(&mut body, "OpenAI", &upstreams_with_foundry(), &off, false),
565            None
566        );
567        assert_eq!(body, before);
568    }
569
570    #[test]
571    fn tier_downgrade_routes_simple_queries_to_cheap_model() {
572        // An explore-style question lands on a non-premium tier (fast, or
573        // standard when the classifier hedges on low confidence). Both map to
574        // the cheap target here — this test pins the routing mechanics; tier
575        // assignment itself is covered by the intent_engine tests.
576        let mut body = json!({
577            "model": "gpt-5.2",
578            "messages": [
579                {"role":"system","content":"be helpful"},
580                {"role":"user","content":"where is the config file for the proxy?"}
581            ]
582        });
583        let d = route_request(
584            &mut body,
585            "OpenAI",
586            &upstreams_with_foundry(),
587            &rules(
588                &[],
589                &[("fast", "foundry:phi-4"), ("standard", "foundry:phi-4")],
590            ),
591            false,
592        )
593        .expect("non-premium query must route");
594        assert_eq!(body["model"], "phi-4");
595        assert_eq!(d.routed_from, "gpt-5.2");
596        assert_eq!(d.provider_id.as_deref(), Some("foundry"));
597    }
598
599    #[test]
600    fn premium_tier_unset_keeps_requested_model() {
601        // Generation work classifies premium; with no premium target the
602        // request passes through untouched.
603        let mut body = json!({
604            "model": "gpt-5.2",
605            "messages": [{"role":"user","content":
606                "implement a new distributed lock manager with leader election and fencing tokens"}]
607        });
608        let before = body.clone();
609        let d = route_request(
610            &mut body,
611            "OpenAI",
612            &upstreams_with_foundry(),
613            &rules(&[], &[("fast", "foundry:phi-4"), ("premium", "")]),
614            false,
615        );
616        assert_eq!(d, None);
617        assert_eq!(body, before);
618    }
619
620    #[test]
621    fn responses_input_string_and_items_are_extractable() {
622        let s = json!({"model":"m","input":"quick question about rust"});
623        assert!(extract_user_query(&s, WireShape::OpenAi).is_some());
624
625        let items = json!({"model":"m","input":[
626            {"type":"message","role":"user","content":[{"type":"input_text","text":"what does this do"}]}
627        ]});
628        assert_eq!(
629            extract_user_query(&items, WireShape::OpenAi).as_deref(),
630            Some("what does this do")
631        );
632
633        let anthropic = json!({"model":"m","messages":[
634            {"role":"user","content":[{"type":"text","text":"first"}]},
635            {"role":"assistant","content":"a"},
636            {"role":"user","content":[{"type":"text","text":"latest question"}]}
637        ]});
638        assert_eq!(
639            extract_user_query(&anthropic, WireShape::Anthropic).as_deref(),
640            Some("latest question")
641        );
642    }
643
644    #[test]
645    fn missing_model_or_query_is_passthrough() {
646        let mut no_model = json!({"messages":[{"role":"user","content":"hi"}]});
647        assert_eq!(
648            route_request(
649                &mut no_model,
650                "OpenAI",
651                &upstreams_with_foundry(),
652                &rules(&[], &[("fast", "foundry:phi-4")]),
653                false,
654            ),
655            None
656        );
657        let mut no_user = json!({"model":"m","messages":[{"role":"system","content":"x"}]});
658        assert_eq!(
659            route_request(
660                &mut no_user,
661                "OpenAI",
662                &upstreams_with_foundry(),
663                &rules(&[], &[("fast", "foundry:phi-4")]),
664                false,
665            ),
666            None
667        );
668    }
669
670    #[test]
671    fn gemini_and_chatgpt_labels_are_passthrough() {
672        let mut body = json!({"model":"m","messages":[{"role":"user","content":"hi"}]});
673        for label in ["Gemini", "ChatGPT"] {
674            assert_eq!(
675                route_request(
676                    &mut body,
677                    label,
678                    &upstreams_with_foundry(),
679                    &rules(&[("m", "x")], &[]),
680                    false,
681                ),
682                None,
683                "{label} must not route in M1"
684            );
685        }
686    }
687
688    #[test]
689    fn poor_feedback_triggers_fallback() {
690        let feedback = RoutingFeedback::new();
691        feedback.record_outcome("expensive", "fast", 0.4, 0, 0);
692        assert!(feedback.should_use_fallback());
693    }
694}