Add ullm models sync command to download OpenRouter model metadata

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 6908e4dfbe5e
This commit is contained in:
Bryan Helmkamp 2026-02-24 14:18:43 -05:00
parent 52d3536a1d
commit b74d837b95
3 changed files with 408 additions and 17 deletions

259
Cargo.lock generated
View file

@ -123,6 +123,16 @@ version = "1.0.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
[[package]]
name = "assert-json-diff"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "assert_cmd"
version = "2.1.2"
@ -138,6 +148,27 @@ dependencies = [
"wait-timeout",
]
[[package]]
name = "async-lock"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
dependencies = [
"event-listener",
"event-listener-strategy",
"pin-project-lite",
]
[[package]]
name = "async-object-pool"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1ac0219111eb7bb7cb76d4cf2cb50c598e7ae549091d3616f9e95442c18486f"
dependencies = [
"async-lock",
"event-listener",
]
[[package]]
name = "async-trait"
version = "0.1.89"
@ -293,6 +324,15 @@ version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "bollard"
version = "0.18.1"
@ -371,6 +411,9 @@ name = "bytes"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
dependencies = [
"serde",
]
[[package]]
name = "cc"
@ -481,6 +524,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "concurrent-queue"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "console"
version = "0.16.2"
@ -510,6 +562,31 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "data-encoding"
version = "2.10.0"
@ -544,6 +621,16 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8"
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "dirs"
version = "6.0.0"
@ -625,6 +712,27 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "event-listener"
version = "5.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
dependencies = [
"concurrent-queue",
"parking",
"pin-project-lite",
]
[[package]]
name = "event-listener-strategy"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
dependencies = [
"event-listener",
"pin-project-lite",
]
[[package]]
name = "fancy-regex"
version = "0.17.0"
@ -793,6 +901,12 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-timer"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24"
[[package]]
name = "futures-util"
version = "0.3.32"
@ -810,6 +924,16 @@ dependencies = [
"slab",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@ -901,6 +1025,30 @@ dependencies = [
"foldhash 0.2.0",
]
[[package]]
name = "headers"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb"
dependencies = [
"base64",
"bytes",
"headers-core",
"http",
"httpdate",
"mime",
"sha1",
]
[[package]]
name = "headers-core"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4"
dependencies = [
"http",
]
[[package]]
name = "heck"
version = "0.5.0"
@ -958,6 +1106,40 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "httpmock"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf4888a4d02d8e1f92ffb6b4965cf5ff56dda36ef41975f41c6fa0f6bde78c4e"
dependencies = [
"assert-json-diff",
"async-object-pool",
"async-trait",
"base64",
"bytes",
"crossbeam-utils",
"form_urlencoded",
"futures-timer",
"futures-util",
"headers",
"http",
"http-body-util",
"hyper",
"hyper-util",
"path-tree",
"regex",
"serde",
"serde_json",
"serde_regex",
"similar",
"stringmetrics",
"tabwriter",
"thiserror 2.0.18",
"tokio",
"tracing",
"url",
]
[[package]]
name = "hyper"
version = "1.8.1"
@ -1359,11 +1541,13 @@ dependencies = [
"dotenvy",
"futures",
"http",
"httpmock",
"predicates",
"rand 0.8.5",
"reqwest 0.12.28",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.18",
"tokio",
"tokio-stream",
@ -1558,6 +1742,12 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]]
name = "parking_lot"
version = "0.12.5"
@ -1587,6 +1777,15 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "path-tree"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a97453bc21a968f722df730bfe11bd08745cb50d1300b0df2bda131dece136"
dependencies = [
"smallvec",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
@ -2232,6 +2431,16 @@ dependencies = [
"serde_core",
]
[[package]]
name = "serde_regex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8136f1a4ea815d7eac4101cfd0b16dc0cb5e1fe1b8609dfd728058656b7badf"
dependencies = [
"regex",
"serde",
]
[[package]]
name = "serde_repr"
version = "0.1.20"
@ -2273,6 +2482,17 @@ dependencies = [
"time",
]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "shell-words"
version = "1.1.1"
@ -2295,6 +2515,12 @@ dependencies = [
"libc",
]
[[package]]
name = "similar"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
[[package]]
name = "slab"
version = "0.4.12"
@ -2323,6 +2549,12 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "stringmetrics"
version = "2.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b3c8667cd96245cbb600b8dec5680a7319edd719c5aa2b5d23c6bff94f39765"
[[package]]
name = "strsim"
version = "0.11.1"
@ -2366,6 +2598,15 @@ dependencies = [
"syn",
]
[[package]]
name = "tabwriter"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fce91f2f0ec87dff7e6bcbbeb267439aa1188703003c6055193c821487400432"
dependencies = [
"unicode-width",
]
[[package]]
name = "tar"
version = "0.4.44"
@ -2613,9 +2854,21 @@ checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
@ -2631,6 +2884,12 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "typenum"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "unicode-general-category"
version = "1.1.0"

View file

@ -39,3 +39,5 @@ http = "1"
tokio = { workspace = true, features = ["test-util", "macros"] }
assert_cmd = "2"
predicates = "3"
httpmock = "0.8"
tempfile = "3"

View file

@ -64,6 +64,17 @@ enum ModelsCommand {
#[arg(short, long)]
query: Option<String>,
},
/// Download model metadata from OpenRouter
Sync {
/// URL to fetch models from
#[arg(long, default_value = "https://openrouter.ai/api/v1/models")]
url: String,
/// Output file path
#[arg(short, long, default_value = "openrouter_models.json")]
output: String,
},
}
fn parse_option(s: &str) -> Result<(String, String), String> {
@ -219,6 +230,27 @@ async fn run_prompt(args: PromptArgs) -> Result<()> {
Ok(())
}
async fn sync_models(url: &str, output: &str) -> Result<()> {
let body = reqwest::get(url)
.await
.context("failed to connect to models endpoint")?
.error_for_status()
.context("models endpoint returned an error")?
.text()
.await
.context("failed to read response body")?;
let json: serde_json::Value =
serde_json::from_str(&body).context("response is not valid JSON")?;
let pretty =
serde_json::to_string_pretty(&json).context("failed to format JSON")?;
std::fs::write(output, &pretty).with_context(|| format!("failed to write {output}"))?;
eprintln!("Saved models to {output}");
Ok(())
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
@ -246,26 +278,32 @@ async fn main() -> Result<()> {
.await?;
}
Command::Models { command } => {
let ModelsCommand::List { provider, query } =
command.unwrap_or(ModelsCommand::List {
provider: None,
query: None,
});
let command = command.unwrap_or(ModelsCommand::List {
provider: None,
query: None,
});
let mut models = catalog::list_models(provider.as_deref());
match command {
ModelsCommand::List { provider, query } => {
let mut models = catalog::list_models(provider.as_deref());
if let Some(q) = &query {
let q_lower = q.to_lowercase();
models.retain(|m| {
m.id.to_lowercase().contains(&q_lower)
|| m.display_name.to_lowercase().contains(&q_lower)
|| m.aliases
.iter()
.any(|a| a.to_lowercase().contains(&q_lower))
});
if let Some(q) = &query {
let q_lower = q.to_lowercase();
models.retain(|m| {
m.id.to_lowercase().contains(&q_lower)
|| m.display_name.to_lowercase().contains(&q_lower)
|| m.aliases
.iter()
.any(|a| a.to_lowercase().contains(&q_lower))
});
}
print_models_table(&models);
}
ModelsCommand::Sync { url, output } => {
sync_models(&url, &output).await?;
}
}
print_models_table(&models);
}
}
@ -355,6 +393,98 @@ mod tests {
.stdout(predicate::str::contains("gemini-3.1-pro-preview"));
}
// models sync downloads and saves pretty-printed JSON
#[test]
fn models_sync_downloads_and_saves() {
let server = httpmock::MockServer::start();
let mock_response = serde_json::json!({
"data": [{"id": "test-model", "name": "Test Model"}]
});
server.mock(|when, then| {
when.method("GET").path("/api/v1/models");
then.status(200)
.header("content-type", "application/json")
.body(serde_json::to_string(&mock_response).unwrap());
});
let dir = tempfile::tempdir().unwrap();
let output_path = dir.path().join("models.json");
ullm()
.args([
"models",
"sync",
"--url",
&server.url("/api/v1/models"),
"--output",
output_path.to_str().unwrap(),
])
.assert()
.success()
.stderr(predicate::str::contains("Saved models to"));
let contents = std::fs::read_to_string(&output_path).unwrap();
let expected = serde_json::to_string_pretty(&mock_response).unwrap();
assert_eq!(contents, expected);
}
// models sync reports HTTP errors
#[test]
fn models_sync_reports_http_errors() {
let server = httpmock::MockServer::start();
server.mock(|when, then| {
when.method("GET").path("/api/v1/models");
then.status(500);
});
let dir = tempfile::tempdir().unwrap();
let output_path = dir.path().join("models.json");
ullm()
.args([
"models",
"sync",
"--url",
&server.url("/api/v1/models"),
"--output",
output_path.to_str().unwrap(),
])
.assert()
.failure()
.stderr(predicate::str::contains("error").or(predicate::str::contains("Error")));
}
// models sync with real OpenRouter (requires network)
#[test]
#[ignore = "requires network"]
fn models_sync_integration_smoke_test() {
let dir = tempfile::tempdir().unwrap();
let output_path = dir.path().join("models.json");
ullm()
.args([
"models",
"sync",
"--output",
output_path.to_str().unwrap(),
])
.assert()
.success();
let contents = std::fs::read_to_string(&output_path).unwrap();
assert!(contents.contains("\"data\""));
}
// models sync --help succeeds and mentions openrouter
#[test]
fn models_sync_help_mentions_openrouter() {
ullm()
.args(["models", "sync", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("openrouter").or(predicate::str::contains("OpenRouter")));
}
// Step 5: prompt requires prompt text (errors when no prompt and stdin is tty)
#[test]
fn prompt_errors_without_prompt_text() {