1use serde::{Deserialize, Serialize};
10
11use crate::domain::Model;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
21pub enum ModelSortBy {
22 #[default]
24 AddedAt,
25 Name,
27 ParamCount,
29 LatestTgTps,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
37pub enum SortOrder {
38 #[default]
40 Desc,
41 Asc,
43}
44
45#[derive(Debug, Clone, Default, Serialize, Deserialize)]
59pub struct ModelListQuery {
60 #[serde(default)]
62 pub sort_by: ModelSortBy,
63 #[serde(default)]
65 pub order: SortOrder,
66 pub min_params: Option<f64>,
68 pub max_params: Option<f64>,
70 pub min_context: Option<f64>,
72 pub max_context: Option<f64>,
74 pub quantizations: Option<Vec<String>>,
78 pub tags: Option<Vec<String>>,
80 pub min_speed: Option<f64>,
83 pub max_speed: Option<f64>,
86}
87
88#[must_use]
116pub fn apply_query(mut models: Vec<Model>, query: &ModelListQuery) -> Vec<Model> {
117 models.retain(|m| matches_query(m, query));
118
119 match (query.sort_by, query.order) {
120 (ModelSortBy::Name, SortOrder::Asc) => {
121 models.sort_by(|a, b| a.name.cmp(&b.name));
122 }
123 (ModelSortBy::Name, SortOrder::Desc) => {
124 models.sort_by(|a, b| b.name.cmp(&a.name));
125 }
126 (ModelSortBy::ParamCount, SortOrder::Asc) => {
127 models.sort_by(|a, b| {
128 a.param_count_b
129 .partial_cmp(&b.param_count_b)
130 .unwrap_or(std::cmp::Ordering::Equal)
131 });
132 }
133 (ModelSortBy::ParamCount, SortOrder::Desc) => {
134 models.sort_by(|a, b| {
135 b.param_count_b
136 .partial_cmp(&a.param_count_b)
137 .unwrap_or(std::cmp::Ordering::Equal)
138 });
139 }
140 (ModelSortBy::LatestTgTps, SortOrder::Asc) => {
141 models.sort_by(|a, b| cmp_tps_asc(tps(a), tps(b)));
142 }
143 (ModelSortBy::LatestTgTps, SortOrder::Desc) => {
144 models.sort_by(|a, b| cmp_tps_desc(tps(a), tps(b)));
145 }
146 (ModelSortBy::AddedAt, SortOrder::Asc) => {
147 models.sort_by_key(|a| a.added_at);
148 }
149 (ModelSortBy::AddedAt, SortOrder::Desc) => {
150 models.sort_by_key(|b| std::cmp::Reverse(b.added_at));
151 }
152 }
153
154 models
155}
156
157fn matches_query(m: &Model, query: &ModelListQuery) -> bool {
159 if let Some(min) = query.min_params {
161 if m.param_count_b < min {
162 return false;
163 }
164 }
165 if let Some(max) = query.max_params {
166 if m.param_count_b > max {
167 return false;
168 }
169 }
170
171 if let Some(ctx) = m.context_length {
173 #[allow(clippy::cast_precision_loss)]
174 let ctx_f = ctx as f64;
175 if let Some(min) = query.min_context {
176 if ctx_f < min {
177 return false;
178 }
179 }
180 if let Some(max) = query.max_context {
181 if ctx_f > max {
182 return false;
183 }
184 }
185 }
186
187 if let Some(quants) = &query.quantizations {
189 if !quants.is_empty() {
190 match &m.quantization {
191 Some(q) if quants.contains(q) => {}
192 _ => return false,
193 }
194 }
195 }
196
197 if let Some(tags) = &query.tags {
199 if !tags.is_empty() && !tags.iter().all(|t| m.tags.contains(t)) {
200 return false;
201 }
202 }
203
204 let speed_active = query.min_speed.is_some() || query.max_speed.is_some();
206 if speed_active {
207 match m.benchmark_summary.as_ref().and_then(|s| s.latest_tg_tps) {
208 None => return false,
209 Some(v) => {
210 if query.min_speed.is_some_and(|min| v < min) {
211 return false;
212 }
213 if query.max_speed.is_some_and(|max| v > max) {
214 return false;
215 }
216 }
217 }
218 }
219
220 true
221}
222
223fn tps(m: &Model) -> Option<f64> {
225 m.benchmark_summary.as_ref()?.latest_tg_tps
226}
227
228fn cmp_tps_asc(a: Option<f64>, b: Option<f64>) -> std::cmp::Ordering {
230 match (a, b) {
231 (Some(a), Some(b)) => a.partial_cmp(&b).unwrap_or(std::cmp::Ordering::Equal),
232 (None, None) => std::cmp::Ordering::Equal,
233 (None, Some(_)) => std::cmp::Ordering::Greater,
234 (Some(_), None) => std::cmp::Ordering::Less,
235 }
236}
237
238fn cmp_tps_desc(a: Option<f64>, b: Option<f64>) -> std::cmp::Ordering {
240 match (a, b) {
241 (Some(a), Some(b)) => b.partial_cmp(&a).unwrap_or(std::cmp::Ordering::Equal),
242 (None, None) => std::cmp::Ordering::Equal,
243 (None, Some(_)) => std::cmp::Ordering::Greater,
244 (Some(_), None) => std::cmp::Ordering::Less,
245 }
246}
247
248#[cfg(test)]
253mod tests {
254 use super::*;
255 use crate::ModelCapabilities;
256 use crate::domain::benchmark::ModelBenchmarkSummary;
257 use chrono::Utc;
258 use std::collections::HashMap;
259 use std::path::PathBuf;
260
261 fn make_model(id: i64, name: &str, params: f64) -> Model {
262 Model {
263 dialect_spec: None,
264 id,
265 name: name.to_string(),
266 model_key: String::new(),
267 file_path: PathBuf::from(format!("/models/{name}.gguf")),
268 param_count_b: params,
269 architecture: None,
270 quantization: None,
271 context_length: None,
272 expert_count: None,
273 expert_used_count: None,
274 expert_shared_count: None,
275 metadata: HashMap::new(),
276 added_at: Utc::now(),
277 hf_repo_id: None,
278 hf_commit_sha: None,
279 hf_filename: None,
280 download_date: None,
281 last_update_check: None,
282 tags: vec![],
283 capabilities: ModelCapabilities::default(),
284 inference_defaults: None,
285 defaults_origin: None,
286 server_defaults: None,
287 template_caps: None,
288 benchmark_summary: None,
289 }
290 }
291
292 fn with_quant(mut m: Model, quant: &str) -> Model {
293 m.quantization = Some(quant.to_string());
294 m
295 }
296
297 fn with_tags(mut m: Model, tags: &[&str]) -> Model {
298 m.tags = tags.iter().map(ToString::to_string).collect();
299 m
300 }
301
302 fn with_tps(mut m: Model, tps: f64) -> Model {
303 use chrono::Utc;
304 m.benchmark_summary = Some(ModelBenchmarkSummary {
305 model_id: m.id,
306 best_tg_tps: Some(tps),
307 best_pp_tps: None,
308 latest_tg_tps: Some(tps),
309 latest_pp_tps: None,
310 latest_backend: None,
311 perf_run_count: 1,
312 compare_run_count: 0,
313 last_benchmarked_at: Utc::now(),
314 updated_at: Utc::now(),
315 });
316 m
317 }
318
319 fn models() -> Vec<Model> {
320 vec![
321 with_quant(make_model(1, "alpha", 7.0), "Q4_K_M"),
322 with_quant(make_model(2, "beta", 13.0), "Q8_0"),
323 with_quant(make_model(3, "gamma", 70.0), "Q4_K_M"),
324 ]
325 }
326
327 #[test]
328 fn default_query_preserves_all_models() {
329 let result = apply_query(models(), &ModelListQuery::default());
330 assert_eq!(result.len(), 3);
331 }
332
333 #[test]
334 fn param_range_filters_correctly() {
335 let query = ModelListQuery {
336 min_params: Some(8.0),
337 max_params: Some(20.0),
338 ..Default::default()
339 };
340 let result = apply_query(models(), &query);
341 assert_eq!(result.len(), 1);
342 assert_eq!(result[0].name, "beta");
343 }
344
345 #[test]
346 fn quantization_filter_keeps_matching() {
347 let query = ModelListQuery {
348 quantizations: Some(vec!["Q4_K_M".to_string()]),
349 ..Default::default()
350 };
351 let result = apply_query(models(), &query);
352 assert_eq!(result.len(), 2);
353 assert!(
354 result
355 .iter()
356 .all(|m| m.quantization.as_deref() == Some("Q4_K_M"))
357 );
358 }
359
360 #[test]
361 fn tag_filter_uses_and_semantics() {
362 let tagged = vec![
363 with_tags(make_model(1, "a", 7.0), &["chat", "code"]),
364 with_tags(make_model(2, "b", 13.0), &["chat"]),
365 with_tags(make_model(3, "c", 70.0), &["code"]),
366 ];
367 let query = ModelListQuery {
368 tags: Some(vec!["chat".to_string(), "code".to_string()]),
369 ..Default::default()
370 };
371 let result = apply_query(tagged, &query);
372 assert_eq!(result.len(), 1);
373 assert_eq!(result[0].name, "a");
374 }
375
376 #[test]
377 fn speed_filter_excludes_models_without_benchmark() {
378 let ms = vec![
379 with_tps(make_model(1, "fast", 7.0), 80.0),
380 make_model(2, "no-bench", 13.0), with_tps(make_model(3, "slow", 70.0), 10.0),
382 ];
383 let query = ModelListQuery {
384 min_speed: Some(50.0),
385 ..Default::default()
386 };
387 let result = apply_query(ms, &query);
388 assert_eq!(result.len(), 1);
389 assert_eq!(result[0].name, "fast");
390 }
391
392 #[test]
393 fn sort_by_name_asc() {
394 let query = ModelListQuery {
395 sort_by: ModelSortBy::Name,
396 order: SortOrder::Asc,
397 ..Default::default()
398 };
399 let result = apply_query(models(), &query);
400 assert_eq!(result[0].name, "alpha");
401 assert_eq!(result[1].name, "beta");
402 assert_eq!(result[2].name, "gamma");
403 }
404
405 #[test]
406 fn sort_by_tps_desc_puts_none_last() {
407 let ms = vec![
408 with_tps(make_model(1, "fast", 7.0), 80.0),
409 make_model(2, "no-bench", 13.0),
410 with_tps(make_model(3, "slow", 70.0), 10.0),
411 ];
412 let query = ModelListQuery {
413 sort_by: ModelSortBy::LatestTgTps,
414 order: SortOrder::Desc,
415 ..Default::default()
416 };
417 let result = apply_query(ms, &query);
418 assert_eq!(result[0].name, "fast");
419 assert_eq!(result[1].name, "slow");
420 assert_eq!(result[2].name, "no-bench");
421 }
422
423 #[test]
424 fn empty_quantizations_vec_passes_all() {
425 let query = ModelListQuery {
426 quantizations: Some(vec![]),
427 ..Default::default()
428 };
429 let result = apply_query(models(), &query);
430 assert_eq!(result.len(), 3);
431 }
432}