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 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#[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 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 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 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 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 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 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 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 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}