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        // The subscription models (mz6) are reached at `/providers/leanctx/…`
172        // only: an alias would need the instance-token admission on the
173        // shared forward path, and serving Anthropic-shaped agents through
174        // translation is the owner-open Q3 (`[hive] models_anthropic_agents`,
175        // docs/model-access-subscription.md §9). Until then: passthrough.
176        id if id == lean_ctx::core::hive_models::PROVIDER_ID => {
177            tracing::warn!(
178                "[proxy.routing] target '{id}' is served at /providers/{id}/ only; \
179                 routing aliases to it are not supported yet (Q3) — passthrough"
180            );
181            return None;
182        }
183        id => {
184            let Some(p) = upstreams.provider_by_id(id) else {
185                tracing::warn!(
186                    "[proxy.routing] target provider '{id}' not in [[proxy.providers]] — passthrough"
187                );
188                return None;
189            };
190            (
191                p.shape,
192                p.base_url.clone(),
193                p.api_key_env.is_some().then(|| p.clone()),
194                Some(p.local),
195                p.account_ref.clone(),
196                p.region.clone(),
197                p.api_key_env.is_some() || p.shape == WireShape::Bedrock,
198            )
199        }
200    };
201    let xlat = if target_shape == request_shape {
202        false
203    } else if can_translate(
204        request_shape,
205        target_shape,
206        xlat_ok,
207        credential.as_ref(),
208        local,
209    ) {
210        true
211    } else {
212        tracing::warn!(
213            "[proxy.routing] target '{name}' speaks {} but the request is {} — \
214             not translatable here, passthrough",
215            target_shape.as_str(),
216            request_shape.as_str()
217        );
218        return None;
219    };
220    Some(ResolvedTarget {
221        provider_id: Some(name.to_string()),
222        upstream_base: Some(base_url),
223        credential,
224        local,
225        account_ref,
226        region,
227        gateway_managed_credential,
228        xlat,
229    })
230}
231
232/// Anthropic→OpenAI is the supported translation pair (enterprise#16). The
233/// upstream must be gateway-authenticated (`api_key_env`) or a local endpoint
234/// (no auth) — the caller's Anthropic credentials mean nothing to an
235/// OpenAI-shape provider.
236#[cfg(feature = "shape-xlat")]
237fn can_translate(
238    request_shape: WireShape,
239    target_shape: WireShape,
240    xlat_ok: bool,
241    credential: Option<&ResolvedProvider>,
242    local: Option<bool>,
243) -> bool {
244    xlat_ok
245        && request_shape == WireShape::Anthropic
246        && target_shape == WireShape::OpenAi
247        && (credential.is_some() || local == Some(true))
248}
249
250#[cfg(not(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    false
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use serde_json::json;
265
266    fn upstreams_with_foundry() -> Upstreams {
267        Upstreams {
268            anthropic: "https://api.anthropic.com".into(),
269            openai: "https://api.openai.com".into(),
270            chatgpt: "https://chatgpt.com".into(),
271            gemini: "https://generativelanguage.googleapis.com".into(),
272            providers: vec![
273                ResolvedProvider {
274                    id: "foundry".into(),
275                    shape: WireShape::OpenAi,
276                    base_url: "https://acme.services.ai.azure.com/openai".into(),
277                    api_key_env: Some("FOUNDRY_API_KEY".into()),
278                    aws_region: None,
279                    account_ref: Some("finance-prod".into()),
280                    region: Some("eastus".into()),
281                    local: false,
282                    credential: lean_ctx::core::config::CredentialSource::Configured,
283                },
284                ResolvedProvider {
285                    id: "claudeish".into(),
286                    shape: WireShape::Anthropic,
287                    base_url: "https://anthropic-gw.example.com".into(),
288                    api_key_env: None,
289                    aws_region: None,
290                    account_ref: Some("caller-key-scope".into()),
291                    region: None,
292                    local: false,
293                    credential: lean_ctx::core::config::CredentialSource::Configured,
294                },
295            ],
296        }
297    }
298
299    fn rules(aliases: &[(&str, &str)]) -> RoutingRules {
300        RoutingRules {
301            enabled: Some(true),
302            aliases: aliases
303                .iter()
304                .map(|(k, v)| (k.to_string(), v.to_string()))
305                .collect(),
306            ..RoutingRules::default()
307        }
308    }
309
310    #[test]
311    fn alias_routes_to_registry_provider_and_rewrites_model() {
312        let mut body = json!({"model": "acme/fast", "messages": [{"role":"user","content":"hi"}]});
313        let d = route_request(
314            &mut body,
315            "OpenAI",
316            &upstreams_with_foundry(),
317            &rules(&[("acme/fast", "foundry:gpt-4o-mini")]),
318            false,
319        )
320        .expect("routed");
321        assert_eq!(body["model"], "gpt-4o-mini");
322        assert_eq!(d.routed_from, "acme/fast");
323        assert_eq!(d.provider_id.as_deref(), Some("foundry"));
324        assert_eq!(
325            d.upstream_base.as_deref(),
326            Some("https://acme.services.ai.azure.com/openai")
327        );
328        assert!(
329            d.credential.is_some(),
330            "foundry has api_key_env — credential must be injected"
331        );
332        assert_eq!(d.account_ref.as_deref(), Some("finance-prod"));
333        assert_eq!(d.region.as_deref(), Some("eastus"));
334        assert!(d.gateway_managed_credential);
335    }
336
337    #[test]
338    fn alias_model_only_keeps_upstream() {
339        let mut body =
340            json!({"model": "claude-opus-4-5", "messages": [{"role":"user","content":"hi"}]});
341        let d = route_request(
342            &mut body,
343            "Anthropic",
344            &upstreams_with_foundry(),
345            &rules(&[("claude-opus-4-5", "claude-sonnet-4-5")]),
346            false,
347        )
348        .expect("routed");
349        assert_eq!(body["model"], "claude-sonnet-4-5");
350        assert_eq!(d.upstream_base, None);
351        assert_eq!(d.provider_id, None);
352        assert_eq!(d.credential, None);
353        assert_eq!(d.account_ref, None);
354        assert_eq!(d.region, None);
355        assert!(!d.gateway_managed_credential);
356    }
357
358    #[test]
359    fn cross_shape_target_is_passthrough_when_xlat_not_allowed() {
360        // Anthropic request → OpenAI-shape foundry with xlat_ok=false (wrong
361        // path, e.g. count_tokens): must stay passthrough.
362        let mut body =
363            json!({"model": "claude-opus-4-5", "messages": [{"role":"user","content":"hi"}]});
364        let before = body.clone();
365        let d = route_request(
366            &mut body,
367            "Anthropic",
368            &upstreams_with_foundry(),
369            &rules(&[("claude-opus-4-5", "foundry:gpt-4o-mini")]),
370            false,
371        );
372        assert_eq!(d, None);
373        assert_eq!(body, before, "fail-open must leave the body untouched");
374    }
375
376    #[cfg(feature = "shape-xlat")]
377    #[test]
378    fn cross_shape_target_routes_with_translation_flag() {
379        // enterprise#16: with the feature compiled in and the caller vouching
380        // for the path, Anthropic → OpenAI-shape routes and marks xlat.
381        let mut body =
382            json!({"model": "claude-opus-4-5", "messages": [{"role":"user","content":"hi"}]});
383        let d = route_request(
384            &mut body,
385            "Anthropic",
386            &upstreams_with_foundry(),
387            &rules(&[("claude-opus-4-5", "foundry:gpt-4o-mini")]),
388            true,
389        )
390        .expect("cross-shape route with translation");
391        assert!(d.xlat, "decision must carry the translation flag");
392        assert_eq!(body["model"], "gpt-4o-mini");
393        assert_eq!(d.provider_id.as_deref(), Some("foundry"));
394        assert!(d.credential.is_some());
395
396        // Within-shape decisions never set xlat.
397        let mut body2 = json!({"model": "acme/fast", "messages": [{"role":"user","content":"hi"}]});
398        let d2 = route_request(
399            &mut body2,
400            "OpenAI",
401            &upstreams_with_foundry(),
402            &rules(&[("acme/fast", "foundry:gpt-4o-mini")]),
403            true,
404        )
405        .expect("within-shape route");
406        assert!(!d2.xlat);
407    }
408
409    #[cfg(feature = "shape-xlat")]
410    #[test]
411    fn cross_shape_needs_gateway_credential_or_local_target() {
412        // An OpenAI-shape target without api_key_env and not local cannot be
413        // reached with the caller's Anthropic credentials → passthrough.
414        let mut upstreams = upstreams_with_foundry();
415        upstreams.providers.push(ResolvedProvider {
416            id: "openaiish".into(),
417            shape: WireShape::OpenAi,
418            base_url: "https://oai-compat.example.com".into(),
419            api_key_env: None,
420            aws_region: None,
421            account_ref: None,
422            region: None,
423            local: false,
424            credential: lean_ctx::core::config::CredentialSource::Configured,
425        });
426        let mut body =
427            json!({"model": "claude-opus-4-5", "messages": [{"role":"user","content":"hi"}]});
428        let before = body.clone();
429        let d = route_request(
430            &mut body,
431            "Anthropic",
432            &upstreams,
433            &rules(&[("claude-opus-4-5", "openaiish:gpt-4o-mini")]),
434            true,
435        );
436        assert_eq!(d, None);
437        assert_eq!(body, before);
438
439        // The same target declared local (e.g. Ollama) needs no credential.
440        upstreams.providers.last_mut().unwrap().local = true;
441        let d = route_request(
442            &mut body,
443            "Anthropic",
444            &upstreams,
445            &rules(&[("claude-opus-4-5", "openaiish:llama3.3")]),
446            true,
447        )
448        .expect("local cross-shape target routes");
449        assert!(d.xlat);
450        assert_eq!(d.local, Some(true));
451    }
452
453    #[cfg(feature = "shape-xlat")]
454    #[test]
455    fn openai_to_anthropic_direction_stays_passthrough() {
456        // Only Anthropic→OpenAI is translated; the reverse pair passes through.
457        let mut body = json!({"model": "gpt-5.2", "messages": [{"role":"user","content":"hi"}]});
458        let d = route_request(
459            &mut body,
460            "OpenAI",
461            &upstreams_with_foundry(),
462            &rules(&[("gpt-5.2", "claudeish:claude-sonnet-4-5")]),
463            true,
464        );
465        assert_eq!(d, None);
466    }
467
468    #[test]
469    fn unknown_provider_and_disabled_rules_are_passthrough() {
470        let mut body = json!({"model": "m", "messages": [{"role":"user","content":"hi"}]});
471        let before = body.clone();
472        assert_eq!(
473            route_request(
474                &mut body,
475                "OpenAI",
476                &upstreams_with_foundry(),
477                &rules(&[("m", "nope:x")]),
478                false,
479            ),
480            None
481        );
482        // enabled=false → inactive even with rules present.
483        let mut off = rules(&[("m", "foundry:x")]);
484        off.enabled = Some(false);
485        assert_eq!(
486            route_request(&mut body, "OpenAI", &upstreams_with_foundry(), &off, false),
487            None
488        );
489        assert_eq!(body, before);
490    }
491
492    #[test]
493    fn gemini_and_chatgpt_labels_are_passthrough() {
494        let mut body = json!({"model":"m","messages":[{"role":"user","content":"hi"}]});
495        for label in ["Gemini", "ChatGPT"] {
496            assert_eq!(
497                route_request(
498                    &mut body,
499                    label,
500                    &upstreams_with_foundry(),
501                    &rules(&[("m", "x")]),
502                    false,
503                ),
504                None,
505                "{label} must not route in M1"
506            );
507        }
508    }
509
510    #[test]
511    fn a_removed_tier_table_never_rewrites_the_model() {
512        // v4 removed automatic model selection: a legacy `[proxy.routing.tiers]`
513        // table must neither activate the router nor change the request.
514        let mut legacy = rules(&[]);
515        legacy
516            .tiers
517            .insert("fast".to_string(), "foundry:phi-4".to_string());
518        assert!(!legacy.is_active());
519        let mut body = json!({"model":"gpt-5.2","messages":[{"role":"user","content":"where is the config?"}]});
520        let before = body.clone();
521        assert_eq!(
522            route_request(
523                &mut body,
524                "OpenAI",
525                &upstreams_with_foundry(),
526                &legacy,
527                false
528            ),
529            None
530        );
531        assert_eq!(body, before);
532    }
533}