From b74d837b95aaa2e4d21733347d784dd8dad38e74 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Tue, 24 Feb 2026 14:18:43 -0500 Subject: [PATCH] Add `ullm models sync` command to download OpenRouter model metadata Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: 6908e4dfbe5e --- Cargo.lock | 259 +++++++++++++++++++++++++++++++++++++ crates/llm/Cargo.toml | 2 + crates/llm/src/bin/ullm.rs | 164 ++++++++++++++++++++--- 3 files changed, 408 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c2b7cfc8d..ab2993f41 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/crates/llm/Cargo.toml b/crates/llm/Cargo.toml index 590eff946..1211643fe 100644 --- a/crates/llm/Cargo.toml +++ b/crates/llm/Cargo.toml @@ -39,3 +39,5 @@ http = "1" tokio = { workspace = true, features = ["test-util", "macros"] } assert_cmd = "2" predicates = "3" +httpmock = "0.8" +tempfile = "3" diff --git a/crates/llm/src/bin/ullm.rs b/crates/llm/src/bin/ullm.rs index 01810c268..7cd0989a4 100644 --- a/crates/llm/src/bin/ullm.rs +++ b/crates/llm/src/bin/ullm.rs @@ -64,6 +64,17 @@ enum ModelsCommand { #[arg(short, long)] query: Option, }, + + /// 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() {