1use crate::core::config::{
27 ResolvedProvider, RoutingRules, Upstreams, WireShape, parse_route_target,
28};
29use crate::core::intent_engine::{classify, route_intent};
30
31#[derive(Debug, Clone, PartialEq)]
35pub struct RouteDecision {
36 pub model: String,
38 pub routed_from: String,
40 pub provider_id: Option<String>,
43 pub upstream_base: Option<String>,
45 pub credential: Option<ResolvedProvider>,
48 pub local: Option<bool>,
51 pub xlat: bool,
55}
56
57const CLASSIFY_QUERY_CAP: usize = 2000;
60
61pub 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 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; }
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#[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
131fn 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#[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
222fn 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
244fn 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 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 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 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 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 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 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 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 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 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 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}