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