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