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