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