commit - 263a717a36dbe0de46c56e14ce13b4360c82ed16
commit + 2b7e8021abe55fdf0bb84b568215ea14f6185f98
blob - 48fb9572cebdccd79bb533df8901ccc060f920bd
blob + 85e0ebdee9ef179921842f13dd98b45c65e907ed
--- Cargo.lock
+++ Cargo.lock
[[package]]
name = "alpaca"
-version = "0.7.2"
+version = "0.7.3"
dependencies = [
"anyhow",
"assert_cmd",
blob - 018a5c92c417e149cb77eb8c435ada23ff43d923
blob + 302a294a01cffb9b332fd54a9b6679776da1b21e
--- Cargo.toml
+++ Cargo.toml
[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
# 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
`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
```
`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 |
| --- | --- | --- |
| `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
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
```
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
50 + 50 equals 100.
# Via flags
-# -s, --system <MSG> Sets system prompt (Always first)
-# -a, --assistant <MSG> Appends assistant message
-# -u, --user <MSG> Appends user message
+# -s, --system <MSG> system prompt, always first
+# -a, --assistant <MSG> append assistant message
+# -u, --user <MSG> 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" \
-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" \
## 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
- `--apikey <KEY>`, `--base-url <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`.
## 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
$ 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:
| `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 <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 <SECS>`: request timeout in seconds
- `--apikey <KEY>`: API key or OAuth token, replacing the provider default
- `--base-url <URL>`: base URL, replacing the provider default
## 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
### 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)
### 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
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
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.
*--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:
$ 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
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_.
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
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.
*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"
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
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
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
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
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_]
*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:
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*.
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:
$ 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
-//! 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;
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,
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
Completions,
}
-/// Requests for the chat API
+/// Chat request
#[derive(Builder, Default)]
pub struct ChatRequest {
model: String,
think: Option<ReasoningEffort>,
}
-/// 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")]
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 {
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")]
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,
}
}
-/// Reason generation stopped
+/// Why the model stopped generating
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum FinishReason {
}
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| {
}
}
- /// Path of the chat endpoint
+ /// Chat endpoint path for this dialect
#[must_use]
pub fn path(self) -> &'static str {
match self {
///
/// # 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<String>, base_url: String) -> Result<Self, Error> {
let http = reqwest::Client::builder().build()?;
let dialect = Dialect::from_base_url(&base_url);
})
}
- /// 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
///
}
}
-/// A response in either dialect
+/// Response body in either dialect
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum APIResponse {
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<String, D::Error>
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<FinishReason, ResponseError> {
match reason {
Some("length") => Ok(FinishReason::Length),
blob - 24989b06d332c9fd4ab3769563fea10484047512
blob + 29f38133ec2a530552456395c0251d33a301c736
--- src/cli/config_tests.rs
+++ src/cli/config_tests.rs
// 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());
};
.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(())
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(),
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
-//! Command line interface for alpaca
+//! Command-line interface for alpaca
use std::collections::HashMap;
use std::ffi::OsString;
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)]
struct Config {
settings: Settings,
profiles: HashMap<String, Settings>,
- /// 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<Provider, String>,
}
})
}
- /// 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)
}
})
}
- /// 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<Self> {
let active = profile.or(self.default_profile.as_deref());
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());
.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 {
};
}
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;
.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<String>) -> Arg {
if let Some(value) = value {
}
}
-/// 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
}
/// 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)]
/// 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 {
pub(crate) base_url: Option<String>,
}
-/// 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 {
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
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 {
}
}
-/// 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
///
pub fn parse() -> Result<Invocation> {
let args: Vec<OsString> = 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);
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())
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
.into()
}
-/// Given `clap::ArgMatches`, creates a vector of `Message` with assigned roles and ordering
+/// Build the message list from command-line and configured messages, in order
fn messages_from_matches(matches: &ArgMatches, config: &Config) -> Vec<Message> {
let mut messages = config
.assistant
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::<String>("system_message") {
messages.insert(0, Message::system(system_msg));
}
}
impl ChatArgs {
- /// Builder
+ /// Build `ChatArgs`
#[must_use]
pub fn builder() -> ChatArgsBuilder {
ChatArgsBuilder::default()
}
impl QuotaArgs {
- /// Builder
+ /// Build `ChatArgs`
#[must_use]
pub fn builder() -> QuotaArgsBuilder {
QuotaArgsBuilder::default()
}
impl ComposeArgs {
- /// Builder
+ /// Build `ChatArgs`
#[must_use]
pub fn builder() -> ComposeArgsBuilder {
ComposeArgsBuilder::default()
Self::OpenAI,
Self::DeepInfra,
Self::Codex,
+ Self::Ollama,
]
}
Self::OpenAI => "openai",
Self::DeepInfra => "deepinfra",
Self::Codex => "codex",
+ Self::Ollama => "ollama",
}))
}
}
blob - 8a27cda418f83ba922a492655a304aa94e0dd1ab
blob + 8d9ba808d72cf18d4a7926a71940422e02043b75
--- src/editor.rs
+++ src/editor.rs
-//! 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;
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})";
///
/// # 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<String, Error> {
let mut file = tempfile::Builder::new()
.prefix("alpaca-")
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.
/// 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)
/// 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())
/// 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
-//! Errors for alpaca library crate
+//! Errors for the alpaca library
use std::path::PathBuf;
#[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
-//! Implements chat subcommand
+//! Chat subcommand
use crate::Error;
use crate::api::{self, Message};
use std::io::{self, IsTerminal, Read};
use std::path::Path;
-/// Executes `ChatArgs` via given args
+/// Run a chat request from `ChatArgs`
///
/// # Errors
///
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<Vec<Message>> {
let reader: Option<Box<dyn Read>> = match file.to_str() {
Some("-") => {
blob - 78864d62eae37e5591d4d6cfe75e6c384b7dbb6f
blob + 3ceeaf69f53a4cf5db30d5c8cc1c3fce4deb44d7
--- src/exec/compose.rs
+++ src/exec/compose.rs
-//! 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};
use crate::transcript::Transcript;
use crate::{Error, editor};
-/// Executes `ComposeArgs`
+/// Run a compose session from `ComposeArgs`
///
/// # Errors
///
/// 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,
/// 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,
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<String, Error> {
let stdin = io::stdin();
if stdin.is_terminal() {
blob - e518cbee4af064ba36d2679aa89b96131ecb3c17
blob + 88bef4afb5fbbbac3e6fa715e0bb9654d41bfa17
--- src/exec/mod.rs
+++ src/exec/mod.rs
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
-//! 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.
use crate::cli::{OutputFormat, QuotaArgs};
use crate::usage::{self, Provider, render::Style};
-/// Executes `QuotaArgs`
+/// Run a quota report from `QuotaArgs`
///
/// # Errors
///
/// 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
///
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<String, &usage::View> = 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<String, usage::View> = 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<usage::Client> {
// An explicit --apikey wins; a report over all providers falls back to
.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<Vec<u8>> {
client_for(args, provider)?
.fetch()
/// 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<Vec<u8>> {
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
///
/// # 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::path::PathBuf> {
std::env::var_os("HOME")
.filter(|home| !home.is_empty())
blob - d8e362f6322c76e64833576733b471154a9964de
blob + 2e7006a58ee41d771ad912c7fc38963aa1af8566
--- src/main.rs
+++ src/main.rs
};
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::<Error>() {
std::process::exit(*code);
blob - c1604d171e0ee1f15c8c6d7a32981f06bdb0695d
blob + ff6d78a0aa541db77107554007140d9f11b4a2e2
--- src/parse.rs
+++ src/parse.rs
-//! 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
//! 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;
}
impl Transcript {
- /// Start a transcript with no messages yet
+ /// Start a transcript with no messages
#[must_use]
pub fn new(model: String, effort: Option<ReasoningEffort>, system: Option<String>) -> Self {
Self {
blob - ff4a9f8d0a9c0f300245c717122595b3c7a477c4
blob + 7de9dd4a3c245e2cdc91eaca56b693cac841ca41
--- src/usage/anthropic.rs
+++ src/usage/anthropic.rs
resets_at: Option<String>,
}
-/// 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)]
/// 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
secondary_window: Option<RateLimitWindow>,
}
-/// The `OpenAI` credits attached to the plan
+/// `OpenAI` credits attached to the plan
#[derive(Debug, Default, Deserialize)]
struct Credits {
#[serde(default, rename = "has_credits")]
/// 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
///
})
}
-/// 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<i64>) -> Option<DateTime<Utc>> {
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<String> {
if credits.unlimited {
blob - b131913c165ac995cf6fb1386cdedbce068aa660
blob + 0e16ad8027ed99b0d05fcde89bd468fa7432a3b8
--- src/usage/deepinfra.rs
+++ src/usage/deepinfra.rs
//!
//! `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;
struct Checklist {
#[serde(default)]
stripe_balance: f64,
+ #[serde(default)]
+ recent: f64,
}
/// Normalise the `DeepInfra` billing checklist response
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 {
}
#[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
//! 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;
/// 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 {
OpenAI,
DeepInfra,
Codex,
+ Ollama,
}
/// Provider usage, normalised for rendering
pub rows: Vec<ModelCount>,
}
-/// 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,
}
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<String>, used: f64, resets_at: Option<DateTime<Utc>>) -> Self {
Self {
/// Unix timestamp of the start of the current UTC month
///
/// `OpenAI`'s organization usage and costs endpoints have no "current period"
-/// shortcut: the caller must compute and pass a `start_time` itself.
+/// shortcut. The caller must compute and pass a `start_time` itself.
fn month_start_unix() -> i64 {
let now = Utc::now();
Utc.with_ymd_and_hms(now.year(), now.month(), 1, 0, 0, 0)
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
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()),
}
}
// Serves the Codex CLI and the ChatGPT client; not a documented
// public API.
Self::Codex => "/backend-api/wham/usage",
+ Self::Ollama => "/api/usage",
}
}
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
+ }
}
}
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();
}
Self::OpenAI => openai::view(body, extra),
Self::DeepInfra => deepinfra::view(body),
Self::Codex => codex::view(body),
+ Self::Ollama => ollama::view(body),
}
}
/// 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
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
Self::OpenAI => "openai",
Self::DeepInfra => "deepinfra",
Self::Codex => "codex",
+ Self::Ollama => "ollama",
};
f.write_str(name)
}
/// 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<Credential, Error> {
let home = match std::env::var_os("CODEX_HOME") {
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
//! 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.
/// 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
+//! 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<ModelCount>,
+}
+
+#[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<View, Error> {
+ 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<Utc>) -> 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<Utc>, period: TimeDelta, offset: TimeDelta) -> DateTime<Utc> {
+ 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<ModelCount> {
+ 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
-//! Rendering usage views for a terminal
+//! Render usage views for a terminal
use std::io::Write;
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
renews_at: Option<String>,
}
-/// 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
-//! Integration tests for chat subcommand
+//! Integration tests for the chat subcommand
use assert_cmd::Command;
use assert_fs::prelude::*;
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",
.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();
.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();
));
}
-/// 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();
.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
-//! Integration tests for compose subcommand
+//! Integration tests for the compose subcommand
use assert_cmd::Command;
use predicates::prelude::*;
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#"{{
)
}
-/// 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(
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(
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"])
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
-//! Integration tests for quota subcommand
+//! Integration tests for the quota subcommand
use assert_cmd::Command;
use predicates::prelude::*;
static ISOLATED_CONFIG_HOME: LazyLock<TempDir> =
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": {
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")
#[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()
.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")
#[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")
#[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();
.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",
#[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()
.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([
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"));
+}