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}