1use std::collections::{HashMap, HashSet, hash_map::DefaultHasher};
21use std::hash::{Hash, Hasher};
22use std::sync::atomic::{AtomicBool, Ordering};
23
24use async_trait::async_trait;
25use parking_lot::Mutex;
26use serde::Deserialize;
27use switchyard_protocol::{Category, ContentBlock, Message, ModelId, Request, Role};
28
29use crate::core::algorithm::{Driver, RoutingIdentity};
30use crate::core::classifier::{Classification, Classifier, Score};
31use crate::core::processor::{Event, Processor};
32
33const MAX_ASSIGNMENTS: usize = 4096;
36
37#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
39#[serde(rename_all = "snake_case")]
40pub enum ClassifyTrigger {
41 #[default]
43 EveryRequest,
44 UserTurn,
46 NewSession,
48}
49
50fn is_user_turn(message: &Message) -> bool {
53 message.role == Role::User
54 && !message
55 .content
56 .iter()
57 .all(|block| matches!(block, ContentBlock::ToolResult(_)))
58}
59
60pub(crate) fn has_new_user_turn(messages: &[Message]) -> bool {
61 messages.last().is_some_and(is_user_turn)
62}
63
64#[derive(Default)]
75pub struct AffinityRouter {
76 latch_only: Option<HashSet<ModelId>>,
78 subagents_only: bool,
80 message_hash_fallback: bool,
82 release_on_user_turn: bool,
84 assignments: Mutex<HashMap<RoutingIdentity, (ModelId, Option<Category>)>>,
89 unkeyed_warning_emitted: AtomicBool,
91}
92
93impl AffinityRouter {
94 pub fn new() -> Self {
96 Self::default()
97 }
98
99 pub fn for_subagents() -> Self {
104 Self {
105 subagents_only: true,
106 ..Self::default()
107 }
108 }
109
110 pub fn with_message_hash_fallback(mut self) -> Self {
112 self.message_hash_fallback = true;
113 self
114 }
115
116 pub fn with_release_on_user_turn(mut self) -> Self {
118 self.release_on_user_turn = true;
119 self
120 }
121
122 pub fn with_latch_only(mut self, models: impl IntoIterator<Item = impl Into<ModelId>>) -> Self {
125 self.latch_only = Some(models.into_iter().map(Into::into).collect());
126 self
127 }
128
129 fn should_latch(&self, model: &str) -> bool {
131 self.latch_only
132 .as_ref()
133 .is_none_or(|set| set.contains(model))
134 }
135
136 fn affinity_key(&self, request: &Request) -> Option<RoutingIdentity> {
138 let metadata = request.metadata.as_ref();
139 let is_subagent = metadata.is_some_and(|metadata| metadata.is_subagent);
140 let is_subagent_work = metadata.is_some_and(|metadata| metadata.is_subagent_work());
141 if self.subagents_only && !is_subagent_work {
143 return None;
144 }
145
146 let key = retention_key(request, self.message_hash_fallback && !is_subagent);
149 if key.is_none() && self.should_warn_unkeyed() {
152 tracing::warn!(
153 target: "libsy",
154 is_subagent,
155 message_hash_fallback = self.message_hash_fallback,
156 "affinity is enabled but this request carries no usable identity, so no \
157 affinity is applied; root requests need a session id or message-hash \
158 fallback with usable first-user text, and child requests need both session \
159 and agent ids"
160 );
161 }
162 key
163 }
164
165 fn should_warn_unkeyed(&self) -> bool {
167 !self.unkeyed_warning_emitted.swap(true, Ordering::Relaxed)
168 }
169}
170
171#[async_trait]
172impl<S> Processor<S> for AffinityRouter
173where
174 S: Send + 'static,
175{
176 async fn process(&self, _state: &mut S, event: Event<'_>) -> crate::Result<()> {
177 if let Event::Decision {
178 request,
179 selected_model_id,
180 category,
181 ..
182 } = event
183 && let Some(key) = self.affinity_key(request)
184 {
185 let mut assignments = self.assignments.lock();
186 let writable = self.release_on_user_turn || !assignments.contains_key(&key);
187 if self.should_latch(selected_model_id) && writable {
188 evict_if_full(&mut assignments);
189 assignments.insert(key, (selected_model_id.clone(), category));
190 }
191 }
192 Ok(())
193 }
194}
195
196pub(crate) fn retention_key(request: &Request, hash_fallback: bool) -> Option<RoutingIdentity> {
202 if let Some(identity) = RoutingIdentity::from_request(request) {
203 return Some(identity);
204 }
205 if !hash_fallback {
206 return None;
207 }
208 first_user_message_hash(request).map(|hash| {
209 tracing::debug!(retention_key = %hash, "retaining by message hash fallback");
210 RoutingIdentity::Session(hash)
211 })
212}
213
214fn first_user_message_hash(request: &Request) -> Option<String> {
218 let message = request
219 .llm_request
220 .messages
221 .iter()
222 .find(|message| message.role == Role::User)?;
223 let mut hasher = DefaultHasher::new();
224 message.text_content("")?.hash(&mut hasher);
225 Some(format!("{:016x}", hasher.finish()))
226}
227
228#[async_trait]
229impl<S> Classifier<S> for AffinityRouter
230where
231 S: Send + 'static,
232{
233 async fn score(
234 &self,
235 _state: &mut S,
236 request: &mut Request,
237 driver: &Driver,
238 ) -> crate::Result<(Classification, Option<switchyard_protocol::Response>)> {
239 let Some(key) = self.affinity_key(request) else {
240 return Ok((Classification::Scores(Vec::new()), None));
241 };
242 if self.release_on_user_turn && has_new_user_turn(&request.llm_request.messages) {
245 return Ok((Classification::Scores(Vec::new()), None));
246 }
247 let available = driver.models_for(&Category::Any);
250 let mut assignments = self.assignments.lock();
251 let assigned = assignments.get(&key).cloned();
252 let assigned = match assigned.as_ref() {
253 Some((target, _)) if !available.is_empty() && !available.contains(target) => {
254 assignments.remove(&key);
255 None
256 }
257 assigned => assigned,
258 };
259 if assigned.is_some() {
260 driver.set_evidence(serde_json::json!({"source": "retained"}));
261 }
262 Ok((
263 Classification::Scores(match assigned {
264 Some((target, category)) => vec![Score {
265 confidence: 1.0,
266 target: target.clone(),
267 category: category.clone(),
268 }],
269 None => Vec::new(),
270 }),
271 None,
272 ))
273 }
274}
275
276pub(crate) fn evict_if_full<V>(retained: &mut HashMap<RoutingIdentity, V>) {
278 if retained.len() >= MAX_ASSIGNMENTS
279 && let Some(evicted) = retained.keys().next().cloned()
280 {
281 retained.remove(&evicted);
282 }
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288
289 use std::sync::Arc;
290
291 use switchyard_protocol::{LlmRequest, Metadata, ToolResult, text_request};
292
293 use crate::core::algorithm::RuntimeModels;
294
295 type BoxErr = Box<dyn std::error::Error + Send + Sync>;
297
298 fn fixed_model(target: &str) -> ModelId {
299 ModelId::from(target)
300 }
301
302 fn driver() -> Driver {
303 Driver::new(
304 "test",
305 Arc::new(RuntimeModels::new(
306 [(
307 Category::Any,
308 ["model-a", "model-b", "weak", "strong"]
309 .map(ModelId::from)
310 .to_vec(),
311 )]
312 .into(),
313 )),
314 )
315 .0
316 }
317
318 fn request(metadata: Metadata) -> Request {
319 Request {
320 llm_request: text_request(Some("auto".to_string()), "hi"),
321 raw_request: None,
322 metadata: Some(metadata),
323 }
324 }
325
326 fn task_request(
327 metadata: Option<Metadata>,
328 first_user: &str,
329 follow_up: Option<&str>,
330 ) -> Request {
331 let mut messages = vec![
332 Message::text(Role::System, "follow repository instructions"),
333 Message::text(Role::User, first_user),
334 ];
335 if let Some(follow_up) = follow_up {
336 messages.push(Message::text(Role::Assistant, "I will inspect the code."));
337 messages.push(Message::text(Role::User, follow_up));
338 }
339 Request {
340 llm_request: LlmRequest {
341 model: Some("auto".to_string()),
342 messages,
343 ..LlmRequest::default()
344 },
345 raw_request: None,
346 metadata,
347 }
348 }
349
350 fn session(session_id: &str, agent_id: &str) -> Metadata {
351 Metadata {
352 session_id: Some(session_id.to_string()),
353 agent_id: Some(agent_id.to_string()),
354 ..Metadata::default()
355 }
356 }
357
358 fn subagent(agent_id: &str, task_id: &str) -> Metadata {
359 Metadata {
360 session_id: Some("session-1".to_string()),
361 agent_id: Some(agent_id.to_string()),
362 task_id: Some(task_id.to_string()),
363 is_subagent: true,
364 is_delegated_work: true,
365 ..Metadata::default()
366 }
367 }
368
369 async fn retain(
371 router: &AffinityRouter,
372 state: &mut (),
373 request: &mut Request,
374 model: &'static str,
375 ) -> Result<(), BoxErr> {
376 let selected_model_id = ModelId::from(model);
377 router
378 .process(
379 state,
380 Event::Decision {
381 request,
382 selected_model_id: &selected_model_id,
383 category: None,
384 driver: &driver(),
385 },
386 )
387 .await?;
388 Ok(())
389 }
390
391 async fn scores(
393 classifier: &dyn Classifier,
394 state: &mut (),
395 request: &mut Request,
396 ) -> Result<Vec<Score>, BoxErr> {
397 match classifier.score(state, request, &driver()).await?.0 {
398 Classification::Scores(scores) => Ok(scores),
399 Classification::Ambiguous(_) => Err("affinity never returns ambiguous scores".into()),
400 }
401 }
402
403 #[tokio::test]
404 async fn session_retains_first_model_across_requests() -> Result<(), BoxErr> {
405 let router = AffinityRouter::new();
406 let mut state = ();
407
408 let mut first = request(session("session-1", "agent-a"));
409 retain(&router, &mut state, &mut first, "model-a").await?;
410
411 let mut second = request(session("session-1", "agent-b"));
413 let scores = scores(&router, &mut state, &mut second).await?;
414 assert_eq!(scores.len(), 1);
415 assert_eq!(scores[0].confidence, 1.0);
416 assert_eq!(scores[0].target, "model-a");
417 Ok(())
418 }
419
420 #[tokio::test]
421 async fn subagent_only_retains_children_without_latching_root_traffic() -> Result<(), BoxErr> {
422 let router = AffinityRouter::for_subagents();
423 let mut state = ();
424
425 let mut root = request(session("session-1", "root-agent"));
426 retain(&router, &mut state, &mut root, "model-a").await?;
427 assert!(scores(&router, &mut state, &mut root).await?.is_empty());
428
429 let mut first_child_turn = request(subagent("child-1", "task-1"));
430 retain(&router, &mut state, &mut first_child_turn, "model-b").await?;
431 let mut later_child_turn = request(subagent("child-1", "task-2"));
432 let scores = scores(&router, &mut state, &mut later_child_turn).await?;
433 assert_eq!(
434 scores.first().map(|score| score.target.as_str()),
435 Some("model-b")
436 );
437 Ok(())
438 }
439
440 #[tokio::test]
441 async fn first_decision_wins() -> Result<(), BoxErr> {
442 let router = AffinityRouter::new();
443 let mut state = ();
444
445 let mut req = request(session("session-1", "agent-a"));
446 retain(&router, &mut state, &mut req, "model-a").await?;
447 retain(&router, &mut state, &mut req, "model-b").await?;
449
450 let scores = scores(&router, &mut state, &mut req).await?;
451 assert_eq!(
452 scores.first().map(|score| score.target.as_str()),
453 Some("model-a")
454 );
455 Ok(())
456 }
457
458 #[tokio::test]
459 async fn subagent_is_keyed_by_agent_not_task() -> Result<(), BoxErr> {
460 let router = AffinityRouter::new();
461 let mut state = ();
462
463 let mut first = request(subagent("child-1", "task-1"));
464 retain(&router, &mut state, &mut first, "model-a").await?;
465
466 let mut second = request(subagent("child-1", "task-2"));
468 let scores = scores(&router, &mut state, &mut second).await?;
469 assert_eq!(
470 scores.first().map(|score| score.target.as_str()),
471 Some("model-a")
472 );
473 Ok(())
474 }
475
476 #[tokio::test]
477 async fn distinct_subagents_are_assigned_independently() -> Result<(), BoxErr> {
478 let router = AffinityRouter::new();
479 let mut state = ();
480
481 retain(
483 &router,
484 &mut state,
485 &mut request(subagent("child-1", "task-1")),
486 "model-a",
487 )
488 .await?;
489
490 let mut sibling = request(subagent("child-2", "task-1"));
492 assert!(scores(&router, &mut state, &mut sibling).await?.is_empty());
493 Ok(())
494 }
495
496 #[tokio::test]
497 async fn subagent_does_not_inherit_session_assignment() -> Result<(), BoxErr> {
498 let router = AffinityRouter::new();
499 let mut state = ();
500
501 retain(
503 &router,
504 &mut state,
505 &mut request(session("session-1", "root-1")),
506 "model-a",
507 )
508 .await?;
509
510 let mut child = request(subagent("child-1", "task-1"));
512 assert!(scores(&router, &mut state, &mut child).await?.is_empty());
513 Ok(())
514 }
515
516 #[tokio::test]
517 async fn classifier_abstains_without_a_session() -> Result<(), BoxErr> {
518 let router = AffinityRouter::new();
519 let mut state = ();
520
521 let mut req = request(Metadata::default());
523 assert!(scores(&router, &mut state, &mut req).await?.is_empty());
524 Ok(())
525 }
526
527 #[tokio::test]
528 async fn message_hash_fallback_uses_the_first_user_message() -> Result<(), BoxErr> {
529 let router = AffinityRouter::new().with_message_hash_fallback();
530 let mut state = ();
531
532 let mut first = task_request(
533 None,
534 "Add a unit test for this function.",
535 Some("Now run the test suite."),
536 );
537 retain(&router, &mut state, &mut first, "weak").await?;
538
539 let mut follow_up = task_request(
540 None,
541 "Add a unit test for this function.",
542 Some("Now file a pull request."),
543 );
544 assert_eq!(
545 scores(&router, &mut state, &mut follow_up)
546 .await?
547 .first()
548 .map(|score| score.target.as_str()),
549 Some("weak")
550 );
551
552 let mut other_task = task_request(
553 None,
554 "Reimplement this binary from two input/output pairs.",
555 Some("Now run the test suite."),
556 );
557 assert!(
558 scores(&router, &mut state, &mut other_task)
559 .await?
560 .is_empty()
561 );
562 Ok(())
563 }
564
565 #[tokio::test]
566 async fn subagents_only_root_traffic_does_not_warn() -> Result<(), BoxErr> {
567 let router = AffinityRouter::for_subagents();
569 let mut state = ();
570
571 let mut root = request(session("session-1", "agent-1"));
572 assert!(scores(&router, &mut state, &mut root).await?.is_empty());
573 assert!(
574 router.should_warn_unkeyed(),
575 "an intentional abstention should leave the warning unconsumed"
576 );
577 Ok(())
578 }
579
580 #[test]
581 fn user_message_hash_ignores_non_text_provider_payloads() {
582 let request = |user_message| Request {
583 llm_request: LlmRequest {
584 messages: vec![user_message],
585 ..LlmRequest::default()
586 },
587 raw_request: None,
588 metadata: None,
589 };
590 let text_only = request(Message::text(Role::User, "Implement the parser."));
591 let text_with_reasoning = request(Message {
592 role: Role::User,
593 content: vec![
594 ContentBlock::Text {
595 text: "Implement the parser.".to_string(),
596 },
597 ContentBlock::Reasoning {
598 text: "Internal provider reasoning.".to_string(),
599 signature: Some("provider-signature".to_string()),
600 details: Vec::new(),
601 },
602 ],
603 });
604
605 assert_eq!(
606 first_user_message_hash(&text_only),
607 first_user_message_hash(&text_with_reasoning)
608 );
609 }
610
611 #[tokio::test]
612 async fn metadata_session_takes_precedence_over_message_hash() -> Result<(), BoxErr> {
613 let router = AffinityRouter::new().with_message_hash_fallback();
614 let mut state = ();
615
616 let mut first = task_request(
617 Some(session("session-1", "agent-a")),
618 "Implement the parser.",
619 None,
620 );
621 retain(&router, &mut state, &mut first, "strong").await?;
622
623 let mut other_session = task_request(
624 Some(session("session-2", "agent-a")),
625 "Implement the parser.",
626 None,
627 );
628 assert!(
629 scores(&router, &mut state, &mut other_session)
630 .await?
631 .is_empty()
632 );
633 Ok(())
634 }
635
636 #[tokio::test]
637 async fn subagent_without_a_session_abstains_and_warns() -> Result<(), BoxErr> {
638 for session_id in [None, Some(String::new())] {
639 let router = AffinityRouter::new().with_message_hash_fallback();
640 let mut state = ();
641 let mut subagent = task_request(
642 Some(Metadata {
643 session_id,
644 agent_id: Some("agent-1".to_string()),
645 is_subagent: true,
646 ..Metadata::default()
647 }),
648 "Implement the parser.",
649 None,
650 );
651
652 retain(&router, &mut state, &mut subagent, "model-a").await?;
653 assert!(scores(&router, &mut state, &mut subagent).await?.is_empty());
654 assert!(
655 !router.should_warn_unkeyed(),
656 "an unidentifiable subagent should consume the warning"
657 );
658 }
659 Ok(())
660 }
661
662 #[tokio::test]
663 async fn one_router_serves_both_roles() -> Result<(), BoxErr> {
664 let router = Arc::new(AffinityRouter::new());
667 let processor: Arc<dyn Processor> = router.clone();
668 let classifier: Arc<dyn Classifier> = router;
669 let mut state = ();
670
671 let mut first = request(session("session-1", "agent-a"));
672 processor
673 .process(
674 &mut state,
675 Event::Decision {
676 request: &mut first,
677 selected_model_id: &fixed_model("model-a"),
678 category: None,
679 driver: &driver(),
680 },
681 )
682 .await?;
683
684 let mut second = request(session("session-1", "agent-b"));
685 let scores = scores(classifier.as_ref(), &mut state, &mut second).await?;
686 assert_eq!(
687 scores.first().map(|score| score.target.as_str()),
688 Some("model-a")
689 );
690 Ok(())
691 }
692
693 #[tokio::test]
694 async fn decision_without_an_affinity_identity_is_ignored() -> Result<(), BoxErr> {
695 let router = AffinityRouter::new();
696 let mut state = ();
697 let mut unkeyed = request(Metadata::default());
698
699 router
700 .process(
701 &mut state,
702 Event::Decision {
703 request: &mut unkeyed,
704 selected_model_id: &fixed_model("model-a"),
705 category: None,
706 driver: &driver(),
707 },
708 )
709 .await?;
710
711 let mut req = request(session("session-1", "agent-a"));
712 assert!(scores(&router, &mut state, &mut req).await?.is_empty());
713 Ok(())
714 }
715
716 #[tokio::test]
717 async fn decisions_retain_their_originating_request_identity() -> Result<(), BoxErr> {
718 let router = AffinityRouter::new();
719 let mut state = ();
720 let mut first = request(session("session-1", "agent-a"));
721 let mut second = request(session("session-2", "agent-b"));
722
723 router
726 .process(
727 &mut state,
728 Event::Decision {
729 request: &mut second,
730 selected_model_id: &fixed_model("model-b"),
731 category: None,
732 driver: &driver(),
733 },
734 )
735 .await?;
736 router
737 .process(
738 &mut state,
739 Event::Decision {
740 request: &mut first,
741 selected_model_id: &fixed_model("model-a"),
742 category: None,
743 driver: &driver(),
744 },
745 )
746 .await?;
747
748 let first_scores = scores(&router, &mut state, &mut first).await?;
749 let second_scores = scores(&router, &mut state, &mut second).await?;
750 assert_eq!(
751 first_scores.first().map(|score| score.target.as_str()),
752 Some("model-a")
753 );
754 assert_eq!(
755 second_scores.first().map(|score| score.target.as_str()),
756 Some("model-b")
757 );
758 Ok(())
759 }
760
761 #[tokio::test]
762 async fn distinct_sessions_are_assigned_independently() -> Result<(), BoxErr> {
763 let router = AffinityRouter::new();
764 let mut state = ();
765
766 retain(
767 &router,
768 &mut state,
769 &mut request(session("session-1", "agent-a")),
770 "model-a",
771 )
772 .await?;
773 retain(
774 &router,
775 &mut state,
776 &mut request(session("session-2", "agent-a")),
777 "model-b",
778 )
779 .await?;
780
781 let first = scores(
782 &router,
783 &mut state,
784 &mut request(session("session-1", "other")),
785 )
786 .await?;
787 let second = scores(
788 &router,
789 &mut state,
790 &mut request(session("session-2", "other")),
791 )
792 .await?;
793 assert_eq!(
794 first.first().map(|score| score.target.as_str()),
795 Some("model-a")
796 );
797 assert_eq!(
798 second.first().map(|score| score.target.as_str()),
799 Some("model-b")
800 );
801 Ok(())
802 }
803
804 #[tokio::test]
805 async fn subagent_without_an_agent_id_abstains_and_warns() -> Result<(), BoxErr> {
806 let router = AffinityRouter::new();
807 let mut state = ();
808
809 let metadata = Metadata {
812 session_id: Some("session-1".to_string()),
813 is_subagent: true,
814 ..Metadata::default()
815 };
816 let mut req = request(metadata);
817 retain(&router, &mut state, &mut req, "model-a").await?;
818 assert!(scores(&router, &mut state, &mut req).await?.is_empty());
819 assert!(
820 !router.should_warn_unkeyed(),
821 "an unidentifiable subagent should consume the warning"
822 );
823 Ok(())
824 }
825
826 #[tokio::test]
827 async fn assignments_are_bounded_by_the_cap() -> Result<(), BoxErr> {
828 let router = AffinityRouter::new();
829 let mut state = ();
830
831 for index in 0..=MAX_ASSIGNMENTS {
833 let session_id = format!("session-{index}");
834 retain(
835 &router,
836 &mut state,
837 &mut request(session(&session_id, "agent-a")),
838 "model-a",
839 )
840 .await?;
841 }
842
843 let len = router.assignments.lock().len();
844 assert_eq!(len, MAX_ASSIGNMENTS);
845 Ok(())
846 }
847
848 #[tokio::test]
849 async fn release_on_user_turn_drops_the_assignment_only_when_the_user_speaks()
850 -> Result<(), BoxErr> {
851 let router = AffinityRouter::new().with_release_on_user_turn();
852 let mut state = ();
853 let mut opening = task_request(Some(session("session-1", "agent-a")), "add caching", None);
854 retain(&router, &mut state, &mut opening, "weak").await?;
855
856 let mut continued = opening.clone();
858 continued.llm_request.messages.push(Message {
859 role: Role::User,
860 content: vec![ContentBlock::ToolResult(ToolResult {
861 tool_call_id: "call-1".to_string(),
862 content: Vec::new(),
863 is_error: None,
864 })],
865 });
866 assert_eq!(
867 scores(&router, &mut state, &mut continued)
868 .await?
869 .first()
870 .map(|s| s.target.as_str()),
871 Some("weak")
872 );
873
874 let mut spoke = task_request(
876 Some(session("session-1", "agent-a")),
877 "add caching",
878 Some("no, shared across processes"),
879 );
880 assert!(scores(&router, &mut state, &mut spoke).await?.is_empty());
881 Ok(())
882 }
883
884 #[tokio::test]
885 async fn latch_only_retains_matching_models() -> Result<(), BoxErr> {
886 let router = AffinityRouter::new().with_latch_only(["strong"]);
887 let mut state = ();
888 let mut req = request(session("session-1", "agent-a"));
889
890 retain(&router, &mut state, &mut req, "weak").await?;
892 assert!(scores(&router, &mut state, &mut req).await?.is_empty());
893
894 retain(&router, &mut state, &mut req, "strong").await?;
896 assert_eq!(
897 scores(&router, &mut state, &mut req)
898 .await?
899 .first()
900 .map(|s| s.target.as_str()),
901 Some("strong")
902 );
903 Ok(())
904 }
905
906 #[tokio::test]
907 async fn an_unprovisioned_any_group_does_not_evict_assignments() -> Result<(), BoxErr> {
908 let router = AffinityRouter::new();
909 let mut state = ();
910 let mut req = request(session("session-1", "agent-a"));
911 retain(&router, &mut state, &mut req, "model-a").await?;
912
913 let empty = Driver::new("test", Arc::new(RuntimeModels::default())).0;
914 let (classification, _) = router.score(&mut state, &mut req, &empty).await?;
915 let Classification::Scores(retained) = classification else {
916 return Err("affinity never returns ambiguous scores".into());
917 };
918 assert_eq!(retained.first().map(|s| s.target.as_str()), Some("model-a"));
919
920 assert_eq!(
922 scores(&router, &mut state, &mut req)
923 .await?
924 .first()
925 .map(|s| s.target.as_str()),
926 Some("model-a")
927 );
928 Ok(())
929 }
930}