Skip to main content

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}