1use super::routing_feedback::RoutingFeedback;
27use crate::core::config::{
28 ResolvedProvider, RoutingRules, Upstreams, WireShape, parse_route_target,
29};
30use crate::core::ocla::registry::OclaRegistry;
31use crate::core::ocla::types::{ModelRouteRequest, OclaRequestContext};
32use std::sync::OnceLock;
33
34static ROUTING_FEEDBACK: OnceLock<RoutingFeedback> = OnceLock::new();
35
36#[cfg(test)]
37use crate::core::ocla::builtin::model_router::BuiltinModelRouter;
38#[cfg(test)]
39use crate::core::ocla::registry::with_test_registry;
40#[cfg(test)]
41use std::sync::Arc;
42
43#[derive(Debug, Clone, PartialEq)]
47pub struct RouteDecision {
48 pub model: String,
50 pub routed_from: String,
52 pub provider_id: Option<String>,
55 pub upstream_base: Option<String>,
57 pub credential: Option<ResolvedProvider>,
60 pub local: Option<bool>,
63 pub xlat: bool,
67}
68
69const CLASSIFY_QUERY_CAP: usize = 2000;
72
73pub fn route_request(
83 parsed: &mut serde_json::Value,
84 provider_label: &str,
85 upstreams: &Upstreams,
86 rules: &RoutingRules,
87 xlat_ok: bool,
88) -> Option<RouteDecision> {
89 if ROUTING_FEEDBACK
90 .get_or_init(RoutingFeedback::new)
91 .should_use_fallback()
92 {
93 tracing::warn!("routing quality below threshold, using fallback");
94 return None;
95 }
96 if !rules.is_active() {
97 return None;
98 }
99 let request_shape = match provider_label {
102 "Anthropic" => WireShape::Anthropic,
103 "OpenAI" => WireShape::OpenAi,
104 _ => return None,
105 };
106 let requested = parsed.get("model")?.as_str()?.trim().to_string();
107 if requested.is_empty() {
108 return None;
109 }
110
111 #[cfg(test)]
112 let _registry_guard = {
113 let mut registry = OclaRegistry::with_builtins();
114 registry.model_router = Arc::new(BuiltinModelRouter::with_rules(rules.clone()));
115 Some(with_test_registry(registry))
116 };
117
118 let target = rules.aliases.get(&requested).cloned().or_else(|| {
119 let content_ref = extract_user_query(parsed, request_shape)?;
120 let request_id = format!(
121 "proxy-routing:{}",
122 blake3::hash(&serde_json::to_vec(parsed).ok()?).to_hex()
123 );
124 let request = ModelRouteRequest {
125 context: OclaRequestContext {
126 request_id,
127 session_id: "proxy-routing".into(),
128 agent_id: "proxy-routing".into(),
129 content_ref,
130 tenant_id: None,
131 trace_id: "tr-unit".into(),
132 },
133 candidate_models: vec![requested.clone()],
134 maximum_cost_micros: None,
135 maximum_latency_ms: None,
136 };
137 let decision = OclaRegistry::global()
138 .model_router
139 .route_model(request)
140 .ok()?;
141 if decision.model == requested {
142 None
143 } else if decision.provider.is_empty() {
144 Some(decision.model)
145 } else {
146 Some(format!("{}:{}", decision.provider, decision.model))
147 }
148 })?;
149 let (provider, new_model) = parse_route_target(&target)?;
150 let new_model = new_model.to_string();
151
152 let resolved = match provider {
153 None => ResolvedTarget::default(),
154 Some(p) => resolve_provider(p, request_shape, upstreams, xlat_ok)?,
155 };
156
157 if new_model == requested && resolved.upstream_base.is_none() {
158 return None; }
160
161 parsed["model"] = serde_json::Value::String(new_model.clone());
162 let decision = RouteDecision {
163 model: new_model,
164 routed_from: requested,
165 provider_id: resolved.provider_id,
166 upstream_base: resolved.upstream_base,
167 credential: resolved.credential,
168 local: resolved.local,
169 xlat: resolved.xlat,
170 };
171 Some(decision)
172}
173
174#[derive(Default)]
176struct ResolvedTarget {
177 provider_id: Option<String>,
178 upstream_base: Option<String>,
179 credential: Option<ResolvedProvider>,
180 local: Option<bool>,
181 xlat: bool,
182}
183
184fn resolve_provider(
189 name: &str,
190 request_shape: WireShape,
191 upstreams: &Upstreams,
192 xlat_ok: bool,
193) -> Option<ResolvedTarget> {
194 let (target_shape, base_url, credential, local) = match name {
195 "anthropic" => (
196 WireShape::Anthropic,
197 upstreams.anthropic.clone(),
198 None,
199 None,
200 ),
201 "openai" => (WireShape::OpenAi, upstreams.openai.clone(), None, None),
202 "gemini" => (WireShape::Gemini, upstreams.gemini.clone(), None, None),
203 id => {
204 let Some(p) = upstreams.provider_by_id(id) else {
205 tracing::warn!(
206 "[proxy.routing] target provider '{id}' not in [[proxy.providers]] — passthrough"
207 );
208 return None;
209 };
210 (
211 p.shape,
212 p.base_url.clone(),
213 p.api_key_env.is_some().then(|| p.clone()),
214 Some(p.local),
215 )
216 }
217 };
218 let xlat = if target_shape == request_shape {
219 false
220 } else if can_translate(
221 request_shape,
222 target_shape,
223 xlat_ok,
224 credential.as_ref(),
225 local,
226 ) {
227 true
228 } else {
229 tracing::warn!(
230 "[proxy.routing] target '{name}' speaks {} but the request is {} — \
231 not translatable here, passthrough",
232 target_shape.as_str(),
233 request_shape.as_str()
234 );
235 return None;
236 };
237 Some(ResolvedTarget {
238 provider_id: Some(name.to_string()),
239 upstream_base: Some(base_url),
240 credential,
241 local,
242 xlat,
243 })
244}
245
246#[cfg(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 xlat_ok
259 && request_shape == WireShape::Anthropic
260 && target_shape == WireShape::OpenAi
261 && (credential.is_some() || local == Some(true))
262}
263
264#[cfg(not(feature = "shape-xlat"))]
265fn can_translate(
266 _request_shape: WireShape,
267 _target_shape: WireShape,
268 _xlat_ok: bool,
269 _credential: Option<&ResolvedProvider>,
270 _local: Option<bool>,
271) -> bool {
272 false
273}
274
275fn extract_user_query(parsed: &serde_json::Value, shape: WireShape) -> Option<String> {
283 debug_assert!(matches!(shape, WireShape::Anthropic | WireShape::OpenAi));
284 let items = parsed.get("messages").or_else(|| parsed.get("input"))?;
285
286 if let Some(text) = items.as_str() {
288 return non_empty_prefix(text);
289 }
290 let items = items.as_array()?;
291 let last_user = items.iter().rev().find(|m| {
292 m.get("role").and_then(|r| r.as_str()) == Some("user")
293 || (m.get("type").and_then(|t| t.as_str()) == Some("message")
294 && m.get("role").and_then(|r| r.as_str()) == Some("user"))
295 })?;
296 let content = last_user.get("content")?;
297 if let Some(text) = content.as_str() {
298 return non_empty_prefix(text);
299 }
300 let parts = content.as_array()?;
301 let mut buf = String::new();
302 for part in parts {
303 let is_text = matches!(
304 part.get("type").and_then(|t| t.as_str()),
305 Some("text" | "input_text")
306 );
307 if is_text && let Some(t) = part.get("text").and_then(|t| t.as_str()) {
308 if !buf.is_empty() {
309 buf.push(' ');
310 }
311 buf.push_str(t);
312 if buf.len() >= CLASSIFY_QUERY_CAP {
313 break;
314 }
315 }
316 }
317 non_empty_prefix(&buf)
318}
319fn non_empty_prefix(text: &str) -> Option<String> {
320 let trimmed = text.trim();
321 if trimmed.is_empty() {
322 return None;
323 }
324 let mut end = trimmed.len().min(CLASSIFY_QUERY_CAP);
325 while !trimmed.is_char_boundary(end) {
326 end -= 1;
327 }
328 Some(trimmed[..end].to_string())
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334 use serde_json::json;
335
336 fn upstreams_with_foundry() -> Upstreams {
337 Upstreams {
338 anthropic: "https://api.anthropic.com".into(),
339 openai: "https://api.openai.com".into(),
340 chatgpt: "https://chatgpt.com".into(),
341 gemini: "https://generativelanguage.googleapis.com".into(),
342 providers: vec![
343 ResolvedProvider {
344 id: "foundry".into(),
345 shape: WireShape::OpenAi,
346 base_url: "https://acme.services.ai.azure.com/openai".into(),
347 api_key_env: Some("FOUNDRY_API_KEY".into()),
348 aws_region: None,
349 local: false,
350 },
351 ResolvedProvider {
352 id: "claudeish".into(),
353 shape: WireShape::Anthropic,
354 base_url: "https://anthropic-gw.example.com".into(),
355 api_key_env: None,
356 aws_region: None,
357 local: false,
358 },
359 ],
360 }
361 }
362
363 fn rules(aliases: &[(&str, &str)], tiers: &[(&str, &str)]) -> RoutingRules {
364 RoutingRules {
365 enabled: Some(true),
366 aliases: aliases
367 .iter()
368 .map(|(k, v)| (k.to_string(), v.to_string()))
369 .collect(),
370 tiers: tiers
371 .iter()
372 .map(|(k, v)| (k.to_string(), v.to_string()))
373 .collect(),
374 }
375 }
376
377 #[test]
378 fn route_decision_does_not_record_feedback_before_outcome() {
379 let feedback = ROUTING_FEEDBACK.get_or_init(RoutingFeedback::new);
380 let before = feedback.stats();
381 let mut body = json!({"model":"expensive","messages":[{"role":"user","content":"hi"}]});
382
383 let decision = route_request(
384 &mut body,
385 "OpenAI",
386 &upstreams_with_foundry(),
387 &rules(&[("expensive", "fast")], &[]),
388 false,
389 )
390 .expect("alias should route");
391
392 assert_eq!(decision.routed_from, "expensive");
393 assert_eq!(decision.model, "fast");
394 assert_eq!(feedback.stats(), before);
395 }
396
397 #[test]
398 fn alias_routes_to_registry_provider_and_rewrites_model() {
399 let mut body = json!({"model": "acme/fast", "messages": [{"role":"user","content":"hi"}]});
400 let d = route_request(
401 &mut body,
402 "OpenAI",
403 &upstreams_with_foundry(),
404 &rules(&[("acme/fast", "foundry:gpt-4o-mini")], &[]),
405 false,
406 )
407 .expect("routed");
408 assert_eq!(body["model"], "gpt-4o-mini");
409 assert_eq!(d.routed_from, "acme/fast");
410 assert_eq!(d.provider_id.as_deref(), Some("foundry"));
411 assert_eq!(
412 d.upstream_base.as_deref(),
413 Some("https://acme.services.ai.azure.com/openai")
414 );
415 assert!(
416 d.credential.is_some(),
417 "foundry has api_key_env — credential must be injected"
418 );
419 }
420
421 #[test]
422 fn alias_model_only_keeps_upstream() {
423 let mut body =
424 json!({"model": "claude-opus-4-5", "messages": [{"role":"user","content":"hi"}]});
425 let d = route_request(
426 &mut body,
427 "Anthropic",
428 &upstreams_with_foundry(),
429 &rules(&[("claude-opus-4-5", "claude-sonnet-4-5")], &[]),
430 false,
431 )
432 .expect("routed");
433 assert_eq!(body["model"], "claude-sonnet-4-5");
434 assert_eq!(d.upstream_base, None);
435 assert_eq!(d.provider_id, None);
436 assert_eq!(d.credential, None);
437 }
438
439 #[test]
440 fn cross_shape_target_is_passthrough_when_xlat_not_allowed() {
441 let mut body =
444 json!({"model": "claude-opus-4-5", "messages": [{"role":"user","content":"hi"}]});
445 let before = body.clone();
446 let d = route_request(
447 &mut body,
448 "Anthropic",
449 &upstreams_with_foundry(),
450 &rules(&[("claude-opus-4-5", "foundry:gpt-4o-mini")], &[]),
451 false,
452 );
453 assert_eq!(d, None);
454 assert_eq!(body, before, "fail-open must leave the body untouched");
455 }
456
457 #[cfg(feature = "shape-xlat")]
458 #[test]
459 fn cross_shape_target_routes_with_translation_flag() {
460 let mut body =
463 json!({"model": "claude-opus-4-5", "messages": [{"role":"user","content":"hi"}]});
464 let d = route_request(
465 &mut body,
466 "Anthropic",
467 &upstreams_with_foundry(),
468 &rules(&[("claude-opus-4-5", "foundry:gpt-4o-mini")], &[]),
469 true,
470 )
471 .expect("cross-shape route with translation");
472 assert!(d.xlat, "decision must carry the translation flag");
473 assert_eq!(body["model"], "gpt-4o-mini");
474 assert_eq!(d.provider_id.as_deref(), Some("foundry"));
475 assert!(d.credential.is_some());
476
477 let mut body2 = json!({"model": "acme/fast", "messages": [{"role":"user","content":"hi"}]});
479 let d2 = route_request(
480 &mut body2,
481 "OpenAI",
482 &upstreams_with_foundry(),
483 &rules(&[("acme/fast", "foundry:gpt-4o-mini")], &[]),
484 true,
485 )
486 .expect("within-shape route");
487 assert!(!d2.xlat);
488 }
489
490 #[cfg(feature = "shape-xlat")]
491 #[test]
492 fn cross_shape_needs_gateway_credential_or_local_target() {
493 let mut upstreams = upstreams_with_foundry();
496 upstreams.providers.push(ResolvedProvider {
497 id: "openaiish".into(),
498 shape: WireShape::OpenAi,
499 base_url: "https://oai-compat.example.com".into(),
500 api_key_env: None,
501 aws_region: None,
502 local: false,
503 });
504 let mut body =
505 json!({"model": "claude-opus-4-5", "messages": [{"role":"user","content":"hi"}]});
506 let before = body.clone();
507 let d = route_request(
508 &mut body,
509 "Anthropic",
510 &upstreams,
511 &rules(&[("claude-opus-4-5", "openaiish:gpt-4o-mini")], &[]),
512 true,
513 );
514 assert_eq!(d, None);
515 assert_eq!(body, before);
516
517 upstreams.providers.last_mut().unwrap().local = true;
519 let d = route_request(
520 &mut body,
521 "Anthropic",
522 &upstreams,
523 &rules(&[("claude-opus-4-5", "openaiish:llama3.3")], &[]),
524 true,
525 )
526 .expect("local cross-shape target routes");
527 assert!(d.xlat);
528 assert_eq!(d.local, Some(true));
529 }
530
531 #[cfg(feature = "shape-xlat")]
532 #[test]
533 fn openai_to_anthropic_direction_stays_passthrough() {
534 let mut body = json!({"model": "gpt-5.2", "messages": [{"role":"user","content":"hi"}]});
536 let d = route_request(
537 &mut body,
538 "OpenAI",
539 &upstreams_with_foundry(),
540 &rules(&[("gpt-5.2", "claudeish:claude-sonnet-4-5")], &[]),
541 true,
542 );
543 assert_eq!(d, None);
544 }
545
546 #[test]
547 fn unknown_provider_and_disabled_rules_are_passthrough() {
548 let mut body = json!({"model": "m", "messages": [{"role":"user","content":"hi"}]});
549 let before = body.clone();
550 assert_eq!(
551 route_request(
552 &mut body,
553 "OpenAI",
554 &upstreams_with_foundry(),
555 &rules(&[("m", "nope:x")], &[]),
556 false,
557 ),
558 None
559 );
560 let mut off = rules(&[("m", "foundry:x")], &[]);
562 off.enabled = Some(false);
563 assert_eq!(
564 route_request(&mut body, "OpenAI", &upstreams_with_foundry(), &off, false),
565 None
566 );
567 assert_eq!(body, before);
568 }
569
570 #[test]
571 fn tier_downgrade_routes_simple_queries_to_cheap_model() {
572 let mut body = json!({
577 "model": "gpt-5.2",
578 "messages": [
579 {"role":"system","content":"be helpful"},
580 {"role":"user","content":"where is the config file for the proxy?"}
581 ]
582 });
583 let d = route_request(
584 &mut body,
585 "OpenAI",
586 &upstreams_with_foundry(),
587 &rules(
588 &[],
589 &[("fast", "foundry:phi-4"), ("standard", "foundry:phi-4")],
590 ),
591 false,
592 )
593 .expect("non-premium query must route");
594 assert_eq!(body["model"], "phi-4");
595 assert_eq!(d.routed_from, "gpt-5.2");
596 assert_eq!(d.provider_id.as_deref(), Some("foundry"));
597 }
598
599 #[test]
600 fn premium_tier_unset_keeps_requested_model() {
601 let mut body = json!({
604 "model": "gpt-5.2",
605 "messages": [{"role":"user","content":
606 "implement a new distributed lock manager with leader election and fencing tokens"}]
607 });
608 let before = body.clone();
609 let d = route_request(
610 &mut body,
611 "OpenAI",
612 &upstreams_with_foundry(),
613 &rules(&[], &[("fast", "foundry:phi-4"), ("premium", "")]),
614 false,
615 );
616 assert_eq!(d, None);
617 assert_eq!(body, before);
618 }
619
620 #[test]
621 fn responses_input_string_and_items_are_extractable() {
622 let s = json!({"model":"m","input":"quick question about rust"});
623 assert!(extract_user_query(&s, WireShape::OpenAi).is_some());
624
625 let items = json!({"model":"m","input":[
626 {"type":"message","role":"user","content":[{"type":"input_text","text":"what does this do"}]}
627 ]});
628 assert_eq!(
629 extract_user_query(&items, WireShape::OpenAi).as_deref(),
630 Some("what does this do")
631 );
632
633 let anthropic = json!({"model":"m","messages":[
634 {"role":"user","content":[{"type":"text","text":"first"}]},
635 {"role":"assistant","content":"a"},
636 {"role":"user","content":[{"type":"text","text":"latest question"}]}
637 ]});
638 assert_eq!(
639 extract_user_query(&anthropic, WireShape::Anthropic).as_deref(),
640 Some("latest question")
641 );
642 }
643
644 #[test]
645 fn missing_model_or_query_is_passthrough() {
646 let mut no_model = json!({"messages":[{"role":"user","content":"hi"}]});
647 assert_eq!(
648 route_request(
649 &mut no_model,
650 "OpenAI",
651 &upstreams_with_foundry(),
652 &rules(&[], &[("fast", "foundry:phi-4")]),
653 false,
654 ),
655 None
656 );
657 let mut no_user = json!({"model":"m","messages":[{"role":"system","content":"x"}]});
658 assert_eq!(
659 route_request(
660 &mut no_user,
661 "OpenAI",
662 &upstreams_with_foundry(),
663 &rules(&[], &[("fast", "foundry:phi-4")]),
664 false,
665 ),
666 None
667 );
668 }
669
670 #[test]
671 fn gemini_and_chatgpt_labels_are_passthrough() {
672 let mut body = json!({"model":"m","messages":[{"role":"user","content":"hi"}]});
673 for label in ["Gemini", "ChatGPT"] {
674 assert_eq!(
675 route_request(
676 &mut body,
677 label,
678 &upstreams_with_foundry(),
679 &rules(&[("m", "x")], &[]),
680 false,
681 ),
682 None,
683 "{label} must not route in M1"
684 );
685 }
686 }
687
688 #[test]
689 fn poor_feedback_triggers_fallback() {
690 let feedback = RoutingFeedback::new();
691 feedback.record_outcome("expensive", "fast", 0.4, 0, 0);
692 assert!(feedback.should_use_fallback());
693 }
694}