Skip to main content

gglib_core/ports/
model_catalog.rs

1//! Model catalog port for listing and resolving models.
2//!
3//! This port defines the interface for querying the model catalog.
4//! It provides domain-level model information without exposing
5//! database or storage implementation details.
6
7use async_trait::async_trait;
8use std::fmt;
9use std::path::PathBuf;
10use thiserror::Error;
11
12use crate::domain::DefaultsOrigin;
13use crate::domain::DialectSpec;
14use crate::domain::InferenceConfig;
15use crate::domain::KvElemsPerToken;
16use crate::domain::ModelCapabilities;
17use crate::domain::ModelSamplingDefaults;
18use crate::domain::ServerConfig;
19use crate::domain::TemplateCaps;
20
21/// Domain model summary for catalog operations (listing).
22///
23/// This is a domain type (not an `OpenAI` API type). The proxy layer
24/// is responsible for mapping this to OpenAI-compatible formats.
25///
26/// Note: Does NOT include `file_path` to avoid leaking filesystem details
27/// in catalog/listing operations.
28#[derive(Debug, Clone)]
29pub struct ModelSummary {
30    /// Database ID of the model.
31    pub id: u32,
32    /// Model name (used as identifier).
33    pub name: String,
34    /// Tags/labels associated with the model.
35    pub tags: Vec<String>,
36    /// Detected and persisted capability flags for this model.
37    ///
38    /// This is the single source of truth for model behaviour constraints
39    /// (strict-turn alternation, system-role support, tool calls, reasoning).
40    /// The proxy uses these directly rather than inferring from tags at
41    /// request time, eliminating the split-brain between tags and capabilities.
42    pub capabilities: ModelCapabilities,
43    /// Parameter count as string (e.g., "7B", "13B", "70B").
44    pub param_count: String,
45    /// Quantization type (e.g., "`Q4_K_M`", "`Q8_0`").
46    pub quantization: Option<String>,
47    /// Model architecture (e.g., "llama", "mistral", "qwen2").
48    pub architecture: Option<String>,
49    /// Unix timestamp when the model was added.
50    pub created_at: i64,
51    /// File size in bytes.
52    pub file_size: u64,
53    /// Maximum context length the model supports, in tokens (from GGUF
54    /// metadata). `None` when unknown.
55    ///
56    /// This is a static, per-model ceiling — it does not reflect the
57    /// `--ctx-size` a currently-running instance was actually launched
58    /// with, which can be smaller (see `gglib_core::ports::model_runtime::RunningTarget::effective_ctx`
59    /// for the live value). Consumers that need the true, currently-running
60    /// context size should prefer `effective_ctx` when the model is running
61    /// and fall back to this field otherwise.
62    pub context_length: Option<u64>,
63    /// Per-model inference parameter defaults.
64    ///
65    /// When `Some`, these are resolved per-request via
66    /// [`InferenceConfig::resolve_with_defaults`] before forwarding to llama-server.
67    /// Used by `gglib proxy` to inject resolved defaults into OpenAI-format
68    /// request bodies, and by the agentic loop (`gglib chat`, `gglib q`) to
69    /// apply model-specific sampling parameters.
70    pub inference_defaults: Option<InferenceConfig>,
71    /// Whether [`Self::inference_defaults`] was set by the user or
72    /// auto-detected. See [`DefaultsOrigin`] and
73    /// [`InferenceConfig::resolve_with_profile`](crate::domain::InferenceConfig::resolve_with_profile).
74    pub defaults_origin: Option<DefaultsOrigin>,
75    /// Per-model server defaults (`context_length`, etc.) from the database.
76    pub server_defaults: Option<ServerConfig>,
77    /// Persisted tool-call dialect spec, when detection identified one.
78    ///
79    /// `None` for rows imported before specs existed and for models whose
80    /// dialect could not be derived — consumers fall back to mapping
81    /// `format:*` tags via `normalize::registry::dialect_for_tags`.
82    pub dialect: Option<DialectSpec>,
83    /// llama-server's template-capability self-report, when a launch has
84    /// recorded one (see [`Model::template_caps`](crate::domain::Model)).
85    ///
86    /// `None` is "never observed", never "unsupported" — ADR 0007's
87    /// tri-state, carried whole so no consumer has to reconstruct it.
88    pub template_caps: Option<TemplateCaps>,
89}
90
91/// Launch specification for running a model.
92///
93/// Contains all information needed to actually launch a model,
94/// including the file path. Separate from `ModelSummary` to avoid
95/// leaking filesystem details in catalog operations.
96#[derive(Debug, Clone)]
97pub struct ModelLaunchSpec {
98    /// Database ID of the model.
99    pub id: u32,
100    /// Model name.
101    pub name: String,
102    /// Absolute path to the GGUF file.
103    pub file_path: PathBuf,
104    /// Tags/labels associated with the model.
105    pub tags: Vec<String>,
106    /// Model architecture (for runtime configuration).
107    pub architecture: Option<String>,
108    /// Quantization label (`Q4_K_M`), when the catalog recorded one.
109    ///
110    /// Carried purely so the launch can name what it loaded — see
111    /// [`crate::domain::LaunchNarration`]. `None` for models whose GGUF
112    /// metadata did not identify a quantization.
113    pub quantization: Option<String>,
114    /// Maximum context length the model supports.
115    pub context_length: Option<u64>,
116    /// Per-model server defaults (e.g., `context_length` for launch).
117    pub server_defaults: Option<ServerConfig>,
118    /// Total on-disk size of the model weights in bytes, summed across all
119    /// shards for multi-part GGUFs.
120    ///
121    /// Used to budget host memory at launch (see
122    /// [`crate::server_config::compute_auto_cache_ram_mb`]). `0` when the
123    /// size could not be determined — callers must treat that as "unknown"
124    /// rather than "free".
125    pub file_size_bytes: u64,
126    /// Estimated K/V element counts consumed per token of context, derived
127    /// from the model's GGUF metadata (see
128    /// [`crate::domain::estimate_kv_elems_per_token`]). Type-agnostic —
129    /// callers convert to bytes via [`crate::domain::kv_bytes_per_token`]
130    /// once the launch's resolved K/V cache types are known.
131    ///
132    /// `None` when the metadata doesn't carry the layer/head counts needed;
133    /// callers substitute a conservative allowance.
134    pub kv_elems_per_token: Option<KvElemsPerToken>,
135    /// True when the model's KV memory retains only part of the token history
136    /// — sliding-window, hybrid, or recurrent attention (see
137    /// [`crate::domain::kv_memory_is_partial`]).
138    ///
139    /// Such models cannot be resumed from llama-server's disk slot files: the
140    /// save/restore path does not carry the context checkpoints they need, so
141    /// a "successful" restore still forces a full prompt re-prefill. Callers
142    /// disable the disk slot layer for these models and rely on the in-RAM
143    /// prompt cache, which does preserve checkpoints.
144    pub kv_memory_is_partial: bool,
145    /// What this model's own GGUF declares about sampler defaults (see
146    /// [`crate::domain::ModelSamplingDefaults`]).
147    ///
148    /// llama.cpp applies these over its own build defaults for every field no
149    /// CLI flag sets, and reports the result as `/props`'s
150    /// `default_generation_settings`. Carried to the running target so the
151    /// proxy's baseline check can tell a model's own recommendation from a pin
152    /// bump, rather than reporting the first as the second.
153    ///
154    /// Derived from the metadata already on the catalog row, the same way
155    /// `kv_elems_per_token` and `kv_memory_is_partial` are.
156    pub model_sampling: ModelSamplingDefaults,
157}
158
159impl ModelSummary {
160    /// Create a description string for this model.
161    #[must_use]
162    pub fn description(&self) -> String {
163        let arch = self.architecture.as_deref().unwrap_or("unknown");
164        let quant = self.quantization.as_deref().unwrap_or("unknown");
165        format!("{} - {} parameters, {}", arch, self.param_count, quant)
166    }
167}
168
169/// Errors that can occur during catalog operations.
170#[derive(Debug, Error)]
171pub enum CatalogError {
172    /// Failed to query the catalog.
173    #[error("Failed to query catalog: {0}")]
174    QueryFailed(String),
175
176    /// Internal error during catalog operations.
177    #[error("Internal error: {0}")]
178    Internal(String),
179}
180
181/// Port for querying the model catalog.
182///
183/// This interface provides read-only access to the model catalog
184/// for listing and resolving models. It does not handle model
185/// registration or deletion.
186#[async_trait]
187pub trait ModelCatalogPort: Send + Sync + fmt::Debug {
188    /// List all models in the catalog.
189    ///
190    /// Returns a list of model summaries ordered by name.
191    ///
192    /// # Errors
193    ///
194    /// Returns `CatalogError` if the catalog cannot be queried.
195    async fn list_models(&self) -> Result<Vec<ModelSummary>, CatalogError>;
196
197    /// Resolve a model by name or alias.
198    ///
199    /// This method performs model resolution:
200    /// 1. Exact name match
201    /// 2. Case-insensitive name match
202    /// 3. Fuzzy/partial match (implementation-defined)
203    ///
204    /// Returns `None` if no matching model is found.
205    ///
206    /// # Arguments
207    ///
208    /// * `name` - Model name or alias to resolve
209    ///
210    /// # Errors
211    ///
212    /// Returns `CatalogError` if the catalog cannot be queried.
213    async fn resolve_model(&self, name: &str) -> Result<Option<ModelSummary>, CatalogError>;
214
215    /// Resolve a model for launching.
216    ///
217    /// Returns full launch specification including file path.
218    /// Use this when you need to actually run a model, not just list it.
219    ///
220    /// # Arguments
221    ///
222    /// * `name` - Model name or alias to resolve
223    ///
224    /// # Errors
225    ///
226    /// Returns `CatalogError` if the catalog cannot be queried.
227    async fn resolve_for_launch(&self, name: &str)
228    -> Result<Option<ModelLaunchSpec>, CatalogError>;
229
230    /// Record llama-server's template-capability self-report for model `id`.
231    ///
232    /// Called once per fresh launch, after the just-spawned server's
233    /// `GET /props` has been read (ADR 0007: the caps are a fact about the
234    /// binary–model pair, so only a launch can learn them). Implementations
235    /// persist only when the stored value differs, so repeat launches of an
236    /// unchanged pair write nothing.
237    ///
238    /// Defaulted to a no-op because most implementors of this port are
239    /// read-only views (test doubles, the profiles catalog) with nothing to
240    /// persist into; only the real database-backed catalog overrides it.
241    ///
242    /// # Errors
243    ///
244    /// Returns `CatalogError` if the catalog could not be updated.
245    async fn record_template_caps(
246        &self,
247        _id: u32,
248        _caps: TemplateCaps,
249    ) -> Result<(), CatalogError> {
250        Ok(())
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    /// A read-only implementor that never overrides `record_template_caps` —
259    /// the shape of every test double this port has across the workspace.
260    #[derive(Debug)]
261    struct ReadOnlyCatalog;
262
263    #[async_trait]
264    impl ModelCatalogPort for ReadOnlyCatalog {
265        async fn list_models(&self) -> Result<Vec<ModelSummary>, CatalogError> {
266            Ok(Vec::new())
267        }
268        async fn resolve_model(&self, _name: &str) -> Result<Option<ModelSummary>, CatalogError> {
269            Ok(None)
270        }
271        async fn resolve_for_launch(
272            &self,
273            _name: &str,
274        ) -> Result<Option<ModelLaunchSpec>, CatalogError> {
275            Ok(None)
276        }
277    }
278
279    /// The default body is a successful no-op, so read-only implementors need
280    /// not implement persistence they do not have — and a caps observation
281    /// against one is dropped, never an error that could fail a launch.
282    #[tokio::test]
283    async fn record_template_caps_defaults_to_a_successful_no_op() {
284        let result = ReadOnlyCatalog
285            .record_template_caps(1, TemplateCaps::default())
286            .await;
287        assert!(result.is_ok());
288    }
289}