Skip to main content

oino/cli/
list_models.rs

1//! The `--list-models [search]` table.
2
3use oino_ai::Model;
4
5/// `formatTokenCount` (`200000` → `200K`, `1000000` → `1M`, `1500` → `1.5K`).
6#[must_use]
7pub fn format_token_count(count: u64) -> String {
8    let scaled = |value: u64, unit: u64, suffix: &str| {
9        if value.is_multiple_of(unit) {
10            format!("{}{suffix}", value / unit)
11        } else {
12            // One decimal, rounded half up like `toFixed(1)` for these magnitudes.
13            let tenths = (value * 10 + unit / 2) / unit;
14            format!("{}.{}{suffix}", tenths / 10, tenths % 10)
15        }
16    };
17    if count >= 1_000_000 {
18        scaled(count, 1_000_000, "M")
19    } else if count >= 1_000 {
20        scaled(count, 1_000, "K")
21    } else {
22        count.to_string()
23    }
24}
25
26/// Case-insensitive subsequence match of every whitespace-separated token (the
27/// `fuzzyFilter` approximation).
28fn fuzzy_matches(text: &str, query: &str) -> bool {
29    let text = text.to_lowercase();
30    query.split_whitespace().all(|token| {
31        let mut chars = text.chars();
32        token
33            .to_lowercase()
34            .chars()
35            .all(|wanted| chars.any(|c| c == wanted))
36    })
37}
38
39/// Rows of the table (header first), or the fixed message when nothing matches.
40#[must_use]
41pub fn list_models_output(models: &[Model], search: Option<&str>, no_models: &str) -> String {
42    if models.is_empty() {
43        return format!("{no_models}\n");
44    }
45    let mut filtered: Vec<&Model> = models
46        .iter()
47        .filter(|m| search.is_none_or(|q| fuzzy_matches(&format!("{} {}", m.provider, m.id), q)))
48        .collect();
49    if filtered.is_empty() {
50        return format!("No models matching \"{}\"\n", search.unwrap_or_default());
51    }
52    filtered.sort_by(|a, b| {
53        (
54            a.provider.to_lowercase(),
55            &a.provider,
56            a.id.to_lowercase(),
57            &a.id,
58        )
59            .cmp(&(
60                b.provider.to_lowercase(),
61                &b.provider,
62                b.id.to_lowercase(),
63                &b.id,
64            ))
65    });
66    let header = [
67        "provider", "model", "context", "max-out", "thinking", "images",
68    ]
69    .map(str::to_owned);
70    let rows: Vec<[String; 6]> = filtered
71        .iter()
72        .map(|m| {
73            [
74                m.provider.clone(),
75                m.id.clone(),
76                format_token_count(m.context_window),
77                format_token_count(m.max_tokens),
78                if m.reasoning { "yes" } else { "no" }.to_owned(),
79                if m.input.contains(&oino_ai::InputModality::Image) {
80                    "yes"
81                } else {
82                    "no"
83                }
84                .to_owned(),
85            ]
86        })
87        .collect();
88    let widths: Vec<usize> = (0..6)
89        .map(|c| {
90            rows.iter()
91                .map(|r| r[c].chars().count())
92                .chain([header[c].len()])
93                .max()
94                .unwrap_or(0)
95        })
96        .collect();
97    let line = |cells: &[String; 6]| {
98        let padded: Vec<String> = cells
99            .iter()
100            .zip(&widths)
101            .map(|(cell, width)| format!("{cell:<width$}"))
102            .collect();
103        padded.join("  ")
104    };
105    let mut out = String::new();
106    for cells in std::iter::once(&header).chain(rows.iter()) {
107        out.push_str(&line(cells));
108        out.push('\n');
109    }
110    out
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn token_counts() {
119        assert_eq!(format_token_count(200_000), "200K");
120        assert_eq!(format_token_count(1_000_000), "1M");
121        assert_eq!(format_token_count(1_500), "1.5K");
122        assert_eq!(format_token_count(128_000), "128K");
123        assert_eq!(format_token_count(999), "999");
124    }
125
126    #[test]
127    fn fuzzy_tokens() {
128        assert!(fuzzy_matches("faux faux-1", "fx 1"));
129        assert!(!fuzzy_matches("faux faux-1", "zz"));
130    }
131}