Skip to main content

lean_ctx_proxy/
routing.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Explicit model targets — operator-written aliases in the forward path.
3//!
4//! Runs between body parse and body compression: an exact alias match
5//! (`[proxy.routing.aliases]`, [`RoutingRules`]) may replace the `model` field
6//! and re-target the request to another upstream of the **same wire shape** —
7//! or, with the `shape-xlat` feature (enterprise#16), route an Anthropic
8//! `/v1/messages` request onto an OpenAI-shape upstream with the translation
9//! flag set. `"acme/fast" = "foundry:gpt-4o-mini"` gives clients a stable org
10//! name for an approved endpoint; a `local` target keeps traffic on-device.
11//!
12//! LeanCTX never picks a model on its own. The rewrite is recorded as
13//! `routed_from` on the usage record so the operator can see it happened.
14//! Missing rules/model and unavailable target shapes leave the body unchanged.
15
16use lean_ctx::core::config::{
17    ResolvedProvider, RoutingRules, Upstreams, WireShape, parse_route_target,
18};
19
20/// What an alias resolved to for one request. Applied by the forward path:
21/// `model` already swapped in the body by [`route_request`]; the caller
22/// re-targets the upstream and injects the registry credential if set.
23#[derive(Debug, Clone, PartialEq)]
24pub struct RouteDecision {
25    /// Model now in the body.
26    pub model: String,
27    /// Originally requested model (usage record `routed_from`).
28    pub routed_from: String,
29    /// Registry/builtin provider id serving the request after routing
30    /// (usage attribution); `None` = upstream unchanged.
31    pub provider_id: Option<String>,
32    /// Override for the upstream base URL; `None` = keep the handler's.
33    pub upstream_base: Option<String>,
34    /// Registry entry whose `api_key_env` credential must be injected before
35    /// the request leaves (gateway-held keys, enterprise#7).
36    pub credential: Option<ResolvedProvider>,
37    /// Target's local-inference flag (shadow-rate billing): `Some` for
38    /// registry targets, `None` for built-ins (URL heuristic applies).
39    pub local: Option<bool>,
40    /// Non-secret destination account label from the trusted registry config.
41    /// It is bound only when the route authenticates with gateway-held credentials
42    /// and the caller supplies no provider scope headers.
43    pub account_ref: Option<String>,
44    /// Configured region for the routed provider (Bedrock uses its signing region).
45    pub region: Option<String>,
46    /// Whether authentication is controlled by the gateway for this route.
47    pub gateway_managed_credential: bool,
48    /// Cross-shape route (enterprise#16, feature `shape-xlat`): the Anthropic
49    /// request body must be translated to OpenAI Chat Completions before it
50    /// leaves, and the response translated back. Always `false` within-shape.
51    pub xlat: bool,
52}
53
54/// Applies the alias rules to a parsed request body. On a match the body's
55/// `model` field is rewritten in place and the decision is returned; without a
56/// match or reachable target the body is untouched and `None` is returned.
57///
58/// `xlat_ok` — the caller vouches that this request may be shape-translated
59/// (exact messages-create path, `shape-xlat` compiled in). Subpaths like
60/// `count_tokens`/`batches` have no OpenAI equivalent and must stay
61/// within-shape.
62pub fn route_request(
63    parsed: &mut serde_json::Value,
64    provider_label: &str,
65    upstreams: &Upstreams,
66    rules: &RoutingRules,
67    xlat_ok: bool,
68) -> Option<RouteDecision> {
69    if !rules.is_active() {
70        return None;
71    }
72    // Body-addressed model dialects route. Gemini keys the model in the URL
73    // path and ChatGPT-backend is OAuth'd Codex traffic — both passthrough.
74    let request_shape = match provider_label {
75        "Anthropic" => WireShape::Anthropic,
76        "OpenAI" => WireShape::OpenAi,
77        _ => return None,
78    };
79    let requested = parsed
80        .get("model")
81        .and_then(serde_json::Value::as_str)?
82        .trim()
83        .to_string();
84    let target = rules.aliases.get(&requested)?;
85    let (provider, new_model) = parse_route_target(target)?;
86    let new_model = new_model.to_string();
87
88    let resolved = match provider {
89        None => ResolvedTarget::default(),
90        Some(p) => resolve_provider(p, request_shape, upstreams, xlat_ok)?,
91    };
92
93    if new_model == requested && resolved.upstream_base.is_none() {
94        return None; // no-op rule
95    }
96
97    parsed["model"] = serde_json::Value::String(new_model.clone());
98    Some(RouteDecision {
99        model: new_model,
100        routed_from: requested,
101        provider_id: resolved.provider_id,
102        upstream_base: resolved.upstream_base,
103        credential: resolved.credential,
104        local: resolved.local,
105        account_ref: resolved.account_ref,
106        region: resolved.region,
107        gateway_managed_credential: resolved.gateway_managed_credential,
108        xlat: resolved.xlat,
109    })
110}
111
112/// A resolved route target. `Default` = model-only rewrite (upstream unchanged).
113#[derive(Default)]
114struct ResolvedTarget {
115    provider_id: Option<String>,
116    upstream_base: Option<String>,
117    credential: Option<ResolvedProvider>,
118    local: Option<bool>,
119    account_ref: Option<String>,
120    region: Option<String>,
121    gateway_managed_credential: bool,
122    xlat: bool,
123}
124
125/// Resolves a route-target provider name, enforcing the shape rules: same
126/// shape always routes; Anthropic→OpenAI routes with the translation flag when
127/// the `shape-xlat` feature is compiled in and the caller allowed it. Unknown
128/// ids and untranslatable shape pairs are logged and route nothing.
129fn resolve_provider(
130    name: &str,
131    request_shape: WireShape,
132    upstreams: &Upstreams,
133    xlat_ok: bool,
134) -> Option<ResolvedTarget> {
135    let (
136        target_shape,
137        base_url,
138        credential,
139        local,
140        account_ref,
141        region,
142        gateway_managed_credential,
143    ) = match name {
144        "anthropic" => (
145            WireShape::Anthropic,
146            upstreams.anthropic.clone(),
147            None,
148            None,
149            None,
150            None,
151            false,
152        ),
153        "openai" => (
154            WireShape::OpenAi,
155            upstreams.openai.clone(),
156            None,
157            None,
158            None,
159            None,
160            false,
161        ),
162        "gemini" => (
163            WireShape::Gemini,
164            upstreams.gemini.clone(),
165            None,
166            None,
167            None,
168            None,
169            false,
170        ),
171        id => {
172            let Some(p) = upstreams.provider_by_id(id) else {
173                tracing::warn!(
174                    "[proxy.routing] target provider '{id}' not in [[proxy.providers]] — passthrough"
175                );
176                return None;
177            };
178            (
179                p.shape,
180                p.base_url.clone(),
181                p.api_key_env.is_some().then(|| p.clone()),
182                Some(p.local),
183                p.account_ref.clone(),
184                p.region.clone(),
185                p.api_key_env.is_some() || p.shape == WireShape::Bedrock,
186            )
187        }
188    };
189    let xlat = if target_shape == request_shape {
190        false
191    } else if can_translate(
192        request_shape,
193        target_shape,
194        xlat_ok,
195        credential.as_ref(),
196        local,
197    ) {
198        true
199    } else {
200        tracing::warn!(
201            "[proxy.routing] target '{name}' speaks {} but the request is {} — \
202             not translatable here, passthrough",
203            target_shape.as_str(),
204            request_shape.as_str()
205        );
206        return None;
207    };
208    Some(ResolvedTarget {
209        provider_id: Some(name.to_string()),
210        upstream_base: Some(base_url),
211        credential,
212        local,
213        account_ref,
214        region,
215        gateway_managed_credential,
216        xlat,
217    })
218}
219
220/// Anthropic→OpenAI is the supported translation pair (enterprise#16). The
221/// upstream must be gateway-authenticated (`api_key_env`) or a local endpoint
222/// (no auth) — the caller's Anthropic credentials mean nothing to an
223/// OpenAI-shape provider.
224#[cfg(feature = "shape-xlat")]
225fn can_translate(
226    request_shape: WireShape,
227    target_shape: WireShape,
228    xlat_ok: bool,
229    credential: Option<&ResolvedProvider>,
230    local: Option<bool>,
231) -> bool {
232    xlat_ok
233        && request_shape == WireShape::Anthropic
234        && target_shape == WireShape::OpenAi
235        && (credential.is_some() || local == Some(true))
236}
237
238#[cfg(not(feature = "shape-xlat"))]
239fn can_translate(
240    _request_shape: WireShape,
241    _target_shape: WireShape,
242    _xlat_ok: bool,
243    _credential: Option<&ResolvedProvider>,
244    _local: Option<bool>,
245) -> bool {
246    false
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use serde_json::json;
253
254    fn upstreams_with_foundry() -> Upstreams {
255        Upstreams {
256            anthropic: "https://api.anthropic.com".into(),
257            openai: "https://api.openai.com".into(),
258            chatgpt: "https://chatgpt.com".into(),
259            gemini: "https://generativelanguage.googleapis.com".into(),
260            providers: vec![
261                ResolvedProvider {
262                    id: "foundry".into(),
263                    shape: WireShape::OpenAi,
264                    base_url: "https://acme.services.ai.azure.com/openai".into(),
265                    api_key_env: Some("FOUNDRY_API_KEY".into()),
266                    aws_region: None,
267                    account_ref: Some("finance-prod".into()),
268                    region: Some("eastus".into()),
269                    local: false,
270                },
271                ResolvedProvider {
272                    id: "claudeish".into(),
273                    shape: WireShape::Anthropic,
274                    base_url: "https://anthropic-gw.example.com".into(),
275                    api_key_env: None,
276                    aws_region: None,
277                    account_ref: Some("caller-key-scope".into()),
278                    region: None,
279                    local: false,
280                },
281            ],
282        }
283    }
284
285    fn rules(aliases: &[(&str, &str)]) -> RoutingRules {
286        RoutingRules {
287            enabled: Some(true),
288            aliases: aliases
289                .iter()
290                .map(|(k, v)| (k.to_string(), v.to_string()))
291                .collect(),
292            ..RoutingRules::default()
293        }
294    }
295
296    #[test]
297    fn alias_routes_to_registry_provider_and_rewrites_model() {
298        let mut body = json!({"model": "acme/fast", "messages": [{"role":"user","content":"hi"}]});
299        let d = route_request(
300            &mut body,
301            "OpenAI",
302            &upstreams_with_foundry(),
303            &rules(&[("acme/fast", "foundry:gpt-4o-mini")]),
304            false,
305        )
306        .expect("routed");
307        assert_eq!(body["model"], "gpt-4o-mini");
308        assert_eq!(d.routed_from, "acme/fast");
309        assert_eq!(d.provider_id.as_deref(), Some("foundry"));
310        assert_eq!(
311            d.upstream_base.as_deref(),
312            Some("https://acme.services.ai.azure.com/openai")
313        );
314        assert!(
315            d.credential.is_some(),
316            "foundry has api_key_env — credential must be injected"
317        );
318        assert_eq!(d.account_ref.as_deref(), Some("finance-prod"));
319        assert_eq!(d.region.as_deref(), Some("eastus"));
320        assert!(d.gateway_managed_credential);
321    }
322
323    #[test]
324    fn alias_model_only_keeps_upstream() {
325        let mut body =
326            json!({"model": "claude-opus-4-5", "messages": [{"role":"user","content":"hi"}]});
327        let d = route_request(
328            &mut body,
329            "Anthropic",
330            &upstreams_with_foundry(),
331            &rules(&[("claude-opus-4-5", "claude-sonnet-4-5")]),
332            false,
333        )
334        .expect("routed");
335        assert_eq!(body["model"], "claude-sonnet-4-5");
336        assert_eq!(d.upstream_base, None);
337        assert_eq!(d.provider_id, None);
338        assert_eq!(d.credential, None);
339        assert_eq!(d.account_ref, None);
340        assert_eq!(d.region, None);
341        assert!(!d.gateway_managed_credential);
342    }
343
344    #[test]
345    fn cross_shape_target_is_passthrough_when_xlat_not_allowed() {
346        // Anthropic request → OpenAI-shape foundry with xlat_ok=false (wrong
347        // path, e.g. count_tokens): must stay passthrough.
348        let mut body =
349            json!({"model": "claude-opus-4-5", "messages": [{"role":"user","content":"hi"}]});
350        let before = body.clone();
351        let d = route_request(
352            &mut body,
353            "Anthropic",
354            &upstreams_with_foundry(),
355            &rules(&[("claude-opus-4-5", "foundry:gpt-4o-mini")]),
356            false,
357        );
358        assert_eq!(d, None);
359        assert_eq!(body, before, "fail-open must leave the body untouched");
360    }
361
362    #[cfg(feature = "shape-xlat")]
363    #[test]
364    fn cross_shape_target_routes_with_translation_flag() {
365        // enterprise#16: with the feature compiled in and the caller vouching
366        // for the path, Anthropic → OpenAI-shape routes and marks xlat.
367        let mut body =
368            json!({"model": "claude-opus-4-5", "messages": [{"role":"user","content":"hi"}]});
369        let d = route_request(
370            &mut body,
371            "Anthropic",
372            &upstreams_with_foundry(),
373            &rules(&[("claude-opus-4-5", "foundry:gpt-4o-mini")]),
374            true,
375        )
376        .expect("cross-shape route with translation");
377        assert!(d.xlat, "decision must carry the translation flag");
378        assert_eq!(body["model"], "gpt-4o-mini");
379        assert_eq!(d.provider_id.as_deref(), Some("foundry"));
380        assert!(d.credential.is_some());
381
382        // Within-shape decisions never set xlat.
383        let mut body2 = json!({"model": "acme/fast", "messages": [{"role":"user","content":"hi"}]});
384        let d2 = route_request(
385            &mut body2,
386            "OpenAI",
387            &upstreams_with_foundry(),
388            &rules(&[("acme/fast", "foundry:gpt-4o-mini")]),
389            true,
390        )
391        .expect("within-shape route");
392        assert!(!d2.xlat);
393    }
394
395    #[cfg(feature = "shape-xlat")]
396    #[test]
397    fn cross_shape_needs_gateway_credential_or_local_target() {
398        // An OpenAI-shape target without api_key_env and not local cannot be
399        // reached with the caller's Anthropic credentials → passthrough.
400        let mut upstreams = upstreams_with_foundry();
401        upstreams.providers.push(ResolvedProvider {
402            id: "openaiish".into(),
403            shape: WireShape::OpenAi,
404            base_url: "https://oai-compat.example.com".into(),
405            api_key_env: None,
406            aws_region: None,
407            account_ref: None,
408            region: None,
409            local: false,
410        });
411        let mut body =
412            json!({"model": "claude-opus-4-5", "messages": [{"role":"user","content":"hi"}]});
413        let before = body.clone();
414        let d = route_request(
415            &mut body,
416            "Anthropic",
417            &upstreams,
418            &rules(&[("claude-opus-4-5", "openaiish:gpt-4o-mini")]),
419            true,
420        );
421        assert_eq!(d, None);
422        assert_eq!(body, before);
423
424        // The same target declared local (e.g. Ollama) needs no credential.
425        upstreams.providers.last_mut().unwrap().local = true;
426        let d = route_request(
427            &mut body,
428            "Anthropic",
429            &upstreams,
430            &rules(&[("claude-opus-4-5", "openaiish:llama3.3")]),
431            true,
432        )
433        .expect("local cross-shape target routes");
434        assert!(d.xlat);
435        assert_eq!(d.local, Some(true));
436    }
437
438    #[cfg(feature = "shape-xlat")]
439    #[test]
440    fn openai_to_anthropic_direction_stays_passthrough() {
441        // Only Anthropic→OpenAI is translated; the reverse pair passes through.
442        let mut body = json!({"model": "gpt-5.2", "messages": [{"role":"user","content":"hi"}]});
443        let d = route_request(
444            &mut body,
445            "OpenAI",
446            &upstreams_with_foundry(),
447            &rules(&[("gpt-5.2", "claudeish:claude-sonnet-4-5")]),
448            true,
449        );
450        assert_eq!(d, None);
451    }
452
453    #[test]
454    fn unknown_provider_and_disabled_rules_are_passthrough() {
455        let mut body = json!({"model": "m", "messages": [{"role":"user","content":"hi"}]});
456        let before = body.clone();
457        assert_eq!(
458            route_request(
459                &mut body,
460                "OpenAI",
461                &upstreams_with_foundry(),
462                &rules(&[("m", "nope:x")]),
463                false,
464            ),
465            None
466        );
467        // enabled=false → inactive even with rules present.
468        let mut off = rules(&[("m", "foundry:x")]);
469        off.enabled = Some(false);
470        assert_eq!(
471            route_request(&mut body, "OpenAI", &upstreams_with_foundry(), &off, false),
472            None
473        );
474        assert_eq!(body, before);
475    }
476
477    #[test]
478    fn gemini_and_chatgpt_labels_are_passthrough() {
479        let mut body = json!({"model":"m","messages":[{"role":"user","content":"hi"}]});
480        for label in ["Gemini", "ChatGPT"] {
481            assert_eq!(
482                route_request(
483                    &mut body,
484                    label,
485                    &upstreams_with_foundry(),
486                    &rules(&[("m", "x")]),
487                    false,
488                ),
489                None,
490                "{label} must not route in M1"
491            );
492        }
493    }
494
495    #[test]
496    fn a_removed_tier_table_never_rewrites_the_model() {
497        // v4 removed automatic model selection: a legacy `[proxy.routing.tiers]`
498        // table must neither activate the router nor change the request.
499        let mut legacy = rules(&[]);
500        legacy
501            .tiers
502            .insert("fast".to_string(), "foundry:phi-4".to_string());
503        assert!(!legacy.is_active());
504        let mut body = json!({"model":"gpt-5.2","messages":[{"role":"user","content":"where is the config?"}]});
505        let before = body.clone();
506        assert_eq!(
507            route_request(
508                &mut body,
509                "OpenAI",
510                &upstreams_with_foundry(),
511                &legacy,
512                false
513            ),
514            None
515        );
516        assert_eq!(body, before);
517    }
518}