gglib_core/ports/model_runtime.rs
1//! Model runtime port for proxy model management.
2//!
3//! This port defines the interface for admitting a request to a running model.
4//! It abstracts the process management details from the proxy layer.
5//!
6//! ## Admission, not "ensure running"
7//!
8//! The entry point is [`ModelRuntimePort::admit`], and it returns an
9//! [`Admission`] — a routing target *plus a lease*. The lease is what makes
10//! request batching possible: the runtime cannot decide whether it is safe to
11//! swap models unless it knows how many requests are still being served by the
12//! one currently loaded. Holding the lease for the life of the request is
13//! therefore not bookkeeping, it is the mechanism.
14//!
15//! A caller that only wants a model up and does not care when it goes away
16//! (the GUI's "start model" button) drops the lease immediately; the model
17//! stays resident until something else wins admission.
18
19use async_trait::async_trait;
20use serde::{Deserialize, Serialize};
21use std::fmt;
22use std::sync::Arc;
23use thiserror::Error;
24
25use crate::cache_config::CacheRamSetting;
26use crate::domain::{AdmissionSnapshot, CacheRamHealth, LaunchNarration, ModelSamplingDefaults};
27use crate::ports::ProcessHandle;
28use crate::server_config::ServerConfigOptions;
29
30/// Per-call launch overrides layered on a runtime's standing configuration.
31///
32/// A runtime is normally built once with a standing template — the proxy's
33/// cache settings, say — and then shared, so that one admission queue governs
34/// every llama-server on the machine. This is how an individual caller
35/// contributes launch options on top of that template without needing a manager
36/// of its own.
37///
38/// `Default` means "no opinion": every field falls through to the template.
39#[derive(Debug, Clone, Default)]
40pub struct LaunchOverrides {
41 /// Explicit options merged over the runtime's template, `Some` fields
42 /// winning — see [`ServerConfigOptions::overlay`].
43 pub options: ServerConfigOptions,
44 /// How to size llama-server's host-RAM prompt cache for this launch.
45 ///
46 /// Separate from [`Self::options`] because it is resolved at spawn against
47 /// live system RAM and the model's KV footprint, not carried as a flag.
48 /// `None` defers to the runtime's own setting.
49 pub cache_ram: Option<CacheRamSetting>,
50}
51
52/// Target information for a running model instance.
53///
54/// This struct contains all information needed to route requests
55/// to a running llama-server instance.
56#[derive(Debug, Clone)]
57pub struct RunningTarget {
58 /// Full URL to the server (e.g., <http://127.0.0.1:5500>).
59 /// Future-proof for non-localhost deployments.
60 pub base_url: String,
61 /// Port the server is listening on.
62 pub port: u16,
63 /// Database ID of the model.
64 pub model_id: u32,
65 /// Human-readable model name (for logging/headers).
66 pub model_name: String,
67 /// Actual context size being used.
68 pub effective_ctx: u64,
69 /// True when this instance was freshly spawned (restart or cold start).
70 pub just_started: bool,
71 /// Whether llama-server's disk slot save/restore can actually resume this
72 /// model, i.e. its KV memory retains the full token history.
73 ///
74 /// False for sliding-window, hybrid, and recurrent architectures (see
75 /// [`crate::domain::kv_memory_is_partial`]): the slot file carries KV
76 /// state and tokens but not the server's context checkpoints, so a
77 /// restore leaves the slot unable to resume and llama-server re-prefills
78 /// the whole prompt. Callers skip the disk slot layer when this is false
79 /// and let the in-RAM prompt cache — which does keep checkpoints — handle
80 /// conversation switching.
81 pub slot_restore_supported: bool,
82 /// How healthy the host-RAM prompt cache budget (`--cache-ram`) resolved
83 /// for this launch is.
84 ///
85 /// Classified once at spawn (where the budget arithmetic and the
86 /// auto-vs-explicit distinction are both in scope) and carried here so
87 /// user-facing surfaces can report it without re-deriving thresholds. See
88 /// [`crate::domain::classify_cache_ram`].
89 pub cache_ram_health: CacheRamHealth,
90 /// What this launch decided, and why (see
91 /// [`crate::domain::LaunchNarration`]).
92 ///
93 /// Carried on the target for the same reason as
94 /// [`Self::cache_ram_health`]: the resolutions and their provenance exist
95 /// only at spawn, so anything downstream that wants to explain the
96 /// running model has no way to recover them otherwise. `None` for targets
97 /// that did not come from a gglib launch.
98 pub narration: Option<LaunchNarration>,
99 /// What this model's own GGUF declares about sampler defaults.
100 ///
101 /// `None` for targets that did not come from a gglib launch, in the same
102 /// sense as [`Self::narration`] — nobody read a GGUF for them, so nothing
103 /// is known either way. Distinct from `Some(ModelSamplingDefaults::default())`,
104 /// which is the ordinary case: a GGUF was read and it declares nothing.
105 ///
106 /// Consumers must not flatten those two. `None` means the model's
107 /// contribution to `/props` is unknown and no field can be attributed;
108 /// `Some(default())` means the build's own table is showing through
109 /// unmodified. See [`crate::domain::ModelSamplingDefaults`].
110 pub model_sampling: Option<ModelSamplingDefaults>,
111}
112
113impl RunningTarget {
114 /// Create a new `RunningTarget` for a local server.
115 ///
116 /// `slot_restore_supported` defaults to `true` (the full-attention case)
117 /// and `cache_ram_health` to [`CacheRamHealth::LlamaDefault`] (no flag
118 /// emitted); callers that know the launch's actual resolution narrow them
119 /// with [`Self::with_slot_restore_supported`] and
120 /// [`Self::with_cache_ram_health`].
121 #[must_use]
122 pub fn local(
123 port: u16,
124 model_id: u32,
125 model_name: String,
126 effective_ctx: u64,
127 just_started: bool,
128 ) -> Self {
129 Self {
130 base_url: format!("http://127.0.0.1:{port}"),
131 port,
132 model_id,
133 model_name,
134 effective_ctx,
135 just_started,
136 slot_restore_supported: true,
137 cache_ram_health: CacheRamHealth::LlamaDefault,
138 narration: None,
139 model_sampling: None,
140 }
141 }
142
143 /// Attach what the launched model's GGUF declares about sampling.
144 #[must_use]
145 pub const fn with_model_sampling(mut self, declared: ModelSamplingDefaults) -> Self {
146 self.model_sampling = Some(declared);
147 self
148 }
149
150 /// Attach the narration of the launch that produced this target.
151 #[must_use]
152 pub fn with_narration(mut self, narration: LaunchNarration) -> Self {
153 self.narration = Some(narration);
154 self
155 }
156
157 /// Set whether disk slot restore can resume this model.
158 #[must_use]
159 pub const fn with_slot_restore_supported(mut self, supported: bool) -> Self {
160 self.slot_restore_supported = supported;
161 self
162 }
163
164 /// Set the resolved host-RAM prompt cache health for this launch.
165 #[must_use]
166 pub const fn with_cache_ram_health(mut self, health: CacheRamHealth) -> Self {
167 self.cache_ram_health = health;
168 self
169 }
170}
171
172/// The runtime side of an [`AdmissionLease`]: what to call when a request that
173/// was holding a VRAM slot is finished with it.
174///
175/// A separate trait rather than a closure so the lease stays `Debug` and has no
176/// generic parameter to thread through every signature that carries one. The
177/// implementation lives in `gglib-runtime`; this crate only needs to be able to
178/// call it from a `Drop`, which is why [`Self::release`] is synchronous and
179/// must not block.
180pub trait AdmissionRelease: Send + Sync + fmt::Debug {
181 /// Release one in-flight reference to `slot`, waking the scheduler if that
182 /// was the last one.
183 ///
184 /// Called from [`AdmissionLease`]'s `Drop`, so it must never block, panic,
185 /// or await.
186 fn release(&self, slot: usize);
187}
188
189/// Proof that a request is being served by a resident model, and that the
190/// runtime must not evict that model until the request is done.
191///
192/// Dropping the lease releases the slot. Every exit path a request has — normal
193/// completion, `?`, client disconnect, panic unwind — runs `Drop`, so there is
194/// no path that leaks a reference and wedges the scheduler. This is the same
195/// guarantee, for the same reason, that the proxy's connection registry gets
196/// from its own guard.
197///
198/// Not `Clone`: two owners would mean two releases for one acquisition.
199#[derive(Debug)]
200pub struct AdmissionLease {
201 owner: Option<Arc<dyn AdmissionRelease>>,
202 slot: usize,
203}
204
205impl AdmissionLease {
206 /// Create a lease that releases `slot` on `owner` when dropped.
207 #[must_use]
208 pub fn new(owner: Arc<dyn AdmissionRelease>, slot: usize) -> Self {
209 Self {
210 owner: Some(owner),
211 slot,
212 }
213 }
214
215 /// A lease that owns nothing and releases nothing.
216 ///
217 /// For runtimes with no resident set to account for — test doubles and the
218 /// [`NoopModelRuntime`] — so they are not forced to implement a scheduler
219 /// to satisfy the signature.
220 #[must_use]
221 pub const fn detached() -> Self {
222 Self {
223 owner: None,
224 slot: 0,
225 }
226 }
227
228 /// Which resident slot this lease is holding.
229 #[must_use]
230 pub const fn slot(&self) -> usize {
231 self.slot
232 }
233}
234
235impl Drop for AdmissionLease {
236 fn drop(&mut self) {
237 if let Some(owner) = self.owner.take() {
238 owner.release(self.slot);
239 }
240 }
241}
242
243/// A granted admission: where to send the request, and the lease that keeps the
244/// model loaded while it is in flight.
245///
246/// The two are returned together rather than the lease being attached to
247/// [`RunningTarget`] because the target must stay `Clone` — the startup guard
248/// broadcasts one target to every caller waiting on the same launch — and a
249/// clonable lease would release once per clone.
250#[derive(Debug)]
251pub struct Admission {
252 /// Where to route the request.
253 pub target: RunningTarget,
254 /// Held for the life of the request. See [`AdmissionLease`].
255 pub lease: AdmissionLease,
256}
257
258impl Admission {
259 /// An admission with no slot accounting, for runtimes that do not have any.
260 #[must_use]
261 pub const fn detached(target: RunningTarget) -> Self {
262 Self {
263 target,
264 lease: AdmissionLease::detached(),
265 }
266 }
267
268 /// Take the target and drop the lease immediately.
269 ///
270 /// For callers that want a model launched but have no request to hold it
271 /// for — `gglib model start` and the GUI's start button. The model stays
272 /// resident; it is simply evictable from this moment on.
273 #[must_use]
274 pub fn into_target(self) -> RunningTarget {
275 self.target
276 }
277}
278
279/// Errors that can occur during model runtime operations.
280#[derive(Clone, Debug, Error)]
281pub enum ModelRuntimeError {
282 /// The requested model was not found in the catalog.
283 #[error("Model not found: {0}")]
284 ModelNotFound(String),
285
286 /// A model is currently loading; try again later.
287 /// Callers should return 503 Service Unavailable.
288 #[error("Model is loading, try again")]
289 ModelLoading,
290
291 /// Retryable: the request sat in the admission queue past its deadline
292 /// without ever reaching the front.
293 ///
294 /// Reaching this means the GPU stayed continuously occupied by other models
295 /// for longer than a request can reasonably wait — not that a collision was
296 /// mishandled. The queue's own fairness bounds make it rare; when it does
297 /// happen the caller gets a 503 with `Retry-After` and control of its own
298 /// backoff.
299 #[error("Admission timeout: {0}")]
300 AdmissionTimeout(String),
301
302 /// Failed to spawn the model server process.
303 #[error("Failed to start model: {0}")]
304 SpawnFailed(String),
305
306 /// The model server failed its health check.
307 #[error("Health check failed: {0}")]
308 HealthCheckFailed(String),
309
310 /// The model file was not found on disk.
311 #[error("Model file not found: {0}")]
312 ModelFileNotFound(String),
313
314 /// A model other than the pinned one was requested.
315 ///
316 /// Only reachable in pinned mode (`gglib serve <model>`), which exists to
317 /// give single-model clients — VS Code Copilot's BYOK endpoint, for one —
318 /// an endpoint that never switches models underneath them. Swapping to the
319 /// requested model would defeat that guarantee, so the request is refused
320 /// rather than served.
321 #[error("Server is pinned to model '{expected}'; refusing request for '{requested}'")]
322 PinnedModelMismatch {
323 /// The model this server was pinned to at startup.
324 expected: String,
325 /// The model the caller asked for.
326 requested: String,
327 },
328
329 /// Internal error during runtime operations.
330 #[error("Internal error: {0}")]
331 Internal(String),
332}
333
334impl ModelRuntimeError {
335 /// Returns true if this error indicates a temporary condition
336 /// where retrying may succeed.
337 #[must_use]
338 pub const fn is_retryable(&self) -> bool {
339 matches!(self, Self::ModelLoading | Self::AdmissionTimeout(_))
340 }
341
342 /// Returns a suggested HTTP status code for this error.
343 #[must_use]
344 pub const fn suggested_status_code(&self) -> u16 {
345 match self {
346 Self::ModelLoading | Self::AdmissionTimeout(_) => 503,
347 // A pinned mismatch is 404, not 403: from the client's point of
348 // view the model it asked for does not exist on this endpoint.
349 Self::ModelNotFound(_)
350 | Self::ModelFileNotFound(_)
351 | Self::PinnedModelMismatch { .. } => 404,
352 Self::SpawnFailed(_) | Self::HealthCheckFailed(_) | Self::Internal(_) => 500,
353 }
354 }
355}
356
357/// Canonical `error.type` discriminants, shared by every surface.
358///
359/// `gglib_proxy::models::ErrorResponse` carries one of these over HTTP and
360/// [`RuntimeErrorEnvelope`] carries the same vocabulary over SSE, so a client
361/// that learns it once understands both.
362pub mod error_type {
363 /// Transient unavailability — the same request may succeed if retried.
364 pub const SERVICE_UNAVAILABLE: &str = "service_unavailable";
365 /// The caller asked for something that does not exist or is not permitted.
366 pub const INVALID_REQUEST: &str = "invalid_request_error";
367 /// The server failed in a way that retrying will not fix.
368 pub const SERVER_ERROR: &str = "server_error";
369}
370
371/// Whether a wire `error.type` discriminant denotes a retryable condition.
372///
373/// The single definition of retryability keyed on the wire vocabulary. An HTTP
374/// client parsing an error body and an IPC consumer reading a
375/// [`RuntimeErrorEnvelope`] both route through here, so the two cannot drift
376/// into disagreeing about what is worth retrying.
377#[must_use]
378pub fn is_retryable_error_type(discriminant: &str) -> bool {
379 discriminant == error_type::SERVICE_UNAVAILABLE
380}
381
382/// Structured, serializable view of a [`ModelRuntimeError`].
383///
384/// For event boundaries (SSE) that need machine-readable type +
385/// retry hints alongside the human-readable message, mirroring the shape
386/// `gglib_proxy::models::ErrorResponse` already sends over HTTP.
387#[derive(Clone, Debug, Serialize, Deserialize)]
388#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
389pub struct RuntimeErrorEnvelope {
390 /// Human-readable error message.
391 pub message: String,
392 /// Stable error type discriminant, matching the `type` strings the HTTP
393 /// layer already sends for the same `ModelRuntimeError` variants (e.g.
394 /// `"service_unavailable"`), so GUI and HTTP clients agree on meaning.
395 pub r#type: String,
396 /// Whether retrying the same request may succeed.
397 pub retryable: bool,
398}
399
400impl From<&ModelRuntimeError> for RuntimeErrorEnvelope {
401 fn from(err: &ModelRuntimeError) -> Self {
402 let discriminant = match err {
403 ModelRuntimeError::ModelLoading | ModelRuntimeError::AdmissionTimeout(_) => {
404 error_type::SERVICE_UNAVAILABLE
405 }
406 ModelRuntimeError::ModelNotFound(_)
407 | ModelRuntimeError::ModelFileNotFound(_)
408 | ModelRuntimeError::PinnedModelMismatch { .. } => error_type::INVALID_REQUEST,
409 ModelRuntimeError::SpawnFailed(_)
410 | ModelRuntimeError::HealthCheckFailed(_)
411 | ModelRuntimeError::Internal(_) => error_type::SERVER_ERROR,
412 };
413 Self {
414 message: err.to_string(),
415 r#type: discriminant.to_string(),
416 retryable: err.is_retryable(),
417 }
418 }
419}
420
421/// Port for admitting requests to a running model.
422///
423/// This is the primary interface the proxy uses to get a running
424/// model server. Implementations handle:
425/// - Model resolution (name → file path)
426/// - Process lifecycle (start, stop, health check)
427/// - Context size management
428/// - Admission control: queueing, batching, and the VRAM resident set
429#[async_trait]
430pub trait ModelRuntimePort: Send + Sync + fmt::Debug {
431 /// Admit a request to a running model, launching or swapping if needed.
432 ///
433 /// This method:
434 /// 1. Resolves the model name to a database entry
435 /// 2. Admits immediately if the model is already resident
436 /// 3. Otherwise queues until the model can take a VRAM slot — either by
437 /// co-loading alongside what is already there, or by swapping once the
438 /// outgoing model has no requests left in flight
439 /// 4. Waits for the health check to pass
440 /// 5. Returns the routing target and a lease on the slot
441 ///
442 /// **The returned [`Admission::lease`] must be held for as long as the
443 /// request is being served.** Dropping it early tells the runtime the slot
444 /// is free and permits a swap out from under a live generation. Callers
445 /// that only want the model launched — not served — use
446 /// [`Admission::into_target`], which drops the lease deliberately.
447 ///
448 /// # Arguments
449 ///
450 /// * `model_name` - Name or alias of the model to run
451 /// * `num_ctx` - Optional context size override from request
452 /// * `default_ctx` - Default context size if not specified
453 /// * `overrides` - Per-call launch options layered on the runtime's
454 /// standing template, so one shared runtime can serve callers with
455 /// different launch needs (a GUI start carrying `--mlock`, a benchmark
456 /// that must never gain a prompt cache). [`LaunchOverrides::default`]
457 /// means "no opinion".
458 ///
459 /// # Errors
460 ///
461 /// Returns `ModelRuntimeError` if the model cannot be started, or
462 /// [`ModelRuntimeError::AdmissionTimeout`] if the request never reached
463 /// the front of the queue.
464 async fn admit(
465 &self,
466 model_name: &str,
467 num_ctx: Option<u64>,
468 default_ctx: Option<u64>,
469 overrides: LaunchOverrides,
470 ) -> Result<Admission, ModelRuntimeError>;
471
472 /// What the admission queue and the VRAM resident set look like right now.
473 ///
474 /// Synchronous for the same reason [`Self::pinned_model`] is: it is a
475 /// single read of plain shared state, not a query against live process
476 /// state. The dashboard publisher calls it on every tick.
477 ///
478 /// Defaults to empty for runtimes with no resident set to report (test
479 /// doubles, remote backends).
480 fn admission_snapshot(&self) -> AdmissionSnapshot {
481 AdmissionSnapshot::default()
482 }
483
484 /// Get information about the currently running model, if any.
485 ///
486 /// Returns `None` if no model is currently running.
487 async fn current_model(&self) -> Option<RunningTarget>;
488
489 /// Every llama-server process this runtime currently owns.
490 ///
491 /// Sibling of [`Self::current_model`] for callers that need process-level
492 /// detail — pid and start time — rather than routing information; the GUI
493 /// server list is the motivating case.
494 ///
495 /// Defaults to empty for runtimes that do not track individual processes
496 /// (test doubles, remote backends). Returning nothing is always safe here:
497 /// callers treat it as "no servers to show".
498 async fn list_running(&self) -> Vec<ProcessHandle> {
499 Vec::new()
500 }
501
502 /// Stop the currently running model.
503 ///
504 /// This is primarily for cleanup/shutdown scenarios.
505 async fn stop_current(&self) -> Result<(), ModelRuntimeError>;
506
507 /// The one model this runtime is pinned to, if any.
508 ///
509 /// `Some(name)` means every other model is refused with
510 /// [`ModelRuntimeError::PinnedModelMismatch`] rather than swapped to —
511 /// the mode `gglib serve` runs in. `None` is the ordinary auto-swapping
512 /// runtime.
513 ///
514 /// Synchronous because the pin is plain shared state, unlike
515 /// [`Self::current_model`], which reports live process state. Owned
516 /// rather than borrowed because the pin can change at runtime (see
517 /// [`Self::set_pin`]) — a borrow could not outlive the lock guarding it.
518 ///
519 /// Defaults to unpinned so test doubles and remote backends need not
520 /// implement it. Callers use it to avoid offering a model that would only
521 /// be refused — `/v1/models` being the motivating case.
522 fn pinned_model(&self) -> Option<String> {
523 None
524 }
525
526 /// Pin this runtime to a single model, or clear the pin.
527 ///
528 /// `Some(spec)` makes every request for another model fail with
529 /// [`ModelRuntimeError::PinnedModelMismatch`] instead of swapping; the
530 /// spec's launch overrides are layered onto the runtime's standing
531 /// template for the pinned model's launches. `None` restores ordinary
532 /// auto-swapping. This is how `gglib serve` reaches the daemon's shared
533 /// runtime: the pin travels over `POST /api/proxy/start` rather than
534 /// being fixed at construction.
535 ///
536 /// # Errors
537 ///
538 /// The default refuses, so a runtime that cannot honour a pin (test
539 /// doubles, remote backends) fails loudly instead of silently serving
540 /// every model against the caller's explicit instruction.
541 fn set_pin(&self, pin: Option<PinnedSpec>) -> Result<(), ModelRuntimeError> {
542 let _ = pin;
543 Err(ModelRuntimeError::Internal(
544 "this runtime does not support pinning".to_string(),
545 ))
546 }
547}
548
549/// A runtime pin: the one model a runtime will serve, plus how to launch it.
550///
551/// Carried by [`ModelRuntimePort::set_pin`] and serialized inside the
552/// daemon's `POST /api/proxy/start` body, which is why it derives serde.
553#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
554#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
555pub struct PinnedSpec {
556 /// Name clients must address the model by. Matched exactly.
557 pub name: String,
558 /// Standing launch options for the pinned model, already resolved
559 /// through the caller's cascade — layered onto the runtime's template at
560 /// launch, winning field-wise (the cascade has already run; the template
561 /// must not undo it).
562 #[serde(default)]
563 pub launch_overrides: ServerConfigOptions,
564}
565
566/// A [`ModelRuntimePort`] that never has anything running.
567///
568/// For callers with no shared `ProcessManager` to point at — the CLI's
569/// single-shot commands, whose `is_serving` checks
570/// against a runtime scoped to that one process invocation would report
571/// "nothing running" regardless, since nothing was started in it. Making that
572/// explicit here is more honest than wiring in a real runner that can only
573/// ever agree.
574#[derive(Debug, Default)]
575pub struct NoopModelRuntime;
576
577#[async_trait]
578impl ModelRuntimePort for NoopModelRuntime {
579 async fn admit(
580 &self,
581 _model_name: &str,
582 _num_ctx: Option<u64>,
583 _default_ctx: Option<u64>,
584 _overrides: LaunchOverrides,
585 ) -> Result<Admission, ModelRuntimeError> {
586 Err(ModelRuntimeError::Internal(
587 "no runtime available in this context".to_string(),
588 ))
589 }
590
591 async fn current_model(&self) -> Option<RunningTarget> {
592 None
593 }
594
595 async fn stop_current(&self) -> Result<(), ModelRuntimeError> {
596 Ok(())
597 }
598}
599
600#[cfg(test)]
601mod tests {
602 use super::*;
603 use crate::settings::DEFAULT_CONTEXT_SIZE;
604
605 /// Implements only the three required methods, so the defaulted ones are
606 /// exercised exactly as an untouched test double would get them.
607 #[derive(Debug)]
608 struct MinimalRuntime;
609
610 #[async_trait]
611 impl ModelRuntimePort for MinimalRuntime {
612 async fn admit(
613 &self,
614 model_name: &str,
615 num_ctx: Option<u64>,
616 default_ctx: Option<u64>,
617 _overrides: LaunchOverrides,
618 ) -> Result<Admission, ModelRuntimeError> {
619 Ok(Admission::detached(RunningTarget::local(
620 5500,
621 1,
622 model_name.to_string(),
623 num_ctx.or(default_ctx).unwrap_or(DEFAULT_CONTEXT_SIZE),
624 false,
625 )))
626 }
627
628 async fn current_model(&self) -> Option<RunningTarget> {
629 None
630 }
631
632 async fn stop_current(&self) -> Result<(), ModelRuntimeError> {
633 Ok(())
634 }
635 }
636
637 /// A runtime with no resident set to account for must still hand back a
638 /// usable lease, or every test double would need a scheduler.
639 #[tokio::test]
640 async fn a_minimal_runtime_admits_with_a_detached_lease() {
641 let admission = MinimalRuntime
642 .admit("m", Some(8192), Some(4096), LaunchOverrides::default())
643 .await
644 .expect("minimal runtime admits");
645
646 assert_eq!(admission.target.model_name, "m");
647 assert_eq!(admission.target.effective_ctx, 8192);
648 assert_eq!(admission.lease.slot(), 0);
649 // Dropping it must be a no-op rather than a panic.
650 drop(admission);
651 }
652
653 #[test]
654 fn admission_snapshot_defaults_to_empty() {
655 let snapshot = MinimalRuntime.admission_snapshot();
656 assert!(snapshot.slots.is_empty());
657 assert!(snapshot.queued.is_empty());
658 assert_eq!(snapshot.total_swaps, 0);
659 }
660
661 /// The whole point of the lease: exactly one release per acquisition, on
662 /// every exit path including an unwinding panic.
663 #[test]
664 fn a_lease_releases_its_slot_exactly_once_on_drop() {
665 use std::sync::atomic::{AtomicUsize, Ordering};
666
667 #[derive(Debug, Default)]
668 struct Counter(AtomicUsize);
669
670 impl AdmissionRelease for Counter {
671 fn release(&self, slot: usize) {
672 assert_eq!(slot, 1, "the lease must release the slot it was given");
673 self.0.fetch_add(1, Ordering::SeqCst);
674 }
675 }
676
677 let counter = Arc::new(Counter::default());
678 {
679 let lease = AdmissionLease::new(Arc::clone(&counter) as Arc<dyn AdmissionRelease>, 1);
680 assert_eq!(lease.slot(), 1);
681 assert_eq!(counter.0.load(Ordering::SeqCst), 0, "not yet released");
682 }
683 assert_eq!(counter.0.load(Ordering::SeqCst), 1);
684
685 // A panic unwinds through Drop just the same — this workspace sets no
686 // `panic = "abort"` profile, so a panicking handler cannot leak a slot.
687 let counter2 = Arc::new(Counter::default());
688 let held = Arc::clone(&counter2);
689 let result = std::panic::catch_unwind(move || {
690 let _lease = AdmissionLease::new(held as Arc<dyn AdmissionRelease>, 1);
691 panic!("handler blew up mid-request");
692 });
693 assert!(result.is_err());
694 assert_eq!(counter2.0.load(Ordering::SeqCst), 1, "released on unwind");
695 }
696
697 /// A detached lease has nothing to release, so dropping it must not reach
698 /// for an owner that is not there.
699 #[test]
700 fn a_detached_lease_drops_cleanly() {
701 let lease = AdmissionLease::detached();
702 assert_eq!(lease.slot(), 0);
703 drop(lease);
704 }
705
706 /// Unpinned is the safe default: a runtime that says nothing about
707 /// pinning must not cause callers to narrow what they offer.
708 #[test]
709 fn pinned_model_defaults_to_unpinned() {
710 assert_eq!(MinimalRuntime.pinned_model(), None);
711 }
712
713 #[tokio::test]
714 async fn list_running_defaults_to_empty() {
715 assert!(MinimalRuntime.list_running().await.is_empty());
716 }
717
718 /// "No opinion" has to be the default, or merging one in would silently
719 /// override the runtime's own template.
720 #[test]
721 fn launch_overrides_default_is_empty() {
722 let overrides = LaunchOverrides::default();
723 assert!(overrides.cache_ram.is_none());
724 assert!(overrides.options.context_size.is_none());
725 assert!(overrides.options.mlock.is_none());
726 }
727
728 fn pinned_mismatch() -> ModelRuntimeError {
729 ModelRuntimeError::PinnedModelMismatch {
730 expected: "qwen2.5".to_string(),
731 requested: "llama-3-8b".to_string(),
732 }
733 }
734
735 /// 404 rather than 403: from the caller's point of view the model it asked
736 /// for does not exist on this endpoint.
737 #[test]
738 fn pinned_mismatch_is_not_found() {
739 assert_eq!(pinned_mismatch().suggested_status_code(), 404);
740 }
741
742 /// Retrying the identical request can never succeed — the pin is fixed for
743 /// the process lifetime — so clients must not back off and retry.
744 #[test]
745 fn pinned_mismatch_is_not_retryable() {
746 assert!(!pinned_mismatch().is_retryable());
747 }
748
749 /// Both model names belong in the message; without them the caller cannot
750 /// tell what this endpoint actually serves.
751 #[test]
752 fn pinned_mismatch_names_both_models() {
753 let rendered = pinned_mismatch().to_string();
754 assert!(rendered.contains("qwen2.5"), "{rendered}");
755 assert!(rendered.contains("llama-3-8b"), "{rendered}");
756 }
757
758 /// A retryable error's envelope must carry `retryable: true` and the
759 /// `service_unavailable` type, matching the HTTP layer's 503 mapping.
760 #[test]
761 fn envelope_for_admission_timeout_is_retryable_service_unavailable() {
762 let err = ModelRuntimeError::AdmissionTimeout("waited too long".to_string());
763 let envelope = RuntimeErrorEnvelope::from(&err);
764 assert_eq!(envelope.r#type, "service_unavailable");
765 assert!(envelope.retryable);
766 assert_eq!(envelope.message, err.to_string());
767 }
768
769 /// A non-retryable error's envelope must say so, matching the HTTP
770 /// layer's non-503 mapping.
771 #[test]
772 fn envelope_for_pinned_mismatch_is_not_retryable_invalid_request() {
773 let envelope = RuntimeErrorEnvelope::from(&pinned_mismatch());
774 assert_eq!(envelope.r#type, "invalid_request_error");
775 assert!(!envelope.retryable);
776 }
777
778 /// The wire-vocabulary predicate must agree with `is_retryable()` for every
779 /// variant.
780 ///
781 /// An HTTP client only ever sees the `type` discriminant — it has no
782 /// `ModelRuntimeError` to ask. This is what stops the two from drifting
783 /// into disagreeing about which failures are worth retrying, and it is
784 /// exhaustive so a new variant cannot quietly skip the check.
785 #[test]
786 fn retryable_predicate_agrees_with_the_error_itself() {
787 let all = [
788 ModelRuntimeError::ModelLoading,
789 ModelRuntimeError::AdmissionTimeout("contended".to_string()),
790 ModelRuntimeError::ModelNotFound("m".to_string()),
791 ModelRuntimeError::ModelFileNotFound("f".to_string()),
792 pinned_mismatch(),
793 ModelRuntimeError::SpawnFailed("boom".to_string()),
794 ModelRuntimeError::HealthCheckFailed("unhealthy".to_string()),
795 ModelRuntimeError::Internal("internal".to_string()),
796 ];
797
798 for err in all {
799 let envelope = RuntimeErrorEnvelope::from(&err);
800 assert_eq!(
801 is_retryable_error_type(&envelope.r#type),
802 err.is_retryable(),
803 "wire type {:?} disagrees with is_retryable() for {err:?}",
804 envelope.r#type
805 );
806 }
807 }
808
809 /// A 503 is the only status the retryable discriminant maps to, so the
810 /// HTTP-status fallback used by clients that receive a non-gglib error
811 /// body stays consistent with the discriminant path.
812 #[test]
813 fn retryable_discriminant_lines_up_with_status_503() {
814 let retryable = ModelRuntimeError::AdmissionTimeout("c".to_string());
815 assert!(is_retryable_error_type(error_type::SERVICE_UNAVAILABLE));
816 assert_eq!(retryable.suggested_status_code(), 503);
817 assert!(!is_retryable_error_type(error_type::SERVER_ERROR));
818 assert!(!is_retryable_error_type(error_type::INVALID_REQUEST));
819 }
820}