1use chrono::{DateTime, Utc};
16use serde::{Deserialize, Serialize};
17use uuid::Uuid;
18
19use crate::driver_registry::OpenRouterRoutingConfig;
20use crate::typed_id::{ModelId, ModelRouterId};
21
22#[cfg(feature = "openapi")]
23use utoipa::ToSchema;
24
25#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
27#[cfg_attr(feature = "openapi", derive(ToSchema))]
28#[serde(rename_all = "lowercase")]
29pub enum ModelRouterStatus {
30 Active,
31 Archived,
32 Deleted,
33}
34
35impl std::fmt::Display for ModelRouterStatus {
36 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37 match self {
38 ModelRouterStatus::Active => write!(f, "active"),
39 ModelRouterStatus::Archived => write!(f, "archived"),
40 ModelRouterStatus::Deleted => write!(f, "deleted"),
41 }
42 }
43}
44
45impl From<&str> for ModelRouterStatus {
46 fn from(s: &str) -> Self {
47 match s {
48 "archived" => ModelRouterStatus::Archived,
49 "deleted" => ModelRouterStatus::Deleted,
50 _ => ModelRouterStatus::Active,
51 }
52 }
53}
54
55#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
57#[cfg_attr(feature = "openapi", derive(ToSchema))]
58#[serde(rename_all = "snake_case")]
59pub enum ModelRouterStrategy {
60 Single,
62 OrderedFallback,
65 Weighted,
67 Rules,
69 Custom,
72}
73
74impl std::fmt::Display for ModelRouterStrategy {
75 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76 match self {
77 ModelRouterStrategy::Single => write!(f, "single"),
78 ModelRouterStrategy::OrderedFallback => write!(f, "ordered_fallback"),
79 ModelRouterStrategy::Weighted => write!(f, "weighted"),
80 ModelRouterStrategy::Rules => write!(f, "rules"),
81 ModelRouterStrategy::Custom => write!(f, "custom"),
82 }
83 }
84}
85
86impl ModelRouterStrategy {
87 pub fn parse(s: &str) -> Result<Self, String> {
90 match s {
91 "single" => Ok(ModelRouterStrategy::Single),
92 "ordered_fallback" => Ok(ModelRouterStrategy::OrderedFallback),
93 "weighted" => Ok(ModelRouterStrategy::Weighted),
94 "rules" => Ok(ModelRouterStrategy::Rules),
95 "custom" => Ok(ModelRouterStrategy::Custom),
96 other => Err(format!(
97 "unknown model router strategy '{other}'; expected one of single, ordered_fallback, weighted, rules, custom"
98 )),
99 }
100 }
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
105#[cfg_attr(feature = "openapi", derive(ToSchema))]
106pub struct ModelRouter {
107 #[serde(rename = "id")]
109 #[cfg_attr(
110 feature = "openapi",
111 schema(value_type = String, example = "mrtr_01933b5a000070008000000000000001")
112 )]
113 pub public_id: ModelRouterId,
114 #[serde(skip, default = "Uuid::nil")]
116 pub internal_id: Uuid,
117 pub name: String,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub description: Option<String>,
122 #[serde(default = "default_empty_object")]
126 pub param_schema: serde_json::Value,
127 pub status: ModelRouterStatus,
128 pub created_at: DateTime<Utc>,
129 pub updated_at: DateTime<Utc>,
130 #[serde(skip_serializing_if = "Option::is_none")]
131 pub archived_at: Option<DateTime<Utc>>,
132 #[serde(skip_serializing_if = "Option::is_none")]
133 pub deleted_at: Option<DateTime<Utc>>,
134 #[serde(default, skip_serializing_if = "Vec::is_empty")]
136 pub routes: Vec<ModelRouterRoute>,
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
143#[cfg_attr(feature = "openapi", derive(ToSchema))]
144pub struct ModelRouterRoute {
145 pub id: Uuid,
146 pub key: String,
148 pub purpose: String,
150 pub when_to_use: String,
152 pub strategy: ModelRouterStrategy,
153 pub position: i32,
155 #[serde(default, skip_serializing_if = "Vec::is_empty")]
157 pub candidates: Vec<ModelRouterCandidate>,
158 pub created_at: DateTime<Utc>,
159 pub updated_at: DateTime<Utc>,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
166#[cfg_attr(feature = "openapi", derive(ToSchema))]
167pub struct ModelRouterCandidate {
168 pub id: Uuid,
169 #[cfg_attr(
171 feature = "openapi",
172 schema(value_type = String, example = "model_01933b5a000070008000000000000001")
173 )]
174 pub model_id: ModelId,
175 #[serde(default = "default_empty_object")]
180 pub request_overrides: serde_json::Value,
181 #[serde(default = "default_weight")]
183 pub weight: i32,
184 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub rules: Option<serde_json::Value>,
187 pub position: i32,
189 pub created_at: DateTime<Utc>,
190 pub updated_at: DateTime<Utc>,
191}
192
193fn default_weight() -> i32 {
194 1
195}
196
197fn default_empty_object() -> serde_json::Value {
198 serde_json::Value::Object(serde_json::Map::new())
199}
200
201pub const MAX_ROUTE_KEY_LEN: usize = 64;
203
204pub fn validate_route_key(key: &str) -> Result<(), String> {
208 if key.is_empty() {
209 return Err("route key must not be empty".into());
210 }
211 if key.len() > MAX_ROUTE_KEY_LEN {
212 return Err(format!(
213 "route key must be at most {MAX_ROUTE_KEY_LEN} characters"
214 ));
215 }
216 if !key
217 .bytes()
218 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
219 {
220 return Err("route key must contain only lowercase letters, digits, and hyphens".into());
221 }
222 if key.starts_with('-') || key.ends_with('-') {
223 return Err("route key must not start or end with a hyphen".into());
224 }
225 Ok(())
226}
227
228pub fn validate_candidate_shape(
232 candidate: &ModelRouterCandidate,
233 strategy: ModelRouterStrategy,
234) -> Result<(), String> {
235 if candidate.weight < 0 {
236 return Err(format!(
237 "candidate.weight must be non-negative, got {}",
238 candidate.weight
239 ));
240 }
241 match strategy {
242 ModelRouterStrategy::Rules => {
243 if candidate.rules.is_none() {
244 return Err(
245 "candidates under a 'rules' strategy must have a rules document set"
246 .to_string(),
247 );
248 }
249 }
250 ModelRouterStrategy::Single
251 | ModelRouterStrategy::OrderedFallback
252 | ModelRouterStrategy::Weighted
253 | ModelRouterStrategy::Custom => {
254 }
256 }
257 Ok(())
258}
259
260pub fn validate_route_shape(route: &ModelRouterRoute) -> Result<(), String> {
264 validate_route_key(&route.key)?;
265 if matches!(route.strategy, ModelRouterStrategy::Single) && route.candidates.len() != 1 {
266 return Err(format!(
267 "route '{}' has strategy 'single' but {} candidates; single-strategy routes must have exactly one candidate",
268 route.key,
269 route.candidates.len()
270 ));
271 }
272 for candidate in &route.candidates {
273 validate_candidate_shape(candidate, route.strategy)?;
274 }
275 Ok(())
276}
277
278#[derive(Debug, Clone, PartialEq)]
280pub struct OpenRouterRoutePlan {
281 pub primary_model: String,
283 pub routing: Option<OpenRouterRoutingConfig>,
286}
287
288pub fn compile_openrouter_route_plan(
296 route: &ModelRouterRoute,
297 model_slug_for_candidate: impl Fn(&ModelRouterCandidate) -> Option<String>,
298) -> Result<OpenRouterRoutePlan, String> {
299 validate_route_shape(route)?;
300
301 match route.strategy {
302 ModelRouterStrategy::Single => {
303 let candidate = route
304 .candidates
305 .first()
306 .ok_or_else(|| format!("route '{}' has no candidates", route.key))?;
307 let primary_model = model_slug_for_candidate(candidate).ok_or_else(|| {
308 format!(
309 "route '{}' candidate '{}' does not resolve to an OpenRouter model slug",
310 route.key, candidate.id
311 )
312 })?;
313 Ok(OpenRouterRoutePlan {
314 primary_model,
315 routing: None,
316 })
317 }
318 ModelRouterStrategy::OrderedFallback => {
319 let mut candidates = route.candidates.iter().collect::<Vec<_>>();
320 candidates.sort_by_key(|candidate| candidate.position);
321
322 let mut models = Vec::with_capacity(candidates.len());
323 for candidate in candidates {
324 let slug = model_slug_for_candidate(candidate).ok_or_else(|| {
325 format!(
326 "route '{}' candidate '{}' does not resolve to an OpenRouter model slug",
327 route.key, candidate.id
328 )
329 })?;
330 models.push(slug);
331 }
332
333 let primary_model = models
334 .first()
335 .cloned()
336 .ok_or_else(|| format!("route '{}' has no candidates", route.key))?;
337 Ok(OpenRouterRoutePlan {
338 primary_model,
339 routing: Some(OpenRouterRoutingConfig::fallback_models(models)),
340 })
341 }
342 ModelRouterStrategy::Weighted
343 | ModelRouterStrategy::Rules
344 | ModelRouterStrategy::Custom => Err(format!(
345 "route '{}' strategy '{}' cannot be compiled directly to OpenRouter fallback routing",
346 route.key, route.strategy
347 )),
348 }
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354
355 fn now() -> DateTime<Utc> {
356 DateTime::<Utc>::from_timestamp(1_700_000_000, 0).unwrap()
357 }
358
359 fn candidate(weight: i32, rules: Option<serde_json::Value>) -> ModelRouterCandidate {
360 candidate_with(weight, rules, 0, 1)
361 }
362
363 fn candidate_with(
364 weight: i32,
365 rules: Option<serde_json::Value>,
366 position: i32,
367 model_seed: u128,
368 ) -> ModelRouterCandidate {
369 ModelRouterCandidate {
370 id: Uuid::from_u128(model_seed),
371 model_id: ModelId::from_seed(model_seed),
372 request_overrides: serde_json::Value::Null,
373 weight,
374 rules,
375 position,
376 created_at: now(),
377 updated_at: now(),
378 }
379 }
380
381 fn route(
382 strategy: ModelRouterStrategy,
383 candidates: Vec<ModelRouterCandidate>,
384 ) -> ModelRouterRoute {
385 ModelRouterRoute {
386 id: Uuid::nil(),
387 key: "base".into(),
388 purpose: "default route".into(),
389 when_to_use: "use this when no specific route fits".into(),
390 strategy,
391 position: 0,
392 candidates,
393 created_at: now(),
394 updated_at: now(),
395 }
396 }
397
398 #[test]
399 fn status_round_trip() {
400 assert_eq!(ModelRouterStatus::from("active").to_string(), "active");
401 assert_eq!(ModelRouterStatus::from("archived").to_string(), "archived");
402 assert_eq!(ModelRouterStatus::from("deleted").to_string(), "deleted");
403 assert_eq!(ModelRouterStatus::from("unknown").to_string(), "active");
404 }
405
406 #[test]
407 fn strategy_parse_round_trip() {
408 for s in ["single", "ordered_fallback", "weighted", "rules", "custom"] {
409 assert_eq!(ModelRouterStrategy::parse(s).unwrap().to_string(), s);
410 }
411 }
412
413 #[test]
414 fn strategy_parse_rejects_unknown() {
415 let err = ModelRouterStrategy::parse("invalid").unwrap_err();
416 assert!(err.contains("unknown model router strategy"));
417 }
418
419 #[test]
420 fn route_key_accepts_canonical_keys() {
421 for key in ["base", "utility", "analysis", "review", "fast-path", "v1"] {
422 assert!(validate_route_key(key).is_ok(), "should accept key '{key}'");
423 }
424 }
425
426 #[test]
427 fn route_key_rejects_empty() {
428 assert!(validate_route_key("").is_err());
429 }
430
431 #[test]
432 fn route_key_rejects_uppercase() {
433 assert!(validate_route_key("Analysis").is_err());
434 }
435
436 #[test]
437 fn route_key_rejects_underscore() {
438 assert!(validate_route_key("fast_path").is_err());
439 }
440
441 #[test]
442 fn route_key_rejects_leading_hyphen() {
443 assert!(validate_route_key("-fast").is_err());
444 }
445
446 #[test]
447 fn route_key_rejects_trailing_hyphen() {
448 assert!(validate_route_key("fast-").is_err());
449 }
450
451 #[test]
452 fn route_key_rejects_too_long() {
453 let key = "a".repeat(MAX_ROUTE_KEY_LEN + 1);
454 assert!(validate_route_key(&key).is_err());
455 }
456
457 #[test]
458 fn candidate_shape_rejects_negative_weight() {
459 let cand = candidate(-1, None);
460 assert!(validate_candidate_shape(&cand, ModelRouterStrategy::Weighted).is_err());
461 }
462
463 #[test]
464 fn candidate_shape_rules_strategy_requires_rules_doc() {
465 let cand = candidate(1, None);
466 let err = validate_candidate_shape(&cand, ModelRouterStrategy::Rules).unwrap_err();
467 assert!(err.contains("rules"));
468 }
469
470 #[test]
471 fn candidate_shape_rules_strategy_accepts_rules_doc() {
472 let cand = candidate(1, Some(serde_json::json!({ "if": { "tier": "fast" } })));
473 assert!(validate_candidate_shape(&cand, ModelRouterStrategy::Rules).is_ok());
474 }
475
476 #[test]
477 fn route_shape_rejects_single_with_multiple_candidates() {
478 let route = ModelRouterRoute {
479 id: Uuid::nil(),
480 key: "base".into(),
481 purpose: "default route".into(),
482 when_to_use: "use this when no specific route fits".into(),
483 strategy: ModelRouterStrategy::Single,
484 position: 0,
485 candidates: vec![candidate(1, None), candidate(1, None)],
486 created_at: now(),
487 updated_at: now(),
488 };
489 let err = validate_route_shape(&route).unwrap_err();
490 assert!(err.contains("single"));
491 }
492
493 #[test]
494 fn route_shape_rejects_single_with_zero_candidates() {
495 let route = ModelRouterRoute {
496 id: Uuid::nil(),
497 key: "base".into(),
498 purpose: "default route".into(),
499 when_to_use: "use this when no specific route fits".into(),
500 strategy: ModelRouterStrategy::Single,
501 position: 0,
502 candidates: vec![],
503 created_at: now(),
504 updated_at: now(),
505 };
506 let err = validate_route_shape(&route).unwrap_err();
507 assert!(err.contains("single"));
508 }
509
510 #[test]
511 fn route_shape_accepts_single_with_exactly_one_candidate() {
512 let route = ModelRouterRoute {
513 id: Uuid::nil(),
514 key: "base".into(),
515 purpose: "default route".into(),
516 when_to_use: "use this when no specific route fits".into(),
517 strategy: ModelRouterStrategy::Single,
518 position: 0,
519 candidates: vec![candidate(1, None)],
520 created_at: now(),
521 updated_at: now(),
522 };
523 assert!(validate_route_shape(&route).is_ok());
524 }
525
526 #[test]
527 fn route_shape_accepts_ordered_fallback_with_multiple_candidates() {
528 let route = route(
529 ModelRouterStrategy::OrderedFallback,
530 vec![candidate(1, None), candidate(1, None)],
531 );
532 assert!(validate_route_shape(&route).is_ok());
533 }
534
535 #[test]
536 fn openrouter_plan_single_returns_primary_without_routing() {
537 let route = route(ModelRouterStrategy::Single, vec![candidate(1, None)]);
538
539 let plan = compile_openrouter_route_plan(&route, |candidate| {
540 assert_eq!(candidate.model_id, ModelId::from_seed(1));
541 Some("openai/gpt-5-mini".to_string())
542 })
543 .unwrap();
544
545 assert_eq!(plan.primary_model, "openai/gpt-5-mini");
546 assert_eq!(plan.routing, None);
547 }
548
549 #[test]
550 fn openrouter_plan_ordered_fallback_preserves_candidate_order() {
551 let route = route(
552 ModelRouterStrategy::OrderedFallback,
553 vec![
554 candidate_with(1, None, 10, 2),
555 candidate_with(1, None, 0, 1),
556 ],
557 );
558
559 let plan = compile_openrouter_route_plan(&route, |candidate| {
560 if candidate.model_id == ModelId::from_seed(1) {
561 Some("openai/gpt-5-mini".to_string())
562 } else if candidate.model_id == ModelId::from_seed(2) {
563 Some("anthropic/claude-sonnet-4.5".to_string())
564 } else {
565 None
566 }
567 })
568 .unwrap();
569
570 assert_eq!(plan.primary_model, "openai/gpt-5-mini");
571 let routing = plan.routing.unwrap();
572 assert_eq!(
573 routing.models,
574 vec![
575 "openai/gpt-5-mini".to_string(),
576 "anthropic/claude-sonnet-4.5".to_string(),
577 ]
578 );
579 assert_eq!(
580 routing.route,
581 Some(crate::driver_registry::OpenRouterRoute::Fallback)
582 );
583 }
584
585 #[test]
586 fn openrouter_plan_rejects_uncompiled_strategies() {
587 let route = route(ModelRouterStrategy::Weighted, vec![candidate(1, None)]);
588
589 let err = compile_openrouter_route_plan(&route, |_| Some("openai/gpt-5-mini".to_string()))
590 .unwrap_err();
591
592 assert!(err.contains("cannot be compiled directly"));
593 }
594
595 #[test]
596 fn openrouter_plan_rejects_missing_model_slug() {
597 let route = route(ModelRouterStrategy::Single, vec![candidate(1, None)]);
598
599 let err = compile_openrouter_route_plan(&route, |_| None).unwrap_err();
600
601 assert!(err.contains("does not resolve to an OpenRouter model slug"));
602 }
603
604 #[test]
605 fn candidate_default_weight_is_one() {
606 let json = r#"{
607 "id": "00000000-0000-0000-0000-000000000000",
608 "model_id": "model_00000000000000000000000000000001",
609 "position": 0,
610 "created_at": "2024-01-01T00:00:00Z",
611 "updated_at": "2024-01-01T00:00:00Z"
612 }"#;
613 let cand: ModelRouterCandidate = serde_json::from_str(json).unwrap();
614 assert_eq!(cand.weight, 1);
615 }
616
617 #[test]
618 fn candidate_default_request_overrides_is_empty_object() {
619 let json = r#"{
623 "id": "00000000-0000-0000-0000-000000000000",
624 "model_id": "model_00000000000000000000000000000001",
625 "position": 0,
626 "created_at": "2024-01-01T00:00:00Z",
627 "updated_at": "2024-01-01T00:00:00Z"
628 }"#;
629 let cand: ModelRouterCandidate = serde_json::from_str(json).unwrap();
630 assert!(
631 cand.request_overrides.is_object(),
632 "expected default request_overrides to be a JSON object, got {:?}",
633 cand.request_overrides
634 );
635 assert_eq!(cand.request_overrides.as_object().unwrap().len(), 0);
636 }
637
638 #[test]
639 fn router_default_param_schema_is_empty_object() {
640 let json = r#"{
643 "id": "mrtr_00000000000000000000000000000001",
644 "name": "default",
645 "status": "active",
646 "created_at": "2024-01-01T00:00:00Z",
647 "updated_at": "2024-01-01T00:00:00Z"
648 }"#;
649 let router: ModelRouter = serde_json::from_str(json).unwrap();
650 assert!(
651 router.param_schema.is_object(),
652 "expected default param_schema to be a JSON object, got {:?}",
653 router.param_schema
654 );
655 assert_eq!(router.param_schema.as_object().unwrap().len(), 0);
656 }
657}