commit 2b7e8021abe55fdf0bb84b568215ea14f6185f98 from: mtmn date: Mon Sep 7 13:17:10 2026 UTC fix deepinfra, add ollama cloud commit - 263a717a36dbe0de46c56e14ce13b4360c82ed16 commit + 2b7e8021abe55fdf0bb84b568215ea14f6185f98 blob - 48fb9572cebdccd79bb533df8901ccc060f920bd blob + 85e0ebdee9ef179921842f13dd98b45c65e907ed --- Cargo.lock +++ Cargo.lock @@ -13,7 +13,7 @@ dependencies = [ [[package]] name = "alpaca" -version = "0.7.2" +version = "0.7.3" dependencies = [ "anyhow", "assert_cmd", blob - 018a5c92c417e149cb77eb8c435ada23ff43d923 blob + 302a294a01cffb9b332fd54a9b6679776da1b21e --- Cargo.toml +++ Cargo.toml @@ -1,7 +1,7 @@ [package] name = "alpaca" authors = ["leoshimo", "mtmn"] -version = "0.7.2" +version = "0.7.3" edition = "2024" description = "Unix native interface for LLMs" repository = "https://github.com/leoshimo/cogni" blob - 8f99dccfb1a3e966e6ef4cc52f27b981b6cb5c91 blob + 1855a11ac15917996b9fcb9e25fe772d44430c1b --- README.md +++ README.md @@ -1,42 +1,42 @@ # alpaca -Unix native interface for interacting with LLMs. +Use language models from your Unix shell. +`alpaca` sends chat requests to a model and writes the reply to standard +output. It works in pipelines with files, editor buffers and other programs. + `alpaca` is a fork of [`cogni`](https://github.com/leoshimo/cogni) by [leoshimo](https://github.com/leoshimo). -## Focus +## What alpaca does -`alpaca` brings language model scripting (prompting) into the familiar Unix -environment. It focuses on: +`alpaca` brings language model scripting into the Unix environment. You can: -- ergonomics and accessibility in the Unix shell -- composability and interop with other programs, including `alpaca` itself -- easy language model programming, both ad-hoc and repeatable +- work with standard streams, files and pipes +- compose it with other programs +- run ad-hoc or repeatable prompts -`alpaca` reads and writes standard streams, so it works with files, editor -buffers, clipboards, system logs, sockets and many external tools, with no -special integrations needed. +`alpaca` reads and writes standard streams. This means it works with files, +editor buffers, clipboards, system logs, sockets and many other tools. You do +not need any special integrations. -## Features +## What you get -`alpaca` gives you: - -- a Unix-minded design (input and output redirection, composability, interop) +- a Unix-minded design with input and output redirection - ad-hoc language model scripting -- flexible input and output formats (text, JSON, transcript) -- a standalone binary, with no Python required +- flexible input and output formats, including text, JSON and transcript +- a standalone binary, with no Python needed - support for any compatible chat endpoint, hosted or local -- editor-backed prompting and provider quota reporting in the same binary +- editor-backed prompting and provider quota reporting in one binary -## Non-features +## What alpaca does not do -`alpaca` is not built for interactive use. Invoke it from within interactive -environments instead, such as REPLs and Emacs. +`alpaca` is not built for interactive use. Use it from within interactive +environments, such as REPLs or Emacs. -## Installation +## Install -Building requires Rust and `scdoc`: +You need Rust and `scdoc` to build: ```sh $ make @@ -47,10 +47,9 @@ $ make install `PREFIX` defaults to `/usr/local`. You can override `DESTDIR`, `BINDIR` and `MANDIR` for packaging. -## Setup +## Set up -`alpaca` reads an API key from `--apikey` or, more simply, the `ALPACA_API_KEY` -environment variable: +Set an API key with `--apikey` or the `ALPACA_API_KEY` environment variable: ```sh # in shell configuration @@ -58,24 +57,23 @@ export ALPACA_API_KEY=your-api-key ``` `alpaca` sends requests to `https://api.openai.com/v1` by default. Set -`ALPACA_ENDPOINT` or pass `--base-url` to target another host, such as a local +`ALPACA_ENDPOINT` or pass `--base-url` to use another host, such as a local server: ```sh export ALPACA_ENDPOINT=http://localhost:11434 ``` -The base URL sets the dialect of the endpoint. A base URL ending in a -version segment, such as `https://host/v1`, gets `/chat/completions`: this -covers any OpenAI-compatible endpoint, hosted or local, not just OpenAI -itself. Any other base URL gets `/api/chat`, the Ollama dialect. `alpaca` -sends authorization only when it knows a key, so endpoints needing none also -work. +The base URL sets the endpoint dialect. A base URL ending in a version segment, +such as `https://host/v1`, uses `/chat/completions`. This covers any +OpenAI-compatible endpoint. Any other base URL uses `/api/chat`, the Ollama +dialect. `alpaca` only sends authorization when it knows a key, so endpoints +that need no authentication also work. -Pick a model with `-m/--model` (default `gpt-4o-mini`). Model identifiers are -whatever your endpoint serves. +Pick a model with `-m` or `--model`. The default is `gpt-4o-mini`. Model +identifiers are whatever your endpoint serves. -Shared settings, honoured by every subcommand: +Shared settings, used by every subcommand: | variable | meaning | default | | --- | --- | --- | @@ -85,12 +83,13 @@ Shared settings, honoured by every subcommand: | `ALPACA_EFFORT` | reasoning effort | `none` | | `ALPACA_TIMEOUT` | request timeout, in seconds | `60` | -## Configuration +## Configure -Put shared settings in `$XDG_CONFIG_HOME/alpaca/config.toml`, or -`~/.config/alpaca/config.toml` if `XDG_CONFIG_HOME` is unset. +Put shared settings in `$XDG_CONFIG_HOME/alpaca/config.toml`. If you do not set +`XDG_CONFIG_HOME`, use `~/.config/alpaca/config.toml`. Settings take priority in this order: + - command-line options - environment variables - the configuration file @@ -99,8 +98,8 @@ Settings take priority in this order: See `config.example.toml` for every supported key, and `alpaca-config(5)` for how each command uses them. Keep the file private if it holds an `apikey`. -A `[profile-name]` table defines a profile: it inherits top-level settings -and overrides only the keys it sets. `default_profile` activates a profile +A `[profile-name]` table defines a profile. Profiles inherit top-level settings +and override only the keys they set. `default_profile` activates a profile automatically. `--profile` overrides that: ```toml @@ -124,19 +123,19 @@ alpaca -u "Hello" ``` alpaca [OPTIONS] [FILE] # chat, the default command -alpaca chat [OPTIONS] [FILE] # the same thing, named +alpaca chat [OPTIONS] [FILE] # the same command, named alpaca quota [OPTIONS] # provider quota usage -alpaca compose [OPTIONS] # edit prompts in $EDITOR until an empty buffer is saved +alpaca compose [OPTIONS] # edit prompts in $EDITOR until you save an empty buffer ``` -`alpaca` treats a first argument matching a subcommand name as that +`alpaca` treats a first argument that matches a subcommand name as that subcommand. Read a file with such a name using `--`, as in `alpaca -- quota`. --- ## Basic usage -See `alpaca --help` for documentation. +See `alpaca --help` for full documentation. ```sh # Via stdin @@ -149,13 +148,13 @@ $ alpaca input.txt 50 + 50 equals 100. # Via flags -# -s, --system Sets system prompt (Always first) -# -a, --assistant Appends assistant message -# -u, --user Appends user message +# -s, --system system prompt, always first +# -a, --assistant append assistant message +# -u, --user append user message $ alpaca --system "Solve the following math problem" --user "50 + 50" 50 + 50 equals 100. -# Via repetitions of same flags. Useful for few-shot prompting +# Via repeated flags, useful for few-shot prompting $ alpaca --system "Solve the following math problem" \ -u "1 + 1" \ -a "2" \ @@ -164,7 +163,7 @@ $ alpaca --system "Solve the following math problem" \ -u "50 + 50" 100 -# Via both flags and stdin. Flag messages come before stdin / file +# Via both flags and stdin. Flag messages come before stdin or file input. $ echo "50 + 50" | alpaca --system "Solve the following math problem" \ -u "1 + 1" \ -a "2" \ @@ -177,14 +176,16 @@ $ echo "50 + 50" | alpaca --system "Solve the followin ## alpaca compose -`alpaca compose` edits a prompt in `$VISUAL`, `$EDITOR`, or `vim`, sends the -saved text, then opens the editor again with the reply in the buffer, ready -for the next prompt. It keeps the conversation going until you save an empty -buffer. Piped input fills the buffer first. The editor runs on the -controlling terminal, so editor input and output stay out of the pipeline. -If the process has no controlling terminal, the editor inherits `alpaca`'s -streams. Saving an empty buffer sends nothing and ends the session. +`alpaca compose` opens a temporary file in your editor. It sends the saved +text as a prompt. It then opens the editor again with the reply in the buffer, +ready for the next prompt. The session continues until you save an empty +buffer. +Piped input fills the buffer first. The editor runs on the controlling +terminal, so editor input and output stay out of the pipeline. If the process +has no controlling terminal, the editor inherits `alpaca`'s streams. Saving +an empty buffer sends nothing and ends the session. + ```sh # Chat in the editor until you save an empty buffer $ alpaca compose @@ -212,24 +213,25 @@ Options: - `--apikey `, `--base-url `: endpoint settings - `--json`, `--jsonp`: print the response as JSON instead of the reply text -`$VISUAL` and `$EDITOR` split on whitespace, with no quote or escape parsing, -so use a wrapper script for complex editor commands. `alpaca` always removes -the temporary file. When the editor exits nonzero, `alpaca` exits with that -same status. +`$VISUAL` and `$EDITOR` split on whitespace, with no quote or escape parsing. +Use a wrapper script for complex editor commands. `alpaca` always removes the +temporary file. When the editor exits nonzero, `alpaca` exits with the same +status. `alpaca` saves each successful request as a JSON transcript in -`$XDG_DATA_HOME/alpaca` (or `~/.local/share/alpaca`), mode 0600 in a mode -0700 directory. Each turn sends the whole conversation as context. +`$XDG_DATA_HOME/alpaca` or `~/.local/share/alpaca`. Files are written with mode +`0600` in a directory with mode `0700`. Each turn sends the whole conversation +as context. When the editor is `vim` or `neovim`, `alpaca` keeps one editor open for the -whole conversation instead of reopening it each turn. Writing the buffer -sends the prompt, and the reply replaces the buffer in place, so every turn -happens in the same window. Quit the editor to end the session. Writing an -empty buffer sends nothing. The editor reloads the buffer through `autoread` -and `checktime`, so a reply that arrives while you have unsaved changes -waits for your next write. In this mode, `alpaca` writes the reply to -standard output only when standard output is not a terminal, since the -editor owns the screen. +whole conversation instead of reopening it each turn. Writing the buffer sends +the prompt, and the reply replaces the buffer in place. Every turn happens in +the same window. Quit the editor to end the session. Writing an empty buffer +sends nothing. The editor reloads the buffer through `autoread` and +`checktime`, so a reply that arrives while you have unsaved changes waits for +your next write. In this mode, `alpaca` only writes the reply to standard +output when standard output is not a terminal, because the editor owns the +screen. See `man/alpaca-compose.1`. @@ -237,11 +239,12 @@ See `man/alpaca-compose.1`. ## alpaca quota -`alpaca quota` shows how much quota you have used: a bar per window, with a -countdown to its reset, and for some providers a table of per-model counts. +`alpaca quota` shows how much quota you have used. It prints each quota +window as a bar showing the used share, with a countdown to the next reset. +Some providers also report a table of per-model counts. -Pick the provider with `-p`. Without it, alpaca reports all providers in one -output and skips the ones it cannot reach: +Pick a provider with `-p`. Without it, alpaca reports all providers in one output +and skips the ones it cannot reach: ```sh $ alpaca quota # all providers @@ -250,11 +253,12 @@ $ alpaca quota -p anthropic # Claude Code $ alpaca quota -p openai # OpenAI organization usage $ alpaca quota -p deepinfra # DeepInfra $ alpaca quota -p codex # OpenAI Codex +$ alpaca quota -p ollama # Ollama Cloud ``` -A provider with no usable credential, or a failed request or response, is -left off the screen. The rest are still drawn, and alpaca exits successfully. -With `-p`, failures for that provider are reported instead. Without `-p`, +A provider with no usable credential, or a failed request or response, is left +off the screen. The rest are still drawn, and alpaca exits successfully. With +`-p`, failures for that provider are reported instead. Without `-p`, `--apikey` and `--base-url` apply to all providers. `--raw` requires `-p`. Each provider has its own default credential and base URL: @@ -266,55 +270,61 @@ Each provider has its own default credential and base | `openai` | none | `https://api.openai.com` | | `deepinfra` | `$DEEPINFRA_API_KEY` | `https://api.deepinfra.com` | | `codex` | the OAuth token in `$CODEX_HOME/auth.json` | `https://chatgpt.com` | +| `ollama` | `$OLLAMA_API_KEY` | `https://ollama.com` | Every provider also accepts `--apikey`, an explicit `--profile`, or a table named after the provider, such as `[anthropic]`. Anthropic and OpenAI have no -default credential, so one of these is required. +default credential, so you must supply one of these. The Anthropic endpoint serves the Claude Code CLI rather than a documented public API, so it may change without notice. It expects the same short-lived -OAuth token the CLI uses: take a fresh one from the CLI and pass it again -once it expires. +OAuth token the CLI uses. Take a fresh token from the CLI and pass it again +when it expires. -The OpenAI endpoint is the documented organization Usage API, reporting spend -and token usage for the whole organization rather than the rate limit for one +The OpenAI endpoint is the documented organization Usage API. It reports +spend and token usage for the whole organization, not the rate limit for one account. It needs an Admin API key with the `api.usage.read` scope, from -`platform.openai.com/settings/organization/admin-keys`: a regular project key -(`sk-proj-...`) gets a 403. +`platform.openai.com/settings/organization/admin-keys`. A regular project +key, such as `sk-proj-...`, gets a 403. -DeepInfra bills per token or per second, with no fixed quota, so alpaca -prints no window for it. It instead prints the remaining credit as a note. -A negative balance is funds ready to spend, and a positive one is money -owed. +DeepInfra bills per token or per second, with no fixed quota. alpaca prints +no window for it. It prints the remaining credit as a note. A negative balance +is funds ready to spend, and a positive one is money owed. +The Ollama provider reports Ollama Cloud usage. It takes the key from +`$OLLAMA_API_KEY` and defaults to `https://ollama.com`. You can override this +with `$OLLAMA_API_ENDPOINT`. alpaca prints the 5 hour and weekly usage windows +with locally computed reset boundaries, a table of per-model request counts +for each window, and any extra spend the account has accrued. + The Codex provider reports your ChatGPT plan usage, not organization spend. -alpaca reads the OAuth token the Codex CLI stores in `$CODEX_HOME/auth.json` -(default `~/.codex/auth.json`), so sign in once with `codex` and the report -works; `--apikey` replaces that token. The endpoint serves the Codex CLI and +alpaca reads the OAuth token the Codex CLI stores in `$CODEX_HOME/auth.json`, +defaulting to `~/.codex/auth.json`. Sign in once with `codex` and the report +works. `--apikey` replaces that token. The endpoint serves the Codex CLI and the ChatGPT client, so it is not a documented public API and may change without notice. alpaca prints the 5 hour and weekly windows with their reset times, the plan name, and the credits balance when the plan has one. -The top-level `base_url` configures the chat endpoint only, so `alpaca -quota` never uses it: each provider already has the correct endpoint built -in. A table named after a provider is picked up automatically, but only for -its `apikey`. Its `base_url`, if set, is assumed to be for chat, not quota. -To deliberately override the endpoint for a provider too, select a profile -by name with `--profile` instead. `default_profile` does not apply here. -Command-line options and `ALPACA_ENDPOINT` still override any profile. +The top-level `base_url` configures the chat endpoint only, so `alpaca quota` +never uses it. Each provider already has the correct endpoint built in. A +table named after a provider is picked up automatically, but only for its +`apikey`. Its `base_url`, if set, is assumed to be for chat, not quota. To +also override the endpoint for a provider, select a profile by name with +`--profile`. `default_profile` does not apply here. Command-line options +and `ALPACA_ENDPOINT` still override any profile. -Synthetic and DeepInfra also fall back to the top-level `apikey` when nothing -more specific names one, since both double as chat endpoints. Anthropic and -OpenAI never serve chat, so they ignore the top-level `apikey` and always -need a key from a table or profile. Without a chosen provider, alpaca -resolves each provider key on its own, exactly as an explicit `-p` would, -never sending the top-level `apikey` to them all. +Synthetic, DeepInfra and Ollama also fall back to the top-level `apikey` when +nothing more specific names one, because all three double as chat endpoints. +Anthropic and OpenAI never serve chat, so they ignore the top-level `apikey` +and always need a key from a table or profile. Without a chosen provider, +alpaca resolves each provider key on its own, exactly as an explicit `-p` +would. It never sends the top-level `apikey` to all providers. Options: - `-p, --provider `: one of `synthetic`, `anthropic`, `openai`, - `deepinfra`, `codex`. Without it, all providers are reported and the - unreachable ones are omitted + `deepinfra`, `codex`, `ollama`. Without it, all providers are reported and + the unreachable ones are omitted - `-T, --timeout `: request timeout in seconds - `--apikey `: API key or OAuth token, replacing the provider default - `--base-url `: base URL, replacing the provider default @@ -330,24 +340,24 @@ See `man/alpaca-quota.1`. ## Tour of alpaca -Examples to get you started. +These examples will help you get started. -Whatever you feed `alpaca` is sent to the endpoint you configure. Point +Whatever you send to `alpaca` goes to the endpoint you configure. Point `ALPACA_ENDPOINT` at a local server if the data should not leave your machine. ### In the shell ```sh -# Creating Summary of Meeting Transcripts +# Summarise a meeting transcript $ cat meeting_saved_chat.txt \ | alpaca -s "Extract the links mentioned in this transcript, and provide a high level summary of the discussion points" -# Narrate Weather Summary +# Narrate a weather summary $ curl -s "wttr.in/?1" \ | alpaca -s "Summarize today's weather using the output. Respond in 1 short sentence." \ | say -# Create a ffmpeg cheatsheet from man page +# Create an ffmpeg cheatsheet from a man page $ man ffmpeg \ | alpaca -T 300 -s "Create a cheatsheet given a man page. Output should be in Markdown, and should be a set of example usages under headings." \ > cheatsheet.md @@ -361,8 +371,8 @@ $ git diff --staged \ ### In Emacs Emacs can pipe buffer regions to `alpaca` with `shell-command-on-region`. The -following command sends the selected region to `alpaca`, optionally -replacing the original text: +following command sends the selected region to `alpaca`, and can replace the +original text: ```emacs-lisp (defun leoshimo/alpaca-on-region (start end prompt replace) @@ -383,8 +393,8 @@ Use this binding for tasks such as: ### In Vim -Vim runs external shell commands on the whole buffer or a visual selection -too, giving similar workflows to Emacs. See `h :!` in Vim. +Vim can also run external shell commands on the whole buffer or a visual +selection, giving similar workflows to Emacs. See `:help :!` in Vim. For example, to sort a bulleted list of fruits by colour: blob - 965af320ad6f48009192182e3a6702881ab6d19b blob + adece4abc166a2b72f7d505d0eabb091738876f0 --- man/alpaca-compose.1.scd +++ man/alpaca-compose.1.scd @@ -1,34 +1,35 @@ alpaca-compose(1) -# NAME +# Name alpaca compose - edit prompts in the editor and chat until you save an empty buffer -# SYNOPSIS +# Synopsis *alpaca compose* [*-m* _model_] [*-s* _msg_] [*-t* _temp_] \[*-T* _secs_] [*--reasoning-effort* _effort_] \[*--apikey* _key_] [*--base-url* _url_] [*--profile* _name_] \[*--json* | *--jsonp*] -# DESCRIPTION +# Description -*alpaca compose* opens a temporary file in the editor and sends the saved text -as a user prompt. It writes the reply to standard output, then opens the -editor again with the reply in the buffer, ready for the next prompt. It -keeps the conversation going until you save an empty buffer. Piped standard -input fills the buffer first, so you can use *alpaca compose* in the middle -of a pipeline: pipe data in, edit it, pipe the reply onward. +*alpaca compose* opens a temporary file in the editor. It sends the saved +text as your prompt. It writes the reply to standard output, then opens the +editor again with the reply in the buffer. It continues the conversation until +you save an empty buffer. +Piped standard input fills the buffer first. This lets you use *alpaca compose* +in the middle of a pipeline: pipe data in, edit it, and pipe the reply onward. + The editor runs on the controlling terminal. This keeps editor input and screen output out of the pipeline. If there is no controlling terminal, the editor -inherits this process's streams. Saving an empty buffer sends nothing and ends +inherits the process's streams. Saving an empty buffer sends nothing and ends the session with status 0. -Each successful request is saved as a JSON transcript in -_$XDG_DATA_HOME/alpaca_, or, when that variable is unset, -_~/.local/share/alpaca_. Transcripts are written with mode 0600 in a directory -with mode 0700. Each turn sends the whole conversation as context. +Each successful request is saved as a JSON transcript in _$XDG_DATA_HOME/alpaca_, +or _~/.local/share/alpaca_ when that variable is unset. Transcripts are written +with mode 0600 in a directory with mode 0700. Each turn sends the whole +conversation as context. When the editor is *vim* or *neovim*, *alpaca* keeps one editor open for the whole conversation instead of reopening it each turn. Writing the buffer @@ -40,28 +41,28 @@ waits for your next write. In this mode, *alpaca* writ standard output only when standard output is not a terminal, since the editor owns the screen. -# OPTIONS +# Options *-m*, *--model* _model_ - Model to use. Defaults to *ALPACA_MODEL*, else *gpt-4o-mini*. + The model to use. It defaults to *ALPACA_MODEL*, or *gpt-4o-mini* if that is not set. *-s*, *--system* _msg_ - System prompt, sent before the conversation. + The system prompt, sent before the conversation. *-t*, *--temperature* _temp_ - Sampling temperature. + The sampling temperature. *-T*, *--timeout* _secs_ - Request timeout in seconds. Defaults to *ALPACA_TIMEOUT*, else 60. + The request timeout in seconds. It defaults to *ALPACA_TIMEOUT*, or 60 if that is not set. *--reasoning-effort* _effort_ One of *low*, *medium*, *high* or *none*. *--apikey* _key_ - API key. Defaults to *ALPACA_API_KEY*. + The API key. It defaults to *ALPACA_API_KEY*. *--base-url* _url_ - Base URL of the API endpoint. Defaults to *ALPACA_ENDPOINT*. + The base URL of the API endpoint. It defaults to *ALPACA_ENDPOINT*. *--json*, *--jsonp* Print the response as JSON instead of the reply text. @@ -69,28 +70,28 @@ editor owns the screen. *--profile* _name_ Select a configuration profile. See *alpaca-config*(5). -# ENVIRONMENT +# Environment *VISUAL*, *EDITOR* - Editor command, split on whitespace. *VISUAL* wins. Quotes and escapes are + The editor command, split on whitespace. *VISUAL* wins. Quotes and escapes are not parsed. Use a wrapper script for editor commands that need them. Without either variable, *vim*(1) is used. *XDG_DATA_HOME* - Base directory for saved conversations. + The base directory for saved conversations. -# CONFIGURATION +# Configuration -This command uses its settings from the Alpaca configuration file. +This command reads its settings from the Alpaca configuration file. Command-line options and environment variables take priority. See *alpaca-config*(5). -# EXIT STATUS +# Exit status -An editor that exits nonzero sets the exit status of *alpaca compose*, so +An editor that exits non-zero sets the exit status of *alpaca compose*, so aborting an edit aborts the request. Usage errors exit 2. -# EXAMPLES +# Examples Edit piped input before sending it: @@ -100,6 +101,6 @@ Chat in one editor session: $ alpaca compose -# SEE ALSO +# See also *alpaca*(1), *alpaca-quota*(1), *alpaca-config*(5) blob - d010b42c06a16888c7d2782ade53f4af29cfa441 blob + 682c73da29d5c26d031ce1a3ba47e72f53941cd6 --- man/alpaca-config.5.scd +++ man/alpaca-config.5.scd @@ -1,10 +1,10 @@ alpaca-config(5) -# NAME +# Name -alpaca-config - configure default alpaca options +alpaca-config - configure default options for alpaca -# DESCRIPTION +# Description Alpaca reads shared defaults from _$XDG_CONFIG_HOME/alpaca/config.toml_. If you do not set *XDG_CONFIG_HOME*, Alpaca reads _~/.config/alpaca/config.toml_. @@ -12,49 +12,52 @@ do not set *XDG_CONFIG_HOME*, Alpaca reads _~/.config/ The file is optional. Alpaca exits with an error if the file exists but cannot be read or parsed. -Settings take priority in this order: command-line options, then environment -variables, then this file, then built-in defaults. +Settings take priority in this order: +- command-line options +- environment variables +- this file +- built-in defaults -# FORMAT +# Format The file uses TOML. Unknown keys and invalid values are errors. *output_format* - Output format. Use *plaintext*, *json* or *jsonpretty*. + The output format. Use *plaintext*, *json* or *jsonpretty*. *model* - Model identifier served by the configured endpoint. + The model identifier served by the configured endpoint. *temperature* - Sampling temperature as a number. + The sampling temperature as a number. *timeout* - Request timeout in seconds. The value must be greater than zero. + The request timeout in seconds. The value must be greater than zero. *system* - System message. + The system message. *assistant* - Array of assistant messages. These messages come before configured user + An array of assistant messages. These messages come before configured user messages and command-line messages. *user* - Array of user messages. These messages come after configured assistant + An array of user messages. These messages come after configured assistant messages and before command-line messages. *apikey* - API key. Keep the file private if you set this value. + The API key. Keep the file private if you set this value. *base_url* - Base URL of the API endpoint. + The base URL of the API endpoint. *reasoning_effort* - Reasoning effort. Use *none*, *low*, *medium* or *high*. + The reasoning effort. Use *none*, *low*, *medium* or *high*. *default_profile* - Profile to activate automatically when *--profile* is not given. + The profile to activate automatically when *--profile* is not given. -# PROFILES +# Profiles A *[profile-name]* table defines a profile. Profiles inherit every top-level setting and override only the keys they set. Use *--profile* _profile-name_ to @@ -63,10 +66,10 @@ activate a profile, or set *default_profile* to activa A table named after a provider, such as *[anthropic]* or *[deepinfra]*, also supplies that provider's *alpaca quota* credential, even when it is not selected as a profile with *--profile*. Its *base_url*, if it has one, is -assumed to be for chat, so it is not picked up this way; select the table by +assumed to be for chat, so it is not picked up this way. Select the table by name with *--profile* to also override the quota endpoint deliberately. -# COMMANDS +# Commands *alpaca* and *alpaca chat* use every setting. @@ -82,13 +85,13 @@ deliberately override a provider's endpoint, select a *default_profile*, do not replace provider defaults. A report covering all providers resolves each provider's key on its own, -exactly as an explicit *-p* would. Synthetic and DeepInfra double as chat -endpoints, so each also falls back to the top-level *apikey* when no table or -profile names a more specific key. Anthropic and OpenAI never serve chat, so -they ignore the top-level *apikey* and always need a key from a table or -profile. +exactly as an explicit *-p* would. Synthetic, DeepInfra and Ollama double as +chat endpoints, so each also falls back to the top-level *apikey* when no +table or profile names a more specific key. Anthropic and OpenAI never serve +chat, so they ignore the top-level *apikey* and always need a key from a table +or profile. -# EXAMPLE +# Example output_format = "plaintext" model = "gpt-4o-mini" @@ -102,14 +105,14 @@ profile. reasoning_effort = "none" default_profile = "ollama-cloud" -# FILES +# Files _$XDG_CONFIG_HOME/alpaca/config.toml_ - Configuration file when *XDG_CONFIG_HOME* is set. + The configuration file when *XDG_CONFIG_HOME* is set. _~/.config/alpaca/config.toml_ - Configuration file when *XDG_CONFIG_HOME* is not set. + The configuration file when *XDG_CONFIG_HOME* is not set. -# SEE ALSO +# See also *alpaca*(1), *alpaca-compose*(1), *alpaca-quota*(1) blob - 1fcef493555fb0761fe9d41cfe39843fa6c0061a blob + bf801bce530c8d8bd207c669acc80f6755d06c39 --- man/alpaca-quota.1.scd +++ man/alpaca-quota.1.scd @@ -1,26 +1,25 @@ alpaca-quota(1) -# NAME +# Name alpaca quota - show how much provider quota you have used -# SYNOPSIS +# Synopsis *alpaca quota* [*-p* _provider_] [*-T* _secs_] [*--apikey* _key_] \[*--base-url* _url_] [*--profile* _name_] [*--raw*] [*--json* | *--jsonp*] \[*--color* _when_] -# DESCRIPTION +# Description -*alpaca quota* calls the usage endpoints of your providers with your -credentials. It prints each quota window as a bar showing the used share, -with a countdown to the next reset. Some providers also report a per-model -request table. +*alpaca quota* calls each provider's usage endpoint with your credentials. It +prints each quota window as a bar showing the used share, with a countdown to +the next reset. Some providers also report a per-model request table. -Without *-p*, alpaca asks all providers and prints the reports one after +Without *-p*, *alpaca* asks all providers and prints the reports one after another. A provider with no usable credential, or whose request or response fails, is left off the screen. The remaining providers are still drawn and -alpaca exits successfully. Choose one provider with *-p* to have its failures +*alpaca* exits successfully. Choose one provider with *-p* to have its failures reported instead. Each provider has its own default credential and base URL. You can override @@ -28,62 +27,70 @@ either. With *--apikey* or *--base-url* and no *-p*, t all providers. *synthetic* - Synthetic. Takes the key from *SYNTHETIC_API_KEY*. Base URL - *https://api.synthetic.new*. Prints the subscription quota, with a + Synthetic. It takes the key from *SYNTHETIC_API_KEY*. The base URL is + *https://api.synthetic.new*. It prints the subscription quota, with a countdown to the renewal reported by the endpoint. *anthropic* - Claude Code. Has no default credential: pass an API key or OAuth token + Claude Code. It has no default credential. Pass an API key or OAuth token with *--apikey*, or declare one in a profile or an *[anthropic]* table. - Base URL *https://api.anthropic.com*. Prints whichever windows the - account has, plus extra spend when it is enabled. + The base URL is *https://api.anthropic.com*. It prints whichever windows + the account has, plus extra spend when it is enabled. *openai* - OpenAI organization usage. Has no default credential: pass an Admin API + OpenAI organization usage. It has no default credential. Pass an Admin API key with the *api.usage.read* scope, using *--apikey*, or declare one in a profile or an *[openai]* table. A regular project key - (*sk-proj-...*) gets a 403. Base URL *https://api.openai.com*. Reports - spend and token usage for the whole organization, not one account's rate - limit, so alpaca prints no window for it: instead, the current UTC - month's spend as a note, and billed tokens per model as a table. + (*sk-proj-...*) gets a 403. The base URL is *https://api.openai.com*. + It reports spend and token usage for the whole organization, not one + account's rate limit. Because of this, *alpaca* prints no window for it. + Instead, it prints the current UTC month's spend as a note, and billed + tokens per model as a table. *codex* - OpenAI Codex. Takes the bearer token from the OAuth file the Codex CLI - stores in *$CODEX_HOME/auth.json*, defaulting to *~/.codex/auth.json*; - *--apikey* replaces it. Base URL *https://chatgpt.com*. The endpoint - serves the Codex CLI and the ChatGPT client, so it is not a documented - public API and may change without notice. It needs a ChatGPT sign-in, - not an API key. Prints the 5 hour and weekly usage windows with their - reset times, the plan name, and the credits balance when the plan has - one. + OpenAI Codex. It takes the bearer token from the OAuth file the Codex CLI + stores in *$CODEX_HOME/auth.json*, defaulting to *~/.codex/auth.json*. + *--apikey* replaces it. The base URL is *https://chatgpt.com*. The + endpoint serves the Codex CLI and the ChatGPT client, so it is not a + documented public API and may change without notice. It needs a ChatGPT + sign-in, not an API key. It prints the 5 hour and weekly usage windows + with their reset times, the plan name, and the credits balance when the + plan has one. *deepinfra* - DeepInfra. Takes the key from *DEEPINFRA_API_KEY*. Base URL + DeepInfra. It takes the key from *DEEPINFRA_API_KEY*. The base URL is *https://api.deepinfra.com*. DeepInfra bills per token or per second, with - no fixed quota, so alpaca prints no window for it. Instead, it prints the - remaining credit from the billing checklist as a note. A negative balance - is funds ready to spend, and a positive one is money owed. + no fixed quota, so *alpaca* prints no window for it. Instead, it prints + the remaining credit from the billing checklist as a note. A negative + balance is funds ready to spend, and a positive one is money owed. +*ollama* + Ollama Cloud. It takes the key from *OLLAMA_API_KEY*. The base URL is + *https://ollama.com*, or *$OLLAMA_API_ENDPOINT* when set. It prints the + 5 hour and weekly usage windows with locally computed reset boundaries, + a table of per-model request counts for each window, and any extra spend + the account has accrued. + The *anthropic* endpoint is not a documented public API, so it may change without notice. It expects the short-lived OAuth token the Claude Code CLI uses. When the token expires, take a fresh one from the CLI and pass it again. -# OPTIONS +# Options *-p*, *--provider* _provider_ - One of *synthetic*, *anthropic*, *openai*, *deepinfra* or *codex*. - Without it, alpaca reports all providers and omits the ones it cannot - reach. + One of *synthetic*, *anthropic*, *openai*, *deepinfra*, *codex* or + *ollama*. Without it, *alpaca* reports all providers and omits the ones it + cannot reach. *-T*, *--timeout* _secs_ - Request timeout in seconds. Defaults to *ALPACA_TIMEOUT*, else 60. + The request timeout in seconds. It defaults to *ALPACA_TIMEOUT*, or 60 if that is not set. *--apikey* _key_ - API key or OAuth token, replacing the provider default. + The API key or OAuth token, replacing the provider default. *--base-url* _url_ - Base URL, replacing the provider default. + The base URL, replacing the provider default. *--raw* Print the provider response body verbatim. A raw body belongs to one @@ -94,36 +101,36 @@ again. print one JSON object whose keys are the providers that answered. *--color* _when_ - One of *auto*, *always* or *never*. Defaults to *auto*, which colours only - when standard output is a terminal and *NO_COLOR* is unset. + One of *auto*, *always* or *never*. It defaults to *auto*, which colours + only when standard output is a terminal and *NO_COLOR* is unset. *--profile* _name_ Select a configuration profile. See *alpaca-config*(5). -# CONFIGURATION +# Configuration This command uses *output_format* and *timeout* from the Alpaca configuration file. Top-level *base_url* configures the chat endpoint, so this command never uses it for any provider: each provider already has the correct endpoint built in. A table named after a provider, such as *[anthropic]* or -*[deepinfra]*, supplies just that provider's key; its *base_url*, if it has +*[deepinfra]*, supplies just that provider's key. Its *base_url*, if it has one, is assumed to be for chat and is not picked up this way. To deliberately override a provider's endpoint, select a profile by name with *--profile*. A profile chosen automatically through *default_profile* does not apply here either. A report covering all providers resolves each provider's key on its own, -exactly as an explicit *-p* would. Synthetic and DeepInfra double as chat -endpoints, so each also falls back to the top-level *apikey* when no table or -profile names a more specific key. Anthropic and OpenAI never serve chat, so -they ignore the top-level *apikey* and always need a key from a table or -profile. Command-line options and environment variables take priority. See -*alpaca-config*(5). +exactly as an explicit *-p* would. Synthetic, DeepInfra and Ollama double as +chat endpoints, so each also falls back to the top-level *apikey* when no +table or profile names a more specific key. Anthropic and OpenAI never +serve chat, so they ignore the top-level *apikey* and always need a key +from a table or profile. Command-line options and environment variables +take priority. See *alpaca-config*(5). -# EXIT STATUS +# Exit status Usage errors exit 2. -# SEE ALSO +# See also *alpaca*(1), *alpaca-compose*(1), *alpaca-config*(5) blob - 390ef7866744125d06c610a26bc56c71a63f48aa blob + 1c77f2c18a36211a336d9b1aecef268ee748227a --- man/alpaca.1.scd +++ man/alpaca.1.scd @@ -1,10 +1,10 @@ alpaca(1) -# NAME +# Name -alpaca - Unix native interface for LLMs +alpaca - command-line tool for working with large language models (LLMs) on Unix systems -# SYNOPSIS +# Synopsis *alpaca* [*-m* _model_] [*-t* _temp_] [*-T* _secs_] [*-s* _msg_] \[*-u* _msg_] [*-a* _msg_] [*--reasoning-effort* _effort_] @@ -17,10 +17,10 @@ alpaca - Unix native interface for LLMs *alpaca compose* [_..._] -# DESCRIPTION +# Description -*alpaca* sends chat requests to a model and writes the reply to standard -output. Without a subcommand it chats, so it works in pipelines with files, +*alpaca* sends chat requests to a model. It writes the reply to standard output. +Without a subcommand it chats, so you can use it in pipelines with files, editor buffers and other programs. Three subcommands are available: @@ -36,44 +36,45 @@ Three subcommands are available: Edit prompts in the editor and chat until you save an empty buffer. See *alpaca-compose*(1). -Alpaca treats a first argument that matches a subcommand name as that -subcommand. To read messages from a file with such a name, separate it with -*--*, as in *alpaca -- quota*. +If the first argument matches a subcommand name, *alpaca* treats it as that +subcommand. To read messages from a file with such a name, put *--* before it, +as in *alpaca -- quota*. -# OPTIONS +# Options *-m*, *--model* _model_ - Model to use. Defaults to *ALPACA_MODEL*, else *gpt-4o-mini*. + The model to use. It defaults to *ALPACA_MODEL*, or *gpt-4o-mini* if that is not set. *-t*, *--temperature* _temp_ - Sampling temperature. + The sampling temperature. *-T*, *--timeout* _secs_ - Request timeout in seconds. Defaults to *ALPACA_TIMEOUT*, else 60. + The request timeout in seconds. It defaults to *ALPACA_TIMEOUT*, or 60 if that is not set. *-s*, *--system* _msg_ - System prompt. Always sent first. + The system prompt. It is always sent first. *-u*, *--user* _msg_ - Append a user message. May be repeated. + Append a user message. You can repeat this option. *-a*, *--assistant* _msg_ - Append an assistant message. May be repeated. Repeated *-u* and *-a* - messages keep their command line order, which suits few-shot prompting. + Append an assistant message. You can repeat this option. Repeated *-u* and *-a* + options keep their command-line order, which helps with few-shot prompting. *--reasoning-effort* _effort_ - One of *low*, *medium*, *high* or *none*. Defaults to *ALPACA_EFFORT*, - else *none*, which omits the field from the request. + One of *low*, *medium*, *high* or *none*. It defaults to *ALPACA_EFFORT*, + or *none* if that is not set. *none* omits the field from the request. *--apikey* _key_ - API key. Defaults to *ALPACA_API_KEY*. Authorization is only sent when a key is - known, so endpoints that need no authentication also work. + The API key. It defaults to *ALPACA_API_KEY*. *alpaca* only sends + authorization when it knows a key, so endpoints that need no + authentication still work. *--base-url* _url_ - Base URL of the API endpoint. Defaults to *ALPACA_ENDPOINT*, else - *https://api.openai.com/v1*. A base URL ending in a version segment, such - as _/v1_, addresses a chat completions endpoint. Any other base URL - addresses _{base}/api/chat_. + The base URL of the API endpoint. It defaults to *ALPACA_ENDPOINT*, or + *https://api.openai.com/v1* if that is not set. A base URL ending in a version + segment, such as _/v1_, addresses a chat completions endpoint. Any other + base URL addresses _{base}/api/chat_. *--output-format* _format_ One of *plaintext*, *json* or *jsonpretty*. @@ -82,46 +83,49 @@ subcommand. To read messages from a file with such a n Select a configuration profile. See *alpaca-config*(5). *--json*, *--jsonp* - Shorthands for the two JSON formats. + Shortcuts for the two JSON formats. -# OPERANDS +# Operands _file_ - File providing a message to append to the chat log. Defaults to *-*, which - reads non-tty standard input. + The file that provides a message to append to the chat log. It defaults to + *-* reading non-tty standard input. -# CONFIGURATION +# Configuration Use _$XDG_CONFIG_HOME/alpaca/config.toml_ for shared defaults. If you do not -set *XDG_CONFIG_HOME*, Alpaca uses _~/.config/alpaca/config.toml_. +set *XDG_CONFIG_HOME*, *alpaca* uses _~/.config/alpaca/config.toml_. -Settings take priority in this order: command-line options, then environment -variables, then the configuration file, then built-in defaults. See -*alpaca-config*(5) -for the file format. +Settings take priority in this order: +- command-line options +- environment variables +- the configuration file +- built-in defaults -# ENVIRONMENT +See *alpaca-config*(5) for the file format. +# Environment + *ALPACA_API_KEY* - Default API key. + The default API key. *ALPACA_ENDPOINT* - Default base URL. + The default base URL. *ALPACA_MODEL* - Default model. + The default model. *ALPACA_EFFORT* - Default reasoning effort. + The default reasoning effort. *ALPACA_TIMEOUT* - Default request timeout, in seconds. + The default request timeout in seconds. -# EXIT STATUS +# Exit status Usage errors exit 2. -# EXAMPLES +# Examples Answer a question from standard input: @@ -131,6 +135,6 @@ Write a commit message for staged changes: $ git diff --staged | alpaca -s "Write a conventional commit message" | git commit -F - -# SEE ALSO +# See also *alpaca-compose*(1), *alpaca-quota*(1), *alpaca-config*(5) blob - c384d14199e039afafa3e4435cbfbc87e3b13351 blob + 35fcebbc83e46d71206f50ee4a69295dcb2b45dc --- src/api.rs +++ src/api.rs @@ -1,10 +1,8 @@ -//! Interactions with the chat API +//! Chat API client //! -//! Two request and response dialects are supported, so alpaca works against -//! both the native chat endpoint and any compatible chat completions endpoint. -//! The dialect follows from the base URL: a base URL whose path ends in a -//! version segment, such as `https://host/v1`, uses `/chat/completions`, and -//! anything else uses `/api/chat`. +//! alpaca speaks two chat dialects. The base URL decides which one to use. +//! URLs ending in a version segment, such as `https://host/v1`, use +//! `/chat/completions`. All other URLs use `/api/chat`. use std::fmt; use std::time::Duration; @@ -17,7 +15,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value, json}; use thiserror::Error; -/// Convenience client for the chat API +/// Client for the chat API pub struct Client { /// Inner HTTP client http: reqwest::Client, @@ -29,7 +27,7 @@ pub struct Client { dialect: Dialect, } -/// Wire format a chat endpoint speaks +/// Chat dialect selected by the base URL #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Dialect { /// `POST {base}/api/chat`, with a single message in the response @@ -38,7 +36,7 @@ pub enum Dialect { Completions, } -/// Requests for the chat API +/// Chat request #[derive(Builder, Default)] pub struct ChatRequest { model: String, @@ -50,7 +48,7 @@ pub struct ChatRequest { think: Option, } -/// Normalized response surfaced to the rest of the crate +/// Normalised chat response #[derive(Builder, Default, Debug, Serialize, Deserialize)] pub struct Response { #[serde(with = "ts_seconds")] @@ -60,9 +58,9 @@ pub struct Response { pub usage: Usage, } -/// API errors +/// API error message /// -/// Endpoints report errors either as `{"error": "message"}` or as +/// Endpoints report errors as either `{"error": "message"}` or as /// `{"error": {"message": "..."}}`. #[derive(Debug, Deserialize)] pub struct APIError { @@ -70,8 +68,7 @@ pub struct APIError { pub message: String, } -/// Errors that can occur when converting an API response into a normalized -/// [`Response`]. +/// Errors that can occur when a response is turned into a [`Response`] #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum ResponseError { #[error("response did not contain a message")] @@ -84,7 +81,7 @@ pub enum ResponseError { TokenCountOverflow, } -/// Messages in chat API requests and responses +/// Message in a chat request or response #[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)] pub struct Message { pub role: Role, @@ -126,7 +123,7 @@ impl fmt::Display for ReasoningEffort { } } -/// Reason generation stopped +/// Why the model stopped generating #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum FinishReason { @@ -141,10 +138,10 @@ pub struct Choice { } impl Dialect { - /// The dialect implied by a base URL + /// Pick the dialect implied by a base URL /// - /// A base URL ending in a version segment, such as `/v1`, addresses a chat - /// completions endpoint. Everything else addresses the native endpoint. + /// A URL ending in a version segment, such as `/v1`, selects the chat + /// completions dialect. Every other URL selects the native dialect. #[must_use] pub fn from_base_url(base_url: &str) -> Self { let is_version = |segment: &str| { @@ -165,7 +162,7 @@ impl Dialect { } } - /// Path of the chat endpoint + /// Chat endpoint path for this dialect #[must_use] pub fn path(self) -> &'static str { match self { @@ -180,7 +177,7 @@ impl Client { /// /// # Errors /// - /// Returns an error if the underlying HTTP client cannot be built. + /// Returns an error if the HTTP client cannot be built. pub fn new(api_key: Option, base_url: String) -> Result { let http = reqwest::Client::builder().build()?; let dialect = Dialect::from_base_url(&base_url); @@ -192,10 +189,11 @@ impl Client { }) } - /// Send a chat request and normalize the reply + /// Send a chat request and normalise the reply /// - /// Authorization is sent only when an API key is set, so endpoints that - /// need no authentication, such as a local server, still work. + /// alpaca only sends an authorisation header when it knows a key. This + /// means endpoints that need no authentication, such as a local server, + /// still work. /// /// # Errors /// @@ -309,7 +307,7 @@ impl Response { } } -/// A response in either dialect +/// Response body in either dialect #[derive(Debug, Deserialize)] #[serde(untagged)] enum APIResponse { @@ -371,7 +369,7 @@ fn default_role() -> Role { Role::Assistant } -/// Read an error message from either a bare string or an object with a +/// Read the message from either a plain string or an object with a /// `message` field fn error_message<'de, D>(deserializer: D) -> Result where @@ -389,7 +387,7 @@ where }) } -/// Interpret a finish reason, defaulting to a clean stop +/// Interpret the finish reason, treating a missing value as a clean stop fn finish_reason(reason: Option<&str>) -> Result { match reason { Some("length") => Ok(FinishReason::Length), blob - 24989b06d332c9fd4ab3769563fea10484047512 blob + 29f38133ec2a530552456395c0251d33a301c736 --- src/cli/config_tests.rs +++ src/cli/config_tests.rs @@ -123,9 +123,9 @@ fn config_applies_to_compose_and_quota() -> Result<()> // The top-level connection settings configure the chat endpoint. Quota // never inherits base_url, and a report over all providers resolves each - // provider's own key: only Synthetic and DeepInfra, which double as chat - // endpoints, fall back to the top-level apikey, so the chat key is never - // sent to them all. + // provider's own key: only Synthetic, DeepInfra and Ollama, which double as + // chat endpoints, fall back to the top-level apikey, so the chat key is + // never sent to them all. let Invocation::Quota(quota) = parse_args_with_config(&["alpaca", "quota"], &config)? else { return Err("expected quota invocation".into()); }; @@ -151,6 +151,10 @@ fn config_applies_to_compose_and_quota() -> Result<()> .map(String::as_str), Some("configured-key") ); + assert_eq!( + quota.quota_keys.get(&Provider::Ollama).map(String::as_str), + Some("configured-key") + ); assert_eq!(quota.quota_keys.get(&Provider::Anthropic), None); assert_eq!(quota.quota_keys.get(&Provider::OpenAI), None); Ok(()) @@ -163,9 +167,9 @@ fn top_level_apikey_is_the_last_quota_fallback_for_ded base_url = "https://chat.example" "#; - // Synthetic and DeepInfra double as chat endpoints, so they fall back to - // the top-level apikey when nothing more specific names one. - for provider in ["synthetic", "deepinfra"] { + // Synthetic, DeepInfra and Ollama double as chat endpoints, so they fall + // back to the top-level apikey when nothing more specific names one. + for provider in ["synthetic", "deepinfra", "ollama"] { let quota = configured_quota(toml, &["alpaca", "quota", "-p", provider])?; assert_eq!( quota.api_key.as_deref(), @@ -217,6 +221,10 @@ fn an_all_providers_report_resolves_one_key_per_provid Some("chat-key") ); assert_eq!( + quota.quota_keys.get(&Provider::Ollama).map(String::as_str), + Some("chat-key") + ); + assert_eq!( quota.quota_keys.get(&Provider::Anthropic), None, "a profile that was not selected keys no provider" blob - 3579d23ce7578ddf48e206473c823c33d4970c9f blob + b158f4b4945c720090d4b572d780f067eee95f8b --- src/cli.rs +++ src/cli.rs @@ -1,4 +1,4 @@ -//! Command line interface for alpaca +//! Command-line interface for alpaca use std::collections::HashMap; use std::ffi::OsString; @@ -17,14 +17,14 @@ use clap::{ use derive_builder::Builder; use serde::Deserialize; -/// Default model used by every subcommand that talks to a model +/// Default model for subcommands that call a model pub const DEFAULT_MODEL: &str = "gpt-4o-mini"; -/// Default request timeout, in seconds +/// Default timeout, in seconds pub const DEFAULT_TIMEOUT_SECS: &str = "60"; /// Default API endpoint /// -/// Any host serving a compatible chat endpoint works, so this is only a -/// starting point: set `ALPACA_ENDPOINT` or `--base-url` to point elsewhere. +/// Any host serving a compatible chat endpoint works. Set `ALPACA_ENDPOINT` +/// or pass `--base-url` to point elsewhere. pub const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1"; #[derive(Debug, Default, Deserialize, Clone)] #[serde(deny_unknown_fields)] @@ -48,8 +48,8 @@ struct Settings { struct Config { settings: Settings, profiles: HashMap, - /// Credential each provider resolves from the configuration for a report - /// over all providers, where one `apikey` cannot serve them all + /// Per-provider keys resolved from the configuration for an all-provider + /// quota report, where one `apikey` cannot serve every provider quota_keys: HashMap, } @@ -113,10 +113,10 @@ impl Config { }) } - /// Look up a profile table by name, without merging it onto the base - /// settings. The one place every named lookup goes through, whether the - /// name came from an explicit `--profile`, `default_profile`, or a table - /// that happens to share a provider's name. + /// Look up a profile table by name, without merging it into the base + /// settings. Every named lookup goes through here, whether the name came + /// from an explicit `--profile`, `default_profile`, or a table that shares + /// a provider's name. fn profile(&self, name: &str) -> Option<&Settings> { self.profiles.get(name) } @@ -132,7 +132,7 @@ impl Config { }) } - /// Resolve an active profile and decide which credentials a quota + /// Resolve the active profile and decide which credentials a quota /// invocation may use. fn for_invocation(&self, profile: Option<&str>, quota: QuotaTarget) -> Result { let active = profile.or(self.default_profile.as_deref()); @@ -146,12 +146,12 @@ impl Config { QuotaTarget::One(provider) => { let explicit_profile = profile.and_then(|name| self.profile(name)); - // Quota already has a correct default endpoint for every + // Quota already has the right default endpoint for every // provider, so only an explicit `--profile` overrides it. A // table named after the provider (`[deepinfra]`, `[synthetic]`, - // ...) exists to supply just a key with no `--profile` needed: - // its own `base_url`, if it has one, is for chat and never - // leaks into the predefined quota endpoint. + // ...) supplies just a key; no `--profile` is needed. Its own + // `base_url`, if it has one, is for chat and never leaks into + // the predefined quota endpoint. config.settings.base_url = explicit_profile.and_then(|settings| settings.base_url.clone()); @@ -160,13 +160,15 @@ impl Config { .or(provider_table) .and_then(|settings| settings.apikey.clone()); - // Synthetic and DeepInfra double as chat endpoints, so a key - // that only configures chat is still a reasonable guess for - // them. Anthropic and OpenAI never serve chat, so guessing - // would send the wrong credential; they need a key from a - // profile or `[provider]` table. - let falls_back_to_chat_key = - matches!(provider, Provider::DeepInfra | Provider::Synthetic); + // Synthetic, DeepInfra and Ollama double as chat endpoints, + // so a key that only configures chat is still a reasonable + // guess for them. Anthropic and OpenAI never serve chat, so + // guessing would send the wrong credential; they need a key from + // a profile or `[provider]` table. + let falls_back_to_chat_key = matches!( + provider, + Provider::DeepInfra | Provider::Synthetic | Provider::Ollama + ); config.settings.apikey = if falls_back_to_chat_key { key.or_else(|| config.settings.apikey.clone()) } else { @@ -174,7 +176,7 @@ impl Config { }; } QuotaTarget::All => { - // No single key or endpoint serves all providers, so the chat + // No single key or endpoint serves all providers, so chat // connection settings never apply. Each provider resolves the // key it would get from an explicit `-p`. config.settings.apikey = None; @@ -238,7 +240,7 @@ fn config_path_from(xdg_config_home: Option, .map(|path| path.join("alpaca").join("config.toml")) } -/// Applies `value` as a clap default only when it is set, leaving the argument +/// Use `value` as a clap default only when it is set, leaving the argument /// optional otherwise. fn default_value_opt(arg: Arg, value: Option) -> Arg { if let Some(value) = value { @@ -248,7 +250,7 @@ fn default_value_opt(arg: Arg, value: Option) } } -/// A parsed command line invocation +/// Parsed command-line invocation #[derive(Debug)] pub enum Invocation { /// Send a chat request built from flags, a file or stdin @@ -277,7 +279,7 @@ pub struct ChatArgs { } /// Arguments for the quota subcommand -#[derive(Debug, Default, Builder)] +#[derive(Debug, Default, Clone, Builder)] pub struct QuotaArgs { /// Provider to report, or all providers when unset #[builder(default)] @@ -300,7 +302,7 @@ pub struct QuotaArgs { /// Arguments for the compose subcommand /// -/// Model, effort and system prompt are optional here: values left unset are +/// Model, effort and system prompt are optional here. Values left unset are /// taken from the configuration or the defaults. #[derive(Debug, Default, Builder)] pub struct ComposeArgs { @@ -320,7 +322,7 @@ pub struct ComposeArgs { pub(crate) base_url: Option, } -/// The format that invocation's results are in +/// Output format for an invocation's results #[derive(Debug, Default, PartialEq, Eq, Clone, Copy, Deserialize)] #[serde(rename_all = "lowercase")] pub enum OutputFormat { @@ -330,7 +332,7 @@ pub enum OutputFormat { JSONPretty, } -/// When to emit ANSI colour +/// When to emit colour #[derive(Debug, Default, PartialEq, Eq, Clone, Copy)] pub enum ColorChoice { /// Colour when stdout is a terminal and `NO_COLOR` is unset @@ -340,8 +342,7 @@ pub enum ColorChoice { Never, } -/// Reasoning effort as accepted on the command line, where `none` omits the -/// `think` field from the request +/// Reasoning effort accepted on the command line. `none` omits the `think` field. #[derive(Debug, Default, PartialEq, Eq, Clone, Copy, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Effort { @@ -374,7 +375,7 @@ impl From> for Effort { } } -/// Parse commandline arguments into `Invocation`. May exit with help or error message. +/// Parse command-line arguments into `Invocation`. May exit with help or an error message. /// /// # Errors /// @@ -382,7 +383,7 @@ impl From> for Effort { pub fn parse() -> Result { let args: Vec = std::env::args_os().collect(); - // Show help when invoked with no arguments + // Show help when alpaca is run with no arguments if args.len() <= 1 { cli(&Config::default()).print_help()?; std::process::exit(0); @@ -403,7 +404,7 @@ pub fn parse() -> Result { Ok(invocation_from_matches(&matches, &config)) } -/// Top-level command. Without a subcommand, alpaca chats +/// Top-level command. Without a subcommand, alpaca chats. fn cli(config: &Config) -> Command { chat_args(command!(), config) .arg(profile_arg()) @@ -427,7 +428,7 @@ fn parse_args_with_config(args: &[&str], config: &Conf Ok(invocation_from_matches(&matches, &config)) } -/// Which provider(s) a quota invocation reports +/// Which provider or providers a quota invocation reports #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] enum QuotaTarget { /// Not a quota invocation @@ -722,7 +723,7 @@ fn effort_of(matches: &ArgMatches) -> Option Vec { let mut messages = config .assistant @@ -751,7 +752,7 @@ fn messages_from_matches(matches: &ArgMatches, config: command_line_messages.sort_by_key(|(_, idx)| *idx); messages.extend(command_line_messages.into_iter().map(|(msg, _)| msg)); - // System message is always first + // The system message is always first if let Some(system_msg) = matches.get_one::("system_message") { messages.insert(0, Message::system(system_msg)); } @@ -760,7 +761,7 @@ fn messages_from_matches(matches: &ArgMatches, config: } impl ChatArgs { - /// Builder + /// Build `ChatArgs` #[must_use] pub fn builder() -> ChatArgsBuilder { ChatArgsBuilder::default() @@ -768,7 +769,7 @@ impl ChatArgs { } impl QuotaArgs { - /// Builder + /// Build `ChatArgs` #[must_use] pub fn builder() -> QuotaArgsBuilder { QuotaArgsBuilder::default() @@ -776,7 +777,7 @@ impl QuotaArgs { } impl ComposeArgs { - /// Builder + /// Build `ChatArgs` #[must_use] pub fn builder() -> ComposeArgsBuilder { ComposeArgsBuilder::default() @@ -819,6 +820,7 @@ impl ValueEnum for Provider { Self::OpenAI, Self::DeepInfra, Self::Codex, + Self::Ollama, ] } @@ -829,6 +831,7 @@ impl ValueEnum for Provider { Self::OpenAI => "openai", Self::DeepInfra => "deepinfra", Self::Codex => "codex", + Self::Ollama => "ollama", })) } } blob - 8a27cda418f83ba922a492655a304aa94e0dd1ab blob + 8d9ba808d72cf18d4a7926a71940422e02043b75 --- src/editor.rs +++ src/editor.rs @@ -1,8 +1,8 @@ -//! Editing prompts in the user's editor +//! Edit prompts in the user's editor //! -//! The editor runs on the controlling terminal rather than on the process's -//! own stdin and stdout, so `alpaca compose` can sit in the middle of a -//! pipeline: pipe data in, edit it, pipe the reply onward. +//! The editor runs on the controlling terminal rather than on this +//! process's own stdin and stdout. This lets `alpaca compose` sit in the +//! middle of a pipeline: pipe data in, edit it, pipe the reply onward. use std::fs; use std::io::Write; @@ -13,20 +13,20 @@ use std::time::{Duration, SystemTime}; use crate::Error; -/// Editor used when neither `VISUAL` nor `EDITOR` is set +/// Editor used when `VISUAL` and `EDITOR` are both unset const FALLBACK_EDITOR: &str = "vim"; /// Editors that can host a whole conversation in one buffer const LIVE_EDITORS: [&str; 4] = ["vim", "gvim", "nvim", "neovim"]; -/// How often a live session looks for a write of its buffer +/// How often a live session checks whether the buffer was written const POLL_INTERVAL: Duration = Duration::from_millis(100); /// Reload the buffer when alpaca replaces the file with a reply /// -/// `checktime` only reloads a buffer with no unsaved changes, so a reply that -/// lands while the next prompt is being typed waits for the write instead of -/// discarding it. +/// `checktime` only reloads a buffer with no unsaved changes. A reply that +/// arrives while the next prompt is being typed waits for the write instead +/// of being discarded. const RELOAD_TIMER: &str = "call timer_start(200, {-> execute('silent! checktime')}, {'repeat': -1})"; @@ -36,9 +36,9 @@ const RELOAD_TIMER: &str = /// /// # Errors /// -/// Returns [`Error::EditorExit`] when the editor exits non-zero, and -/// [`Error::Editor`] when it cannot be started or the terminal cannot be -/// opened. +/// Returns [`Error::EditorExit`] when the editor exits non-zero. Returns +/// [`Error::Editor`] when the editor cannot start or the terminal cannot +/// be opened. pub fn edit(initial: &str) -> Result { let mut file = tempfile::Builder::new() .prefix("alpaca-") @@ -52,7 +52,7 @@ pub fn edit(initial: &str) -> Result { Ok(fs::read_to_string(file.path())?) } -/// The editor command, split on whitespace +/// Editor command, split on whitespace /// /// `VISUAL` wins over `EDITOR`. Quotes and escapes are not parsed; use a /// wrapper script for editor commands that need them. @@ -92,8 +92,8 @@ fn run(path: &Path) -> Result<(), Error> { /// Wire a command to the controlling terminal /// -/// Without a controlling terminal there is nothing better to attach the editor -/// to, so it inherits this process's own streams. +/// Without a controlling terminal there is nothing better to attach the +/// editor to, so it inherits this process's own streams. fn attach_tty(editor: &mut Command) -> Result<(), Error> { match fs::OpenOptions::new() .read(true) @@ -119,8 +119,8 @@ fn attach_tty(editor: &mut Command) -> Result<(), Erro /// Whether the configured editor can host a live session /// -/// Only vim and neovim are recognised: the session relies on `autoread` and -/// `checktime`, and on `-c` for setting them up. +/// Only vim and neovim are recognised. The session relies on `autoread` and +/// `checktime`, and on `-c` to set them up. #[must_use] pub fn is_live() -> bool { is_live_editor(&command()) @@ -137,9 +137,9 @@ fn is_live_editor(words: &[String]) -> bool { /// A vim or neovim instance that stays open across turns /// -/// The editor holds one buffer for the whole conversation: writing it submits -/// the prompt, and the reply replaces the buffer in place. The session ends -/// when the editor exits. +/// The editor holds one buffer for the whole conversation. Writing it +/// submits the prompt, and the reply replaces the buffer in place. The +/// session ends when the editor exits. pub struct Session { file: tempfile::NamedTempFile, editor: Child, blob - 8bad9cce6d6718338e677d56f8deb9ae25265f59 blob + 91e71920b72bd26ad31be0b8ef9618cabd312830 --- src/error.rs +++ src/error.rs @@ -1,4 +1,4 @@ -//! Errors for alpaca library crate +//! Errors for the alpaca library use std::path::PathBuf; @@ -34,7 +34,7 @@ pub enum Error { #[error("editor failed - {0}")] Editor(String), - /// Editor exited non-zero. The status is forwarded to alpaca's own exit code. + /// Editor exited non-zero. The status becomes alpaca's exit code. #[error("editor exited with status {0}")] EditorExit(i32), blob - 154c6a56e459afd61686001e53fe1234e5113dbb blob + 80d6013d388bbf22d81d40af9def441f227249cf --- src/exec/chat.rs +++ src/exec/chat.rs @@ -1,4 +1,4 @@ -//! Implements chat subcommand +//! Chat subcommand use crate::Error; use crate::api::{self, Message}; @@ -10,7 +10,7 @@ use std::fs::File; use std::io::{self, IsTerminal, Read}; use std::path::Path; -/// Executes `ChatArgs` via given args +/// Run a chat request from `ChatArgs` /// /// # Errors /// @@ -51,7 +51,7 @@ pub async fn exec(args: ChatArgs) -> Result<()> { Ok(()) } -/// Read messages from non-tty stdin or file specified by `args.file` +/// Read messages from non-tty stdin or the file in `args.file` fn read_messages_from_file(file: &Path) -> Result> { let reader: Option> = match file.to_str() { Some("-") => { blob - 78864d62eae37e5591d4d6cfe75e6c384b7dbb6f blob + 3ceeaf69f53a4cf5db30d5c8cc1c3fce4deb44d7 --- src/exec/compose.rs +++ src/exec/compose.rs @@ -1,6 +1,6 @@ -//! Implements compose subcommand +//! Compose subcommand //! -//! Edits a prompt in the user's editor, sends it, then keeps the editor open +//! Edit a prompt in the user's editor, send it, then keep the editor open //! across turns until an empty buffer is saved. use std::io::{self, IsTerminal, Read}; @@ -13,7 +13,7 @@ use crate::cli::{ComposeArgs, DEFAULT_MODEL}; use crate::transcript::Transcript; use crate::{Error, editor}; -/// Executes `ComposeArgs` +/// Run a compose session from `ComposeArgs` /// /// # Errors /// @@ -49,9 +49,9 @@ pub async fn exec(args: ComposeArgs) -> Result<()> { /// Run the whole conversation inside a live vim or neovim session /// -/// The editor stays open for the whole conversation: writing the buffer sends -/// it, and the reply replaces the buffer in place, so the next prompt is -/// edited in the same window. Quitting the editor ends the session. +/// The editor stays open for the whole conversation. Writing the buffer +/// sends it, and the reply replaces the buffer in place, so the next prompt +/// is edited in the same window. Quitting the editor ends the session. async fn live( client: &api::Client, args: &ComposeArgs, @@ -75,7 +75,7 @@ async fn live( /// Send `prompt` with the conversation so far, then save and show the reply /// -/// `path` is the transcript's file, created on the first saved turn. The reply +/// `path` is the transcript file, created on the first saved turn. The reply /// is written to standard output when `show` is set. async fn turn( client: &api::Client, @@ -116,8 +116,8 @@ async fn turn( Ok(reply) } -/// Piped stdin prefills the editor buffer, so compose can sit in the middle of -/// a pipeline +/// Piped stdin prefills the editor buffer, so compose can sit in the middle +/// of a pipeline fn read_stdin() -> Result { let stdin = io::stdin(); if stdin.is_terminal() { blob - e518cbee4af064ba36d2679aa89b96131ecb3c17 blob + 88bef4afb5fbbbac3e6fa715e0bb9654d41bfa17 --- src/exec/mod.rs +++ src/exec/mod.rs @@ -28,12 +28,12 @@ pub(crate) fn base_url(configured: Option) -> configured.unwrap_or_else(|| crate::cli::DEFAULT_BASE_URL.to_string()) } -/// Show formatted output for a Chat API result +/// Show formatted output for a chat result /// /// # Errors /// -/// Returns an error if the response holds no usable choice, was truncated, or -/// cannot be written. +/// Returns an error if the response holds no usable choice, was truncated, +/// or cannot be written. pub(crate) fn show_response( dest: impl Write, output_format: OutputFormat, blob - ad3e91cdd1979d4083e6e59b89ec90c7e2e3dcf3 blob + a2b624c0f7e96b738ce08b0d8b040d730100a64d --- src/exec/quota.rs +++ src/exec/quota.rs @@ -1,6 +1,6 @@ -//! Implements quota subcommand +//! Quota subcommand //! -//! Asks providers how much of the account's quota is used, then renders each +//! Ask providers how much of the account's quota is used, then render each //! window as a bar with a countdown to the next reset. Without an explicit //! provider, all providers are asked and the unavailable ones are left off //! the screen. @@ -13,7 +13,7 @@ use anyhow::{Context, Result, bail}; use crate::cli::{OutputFormat, QuotaArgs}; use crate::usage::{self, Provider, render::Style}; -/// Executes `QuotaArgs` +/// Run a quota report from `QuotaArgs` /// /// # Errors /// @@ -62,8 +62,8 @@ async fn exec_provider(args: &QuotaArgs, provider: Pro /// Report all providers, leaving the unavailable ones undrawn /// -/// A provider with no usable credential, a failed request or a response that -/// cannot be normalised is skipped; the rest are drawn in a fixed order. +/// A provider with no usable credential, a failed request, or a response that +/// cannot be normalised is skipped. /// /// # Errors /// @@ -74,57 +74,95 @@ async fn exec_all_providers(args: &QuotaArgs) -> Resul bail!("--raw prints one provider's response body; choose the provider with --provider"); } - let (synthetic, anthropic, openai, deepinfra, codex) = tokio::join!( - fetch_view(args, Provider::Synthetic), - fetch_view(args, Provider::Anthropic), - fetch_view(args, Provider::OpenAI), - fetch_view(args, Provider::DeepInfra), - fetch_view(args, Provider::Codex), - ); - let reports = [ - (Provider::Synthetic, synthetic), - (Provider::Anthropic, anthropic), - (Provider::OpenAI, openai), - (Provider::DeepInfra, deepinfra), - (Provider::Codex, codex), - ]; - - let mut stdout = io::stdout(); match args.output_format { - OutputFormat::Plaintext => { - let style = Style::new(args.color, stdout.is_terminal()); - let views: Vec<&usage::View> = reports - .iter() - .filter_map(|(_, result)| result.as_ref().ok()) - .collect(); - usage::render::render_all(&mut stdout, &views, style)?; + // Each provider is drawn the moment its own report is ready, rather + // than waiting on the slowest one: a stuck or slow provider no + // longer holds the rest of the screen back. + OutputFormat::Plaintext => plaintext_as_providers_arrive(args).await, + // A JSON document is one value, so it cannot be written until every + // provider has answered; the fetches themselves still run + // concurrently. + OutputFormat::JSON | OutputFormat::JSONPretty => json_once_all_providers_arrive(args).await, + } +} + +/// Render each provider's report to `stdout` as soon as it arrives +/// +/// # Errors +/// +/// Returns an error if a fetch task panics or writing to `stdout` fails. +async fn plaintext_as_providers_arrive(args: &QuotaArgs) -> Result<()> { + let mut stdout = io::stdout(); + let style = Style::new(args.color, stdout.is_terminal()); + + let mut tasks = tokio::task::JoinSet::new(); + for provider in Provider::ALL { + let args = args.clone(); + tasks.spawn(async move { fetch_view(&args, provider).await }); + } + + let mut drawn_any = false; + while let Some(result) = tasks.join_next().await { + let Ok(view) = result.context("quota fetch task panicked")? else { + continue; + }; + + let mut block = Vec::new(); + usage::render::render(&mut block, &view, style)?; + while block.last() == Some(&b'\n') { + block.pop(); } - OutputFormat::JSON | OutputFormat::JSONPretty => { - let views: BTreeMap = reports - .iter() - .filter_map(|(provider, result)| { - result - .as_ref() - .ok() - .map(|view| (provider.to_string(), view)) - }) - .collect(); - let json = if args.output_format == OutputFormat::JSON { - serde_json::to_string(&views)? - } else { - serde_json::to_string_pretty(&views)? - }; - writeln!(stdout, "{json}")?; + + if drawn_any { + stdout.write_all(b"\n\n")?; } + stdout.write_all(&block)?; + stdout.flush()?; + drawn_any = true; } + if drawn_any { + stdout.write_all(b"\n")?; + } Ok(()) } -/// Build the client one provider's requests use +/// Fetch every provider concurrently, then write the combined JSON document /// /// # Errors /// +/// Returns an error if writing to `stdout` fails. +async fn json_once_all_providers_arrive(args: &QuotaArgs) -> Result<()> { + let mut stdout = io::stdout(); + + let mut tasks = tokio::task::JoinSet::new(); + for provider in Provider::ALL { + let args = args.clone(); + tasks.spawn(async move { (provider, fetch_view(&args, provider).await) }); + } + + let mut views: BTreeMap = BTreeMap::new(); + while let Some(result) = tasks.join_next().await { + let (provider, view) = result.context("quota fetch task panicked")?; + if let Ok(view) = view { + views.insert(provider.to_string(), view); + } + } + + let json = if args.output_format == OutputFormat::JSON { + serde_json::to_string(&views)? + } else { + serde_json::to_string_pretty(&views)? + }; + writeln!(stdout, "{json}")?; + + Ok(()) +} + +/// Build the client that one provider's requests use +/// +/// # Errors +/// /// Returns an error if no credential is found or the client cannot be built. fn client_for(args: &QuotaArgs, provider: Provider) -> Result { // An explicit --apikey wins; a report over all providers falls back to @@ -138,12 +176,12 @@ fn client_for(args: &QuotaArgs, provider: Provider) -> .with_context(|| format!("failed to create {provider} client")) } -/// Fetch the raw usage response body of one provider +/// Fetch the raw usage response body for one provider /// /// # Errors /// -/// Returns an error if no credential is found, the client cannot be built or -/// the request fails. +/// Returns an error if no credential is found, the client cannot be built, +/// or the request fails. async fn fetch(args: &QuotaArgs, provider: Provider) -> Result> { client_for(args, provider)? .fetch() @@ -154,13 +192,13 @@ async fn fetch(args: &QuotaArgs, provider: Provider) - /// Fetch the provider's extra body, when it has one beyond usage /// /// Best-effort: a missing credential, a failed request, or a provider with -/// no extra endpoint all just leave this unset, since it is supplementary to -/// the usage report rather than its point. +/// no extra endpoint all leave this unset. It is supplementary to the usage +/// report rather than its main point. async fn fetch_extra(args: &QuotaArgs, provider: Provider) -> Option> { client_for(args, provider).ok()?.fetch_extra().await } -/// Fetch and normalise the usage view of one provider +/// Fetch and normalise the usage view for one provider /// /// # Errors /// blob - 4fa8c978528e18d877efdb464292904c13bc55bb blob + eb66c7e9559de4e3864024b6a5f0720f66a2a1cc --- src/lib.rs +++ src/lib.rs @@ -17,8 +17,8 @@ pub type Result = std::result::Result; /// /// # Errors /// -/// Returns an error if `HOME` is unset and the platform cannot report a home -/// directory. +/// Returns an error if `HOME` is unset and the platform cannot report a +/// home directory. pub fn home_dir() -> Result { std::env::var_os("HOME") .filter(|home| !home.is_empty()) blob - d8e362f6322c76e64833576733b471154a9964de blob + 2e7006a58ee41d771ad912c7fc38963aa1af8566 --- src/main.rs +++ src/main.rs @@ -11,7 +11,7 @@ async fn main() { }; if let Err(err) = alpaca::exec(invocation).await { - // An editor that exits non-zero sets alpaca's own exit status, so + // An editor that exits non-zero sets alpaca's exit status, so // aborting an edit behaves like aborting any other editor session. if let Some(Error::EditorExit(code)) = err.downcast_ref::() { std::process::exit(*code); blob - c1604d171e0ee1f15c8c6d7a32981f06bdb0695d blob + ff6d78a0aa541db77107554007140d9f11b4a2e2 --- src/parse.rs +++ src/parse.rs @@ -1,11 +1,11 @@ -//! Parse from input streams +//! Parse input streams use std::io::Read; use crate::Error; use crate::api::Message; -/// Read from `std::io::Read` into a vector of messages +/// Read an input stream into a list of messages /// /// # Errors /// blob - 0c5e72686f539aa894623fdeb4c1dfad729cabf5 blob + 348f212740c1c6b731343fae230a32b483620e57 --- src/transcript.rs +++ src/transcript.rs @@ -1,9 +1,9 @@ //! Saved conversations //! -//! Conversations are stored one JSON document per file under +//! Conversations are stored as one JSON file per conversation under //! `$XDG_DATA_HOME/alpaca`, falling back to `~/.local/share/alpaca`. Files are -//! written with mode 0600 and the directory with mode 0700, since prompts and -//! replies are private. +//! written with mode 0600 and the directory with mode 0700, because prompts +//! and replies are private. use std::fs; use std::io::Write; @@ -39,7 +39,7 @@ const fn default_version() -> u32 { } impl Transcript { - /// Start a transcript with no messages yet + /// Start a transcript with no messages #[must_use] pub fn new(model: String, effort: Option, system: Option) -> Self { Self { blob - ff4a9f8d0a9c0f300245c717122595b3c7a477c4 blob + 7de9dd4a3c245e2cdc91eaca56b693cac841ca41 --- src/usage/anthropic.rs +++ src/usage/anthropic.rs @@ -30,7 +30,7 @@ struct Limit { resets_at: Option, } -/// An amount in minor units, as reported in the spend block +/// Amount in minor units, as reported in the spend block #[derive(Debug, Default, Deserialize)] struct Money { #[serde(rename = "amount_minor", default)] @@ -86,7 +86,7 @@ struct Usage { /// Normalise the Claude Code usage response /// -/// The flat `limits` list is preferred, since it names whichever windows the +/// The flat `limits` list is preferred, because it names whichever windows the /// account has. Accounts without it fall back to the fixed five hour and seven /// day fields. /// blob - a83b5cf000a5ae7f86a8209a6fc6de580580114c blob + 2e6261488863c43a1ac96d6d4623d256cb4e16fc --- src/usage/codex.rs +++ src/usage/codex.rs @@ -27,7 +27,7 @@ struct RateLimit { secondary_window: Option, } -/// The `OpenAI` credits attached to the plan +/// `OpenAI` credits attached to the plan #[derive(Debug, Default, Deserialize)] struct Credits { #[serde(default, rename = "has_credits")] @@ -50,8 +50,8 @@ struct Usage { /// Normalise the Codex usage response /// -/// The primary window covers 5 hours and the secondary one a week, matching -/// how the Codex CLI presents them. +/// The primary window covers 5 hours and the secondary one a week. This +/// matches how the Codex CLI presents them. /// /// # Errors /// @@ -85,14 +85,14 @@ pub fn view(body: &[u8]) -> Result { }) } -/// A window reset given as a Unix timestamp in seconds +/// Window reset given as a Unix timestamp in seconds fn reset_at(reset_at: Option) -> Option> { reset_at.and_then(|at| Utc.timestamp_opt(at, 0).single()) } /// Describe the credits balance, when there is one to show /// -/// A plan with no credits, or with a zero balance, says nothing: the windows +/// A plan with no credits, or with a zero balance, says nothing. The windows /// already carry the interesting number. fn credits_note(credits: &Credits) -> Option { if credits.unlimited { blob - b131913c165ac995cf6fb1386cdedbce068aa660 blob + 0e16ad8027ed99b0d05fcde89bd468fa7432a3b8 --- src/usage/deepinfra.rs +++ src/usage/deepinfra.rs @@ -2,9 +2,11 @@ //! //! `DeepInfra` bills per token or per second with no fixed quota, so there is //! no window to show a used share of. The billing checklist instead reports -//! the account balance: a negative `stripe_balance` is funds ready to spend, -//! and a positive one is money owed. - +//! the account balance. A negative `stripe_balance` is funds ready to +//! spend; a positive one is money owed. `stripe_balance` only moves when +//! an invoice is settled, so within a billing period it sits at the account's +//! credit line while `recent` accrues the usage drawn against it. The +//! actual credit left is the line minus what has already been spent. use serde::Deserialize; use super::View; @@ -15,6 +17,8 @@ use crate::Error; struct Checklist { #[serde(default)] stripe_balance: f64, + #[serde(default)] + recent: f64, } /// Normalise the `DeepInfra` billing checklist response @@ -33,7 +37,8 @@ pub fn view(body: &[u8]) -> Result { let balance = checklist.stripe_balance; let note = Some(if balance < 0.0 { - format!("{:.2} USD credit left", -balance) + let left = (-balance - checklist.recent).max(0.0); + format!("{left:.2} USD credit left") } else if balance > 0.0 { format!("owes {balance:.2} USD") } else { @@ -67,6 +72,22 @@ mod test { } #[test] + fn recent_usage_is_deducted_from_the_credit_line() -> Result<()> { + let view = view(br#"{"stripe_balance": -50.0, "recent": 43.68}"#)?; + + assert_eq!(view.note.as_deref(), Some("6.32 USD credit left")); + Ok(()) + } + + #[test] + fn recent_usage_past_the_credit_line_floors_at_zero() -> Result<()> { + let view = view(br#"{"stripe_balance": -50.0, "recent": 61.0}"#)?; + + assert_eq!(view.note.as_deref(), Some("0.00 USD credit left")); + Ok(()) + } + + #[test] fn a_positive_balance_is_money_owed() -> Result<()> { let view = view(br#"{"stripe_balance": 12.5}"#)?; blob - ac9c6c207d1e3d6d057ba7f736bee6b348107608 blob + 62b87c2f59c8f77d407ad0a6372f38c0d5f694ec --- src/usage/mod.rs +++ src/usage/mod.rs @@ -1,12 +1,13 @@ //! Provider quota usage //! //! Each provider exposes a different usage endpoint with a different response -//! shape. They are normalised into a [`View`] so the renderer only has to know -//! about quota windows and per-model request counts. +//! shape. They are normalised into a [`View`] so the renderer only needs to +//! know about quota windows and per-model request counts. pub mod anthropic; pub mod codex; pub mod deepinfra; +pub mod ollama; pub mod openai; pub mod render; pub mod synthetic; @@ -29,7 +30,7 @@ struct Credential { /// Largest response body read from a usage endpoint const MAX_BODY: usize = 1 << 20; -/// A usage provider +/// Usage provider #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Provider { @@ -39,6 +40,7 @@ pub enum Provider { OpenAI, DeepInfra, Codex, + Ollama, } /// Provider usage, normalised for rendering @@ -75,7 +77,7 @@ pub struct Table { pub rows: Vec, } -/// A count of some unit against one model, named by the enclosing [`Table`] +/// Count of a unit against one model, named by the enclosing [`Table`] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ModelCount { pub name: String, @@ -84,7 +86,7 @@ pub struct ModelCount { } impl Window { - /// A window that resets at `resets_at`, if the provider reports one + /// Window that resets at `resets_at`, if the provider reports one #[must_use] pub fn new(name: impl Into, used: f64, resets_at: Option>) -> Self { Self { @@ -103,7 +105,7 @@ fn remaining_secs(at: DateTime, now: DateTime i64 { let now = Utc::now(); Utc.with_ymd_and_hms(now.year(), now.month(), 1, 0, 0, 0) @@ -113,12 +115,13 @@ fn month_start_unix() -> i64 { impl Provider { /// All providers, in report order - pub const ALL: [Provider; 5] = [ + pub const ALL: [Provider; 6] = [ Provider::Synthetic, Provider::Anthropic, Provider::OpenAI, Provider::DeepInfra, Provider::Codex, + Provider::Ollama, ]; /// Default base URL, honouring any environment override @@ -130,6 +133,10 @@ impl Provider { Self::OpenAI => "https://api.openai.com".to_string(), Self::DeepInfra => "https://api.deepinfra.com".to_string(), Self::Codex => "https://chatgpt.com".to_string(), + Self::Ollama => std::env::var("OLLAMA_API_ENDPOINT") + .ok() + .filter(|url| !url.is_empty()) + .unwrap_or_else(|| "https://ollama.com".to_string()), } } @@ -148,6 +155,7 @@ impl Provider { // Serves the Codex CLI and the ChatGPT client; not a documented // public API. Self::Codex => "/backend-api/wham/usage", + Self::Ollama => "/api/usage", } } @@ -160,7 +168,9 @@ impl Provider { Self::OpenAI => { Some("/v1/organization/usage/completions?bucket_width=1d&group_by=model") } - Self::Synthetic | Self::Anthropic | Self::DeepInfra | Self::Codex => None, + Self::Synthetic | Self::Anthropic | Self::DeepInfra | Self::Codex | Self::Ollama => { + None + } } } @@ -189,6 +199,7 @@ impl Provider { let token = match self { Self::Synthetic => from_env("SYNTHETIC_API_KEY"), Self::DeepInfra => from_env("DEEPINFRA_API_KEY"), + Self::Ollama => from_env("OLLAMA_API_KEY"), Self::Codex => { return codex_credential(); } @@ -218,6 +229,7 @@ impl Provider { Self::OpenAI => openai::view(body, extra), Self::DeepInfra => deepinfra::view(body), Self::Codex => codex::view(body), + Self::Ollama => ollama::view(body), } } @@ -233,7 +245,7 @@ impl Provider { /// Parse a usage response body as JSON /// -/// Shared by every provider module, which otherwise each repeated the same +/// Shared by every provider module, which would otherwise repeat the same /// `map_err` wrapping. /// /// # Errors @@ -252,7 +264,7 @@ fn from_json(value: se serde_json::from_value(value).map_err(|err| Error::UnexpectedResponse(err.to_string())) } -/// Parse a usage response body into `T`, requiring at least one of +/// Parse a usage response body into `T`, needing at least one of /// `expected_keys` at the top level /// /// Every provider whose whole response maps onto one struct follows this @@ -286,6 +298,7 @@ impl fmt::Display for Provider { Self::OpenAI => "openai", Self::DeepInfra => "deepinfra", Self::Codex => "codex", + Self::Ollama => "ollama", }; f.write_str(name) } @@ -298,12 +311,12 @@ fn user_agent() -> String { /// The Codex credential from the OAuth token the Codex CLI stores /// /// The CLI keeps its tokens in `$CODEX_HOME/auth.json`, defaulting to -/// `~/.codex/auth.json`. The `ChatGPT` account id rides along, since the usage -/// endpoint wants it next to the bearer token. +/// `~/.codex/auth.json`. The `ChatGPT` account id rides along, because the +/// usage endpoint needs it next to the bearer token. /// /// # Errors /// -/// Returns [`Error::NoCredentials`] when the file is missing, unreadable or +/// Returns [`Error::NoCredentials`] when the file is missing, unreadable, or /// holds no access token. fn codex_credential() -> Result { let home = match std::env::var_os("CODEX_HOME") { @@ -514,14 +527,18 @@ mod test { assert_eq!(Provider::Anthropic.to_string(), "anthropic"); assert_eq!(Provider::OpenAI.to_string(), "openai"); assert_eq!(Provider::DeepInfra.to_string(), "deepinfra"); + assert_eq!(Provider::Codex.to_string(), "codex"); + assert_eq!(Provider::Ollama.to_string(), "ollama"); } #[test] - fn all_providers_use_bearer() { + fn bearer_providers_use_authorization_header() { assert!(Provider::Synthetic.bearer()); assert!(Provider::Anthropic.bearer()); assert!(Provider::OpenAI.bearer()); assert!(Provider::DeepInfra.bearer()); + assert!(Provider::Codex.bearer()); + assert!(!Provider::Ollama.bearer()); } #[test] blob - 62d89ff421a16a4ab7e7654aefe8d59a00ec88f2 blob + 609d6ff425a101aed6f6f4b341f220fe74485288 --- src/usage/openai.rs +++ src/usage/openai.rs @@ -3,7 +3,7 @@ //! The documented usage endpoint reports token usage and spend for the whole //! organization, not a single account's quota, so there is no window to show //! a used share of. It needs an Admin API key with the `api.usage.read` -//! scope: a regular project key is refused. `alpaca` reports the current +//! scope; a regular project key is refused. `alpaca` reports the current //! UTC month's spend from the costs endpoint as the trailing note, and a //! per-model token breakdown from the usage endpoint, fetched separately //! (see [`super::Provider::extra_path`]), as a request table. @@ -63,7 +63,7 @@ struct Completions { /// Normalise the `OpenAI` organization costs response /// /// `completions` is the body of the organization completions usage endpoint, -/// when the caller fetched one; it only ever supplies the per-model table, so +/// when the caller fetched one. It only ever supplies the per-model table, so /// a missing or unparseable body is silently left out rather than failing /// the whole view. /// blob - /dev/null blob + 3d408a6d306c759b7b2d2e5deb9df9661db0ce78 (mode 644) --- /dev/null +++ src/usage/ollama.rs @@ -0,0 +1,249 @@ +//! Ollama Cloud usage + +use chrono::{DateTime, TimeDelta, Utc}; +use serde::Deserialize; + +use super::{ModelCount, Table, View, Window}; +use crate::Error; + +/// Length of the session window +const SESSION: TimeDelta = TimeDelta::hours(5); +/// Length of the weekly window +const WEEKLY: TimeDelta = TimeDelta::days(7); +/// Offset of the weekly boundary from the Unix epoch, so all accounts share it +const WEEKLY_OFFSET: TimeDelta = TimeDelta::days(4); + +#[derive(Debug, Default, Deserialize)] +struct Limit { + #[serde(default)] + usage: f64, + #[serde(default)] + models: Vec, +} + +#[derive(Debug, Default, Deserialize)] +struct Period { + #[serde(rename = "type", default)] + kind: String, + #[serde(rename = "starting_at", default)] + from: String, + #[serde(rename = "ending_at", default)] + to: String, +} + +#[derive(Debug, Default, Deserialize)] +struct Activity { + #[serde(default)] + cost: String, + #[serde(default)] + period: Period, +} + +#[derive(Debug, Default, Deserialize)] +struct Limits { + #[serde(default)] + session: Limit, + #[serde(default)] + weekly: Limit, +} + +#[derive(Debug, Default, Deserialize)] +struct Usage { + #[serde(default)] + activity: Activity, + #[serde(default)] + limits: Limits, +} + +/// Normalise the Ollama Cloud usage response +/// +/// Ollama reports no reset time, so the windows are computed locally. Session +/// resets align to UTC multiples of 5 hours from the epoch. Weekly resets are +/// offset by 4 days so all accounts share the same boundary. This matches the +/// formula in ollama/ollama issue #12532. +/// +/// # Errors +/// +/// Returns an error if the body is not an Ollama usage response. +pub fn view(body: &[u8]) -> Result { + let value: serde_json::Value = + serde_json::from_slice(body).map_err(|err| Error::UnexpectedResponse(err.to_string()))?; + if !["activity", "limits"] + .iter() + .any(|key| value.get(key).is_some()) + { + return Err(Error::UnexpectedResponse( + "response contains no ollama usage fields".to_string(), + )); + } + let usage: Usage = + serde_json::from_value(value).map_err(|err| Error::UnexpectedResponse(err.to_string()))?; + Ok(build(&usage, Utc::now())) +} + +fn build(usage: &Usage, now: DateTime) -> View { + let period = &usage.activity.period; + + View { + title: "ollama cloud usage".to_string(), + subtitle: Some(format!( + "{} . {} to {}", + period.kind, period.from, period.to + )), + note: (!usage.activity.cost.is_empty()) + .then(|| format!("extra {} USD", usage.activity.cost)), + windows: vec![ + Window::new( + "session", + usage.limits.session.usage, + Some(next_boundary(now, SESSION, TimeDelta::zero())), + ), + Window::new( + "weekly", + usage.limits.weekly.usage, + Some(next_boundary(now, WEEKLY, WEEKLY_OFFSET)), + ), + ], + tables: vec![ + Table { + heading: "session models".to_string(), + unit: "reqs", + rows: sorted(&usage.limits.session.models), + }, + Table { + heading: "weekly models".to_string(), + unit: "reqs", + rows: sorted(&usage.limits.weekly.models), + }, + ], + } +} + +/// Next boundary of a window of length `period`, with boundaries offset by +/// `offset` from the Unix epoch +fn next_boundary(now: DateTime, period: TimeDelta, offset: TimeDelta) -> DateTime { + let period_secs = period.num_seconds(); + let since_epoch = now.timestamp() - offset.num_seconds(); + let elapsed = since_epoch.rem_euclid(period_secs); + now + TimeDelta::seconds(period_secs - elapsed) +} + +/// Model counts, busiest first +fn sorted(models: &[ModelCount]) -> Vec { + let mut sorted = models.to_vec(); + sorted.sort_by_key(|model| std::cmp::Reverse(model.requests)); + sorted +} + +#[cfg(test)] +mod test { + use super::*; + use anyhow::Result; + + const BODY: &[u8] = br#"{ + "activity": { + "cost": "1.25", + "period": {"type": "monthly", "starting_at": "2025-10-01", "ending_at": "2025-10-31"} + }, + "limits": { + "session": {"usage": 0.5, "models": [ + {"name": "small", "request_count": 2}, + {"name": "big", "request_count": 9} + ]}, + "weekly": {"usage": 0.25, "models": []} + } + }"#; + + #[test] + fn reads_windows_models_and_cost() -> Result<()> { + let view = view(BODY)?; + + assert_eq!(view.title, "ollama cloud usage"); + assert_eq!( + view.subtitle.as_deref(), + Some("monthly . 2025-10-01 to 2025-10-31") + ); + assert_eq!(view.note.as_deref(), Some("extra 1.25 USD")); + assert_eq!(view.windows.len(), 2); + assert_eq!(view.windows[0].name, "session"); + assert!((view.windows[0].used - 0.5).abs() < f64::EPSILON); + assert_eq!(view.windows[1].name, "weekly"); + assert_eq!( + view.tables[0].rows, + vec![ + ModelCount { + name: "big".to_string(), + requests: 9 + }, + ModelCount { + name: "small".to_string(), + requests: 2 + } + ], + "models are sorted busiest first" + ); + assert!(view.tables[1].rows.is_empty()); + Ok(()) + } + + #[test] + fn omits_note_without_cost() -> Result<()> { + let view = view(br#"{"limits": {"session": {"usage": 0}, "weekly": {"usage": 0}}}"#)?; + + assert_eq!(view.note, None); + Ok(()) + } + + #[test] + fn rejects_non_usage_bodies() { + assert!(view(b"not json").is_err()); + assert!(view(b"{}").is_err()); + } + + #[test] + fn session_boundaries_are_epoch_aligned() { + let now = DateTime::parse_from_rfc3339("2025-10-17T23:14:07Z") + .unwrap() + .with_timezone(&Utc); + + let next = next_boundary(now, SESSION, TimeDelta::zero()); + + assert!(next > now, "boundary is in the future"); + assert!(next - now <= SESSION, "boundary is within one window"); + assert_eq!( + next.timestamp() % SESSION.num_seconds(), + 0, + "session boundaries are multiples of 5h from the epoch" + ); + } + + #[test] + fn weekly_boundaries_use_the_four_day_offset() { + let now = DateTime::parse_from_rfc3339("2025-10-17T23:14:07Z") + .unwrap() + .with_timezone(&Utc); + + let next = next_boundary(now, WEEKLY, WEEKLY_OFFSET); + + assert!(next > now); + assert!(next - now <= WEEKLY); + assert_eq!( + (next.timestamp() - WEEKLY_OFFSET.num_seconds()) % WEEKLY.num_seconds(), + 0 + ); + } + + #[test] + fn boundaries_never_land_in_the_past_before_the_epoch() { + let before_epoch = DateTime::parse_from_rfc3339("1969-01-01T00:00:00Z") + .unwrap() + .with_timezone(&Utc); + + let next = next_boundary(before_epoch, SESSION, TimeDelta::zero()); + + assert!( + next > before_epoch, + "negative timestamps still move forward" + ); + } +} blob - d9688649e8cfe2e980e1bc8aef19cf4f1b79b93a blob + e6f9619462164f5cf69d4cfab1417725e2dda8bd --- src/usage/render.rs +++ src/usage/render.rs @@ -1,4 +1,4 @@ -//! Rendering usage views for a terminal +//! Render usage views for a terminal use std::io::Write; @@ -123,7 +123,7 @@ pub fn render(mut dest: impl Write, view: &View, style Ok(()) } -/// Write several rendered views, one blank line between them +/// Write several rendered views, with one blank line between them /// /// # Errors /// blob - 2dbf6f76a7cad374bbbd97bf3eab3707ecfaf17f blob + e3480cabc8f5072782e1352fee548907bbd06f17 --- src/usage/synthetic.rs +++ src/usage/synthetic.rs @@ -15,7 +15,7 @@ struct Subscription { renews_at: Option, } -/// The weekly credit quota, reported only by subscriptions +/// Weekly credit quota, reported only by subscriptions #[derive(Debug, Default, Deserialize)] struct WeeklyLimit { /// Share of the weekly credits still available, from 0 to 100 blob - 5027ebf20b59686a88c8ed594d9599655570a747 blob + d07c99e153073eefdb7f8c3b880a6fb6e90eb044 --- tests/chat.rs +++ tests/chat.rs @@ -1,4 +1,4 @@ -//! Integration tests for chat subcommand +//! Integration tests for the chat subcommand use assert_cmd::Command; use assert_fs::prelude::*; @@ -16,7 +16,7 @@ fn alpaca() -> Command { cmd } -/// A canned successful native `/api/chat` response body +/// A ready-made successful response body for the native `/api/chat` endpoint fn ok_body() -> &'static str { r#"{ "model": "gpt-4o-mini", @@ -145,11 +145,11 @@ fn chat_with_reasoning_effort() { .stdout(predicate::str::contains("ASSISTANT REPLY")); } -/// Test messages provided via -/// - System message flag -/// - Assistant message flag -/// - User message flag -/// - User message from stdin +/// Checks that messages arrive in order from: +/// - the system message flag +/// - the assistant message flag +/// - the user message flag +/// - standard input #[test] fn chat_multiple_messages() { let mut server = mockito::Server::new(); @@ -198,7 +198,7 @@ fn chat_multiple_messages() { .stdout(predicate::str::contains("ASSISTANT REPLY")); } -/// Test API errors are propagated +/// Checks that API errors are passed through to the user #[test] fn chat_api_error() { let mut server = mockito::Server::new(); @@ -235,7 +235,7 @@ fn chat_api_error() { )); } -/// A base URL ending in a version segment speaks the chat completions dialect +/// Uses the chat completions dialect when the base URL ends with a version segment #[test] fn chat_against_a_completions_endpoint() { let mut server = mockito::Server::new(); @@ -287,7 +287,7 @@ fn chat_against_a_completions_endpoint() { .stdout(predicate::str::contains("ASSISTANT REPLY")); } -/// Test messages from file +/// Checks that a message is read from a file #[test] fn chat_user_message_from_file() { let mut server = mockito::Server::new(); blob - 8b56561a25eb73ecdaa500abd27682f8726da4c2 blob + 9e149a7317ae332d5ca4ae9678fa1449dd4a7ddd --- tests/compose.rs +++ tests/compose.rs @@ -1,4 +1,4 @@ -//! Integration tests for compose subcommand +//! Integration tests for the compose subcommand use assert_cmd::Command; use predicates::prelude::*; @@ -6,7 +6,7 @@ use std::fs; use std::os::unix::fs::PermissionsExt; use tempfile::TempDir; -/// A canned successful native `/api/chat` response body +/// A ready-made successful response body for the native `/api/chat` endpoint fn ok_body(content: &str) -> String { format!( r#"{{ @@ -21,10 +21,10 @@ fn ok_body(content: &str) -> String { ) } -/// A stand-in for neovim that submits two prompts, then quits +/// A fake neovim that submits two prompts, then quits /// -/// It checks the prefill and the replies that alpaca writes back, so a session -/// that never reloads the buffer fails the test. +/// It checks the prefill and the replies alpaca writes back. The test fails +/// if the session never reloads the buffer. fn fake_nvim(dir: &TempDir) -> std::path::PathBuf { let path = dir.path().join("nvim"); fs::write( @@ -54,10 +54,10 @@ done path } -/// A stand-in for an editor that is reopened each turn +/// A fake editor that is reopened each turn /// -/// The first invocation edits the prefill into a prompt; the second saves an -/// empty buffer, which is what ends the session. +/// The first invocation turns the prefill into a prompt. The second saves an +/// empty buffer, which ends the session. fn fake_editor(dir: &TempDir) -> std::path::PathBuf { let path = dir.path().join("editor"); fs::write( @@ -107,8 +107,8 @@ fn compose_runs_turns_in_one_vim_session() { let editor_dir = TempDir::new().unwrap(); let editor = fake_nvim(&editor_dir); - // One editor invocation covers both turns: the fake editor exits only - // after seeing each reply land in its buffer. + // One editor invocation covers both turns. The fake editor exits only + // after it sees each reply in its buffer. Command::cargo_bin("alpaca") .unwrap() .args(["compose"]) @@ -141,8 +141,8 @@ fn compose_reopens_other_editors_until_an_empty_buffer let editor_dir = TempDir::new().unwrap(); let editor = fake_editor(&editor_dir); - // The reply is piped back in as the next prefill; saving an empty buffer - // on the second invocation ends the session without another request. + // The reply becomes the next prefill. Saving an empty buffer on the + // second invocation ends the session without another request. Command::cargo_bin("alpaca") .unwrap() .args(["compose"]) blob - c3722c73aa67ed5226049443bd10b69186d5a8a5 blob + 1a4fb19f07f3acdda32348b6b532af40fef88430 --- tests/quota.rs +++ tests/quota.rs @@ -1,4 +1,4 @@ -//! Integration tests for quota subcommand +//! Integration tests for the quota subcommand use assert_cmd::Command; use predicates::prelude::*; @@ -9,7 +9,7 @@ use tempfile::TempDir; static ISOLATED_CONFIG_HOME: LazyLock = LazyLock::new(|| TempDir::new().expect("failed to create isolated config dir")); -/// A canned successful Synthetic `/v2/quotas` response body +/// A ready-made successful response body for the Synthetic `/v2/quotas` endpoint fn ok_body() -> &'static str { r#"{ "subscription": { @@ -23,14 +23,14 @@ fn ok_body() -> &'static str { fn alpaca() -> Command { let mut cmd = Command::cargo_bin("alpaca").unwrap(); cmd.env("XDG_CONFIG_HOME", ISOLATED_CONFIG_HOME.path()); - // The Codex provider reads the CLI's auth file by default; point it at an - // empty home so tests never touch the real one. + // The Codex provider reads the CLI's auth file by default. Point it at + // an empty home so tests never touch the real one. cmd.env("CODEX_HOME", ISOLATED_CONFIG_HOME.path()); cmd } -/// Removes every ambient credential and connection setting, so a report over -/// all providers talk to nothing outside the mock server. +/// Removes every ambient credential and connection setting. A report over +/// all providers then talks only to the mock server. fn offline(cmd: &mut Command) -> &mut Command { cmd.env_remove("SYNTHETIC_API_KEY") .env_remove("DEEPINFRA_API_KEY") @@ -182,7 +182,7 @@ fn anthropic_and_openai_have_no_default_credential() { #[test] fn an_all_providers_report_omits_providers_without_credentials() { - // Nothing is configured, so no provider can be drawn: the report draws + // Nothing is configured, so no provider can be drawn. The report draws // nothing and still succeeds. offline(alpaca().args(["quota"])) .assert() @@ -190,7 +190,7 @@ fn an_all_providers_report_omits_providers_without_cre .stdout(""); } -/// Mocks one successful endpoint for each provider on `server` +/// Mocks one successful endpoint on `server` for each provider fn mock_all_providers(server: &mut mockito::Server) { server .mock("GET", "/v2/quotas") @@ -240,7 +240,7 @@ fn an_all_providers_report_draws_each_provider_in_one_ #[test] fn an_all_providers_report_skips_failing_providers() { let mut server = mockito::Server::new(); - // Only synthetic answers: anthropic fails its request and the openai and + // Only synthetic answers. Anthropic fails its request. The openai and // deepinfra endpoints are unmocked, so all three are left undrawn. server .mock("GET", "/v2/quotas") @@ -297,9 +297,9 @@ fn raw_is_a_single_provider_report() { #[test] fn top_level_apikey_is_the_last_quota_fallback() { // The top-level apikey configures the chat endpoint. Providers with a - // dedicated key setting fall back to it when the dedicated key is - // unset, so a config that only sets apikey still authenticates quota - // requests for the default provider. + // dedicated key setting fall back to it when the dedicated key is unset. + // A config that only sets apikey still authenticates quota requests for + // the default provider. let config_home = TempDir::new().unwrap(); let config_dir = config_home.path().join("alpaca"); std::fs::create_dir(&config_dir).unwrap(); @@ -387,7 +387,7 @@ fn deepinfra_quota_reports_remaining_credit() { .with_body(r#"{"stripe_balance": -50.0}"#) .create(); - // Only the checklist is fetched: the usage endpoint is never asked. + // Only the checklist is fetched. The usage endpoint is never asked. alpaca() .args([ "quota", @@ -476,7 +476,7 @@ fn codex_reads_the_token_the_cli_stored() { #[test] fn codex_quota_without_credentials_fails() { - // `alpaca()` points CODEX_HOME at an empty directory, so the CLI's auth + // `alpaca()` points `CODEX_HOME` at an empty directory, so the CLI's auth // file is missing. offline(alpaca().args(["quota", "-p", "codex"])) .assert() @@ -578,8 +578,8 @@ fn openai_quota_survives_a_missing_completions_endpoin .match_query(mockito::Matcher::Any) .with_body(r#"{"object":"page","data":[],"has_more":false,"next_page":null}"#) .create(); - // /v1/organization/usage/completions is left unmocked: the report still - // succeeds, just with an empty model table. + // `/v1/organization/usage/completions` is left unmocked. The report still + // succeeds, but the model table is empty. alpaca() .args([ @@ -597,3 +597,46 @@ fn openai_quota_survives_a_missing_completions_endpoin costs.assert(); } + +#[test] +fn ollama_quota_reports_session_and_weekly_windows() { + let mut server = mockito::Server::new(); + let mock = server + .mock("GET", "/api/usage") + .match_header("authorization", "KEY") + .with_body( + r#"{"activity":{"cost":"1.25","period":{"type":"monthly","starting_at":"2025-10-01","ending_at":"2025-10-31"}}, + "limits":{"session":{"usage":0.5,"models":[{"name":"small","request_count":2}]}, + "weekly":{"usage":0.25,"models":[]}}}"#, + ) + .create(); + + offline(alpaca().args([ + "quota", + "-p", + "ollama", + "--apikey", + "KEY", + "--base-url", + &server.url(), + ])) + .assert() + .success() + .stdout( + predicate::str::contains("ollama cloud usage") + .and(predicate::str::contains("session")) + .and(predicate::str::contains("weekly")) + .and(predicate::str::contains("session models")) + .and(predicate::str::contains("extra 1.25 USD")), + ); + + mock.assert(); +} + +#[test] +fn ollama_quota_without_credentials_fails() { + offline(alpaca().args(["quota", "-p", "ollama"])) + .assert() + .failure() + .stderr(predicate::str::contains("no credentials for ollama")); +}