brazen 0.0.19

A stateless, swiss-army-knife adapter for every LLM provider and protocol.
Documentation
//! The routing table (config §6.1): the `bz --list-providers` control flag — the LOCAL
//! sibling of `--list-models`. `--list-models` answers "what models does provider X
//! serve?" over one GET; this answers "which X's can a run reach?" over ZERO
//! round-trips: the rows the operator declared plus the built-in presets a credential
//! resolves for (config §3.2), in routing order. It exists because `--dump-config`
//! deliberately drops the defaults operand (config §6) and so can never show a preset
//! in force; this listing keeps that operand because it writes no file. The CATALOG —
//! every preset, credential or not — is read by a target-less `bz --login` (auth §7).
//!
//! It is the one listing that is **offline by construction**: [`ProvidersIo`] carries
//! `stdout`/`stderr`/`CredStore` and no `Transport` at all, so "makes no network call"
//! is a property of the type, not of the code's discipline.

mod render;

use std::io::Write;

use serde::Serialize;

use crate::auth::cred_state;
use crate::canonical::{CanonicalError, ErrorKind};
use crate::config::provider::Provider;
use crate::config::{config_path, defaults, partial_from_env, read_config_file, routable, OutMode};
use crate::registry::Registry;
use crate::store::{CredStore, Secret};

/// The injected seams + writers for one `bz --list-providers` (config §6.1), the
/// deliberately-thin sibling of `ListIo`. There is no `Transport`, no `Clock`, and no
/// `ModelCache`: the verb reads the merged config, the credential store, and any row's
/// `ambient` file, and that is the whole of its reach.
pub struct ProvidersIo<'a> {
    pub stdout: &'a mut dyn Write,
    pub stderr: &'a mut dyn Write,
    pub store: &'a dyn CredStore,
}

/// One listed row (config §6.1) — the SINGLE projection both output shapes render, so
/// the text table and the `--json` object can never drift into naming different facts.
/// `protocol`/`auth` are `String` rather than the `ProtocolId`/`AuthId` keys because
/// the rendered spelling must come from ONE place ([`spelling`], serde's own rename)
/// whichever shape prints it.
#[derive(Serialize)]
pub(super) struct Row {
    pub(super) name: String,
    pub(super) protocol: String,
    pub(super) auth: String,
    /// Does THIS row take `--reasoning`? The dialect's own [`Tuning`](crate::protocol::Tuning) declaration
    /// AND the row's `unsupported_body_keys` decline — computed from the row, never
    /// stored (config §6.1). Every shipped dialect projects effort, so the
    /// interesting bit is the per-row decline.
    pub(super) effort: bool,
    /// Does THIS row take `--tier`? The same pair, over the dialect's `service_tier`
    /// spelling (providers.md §6.2): the OpenAI family and Anthropic have one,
    /// Google/Ollama/claude_code narrow it away.
    pub(super) priority: bool,
    /// Does THIS row take `--image`? The same pair, over the canonical key `image`
    /// (providers.md §6.3): Google (both envelopes) and OpenAI Responses project it.
    pub(super) image: bool,
    /// Can this row's dialect carry TOOL DECLARATIONS at all? The dialect's own
    /// [`Shapes`](crate::protocol::Shapes) declaration, beside the `encode` that would
    /// otherwise reject them (bl-5053). Unlike the two tuning booleans this pairs with
    /// no row datum: `unsupported_body_keys` is a STRIP, and stripping a tool
    /// declaration would be the silent drop arch §3.1 forbids, so the dialect is the
    /// only home. Every HTTP dialect answers `true`; `claude_code` answers `false`,
    /// which is the fact a host needs BEFORE it validates a tool-bearing role.
    pub(super) tools: bool,
    /// Can this row's dialect carry a TRANSCRIPT — more than one message, an
    /// `assistant` turn among them? The same declaration; `claude_code` is single-turn
    /// (claude-code §4.2), so a replayed conversation rejects there.
    pub(super) multi_turn: bool,
    /// Which HEADLESS sign-in this row serves, if any (config §6.1): the
    /// `device.style` spelling (`rfc8628` / `codex`), `None` for a row that declares
    /// no `device` block and so can only be signed in through `--browser`. The STYLE
    /// rather than a bool, because the fact has a value and a consumer above brazen
    /// reads it as one — a `true` would be the lossy projection of it. Computed off
    /// the row like every other column: delete the block, lose the capability.
    pub(super) device: Option<String>,
    pub(super) credential: &'static str,
}

/// Run `bz --list-providers` and return the POSIX exit code (config §6.1). Reuses the
/// full flag parser and the SAME fold a run does — `flags.or(env).or(file).or(defaults())`
/// — then completes EVERY row instead of routing to one, so the built-in floor is
/// visible. Rows print in `providers` order, which IS routing priority (arch §4.3.1):
/// the head is the row a bare `bz "q"` reaches. Always exit 0 on a config that
/// completes; a malformed file / bad env scalar / incomplete row is the usual 78.
pub fn list_providers(args: &crate::cli::Args, io: &mut ProvidersIo) -> u8 {
    match run_list(args, io) {
        Ok(code) => code,
        Err(e) => {
            let _ = writeln!(io.stderr, "{}", e.message);
            e.exit_code()
        }
    }
}

