1use lean_ctx::core::config::{
17 ResolvedProvider, RoutingRules, Upstreams, WireShape, parse_route_target,
18};
19
20#[derive(Debug, Clone, PartialEq)]
24pub struct RouteDecision {
25 pub model: String,
27 pub routed_from: String,
29 pub provider_id: Option<String>,
32 pub upstream_base: Option<String>,
34 pub credential: Option<ResolvedProvider>,
37 pub local: Option<bool>,
40 pub account_ref: Option<String>,
44 pub region: Option<String>,
46 pub gateway_managed_credential: bool,
48 pub xlat: bool,
52}
53
54pub 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 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; }
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#[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
125fn 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#[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 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 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 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 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 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 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 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 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}