1use std::num::NonZeroUsize;
10use std::sync::Arc;
11use std::time::{Duration, Instant};
12use thiserror::Error;
13use tokio::sync::{OwnedSemaphorePermit, Semaphore};
14use tokio_util::sync::CancellationToken;
15
16const MODEL_GENERATION_POOL_MAX_COMPONENT_BYTES: usize = 256;
17
18#[derive(Debug, Clone, PartialEq, Eq, Error)]
20pub enum ModelGenerationPoolError {
21 #[error("model-generation pool {field} is empty or exceeds {limit} bytes")]
22 InvalidComponent { field: &'static str, limit: usize },
23 #[error("model-generation pool {field} contains a control character")]
24 ControlCharacter { field: &'static str },
25 #[error("model-generation pool endpoint is invalid or has no host")]
26 InvalidEndpoint,
27 #[error("model-generation pool endpoint must not contain credentials")]
28 EndpointCredentials,
29 #[error("model-generation pool identity is invalid: {0}")]
30 InvalidIdentity(String),
31 #[error("model-generation pool concurrency must be greater than zero")]
32 InvalidConcurrency,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
41#[serde(rename_all = "camelCase", deny_unknown_fields)]
42pub struct ModelGenerationPool {
43 pub identity: crate::execution_identity::ExecutionIdentityV1,
46 pub max_concurrency: NonZeroUsize,
48}
49
50impl ModelGenerationPool {
51 pub fn new(
53 identity: crate::execution_identity::ExecutionIdentityV1,
54 max_concurrency: NonZeroUsize,
55 ) -> Result<Self, ModelGenerationPoolError> {
56 identity
57 .validate()
58 .map_err(|error| ModelGenerationPoolError::InvalidIdentity(error.to_string()))?;
59 Ok(Self {
60 identity,
61 max_concurrency,
62 })
63 }
64
65 pub fn for_client(
71 provider: &str,
72 model: &str,
73 endpoint: Option<&str>,
74 account_id: Option<&str>,
75 concurrency: ModelGenerationConcurrency,
76 ) -> Result<Self, ModelGenerationPoolError> {
77 let provider = bounded_component("provider", provider)?;
78 let model = bounded_component("model", model)?;
79 let endpoint_origin = endpoint.map(endpoint_origin).transpose()?;
80 let account_id = account_id
81 .map(|value| bounded_component("accountId", value))
82 .transpose()?;
83 let identity = crate::execution_identity::ExecutionIdentityV1::derive(
84 crate::execution_identity::MODEL_GENERATION_POOL_IDENTITY_DOMAIN_V1,
85 &serde_json::json!({
86 "provider": provider,
87 "model": model,
88 "endpoint_origin": endpoint_origin,
89 "account_id": account_id,
90 }),
91 )
92 .map_err(|error| ModelGenerationPoolError::InvalidIdentity(error.to_string()))?;
93 Self::new(identity, concurrency.max_concurrency())
94 }
95
96 pub fn for_endpoint(
98 provider: &str,
99 model: &str,
100 endpoint: &str,
101 concurrency: ModelGenerationConcurrency,
102 ) -> Result<Self, ModelGenerationPoolError> {
103 Self::for_client(provider, model, Some(endpoint), None, concurrency)
104 }
105
106 pub fn for_account_endpoint(
108 provider: &str,
109 model: &str,
110 endpoint: &str,
111 account_id: &str,
112 concurrency: ModelGenerationConcurrency,
113 ) -> Result<Self, ModelGenerationPoolError> {
114 Self::for_client(
115 provider,
116 model,
117 Some(endpoint),
118 Some(account_id),
119 concurrency,
120 )
121 }
122
123 pub fn identity(&self) -> &crate::execution_identity::ExecutionIdentityV1 {
124 &self.identity
125 }
126
127 pub const fn max_concurrency(&self) -> NonZeroUsize {
128 self.max_concurrency
129 }
130
131 pub fn validate(&self) -> Result<(), ModelGenerationPoolError> {
132 self.identity
133 .validate()
134 .map_err(|error| ModelGenerationPoolError::InvalidIdentity(error.to_string()))?;
135 if self.max_concurrency.get() == 0 {
136 return Err(ModelGenerationPoolError::InvalidConcurrency);
137 }
138 Ok(())
139 }
140}
141
142fn bounded_component(field: &'static str, value: &str) -> Result<String, ModelGenerationPoolError> {
143 let value = value.trim();
144 if value.is_empty() || value.len() > MODEL_GENERATION_POOL_MAX_COMPONENT_BYTES {
145 return Err(ModelGenerationPoolError::InvalidComponent {
146 field,
147 limit: MODEL_GENERATION_POOL_MAX_COMPONENT_BYTES,
148 });
149 }
150 if value
151 .chars()
152 .any(|character| character.is_control() || matches!(character, '\u{2028}' | '\u{2029}'))
153 {
154 return Err(ModelGenerationPoolError::ControlCharacter { field });
155 }
156 Ok(value.to_string())
157}
158
159fn endpoint_origin(value: &str) -> Result<String, ModelGenerationPoolError> {
160 let parsed =
161 url::Url::parse(value.trim()).map_err(|_| ModelGenerationPoolError::InvalidEndpoint)?;
162 if parsed.host_str().is_none() || parsed.scheme().is_empty() {
163 return Err(ModelGenerationPoolError::InvalidEndpoint);
164 }
165 if !parsed.username().is_empty() || parsed.password().is_some() {
166 return Err(ModelGenerationPoolError::EndpointCredentials);
167 }
168 let origin = parsed.origin().ascii_serialization();
169 if origin == "null" {
170 return Err(ModelGenerationPoolError::InvalidEndpoint);
171 }
172 Ok(origin)
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
178pub struct ModelGenerationConcurrency {
179 max_concurrency: NonZeroUsize,
180}
181
182impl ModelGenerationConcurrency {
183 pub const fn single_flight() -> Self {
186 Self {
187 max_concurrency: NonZeroUsize::MIN,
188 }
189 }
190
191 pub const fn bounded(max_concurrency: NonZeroUsize) -> Self {
192 Self { max_concurrency }
193 }
194
195 pub const fn max_concurrency(self) -> NonZeroUsize {
196 self.max_concurrency
197 }
198}
199
200impl Default for ModelGenerationConcurrency {
201 fn default() -> Self {
202 Self::single_flight()
203 }
204}
205
206#[derive(Debug)]
207struct BoundedAdmission {
208 max_concurrency: NonZeroUsize,
209 semaphore: Arc<Semaphore>,
210 scheduler: Option<Arc<SchedulerBinding>>,
211 pool: Option<ModelGenerationPool>,
214}
215
216#[derive(Debug)]
217struct SchedulerBinding {
218 scheduler: Arc<crate::task_scheduler::TaskScheduler>,
219 quota: crate::task_scheduler::TaskSchedulerQuota,
220 priority: crate::task_scheduler::TaskPriority,
221 label: String,
222}
223
224#[derive(Debug, Clone)]
229pub struct ModelGenerationAdmission {
230 bounded: Arc<BoundedAdmission>,
231}
232
233#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
242#[serde(rename_all = "camelCase", deny_unknown_fields)]
243pub struct ModelGenerationPoolHealthSnapshot {
244 pub pool: ModelGenerationPool,
246 pub local_max_concurrency: usize,
248 pub local_reserved: usize,
250 pub local_available: usize,
252 #[serde(skip_serializing_if = "Option::is_none")]
254 pub scheduler: Option<crate::task_scheduler::TaskSchedulerQuotaHealthSnapshot>,
255}
256
257impl ModelGenerationAdmission {
258 pub fn new(concurrency: ModelGenerationConcurrency) -> Self {
259 let max_concurrency = concurrency.max_concurrency();
260 let bounded = Arc::new(BoundedAdmission {
261 max_concurrency,
262 semaphore: Arc::new(Semaphore::new(max_concurrency.get())),
263 scheduler: None,
264 pool: None,
265 });
266 Self { bounded }
267 }
268
269 pub fn with_scheduler_quota(
273 self,
274 scheduler: Arc<crate::task_scheduler::TaskScheduler>,
275 quota: crate::task_scheduler::TaskSchedulerQuota,
276 priority: crate::task_scheduler::TaskPriority,
277 label: impl Into<String>,
278 ) -> Result<Self, crate::task_scheduler::TaskSchedulerError> {
279 self.with_scheduler_binding(scheduler, quota, priority, label.into(), None)
280 }
281
282 pub fn with_model_generation_pool(
287 self,
288 scheduler: Arc<crate::task_scheduler::TaskScheduler>,
289 pool: ModelGenerationPool,
290 priority: crate::task_scheduler::TaskPriority,
291 label: impl Into<String>,
292 ) -> Result<Self, crate::task_scheduler::TaskSchedulerError> {
293 pool.validate().map_err(|error| {
294 crate::task_scheduler::TaskSchedulerError::InvalidConfig(error.to_string())
295 })?;
296 let quota = crate::task_scheduler::TaskSchedulerQuota::new(
297 pool.identity.clone(),
298 pool.max_concurrency.get(),
299 )?;
300 self.with_scheduler_binding(scheduler, quota, priority, label.into(), Some(pool))
301 }
302
303 fn with_scheduler_binding(
304 self,
305 scheduler: Arc<crate::task_scheduler::TaskScheduler>,
306 quota: crate::task_scheduler::TaskSchedulerQuota,
307 priority: crate::task_scheduler::TaskPriority,
308 label: String,
309 pool: Option<ModelGenerationPool>,
310 ) -> Result<Self, crate::task_scheduler::TaskSchedulerError> {
311 quota.validate()?;
312 if let Some(pool) = &pool {
313 if pool.identity != quota.identity || pool.max_concurrency.get() != quota.max_active {
314 return Err(crate::task_scheduler::TaskSchedulerError::InvalidConfig(
315 "model-generation pool and scheduler quota do not match".to_string(),
316 ));
317 }
318 }
319 let bounded = Arc::new(BoundedAdmission {
320 max_concurrency: self.bounded.max_concurrency,
321 semaphore: Arc::clone(&self.bounded.semaphore),
322 scheduler: Some(Arc::new(SchedulerBinding {
323 scheduler,
324 quota,
325 priority,
326 label,
327 })),
328 pool,
329 });
330 Ok(Self { bounded })
331 }
332
333 pub(crate) fn with_scheduler_quota_from(
338 self,
339 source: &Self,
340 label: impl Into<String>,
341 ) -> Result<Self, crate::task_scheduler::TaskSchedulerError> {
342 let Some(binding) = source.bounded.scheduler.as_ref() else {
343 return Ok(self);
344 };
345 self.with_scheduler_binding(
346 Arc::clone(&binding.scheduler),
347 binding.quota.clone(),
348 binding.priority,
349 label.into(),
350 source.bounded.pool.clone(),
351 )
352 }
353
354 pub fn concurrency(&self) -> ModelGenerationConcurrency {
355 ModelGenerationConcurrency::bounded(self.bounded.max_concurrency)
356 }
357
358 pub(crate) fn has_scheduler_quota(&self) -> bool {
361 self.bounded.scheduler.is_some()
362 }
363
364 #[cfg(test)]
368 pub(crate) fn publishes_model_generation_pool(&self) -> bool {
369 self.bounded.pool.is_some()
370 }
371
372 pub async fn pool_health(
377 &self,
378 ) -> Result<Option<ModelGenerationPoolHealthSnapshot>, crate::task_scheduler::TaskSchedulerError>
379 {
380 let Some(pool) = self.bounded.pool.clone() else {
381 return Ok(None);
382 };
383 let local_max_concurrency = self.bounded.max_concurrency.get();
384 let local_available = self.bounded.semaphore.available_permits();
385 let local_reserved = local_max_concurrency.saturating_sub(local_available);
386 let scheduler = match self.bounded.scheduler.as_ref() {
387 Some(binding) => Some(binding.scheduler.quota_health(&binding.quota).await?),
388 None => None,
389 };
390 Ok(Some(ModelGenerationPoolHealthSnapshot {
391 pool,
392 local_max_concurrency,
393 local_reserved,
394 local_available,
395 scheduler,
396 }))
397 }
398
399 pub async fn acquire(
402 &self,
403 cancellation: &CancellationToken,
404 ) -> Result<ModelGenerationPermit, ModelGenerationAdmissionError> {
405 let queued_at = Instant::now();
406 let acquire = Arc::clone(&self.bounded.semaphore).acquire_owned();
407 tokio::pin!(acquire);
408 let permit = tokio::select! {
409 biased;
410 _ = cancellation.cancelled() => {
411 return Err(ModelGenerationAdmissionError::Cancelled);
412 }
413 permit = &mut acquire => permit.map_err(|_| {
414 ModelGenerationAdmissionError::Closed
415 })?,
416 };
417 let scheduler_lease = if let Some(binding) = self.bounded.scheduler.as_ref() {
421 Some(
422 binding
423 .scheduler
424 .acquire_quota(
425 binding.priority,
426 binding.label.clone(),
427 &binding.quota,
428 cancellation,
429 )
430 .await
431 .map_err(ModelGenerationAdmissionError::from_scheduler_error)?,
432 )
433 } else {
434 None
435 };
436 Ok(ModelGenerationPermit {
437 admission: Arc::clone(&self.bounded),
438 _bounded: permit,
439 _scheduler: scheduler_lease,
440 queue_wait: queued_at.elapsed(),
441 })
442 }
443
444 pub(crate) fn owns(&self, permit: &ModelGenerationPermit) -> bool {
445 Arc::ptr_eq(&self.bounded, &permit.admission)
446 }
447
448 #[cfg(test)]
449 fn available_permits(&self) -> usize {
450 self.bounded.semaphore.available_permits()
451 }
452}
453
454impl Default for ModelGenerationAdmission {
455 fn default() -> Self {
456 Self::new(ModelGenerationConcurrency::default())
457 }
458}
459
460#[derive(Debug)]
462#[must_use = "dropping the permit releases model-generation capacity"]
463pub struct ModelGenerationPermit {
464 admission: Arc<BoundedAdmission>,
465 _bounded: OwnedSemaphorePermit,
466 _scheduler: Option<crate::task_scheduler::TaskLease>,
467 queue_wait: Duration,
468}
469
470impl ModelGenerationPermit {
471 pub fn queue_wait(&self) -> Duration {
472 self.queue_wait
473 }
474}
475
476#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
477pub enum ModelGenerationAdmissionError {
478 #[error("model-generation admission cancelled by caller")]
479 Cancelled,
480 #[error("model-generation admission gate closed")]
481 Closed,
482 #[error("model-generation permit belongs to a different admission gate")]
483 ForeignPermit,
484 #[error("scheduler-backed model-generation admission failed: {0}")]
485 Scheduler(String),
486}
487
488impl ModelGenerationAdmissionError {
489 fn from_scheduler_error(error: crate::task_scheduler::TaskSchedulerError) -> Self {
490 match error {
491 crate::task_scheduler::TaskSchedulerError::Cancelled => Self::Cancelled,
492 crate::task_scheduler::TaskSchedulerError::Closed => Self::Closed,
493 crate::task_scheduler::TaskSchedulerError::AtCapacity { limit } => {
494 Self::Scheduler(format!("scheduler admission queue is full (limit {limit})"))
495 }
496 crate::task_scheduler::TaskSchedulerError::InvalidConfig(message) => {
497 Self::Scheduler(message)
498 }
499 }
500 }
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506 use std::time::Duration;
507
508 #[tokio::test]
509 async fn cancelling_a_queued_waiter_does_not_consume_capacity() {
510 let admission = ModelGenerationAdmission::new(ModelGenerationConcurrency::single_flight());
511 let holder = admission
512 .acquire(&CancellationToken::new())
513 .await
514 .expect("first permit");
515 assert_eq!(admission.available_permits(), 0);
516
517 let cancellation = CancellationToken::new();
518 let waiter = tokio::spawn({
519 let admission = admission.clone();
520 let cancellation = cancellation.clone();
521 async move { admission.acquire(&cancellation).await }
522 });
523 tokio::task::yield_now().await;
524 cancellation.cancel();
525 assert!(matches!(
526 waiter.await.expect("waiter join"),
527 Err(ModelGenerationAdmissionError::Cancelled)
528 ));
529
530 drop(holder);
531 let replacement = tokio::time::timeout(
532 Duration::from_millis(100),
533 admission.acquire(&CancellationToken::new()),
534 )
535 .await
536 .expect("cancelled waiter must release its queue position")
537 .expect("replacement permit");
538 drop(replacement);
539 }
540
541 #[test]
542 fn pool_identity_uses_only_non_secret_endpoint_origin() {
543 let concurrency = ModelGenerationConcurrency::bounded(NonZeroUsize::new(2).unwrap());
544 let first = ModelGenerationPool::for_client(
545 "openai-compatible",
546 "model-a",
547 Some("https://example.test:443/v1/chat/completions?key=ignored"),
548 None,
549 concurrency,
550 )
551 .unwrap();
552 let second = ModelGenerationPool::for_client(
553 "openai-compatible",
554 "model-a",
555 Some("https://example.test:443/another-path"),
556 None,
557 concurrency,
558 )
559 .unwrap();
560 assert_eq!(first.identity, second.identity);
561 assert_eq!(first.max_concurrency(), NonZeroUsize::new(2).unwrap());
562 let encoded = serde_json::to_string(&first).unwrap();
563 assert!(!encoded.contains("ignored"));
564 assert!(!encoded.contains("example.test:443/v1"));
565 }
566
567 #[test]
568 fn pool_identity_separates_provider_model_and_account() {
569 let concurrency = ModelGenerationConcurrency::single_flight();
570 let base = ModelGenerationPool::for_endpoint(
571 "provider-a",
572 "model-a",
573 "https://example.test",
574 concurrency,
575 )
576 .unwrap();
577 let provider = ModelGenerationPool::for_endpoint(
578 "provider-b",
579 "model-a",
580 "https://example.test",
581 concurrency,
582 )
583 .unwrap();
584 let model = ModelGenerationPool::for_endpoint(
585 "provider-a",
586 "model-b",
587 "https://example.test",
588 concurrency,
589 )
590 .unwrap();
591 let account = ModelGenerationPool::for_account_endpoint(
592 "provider-a",
593 "model-a",
594 "https://example.test",
595 "account-1",
596 concurrency,
597 )
598 .unwrap();
599 assert_ne!(base.identity, provider.identity);
600 assert_ne!(base.identity, model.identity);
601 assert_ne!(base.identity, account.identity);
602 }
603
604 #[tokio::test]
605 async fn tighter_nested_gate_retains_the_session_scheduler_quota() {
606 let scheduler = Arc::new(
607 crate::task_scheduler::TaskScheduler::new(crate::task_scheduler::TaskSchedulerConfig {
608 max_active: 1,
609 aging_interval_ms: 1_000,
610 })
611 .unwrap(),
612 );
613 let quota =
614 crate::task_scheduler::TaskSchedulerQuota::for_scope("provider-session", 1).unwrap();
615 let session = ModelGenerationAdmission::new(ModelGenerationConcurrency::single_flight())
616 .with_scheduler_quota(
617 Arc::clone(&scheduler),
618 quota,
619 crate::task_scheduler::TaskPriority::Foreground,
620 "session-generation",
621 )
622 .unwrap();
623 let nested = ModelGenerationAdmission::new(ModelGenerationConcurrency::bounded(
624 NonZeroUsize::new(2).unwrap(),
625 ))
626 .with_scheduler_quota_from(&session, "nested-generation")
627 .unwrap();
628
629 let holder = session.acquire(&CancellationToken::new()).await.unwrap();
630 let cancellation = CancellationToken::new();
631 let waiting = tokio::spawn({
632 let nested = nested.clone();
633 let cancellation = cancellation.clone();
634 async move { nested.acquire(&cancellation).await }
635 });
636 tokio::task::yield_now().await;
637 assert!(!waiting.is_finished());
638 drop(holder);
639 let permit = tokio::time::timeout(Duration::from_millis(100), waiting)
640 .await
641 .unwrap()
642 .unwrap()
643 .unwrap();
644 drop(permit);
645 scheduler.shutdown().await;
646 }
647
648 #[test]
649 fn pool_rejects_credentials_and_unbounded_components() {
650 let concurrency = ModelGenerationConcurrency::single_flight();
651 assert!(matches!(
652 ModelGenerationPool::for_endpoint(
653 "provider",
654 "model",
655 "https://user:secret@example.test/v1",
656 concurrency,
657 ),
658 Err(ModelGenerationPoolError::EndpointCredentials)
659 ));
660 assert!(matches!(
661 ModelGenerationPool::for_endpoint(
662 &"p".repeat(MODEL_GENERATION_POOL_MAX_COMPONENT_BYTES + 1),
663 "model",
664 "https://example.test",
665 concurrency,
666 ),
667 Err(ModelGenerationPoolError::InvalidComponent {
668 field: "provider",
669 ..
670 })
671 ));
672 }
673
674 #[tokio::test]
675 async fn scheduler_backed_gates_share_provider_capacity_across_sessions() {
676 let scheduler = Arc::new(
677 crate::task_scheduler::TaskScheduler::new(crate::task_scheduler::TaskSchedulerConfig {
678 max_active: 1,
679 aging_interval_ms: 60_000,
680 })
681 .unwrap(),
682 );
683 let pool = ModelGenerationPool::for_endpoint(
684 "provider",
685 "model",
686 "https://example.test",
687 ModelGenerationConcurrency::single_flight(),
688 )
689 .unwrap();
690 let quota = crate::task_scheduler::TaskSchedulerQuota::new(
691 pool.identity.clone(),
692 pool.max_concurrency().get(),
693 )
694 .unwrap();
695 let admission_a =
696 ModelGenerationAdmission::new(ModelGenerationConcurrency::single_flight())
697 .with_scheduler_quota(
698 Arc::clone(&scheduler),
699 quota.clone(),
700 crate::task_scheduler::TaskPriority::Foreground,
701 "model-generation:session-a",
702 )
703 .unwrap();
704 let admission_b =
705 ModelGenerationAdmission::new(ModelGenerationConcurrency::single_flight())
706 .with_scheduler_quota(
707 Arc::clone(&scheduler),
708 quota.clone(),
709 crate::task_scheduler::TaskPriority::Foreground,
710 "model-generation:session-b",
711 )
712 .unwrap();
713 let first = admission_a
714 .acquire(&CancellationToken::new())
715 .await
716 .unwrap();
717 let cancellation = CancellationToken::new();
718 let waiting = tokio::spawn({
719 let admission = admission_b.clone();
720 let cancellation = cancellation.clone();
721 async move { admission.acquire(&cancellation).await }
722 });
723 for _ in 0..100 {
724 if scheduler.quota_snapshot("a).await.unwrap().pending == 1 {
725 break;
726 }
727 tokio::task::yield_now().await;
728 }
729 assert_eq!(scheduler.quota_snapshot("a).await.unwrap().active, 1);
730 assert_eq!(scheduler.quota_snapshot("a).await.unwrap().pending, 1);
731 cancellation.cancel();
732 assert!(matches!(
733 waiting.await.unwrap(),
734 Err(ModelGenerationAdmissionError::Cancelled)
735 ));
736 drop(first);
737 let replacement = tokio::time::timeout(
738 Duration::from_millis(100),
739 admission_b.acquire(&CancellationToken::new()),
740 )
741 .await
742 .expect("provider quota should be released")
743 .unwrap();
744 drop(replacement);
745 scheduler.shutdown().await;
746 }
747
748 #[tokio::test]
749 async fn pool_health_composes_local_and_scheduler_capacity_without_routing_text() {
750 let scheduler = Arc::new(
751 crate::task_scheduler::TaskScheduler::new(crate::task_scheduler::TaskSchedulerConfig {
752 max_active: 1,
753 aging_interval_ms: 60_000,
754 })
755 .unwrap(),
756 );
757 let pool = ModelGenerationPool::for_client(
758 "secret-provider-name",
759 "secret-model-name",
760 Some("https://provider.test/v1/chat?api_key=secret"),
761 Some("private-account"),
762 ModelGenerationConcurrency::single_flight(),
763 )
764 .unwrap();
765 let admission = ModelGenerationAdmission::new(ModelGenerationConcurrency::single_flight())
766 .with_model_generation_pool(
767 Arc::clone(&scheduler),
768 pool.clone(),
769 crate::task_scheduler::TaskPriority::Foreground,
770 "secret-session-label",
771 )
772 .unwrap();
773
774 let configured = admission.pool_health().await.unwrap().unwrap();
775 assert_eq!(configured.pool, pool);
776 assert_eq!(configured.local_max_concurrency, 1);
777 assert_eq!(configured.local_reserved, 0);
778 assert_eq!(configured.local_available, 1);
779 assert!(!configured.scheduler.as_ref().unwrap().observed);
780
781 let permit = admission.acquire(&CancellationToken::new()).await.unwrap();
782 let active = admission.pool_health().await.unwrap().unwrap();
783 assert_eq!(active.local_reserved, 1);
784 assert_eq!(active.local_available, 0);
785 assert!(active.scheduler.as_ref().unwrap().live);
786 assert_eq!(active.scheduler.as_ref().unwrap().active, 1);
787
788 drop(permit);
789 let retained = admission.pool_health().await.unwrap().unwrap();
790 assert_eq!(retained.local_reserved, 0);
791 assert_eq!(retained.scheduler.as_ref().unwrap().released, 1);
792 assert!(!retained.scheduler.as_ref().unwrap().live);
793 let encoded = serde_json::to_string(&retained).unwrap();
794 for secret in [
795 "secret-provider-name",
796 "secret-model-name",
797 "provider.test",
798 "api_key",
799 "private-account",
800 "secret-session-label",
801 ] {
802 assert!(!encoded.contains(secret), "snapshot leaked {secret}");
803 }
804 scheduler.shutdown().await;
805 }
806
807 #[tokio::test]
808 async fn nested_runtime_rebind_retains_the_exact_provider_pool_health() {
809 let scheduler = Arc::new(
810 crate::task_scheduler::TaskScheduler::new(crate::task_scheduler::TaskSchedulerConfig {
811 max_active: 1,
812 aging_interval_ms: 60_000,
813 })
814 .unwrap(),
815 );
816 let pool = ModelGenerationPool::for_endpoint(
817 "provider",
818 "model",
819 "https://provider.test",
820 ModelGenerationConcurrency::bounded(NonZeroUsize::new(2).unwrap()),
821 )
822 .unwrap();
823 let session = ModelGenerationAdmission::new(ModelGenerationConcurrency::bounded(
824 NonZeroUsize::new(2).unwrap(),
825 ))
826 .with_model_generation_pool(
827 Arc::clone(&scheduler),
828 pool.clone(),
829 crate::task_scheduler::TaskPriority::Foreground,
830 "session-generation",
831 )
832 .unwrap();
833 let nested = ModelGenerationAdmission::new(ModelGenerationConcurrency::single_flight())
834 .with_scheduler_quota_from(&session, "nested-generation")
835 .unwrap();
836
837 let health = nested.pool_health().await.unwrap().unwrap();
838 assert_eq!(health.pool, pool);
839 assert_eq!(health.local_max_concurrency, 1);
840 assert_eq!(health.scheduler.as_ref().unwrap().max_active, 2);
841 let permit = nested.acquire(&CancellationToken::new()).await.unwrap();
842 assert_eq!(
843 session
844 .pool_health()
845 .await
846 .unwrap()
847 .unwrap()
848 .scheduler
849 .unwrap()
850 .active,
851 1
852 );
853 drop(permit);
854 scheduler.shutdown().await;
855 }
856
857 #[tokio::test]
858 async fn scheduler_quota_without_typed_pool_is_not_product_pool_health() {
859 let scheduler = Arc::new(
860 crate::task_scheduler::TaskScheduler::new(crate::task_scheduler::TaskSchedulerConfig {
861 max_active: 1,
862 aging_interval_ms: 60_000,
863 })
864 .unwrap(),
865 );
866 let quota = crate::task_scheduler::TaskSchedulerQuota::for_scope("compat-only", 1).unwrap();
867 let admission = ModelGenerationAdmission::new(ModelGenerationConcurrency::single_flight())
868 .with_scheduler_quota(
869 Arc::clone(&scheduler),
870 quota,
871 crate::task_scheduler::TaskPriority::Foreground,
872 "compat-generation",
873 )
874 .unwrap();
875 assert!(admission.has_scheduler_quota());
876 assert!(!admission.publishes_model_generation_pool());
877 assert!(admission.pool_health().await.unwrap().is_none());
878 scheduler.shutdown().await;
879 }
880
881 #[tokio::test]
882 async fn typed_pool_publishes_product_pool_health() {
883 let scheduler = Arc::new(
884 crate::task_scheduler::TaskScheduler::new(crate::task_scheduler::TaskSchedulerConfig {
885 max_active: 1,
886 aging_interval_ms: 60_000,
887 })
888 .unwrap(),
889 );
890 let pool = ModelGenerationPool::for_client(
891 "provider",
892 "model",
893 Some("https://example.test/v1"),
894 None,
895 ModelGenerationConcurrency::single_flight(),
896 )
897 .unwrap();
898 let admission = ModelGenerationAdmission::new(ModelGenerationConcurrency::single_flight())
899 .with_model_generation_pool(
900 Arc::clone(&scheduler),
901 pool,
902 crate::task_scheduler::TaskPriority::Foreground,
903 "product-generation",
904 )
905 .unwrap();
906 assert!(admission.publishes_model_generation_pool());
907 assert!(admission.pool_health().await.unwrap().is_some());
908 scheduler.shutdown().await;
909 }
910}