fn run_list(args: &crate::cli::Args, io: &mut ProvidersIo) -> Result<u8, CanonicalError> {
    let flags = crate::cli::parse_args(&args.argv)?;
    // The discovery short-circuits ride the SAME flag layer and the SAME doc as the
    // data plane (arch §5.5): `bz --list-providers --help`/`--version` self-describe to
    // stdout and exit 0 BEFORE any config read — a probe must answer with a broken one.
    if flags.help {
        return Ok(super::emit(io.stdout, super::HELP));
    }
    if flags.skill {
        return Ok(super::emit(io.stdout, super::SKILL));
    }
    if flags.version {
        return Ok(super::emit(io.stdout, super::VERSION_LINE));
    }
    let file = read_config_file(&config_path(flags.config_path, &args.env))?;
    let env = partial_from_env(&args.env).map_err(CanonicalError::from)?;
    // The defaults operand STAYS — the one difference from `--dump-config`, and the
    // whole reason this verb exists (config §6.1): the dump omits it so a later
    // brazen's better default still reaches the operator, which costs the dump every
    // built-in row. A listing writes no file, so it pays no such price.
    let merged = flags.config.or(env).or(file).or(defaults());
    // The output shape is the SAME resolved fact the data plane folds, not the `--json`
    // flag alone: `BRAZEN_OUTPUT=ndjson` and a config-file `output = "ndjson"` select
    // the object form too, exactly as they do for `--list-models` (model-discovery §2).
    let json = merged.output == Some(OutMode::Ndjson);
    // The inline key is provider-AGNOSTIC (config §3.4) — it is not a row field, so it
    // is read once here and applied to every keyed row, exactly as a run would.
    let inline = merged.api_key.clone();
    // The TABLE, not the catalog (config §3.2): the rows a bare `bz "q"` can reach — the
    // operator's own and the presets a credential resolves for — through the SAME
    // predicate `route` scans, so the listing cannot show a row a run would never pick.
    let providers = merged.into_rows(|name, r| routable(name, r, inline.as_ref(), io.store))?;
    print_table(io.stdout, &providers, inline.as_ref(), io.store, json)?;
    Ok(0)
}

/// Render completed rows in the listing's two shapes (config §6.1) — the one printer
/// behind `--list-providers` (the table) and a target-less `bz --login` (the catalog
/// rows that can sign in), so the two reads cannot drift into different columns.
pub(crate) fn print_table(
    out: &mut dyn Write,
    providers: &[Provider],
    inline: Option<&Secret>,
    store: &dyn CredStore,
    json: bool,
) -> Result<(), CanonicalError> {
    let rows: Vec<Row> = providers.iter().map(|p| row(p, inline, store)).collect();
    render::print_rows(out, &rows, json).map_err(write_failed)
}

/// Project one completed row into its listed facts (config §6.1). `base_url` and
/// the row's body are deliberately absent: this verb names rows, `--list-models` reads
/// one row's models, and `--dump-config` carries the operator's row bodies. The two
/// TUNING facts are of the row-naming kind — "can I point `--reasoning`/`--tier` at
/// this row?" — as are the two SHAPE facts, one level up: "can this row take a
/// tool-bearing or replayed request AT ALL?" (bl-5053). All four are COMPUTED here,
/// never stored: the dialect declares its
/// projection ([`Tuning`](crate::protocol::Tuning), beside its own `encode`) and the row declines by naming the
/// canonical key in `unsupported_body_keys`, which is the same pair `strip_unsupported`
/// enforces on the data plane. So the answer cannot disagree with what a run does, and
/// a consumer above brazen deletes its own copy of the protocol table.
fn row(provider: &Provider, inline: Option<&Secret>, store: &dyn CredStore) -> Row {
    let dialect = Registry::builtin().protocol(provider.protocol);
    let tuning = dialect.tuning();
    // The SHAPE facts take no row operand: there is no `unsupported_body_keys` spelling
    // that could decline one honestly (see `Row::tools`), so the dialect's declaration
    // IS the row's answer.
    let shapes = dialect.shapes();
    Row {
        name: provider.name.clone(),
        protocol: spelling(&provider.protocol),
        auth: spelling(&provider.auth),
        effort: takes(provider, tuning.effort, "reasoning"),
        priority: takes(provider, tuning.priority, "service_tier"),
        image: takes(provider, tuning.image, "image"),
        tools: shapes.tools,
        multi_turn: shapes.multi_turn,
        device: provider
            .oauth
            .as_ref()
            .and_then(|o| o.device.as_ref())
            .map(|d| spelling(&d.style)),
        credential: credential(provider, inline, store),
    }
}

/// One tuning fact: the dialect projects the knob AND this row does not decline it
/// (config §4.1.1). Two data reads, no policy — deleting the row's
/// `unsupported_body_keys` entry restores the capability with no code edit.
fn takes(provider: &Provider, projected: bool, canonical_key: &str) -> bool {
    projected
        && !provider
            .unsupported_body_keys
            .iter()
            .any(|k| k == canonical_key)
}

/// The config spelling of a registry key (`openai_chat`, `api_key`), read from the SAME
/// serde rename `config.toml` parses through (arch §4.2) — never a second table beside
/// it, which would be two representations of one fact and free to drift.
fn spelling<T: Serialize>(id: &T) -> String {
    serde_json::to_value(id)
        .ok()
        .and_then(|v| v.as_str().map(str::to_owned))
        .unwrap_or_default()
}

/// The credential this row WOULD use — [`cred_state`]'s answer (auth §3.1, §5.5), the
/// same read routing's table predicate makes, spelled for the column.
fn credential(provider: &Provider, inline: Option<&Secret>, store: &dyn CredStore) -> &'static str {
    cred_state(
        store,
        &provider.name,
        provider.auth,
        provider.ambient.as_ref(),
        inline,
    )
    .label()
}

/// A stdout write failure for the listing → `Transport` (→69), the same pre-sink
/// mapping `--list-models` uses for its own listing write.
fn write_failed(e: std::io::Error) -> CanonicalError {
    CanonicalError {
        kind: ErrorKind::Transport,
        message: format!("failed to write provider list: {e}"),
        provider_detail: None,
        retry_after_seconds: None,
    }
}