mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-09 22:33:37 +00:00
Add ullm CLI for unified-llm library
New crate `unified-llm-cli` with binary `ullm` providing: - `prompt` command: generate text via streaming/non-streaming, with system prompts, options (-o temperature=0.5), stdin piping, and token usage display - `models list` command: browse catalog models with --provider and --query filters - Auto-detects provider from catalog for correct API routing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
978a477d1e
commit
fa542605d7
4 changed files with 745 additions and 0 deletions
295
Cargo.lock
generated
295
Cargo.lock
generated
|
|
@ -2,12 +2,86 @@
|
|||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "0.6.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
"anstyle-query",
|
||||
"anstyle-wincon",
|
||||
"colorchoice",
|
||||
"is_terminal_polyfill",
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.101"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
|
||||
|
||||
[[package]]
|
||||
name = "assert_cmd"
|
||||
version = "2.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c5bcfa8749ac45dd12cb11055aeeb6b27a3895560d60d71e3c23bf979e60514"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"bstr",
|
||||
"libc",
|
||||
"predicates",
|
||||
"predicates-core",
|
||||
"predicates-tree",
|
||||
"wait-timeout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
|
|
@ -25,6 +99,12 @@ version = "1.1.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
|
|
@ -37,6 +117,17 @@ version = "2.11.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
||||
|
||||
[[package]]
|
||||
name = "bstr"
|
||||
version = "1.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.1"
|
||||
|
|
@ -65,6 +156,66 @@ version = "1.0.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.5.60"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.5.60"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.5.55"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
|
||||
|
||||
[[package]]
|
||||
name = "coding-agent-loop"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"futures",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"unified-llm",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
|
|
@ -91,6 +242,12 @@ version = "0.8.7"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "difflib"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8"
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.5"
|
||||
|
|
@ -145,6 +302,15 @@ version = "0.1.9"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "float-cmp"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
|
|
@ -587,6 +753,12 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.17"
|
||||
|
|
@ -682,12 +854,33 @@ dependencies = [
|
|||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "normalize-line-endings"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be"
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.75"
|
||||
|
|
@ -797,6 +990,36 @@ dependencies = [
|
|||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "predicates"
|
||||
version = "3.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"difflib",
|
||||
"float-cmp",
|
||||
"normalize-line-endings",
|
||||
"predicates-core",
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "predicates-core"
|
||||
version = "1.0.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144"
|
||||
|
||||
[[package]]
|
||||
name = "predicates-tree"
|
||||
version = "1.0.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2"
|
||||
dependencies = [
|
||||
"predicates-core",
|
||||
"termtree",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
|
|
@ -870,6 +1093,35 @@ dependencies = [
|
|||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.28"
|
||||
|
|
@ -1128,6 +1380,12 @@ version = "1.2.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
|
|
@ -1199,6 +1457,12 @@ dependencies = [
|
|||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "termtree"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.18"
|
||||
|
|
@ -1405,6 +1669,22 @@ dependencies = [
|
|||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unified-llm-cli"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
"clap",
|
||||
"dotenvy",
|
||||
"futures",
|
||||
"predicates",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"unified-llm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
|
|
@ -1429,6 +1709,12 @@ version = "1.0.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.21.0"
|
||||
|
|
@ -1446,6 +1732,15 @@ version = "0.2.15"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "wait-timeout"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "want"
|
||||
version = "0.3.1"
|
||||
|
|
|
|||
|
|
@ -30,3 +30,4 @@ async-trait = "0.1"
|
|||
base64 = "0.22"
|
||||
bytes = "1"
|
||||
tokio-util = "0.7"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
|
|
|
|||
27
crates/unified-llm-cli/Cargo.toml
Normal file
27
crates/unified-llm-cli/Cargo.toml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
[package]
|
||||
name = "unified-llm-cli"
|
||||
description = "CLI for the unified-llm library"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "ullm"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
unified-llm = { path = "../unified-llm" }
|
||||
clap = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tokio-stream = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
dotenvy = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd = "2"
|
||||
predicates = "3"
|
||||
422
crates/unified-llm-cli/src/main.rs
Normal file
422
crates/unified-llm-cli/src/main.rs
Normal file
|
|
@ -0,0 +1,422 @@
|
|||
use std::io::{self, IsTerminal, Read};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
use futures::StreamExt;
|
||||
use unified_llm::catalog;
|
||||
use unified_llm::generate::{self, GenerateParams};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "ullm")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Command {
|
||||
/// Execute a prompt
|
||||
Prompt {
|
||||
/// The prompt text (also accepts stdin)
|
||||
prompt: Option<String>,
|
||||
|
||||
/// Model to use
|
||||
#[arg(short, long)]
|
||||
model: Option<String>,
|
||||
|
||||
/// System prompt
|
||||
#[arg(short, long)]
|
||||
system: Option<String>,
|
||||
|
||||
/// Do not stream output
|
||||
#[arg(long)]
|
||||
no_stream: bool,
|
||||
|
||||
/// Show token usage
|
||||
#[arg(short, long)]
|
||||
usage: bool,
|
||||
|
||||
/// key=value options (temperature, `max_tokens`, `top_p`)
|
||||
#[arg(short, long, value_parser = parse_option)]
|
||||
option: Vec<(String, String)>,
|
||||
},
|
||||
|
||||
/// Manage models
|
||||
Models {
|
||||
#[command(subcommand)]
|
||||
command: Option<ModelsCommand>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum ModelsCommand {
|
||||
/// List available models
|
||||
List {
|
||||
/// Filter by provider
|
||||
#[arg(short, long)]
|
||||
provider: Option<String>,
|
||||
|
||||
/// Search for models matching this string
|
||||
#[arg(short, long)]
|
||||
query: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
fn parse_option(s: &str) -> Result<(String, String), String> {
|
||||
let (key, value) = s
|
||||
.split_once('=')
|
||||
.ok_or_else(|| format!("expected key=value, got {s}"))?;
|
||||
Ok((key.to_string(), value.to_string()))
|
||||
}
|
||||
|
||||
fn print_models_table(models: &[unified_llm::types::ModelInfo]) {
|
||||
println!(
|
||||
"{:<30} {:<12} {:<30} {:>14}",
|
||||
"ID", "PROVIDER", "ALIASES", "CONTEXT"
|
||||
);
|
||||
for model in models {
|
||||
let aliases = model.aliases.join(", ");
|
||||
println!(
|
||||
"{:<30} {:<12} {:<30} {:>14}",
|
||||
model.id, model.provider, aliases, model.context_window
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn read_stdin_prompt() -> Option<String> {
|
||||
let stdin = io::stdin();
|
||||
if stdin.is_terminal() {
|
||||
return None;
|
||||
}
|
||||
let mut buf = String::new();
|
||||
stdin.lock().read_to_string(&mut buf).ok()?;
|
||||
let trimmed = buf.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_prompt(arg: Option<String>, stdin: Option<String>) -> Result<String> {
|
||||
match (stdin, arg) {
|
||||
(Some(s), Some(a)) => Ok(format!("{s}\n{a}")),
|
||||
(Some(s), None) => Ok(s),
|
||||
(None, Some(a)) => Ok(a),
|
||||
(None, None) => bail!("Error: no prompt provided. Pass a prompt as an argument or pipe text via stdin."),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns (model_id, provider) from the catalog, falling back to the first catalog model.
|
||||
fn resolve_model(model_arg: Option<String>) -> (String, Option<String>) {
|
||||
let model_id = model_arg.unwrap_or_else(|| {
|
||||
catalog::list_models(None)
|
||||
.first()
|
||||
.map_or_else(|| "claude-sonnet-4-5".to_string(), |m| m.id.clone())
|
||||
});
|
||||
let provider = catalog::get_model_info(&model_id).map(|info| info.provider);
|
||||
(model_id, provider)
|
||||
}
|
||||
|
||||
fn apply_options(
|
||||
mut params: GenerateParams,
|
||||
options: &[(String, String)],
|
||||
) -> Result<GenerateParams> {
|
||||
let mut provider_opts = serde_json::Map::new();
|
||||
|
||||
for (key, value) in options {
|
||||
match key.as_str() {
|
||||
"temperature" => {
|
||||
let v: f64 = value
|
||||
.parse()
|
||||
.with_context(|| format!("invalid temperature value: {value}"))?;
|
||||
params = params.temperature(v);
|
||||
}
|
||||
"max_tokens" => {
|
||||
let v: i64 = value
|
||||
.parse()
|
||||
.with_context(|| format!("invalid max_tokens value: {value}"))?;
|
||||
params = params.max_tokens(v);
|
||||
}
|
||||
"top_p" => {
|
||||
let v: f64 = value
|
||||
.parse()
|
||||
.with_context(|| format!("invalid top_p value: {value}"))?;
|
||||
params = params.top_p(v);
|
||||
}
|
||||
_ => {
|
||||
provider_opts.insert(key.clone(), serde_json::Value::String(value.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !provider_opts.is_empty() {
|
||||
params = params.provider_options(serde_json::Value::Object(provider_opts));
|
||||
}
|
||||
|
||||
Ok(params)
|
||||
}
|
||||
|
||||
async fn run_prompt(
|
||||
prompt_arg: Option<String>,
|
||||
model: Option<String>,
|
||||
system: Option<String>,
|
||||
no_stream: bool,
|
||||
show_usage: bool,
|
||||
options: Vec<(String, String)>,
|
||||
) -> Result<()> {
|
||||
let stdin_prompt = read_stdin_prompt();
|
||||
let prompt_text = resolve_prompt(prompt_arg, stdin_prompt)?;
|
||||
let (model_id, provider) = resolve_model(model);
|
||||
|
||||
let mut params = GenerateParams::new(&model_id).prompt(&prompt_text);
|
||||
if let Some(p) = provider {
|
||||
params = params.provider(&p);
|
||||
}
|
||||
if let Some(sys) = system {
|
||||
params = params.system(&sys);
|
||||
}
|
||||
params = apply_options(params, &options)?;
|
||||
|
||||
if no_stream {
|
||||
let result = generate::generate(params).await?;
|
||||
print!("{}", result.text());
|
||||
if show_usage {
|
||||
let usage = result.usage();
|
||||
eprintln!(
|
||||
"Tokens: {} input, {} output, {} total",
|
||||
usage.input_tokens, usage.output_tokens, usage.total_tokens
|
||||
);
|
||||
}
|
||||
} else {
|
||||
let mut stream_result = generate::stream(params).await?;
|
||||
while let Some(event) = stream_result.next().await {
|
||||
if let unified_llm::types::StreamEvent::TextDelta { delta, .. } = event? {
|
||||
print!("{delta}");
|
||||
}
|
||||
}
|
||||
println!();
|
||||
if show_usage {
|
||||
if let Some(response) = stream_result.response() {
|
||||
let usage = &response.usage;
|
||||
eprintln!(
|
||||
"Tokens: {} input, {} output, {} total",
|
||||
usage.input_tokens, usage.output_tokens, usage.total_tokens
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
dotenvy::dotenv().ok();
|
||||
let cli = Cli::parse();
|
||||
|
||||
match cli.command {
|
||||
Command::Prompt {
|
||||
prompt,
|
||||
model,
|
||||
system,
|
||||
no_stream,
|
||||
usage,
|
||||
option,
|
||||
} => {
|
||||
let rt = tokio::runtime::Runtime::new()?;
|
||||
rt.block_on(run_prompt(prompt, model, system, no_stream, usage, option))?;
|
||||
}
|
||||
Command::Models { command } => {
|
||||
let command = command.unwrap_or(ModelsCommand::List {
|
||||
provider: None,
|
||||
query: None,
|
||||
});
|
||||
match command {
|
||||
ModelsCommand::List { provider, query } => {
|
||||
let mut models = catalog::list_models(provider.as_deref());
|
||||
|
||||
if let Some(ref 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use assert_cmd::Command;
|
||||
use predicates::prelude::*;
|
||||
|
||||
#[allow(deprecated)] // cargo_bin_cmd! macro has known issues
|
||||
fn ullm() -> Command {
|
||||
Command::cargo_bin("ullm").unwrap()
|
||||
}
|
||||
|
||||
// Step 1: models list prints all catalog models
|
||||
#[test]
|
||||
fn models_list_prints_all_models() {
|
||||
ullm()
|
||||
.args(["models", "list"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("claude-opus-4-6"))
|
||||
.stdout(predicate::str::contains("claude-sonnet-4-5"))
|
||||
.stdout(predicate::str::contains("gpt-5.2"))
|
||||
.stdout(predicate::str::contains("gemini-3-pro-preview"))
|
||||
.stdout(predicate::str::contains("anthropic"))
|
||||
.stdout(predicate::str::contains("openai"))
|
||||
.stdout(predicate::str::contains("gemini"));
|
||||
}
|
||||
|
||||
// Step 2: models list --provider filters to that provider only
|
||||
#[test]
|
||||
fn models_list_filters_by_provider() {
|
||||
let assert = ullm()
|
||||
.args(["models", "list", "--provider", "anthropic"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("claude-opus-4-6"))
|
||||
.stdout(predicate::str::contains("claude-sonnet-4-5"));
|
||||
|
||||
// Should NOT contain other providers
|
||||
assert
|
||||
.stdout(predicate::str::contains("gpt-5.2").not())
|
||||
.stdout(predicate::str::contains("gemini-3-pro-preview").not());
|
||||
}
|
||||
|
||||
// Step 3: models list --query does substring match on id/name/aliases
|
||||
#[test]
|
||||
fn models_list_filters_by_query() {
|
||||
ullm()
|
||||
.args(["models", "list", "--query", "opus"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("claude-opus-4-6"))
|
||||
.stdout(predicate::str::contains("claude-sonnet-4-5").not());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn models_list_query_is_case_insensitive() {
|
||||
ullm()
|
||||
.args(["models", "list", "--query", "OPUS"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("claude-opus-4-6"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn models_list_query_matches_aliases() {
|
||||
ullm()
|
||||
.args(["models", "list", "--query", "codex"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("gpt-5.2-codex"));
|
||||
}
|
||||
|
||||
// Step 4: bare "models" defaults to list
|
||||
#[test]
|
||||
fn models_bare_defaults_to_list() {
|
||||
ullm()
|
||||
.args(["models"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("claude-opus-4-6"))
|
||||
.stdout(predicate::str::contains("gpt-5.2"))
|
||||
.stdout(predicate::str::contains("gemini-3-pro-preview"));
|
||||
}
|
||||
|
||||
// Step 5: prompt requires prompt text (errors when no prompt and stdin is tty)
|
||||
#[test]
|
||||
fn prompt_errors_without_prompt_text() {
|
||||
// assert_cmd provides no stdin by default (simulating a tty-like "empty pipe")
|
||||
// We pass an empty stdin to avoid tty detection
|
||||
ullm()
|
||||
.args(["prompt"])
|
||||
.write_stdin("")
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("no prompt provided"));
|
||||
}
|
||||
|
||||
// Step 9: stdin piping — reads from stdin when no prompt arg
|
||||
#[test]
|
||||
fn prompt_reads_from_stdin() {
|
||||
// This test verifies stdin is read, but will fail at the API call stage
|
||||
// since no API key is set. The error should NOT be "no prompt provided".
|
||||
let result = ullm()
|
||||
.args(["prompt", "--no-stream", "-m", "test-model"])
|
||||
.write_stdin("hello from stdin")
|
||||
.assert()
|
||||
.failure();
|
||||
|
||||
// Should NOT complain about missing prompt
|
||||
result.stderr(predicate::str::contains("no prompt provided").not());
|
||||
}
|
||||
|
||||
// Step 9b: stdin + arg concatenation
|
||||
#[test]
|
||||
fn prompt_concatenates_stdin_and_arg() {
|
||||
// Same as above — verifies it doesn't error on "no prompt"
|
||||
let result = ullm()
|
||||
.args(["prompt", "--no-stream", "-m", "test-model", "summarize this"])
|
||||
.write_stdin("some input text")
|
||||
.assert()
|
||||
.failure();
|
||||
|
||||
result.stderr(predicate::str::contains("no prompt provided").not());
|
||||
}
|
||||
|
||||
// Step 10: -o option parsing
|
||||
#[test]
|
||||
fn prompt_rejects_bad_option_format() {
|
||||
ullm()
|
||||
.args(["prompt", "-o", "bad_option", "hello"])
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("expected key=value"));
|
||||
}
|
||||
|
||||
// Step 6/7/8: Integration tests gated behind API key
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn prompt_no_stream_generates_response() {
|
||||
ullm()
|
||||
.args(["prompt", "--no-stream", "-m", "claude-sonnet-4-5", "Say just the word 'hello'"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::is_empty().not());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn prompt_stream_generates_response() {
|
||||
ullm()
|
||||
.args(["prompt", "-m", "claude-sonnet-4-5", "Say just the word 'hello'"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::is_empty().not());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn prompt_usage_shows_tokens() {
|
||||
ullm()
|
||||
.args(["prompt", "--no-stream", "-u", "-m", "claude-sonnet-4-5", "Say just the word 'hello'"])
|
||||
.assert()
|
||||
.success()
|
||||
.stderr(predicate::str::contains("Tokens:"));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue