mod events;
mod generate;
mod models;
mod serve;
pub use generate::generate;
#[cfg(test)]
pub(crate) use models::models_req;
pub use models::{list_models, ListIo};
use std::io::{self, Read, Write};
use crate::canonical::{CanonicalError, ExitClass};
use crate::cli::{parse_args, Args};
use crate::config::partial::OutMode;
use crate::config::{
config_path, defaults, dump_config, partial_from_env, read_config_file, EnvSnapshot,
PartialConfig,
};
use crate::pipeline::{
open_input, pump, read_files, read_request, NdjsonSink, PrettySink, RawSink, Sink, Style,
TextSink,
};
use crate::store::{Clock, CredStore, ModelCache};
use crate::transport::{Bytes, Transport};
pub struct Host<'a> {
pub transport: &'a dyn Transport,
pub store: &'a dyn CredStore,
pub cache: &'a dyn ModelCache,
pub clock: &'a dyn Clock,
}
pub fn run(
args: Args,
stdin: &mut dyn Read,
stdout: &mut dyn Write,
stderr: &mut dyn Write,
host: &Host,
) -> u8 {
let mut flags = match parse_args(&args.argv) {
Ok(f) => f,
Err(e) => return fail_early(stderr, e),
};
if flags.help {
return emit(stdout, HELP);
}
if flags.version {
return emit(stdout, VERSION_LINE);
}
let env = &args.env;
let cfg_path = config_path(flags.config_path.take(), env);
let file = match read_config_file(&cfg_path) {
Ok(p) => p,
Err(e) => return fail_early(stderr, e),
};
if flags.dump_config {
return dump(stdout, stderr, flags.config, env, file);
}
if args.tty && flags.prompt.is_none() && flags.input.is_none() && flags.files.is_empty() {
let _ = stderr.write_all(HELP.as_bytes());
return ExitClass::Usage.code();
}
let env_partial = match partial_from_env(env) {
Ok(p) => p,
Err(e) => return fail_early(stderr, e.into()),
};
let merged = flags.config.or(env_partial).or(file).or(defaults());
let output = merged.output.unwrap_or(OutMode::Text);
let thinking = merged.thinking.unwrap_or(false);
let raw = output == OutMode::Raw;
if raw && !flags.files.is_empty() {
let _ = writeln!(stderr, "--file cannot be combined with --raw");
return ExitClass::Usage.code();
}
let mut input_file;
let reader: &mut dyn Read = match &flags.input {
Some(path) => match open_input(Some(path)) {
Ok(f) => {
input_file = f;
&mut *input_file
}
Err(_) => {
let _ = writeln!(stderr, "cannot open --input file `{}`", path.display());
return ExitClass::NoInput.code();
}
},
None => stdin,
};
let file_parts = match read_files(&flags.files) {
Ok(parts) => parts,
Err((path, e)) => {
let _ = writeln!(stderr, "cannot read --file `{}`: {e}", path.display());
return ExitClass::NoInput.code();
}
};
let mut sink: Box<dyn Sink + '_> = match output {
OutMode::Text => match Style::resolve(args.stdout_tty, output, env) {
style if style.is_pretty() => {
Box::new(PrettySink::new(&mut *stdout, &mut *stderr, thinking, style))
}
_ => Box::new(TextSink::new(&mut *stdout, &mut *stderr, thinking)),
},
OutMode::Ndjson => Box::new(NdjsonSink::new(&mut *stdout)),
OutMode::Raw => Box::new(RawSink::new(&mut *stdout)),
};
if raw {
return serve::serve_raw(reader, merged, &mut *sink, host);
}
let request = match read_request(flags.prompt.as_deref(), file_parts, reader) {
Ok(r) => r,
Err(e) => return events::fail_inband(&mut *sink, e),
};
let req_model = (!request.model.is_empty()).then(|| request.model.clone());
match merged.into_resolved(req_model.as_deref()) {
Ok(cfg) => pump(generate(request, cfg, host), &mut *sink),
Err(e) => events::fail_inband(&mut *sink, e.into()),
}
}
fn fail_early(stderr: &mut dyn Write, err: CanonicalError) -> u8 {
let _ = writeln!(stderr, "{}", err.message);
err.exit_code()
}
fn dump(
stdout: &mut dyn Write,
stderr: &mut dyn Write,
flags: PartialConfig,
env: &EnvSnapshot,
file: PartialConfig,
) -> u8 {
match dump_config(flags, env, file) {
Ok(toml) => match stdout
.write_all(toml.as_bytes())
.and_then(|()| stdout.flush())
{
Ok(()) => ExitClass::Ok.code(),
Err(io) => ExitClass::from_io(&io).code(),
},
Err(e) => fail_early(stderr, e.into()),
}
}
pub(crate) fn emit(stdout: &mut dyn Write, doc: &str) -> u8 {
match stdout
.write_all(doc.as_bytes())
.and_then(|()| stdout.flush())
{
Ok(()) => ExitClass::Ok.code(),
Err(io) => ExitClass::from_io(&io).code(),
}
}
pub(crate) const VERSION_LINE: &str = concat!("bz ", env!("CARGO_PKG_VERSION"), "\n");
pub(crate) const HELP: &str = concat!(
"bz ",
env!("CARGO_PKG_VERSION"),
" — a stateless LLM adapter: one request, one round-trip, one POSIX exit.\n",
"\n",
"USAGE:\n",
" bz [FLAGS] \"PROMPT\" one-shot: the positional prompt is the request\n",
" echo '{…}' | bz [FLAGS] pipe a canonical request (JSON) on stdin instead\n",
" bz --login --provider <id> [--browser] | bz --list-models [--provider <id>]\n",
"\n",
"The request arrives exactly one way: a positional PROMPT (argv) XOR a canonical\n",
"request on stdin. A prompt wins and stdin is not read. A leading bare word is\n",
"ALWAYS a prompt — control operations are flags, never verbs. Output is a\n",
"projection chosen by flag; the default is plain text.\n",
"\n",
"CONTROL (each replaces the data-plane run with a control action, then exits):\n",
" --login obtain and store an OAuth/SSO credential for --provider\n",
" (the one interactive surface; never entered by the data\n",
" plane). Default: the headless device flow (shows a code to\n",
" enter on another device). --browser: the loopback browser\n",
" flow (opens a URL, captures the redirect).\n",
" --list-models one GET: list the resolved provider's models\n",
" --dump-config print the merged config as TOML, exit 0\n",
" --help, -h print this help, exit 0\n",
" --version, -V print the version, exit 0\n",
"\n",
"FLAGS:\n",
" --provider <id> provider row id (else routed from the model)\n",
" --model <id> model id; a partial/absent id resolves against the cache\n",
" --api-key <key> inline credential (else the credential store / env)\n",
" --system <text> leading system prompt\n",
" --max-tokens <n> generation cap\n",
" --temperature <f> sampling temperature\n",
" --top-p <f> nucleus sampling\n",
" --stream/--no-stream stream the response (default) or fold one JSON body\n",
" --thinking include reasoning/thinking output (text mode)\n",
" --text human-readable text (default)\n",
" --json the full NDJSON canonical event stream\n",
" --raw pass bytes through verbatim, provider-native both ways\n",
" -f, --file <path> attach a file's text as context (repeatable; before the prompt)\n",
" --input <file> read the request from a file instead of stdin\n",
" --config <file> use this config file (else the default search path)\n",
" --timeout-connect <s> / --timeout-response <s> / --timeout-idle <s>\n",
"\n",
"EXIT CODES (sysexits):\n",
" 0 success (incl. a provider refusal — a 200)\n",
" 64 usage: bad/unknown flag, malformed stdin request\n",
" 66 --input file missing or unreadable\n",
" 69 transport error, upstream 4xx (incl. 429), premature EOF\n",
" 70 upstream 5xx (retryable)\n",
" 77 auth: 401/403, missing credentials, login/refresh failure\n",
" 78 config: no/unknown/ambiguous provider or model, bad config\n",
" 130/141/143 interrupted by signal (SIGINT/SIGPIPE/SIGTERM)\n",
);
fn drain(body: Box<dyn Iterator<Item = io::Result<Bytes>>>) -> Result<Vec<u8>, io::Error> {
let mut buf = Vec::new();
for chunk in body {
buf.extend_from_slice(&chunk?);
}
Ok(buf)
}