commit - 48182fda9a11903d8e5bc1df49e2a6f00deea33b
commit + ab17466fc0927d8fb701d37f553e79fc4b4dec07
blob - 095f3684503a9f5661db7b6e347fca256f267d93
blob + 862d1d32009acf1d27983f63a03d9b15c92958b9
--- Cargo.lock
+++ Cargo.lock
[[package]]
name = "alpaca"
-version = "0.6.0"
+version = "0.7.0"
dependencies = [
"anyhow",
"assert_cmd",
[[package]]
name = "crossbeam-deque"
-version = "0.8.7"
+version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb"
+checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
[[package]]
name = "crossbeam-epoch"
-version = "0.9.20"
+version = "0.9.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
+checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
-version = "0.8.22"
+version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
+checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
[[package]]
name = "darling"
[[package]]
name = "ipnet"
-version = "2.12.1"
+version = "2.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78"
+checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
[[package]]
name = "is_terminal_polyfill"
"rustls-platform-verifier",
"serde",
"serde_json",
+ "serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
blob - 2778703e010dc80903516e05ec2ed144be3103f0
blob + 30eb3dd475fc9fa578f4cecec02a68b52d434810
--- Cargo.toml
+++ Cargo.toml
[package]
name = "alpaca"
authors = ["leoshimo", "mtmn"]
-version = "0.6.0"
+version = "0.7.0"
edition = "2024"
description = "Unix native interface for LLMs"
repository = "https://github.com/leoshimo/cogni"
chrono = { version = "0.4.45", features = ["serde"] }
clap = { version = "4.6.6", features = ["default", "cargo", "env", "string"] }
derive_builder = "0.20.2"
-reqwest = { version = "0.13.4", features = ["json"] }
+reqwest = { version = "0.13.4", features = ["json", "query"] }
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
tempfile = "3.27.0"
blob - eb5843e4b733c95556585e0c855b2fd12c90b2b2
blob + 19d3b54e55db5567125a04255e4545076905e0a4
--- Makefile
+++ Makefile
MAN5PAGES := $(patsubst %.scd,%,$(wildcard man/*.5.scd))
MANPAGES := $(MAN1PAGES) $(MAN5PAGES)
-.PHONY: all build test install clean
+.PHONY: all build test fmt check install clean
all: build
test:
$(CARGO) test --locked
+fmt:
+ $(CARGO) fmt
+
check:
$(CARGO) check
$(CARGO) clippy -- -W clippy::pedantic
blob - 8068673f35d24b281739faae2d7a9d74c764730b
blob + a92f7d0b2d7d92517d7c327c56d364640319e696
--- README.md
+++ README.md
## Focus
-`alpaca` brings language model scripting (prompting) into familiar Unix
-environment by focusing on:
+`alpaca` brings language model scripting (prompting) into the familiar Unix
+environment. It focuses on:
-- Ergonomics and accessibility in Unix shell
-- Composability and interop with other programs - including `alpaca` itself
-- Easy language model programming, both ad-hoc and repeatable
+- 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
-For example, designing for IO redirection (`stdin`, `stdout`) allows `alpaca` to
-work with files, editor buffers, clipboards, syslogs, sockets, and many external
-tools without bespoke integrations.
+`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.
## Features
-- Unix-minded design (IO redirection, composability, interop)
-- Ad-hoc language model scripting
-- Flexible input and output formats (text, JSON, transcript)
-- Standalone binary, with no Python required
-- Works against any compatible chat endpoint, hosted or local
-- Editor-backed prompting and provider quota reporting in the same binary
+`alpaca` gives you:
+- a Unix-minded design (input and output redirection, composability, interop)
+- ad-hoc language model scripting
+- flexible input and output formats (text, JSON, transcript)
+- a standalone binary, with no Python required
+- support for any compatible chat endpoint, hosted or local
+- editor-backed prompting and provider quota reporting in the same binary
+
## Non-features
-- Interactive use. Instead, invoke `alpaca` from within interactive environments, such as REPLs and emacs.
+`alpaca` is not built for interactive use. Invoke it from within interactive
+environments instead, such as REPLs and Emacs.
## Installation
$ make install
```
-`PREFIX` defaults to `/usr/local`; `DESTDIR`, `BINDIR`, and `MANDIR` may be
-overridden for packaging.
+`PREFIX` defaults to `/usr/local`. You can override `DESTDIR`, `BINDIR` and
+`MANDIR` for packaging.
## Setup
-`alpaca` expects an API key supplied with the `--apikey` option or, more
-conveniently, the `API_KEY` environment variable:
+`alpaca` reads an API key from `--apikey` or, more simply, the `API_KEY`
+environment variable:
```sh
# in shell configuration
export API_KEY=your-api-key
```
-Requests go to `https://ollama.com` unless told otherwise. To target a
-different host, for example a local server, set `API_ENDPOINT` or pass
-`--base-url`:
+`alpaca` sends requests to `https://api.openai.com/v1` by default. Set
+`API_ENDPOINT` or pass `--base-url` to target another host, such as a local
+server:
```sh
export API_ENDPOINT=http://localhost:11434
```
-The endpoint's dialect follows from the base URL. A base URL ending in a
-version segment, such as `https://host/v1`, is sent to `/chat/completions`;
-any other base URL is sent to `/api/chat`. Authorization is only sent when a
-key is known, so endpoints that need no authentication also work.
+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.
-Pick a model with `-m/--model` (default `gpt-oss:120b`). Model identifiers are
+Pick a model with `-m/--model` (default `gpt-4o-mini`). Model identifiers are
whatever your endpoint serves.
Shared settings, honoured by every subcommand:
| variable | meaning | default |
| --- | --- | --- |
| `API_KEY` | API key | none |
-| `API_ENDPOINT` | base URL | `https://ollama.com` |
-| `ALPACA_MODEL` | model | `gpt-oss:120b` |
+| `API_ENDPOINT` | base URL | `https://api.openai.com/v1` |
+| `ALPACA_MODEL` | model | `gpt-4o-mini` |
| `ALPACA_EFFORT` | reasoning effort | `none` |
| `ALPACA_TIMEOUT` | request timeout, in seconds | `60` |
## Configuration
-Put shared settings in `$XDG_CONFIG_HOME/alpaca/config.toml`. If you do not set
-`XDG_CONFIG_HOME`, Alpaca uses `~/.config/alpaca/config.toml`.
+Put shared settings in `$XDG_CONFIG_HOME/alpaca/config.toml`, or
+`~/.config/alpaca/config.toml` if `XDG_CONFIG_HOME` is unset.
Settings take priority in this order:
- command-line options
- environment variables
-- the config file
+- the configuration file
- built-in defaults
-See `config.example.toml` for an example with every supported key. See
-`alpaca-config(5)` for all fields and how each command uses them. Keep the file
-private if it contains an `apikey`.
+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`.
-Use a `[profile-name]` table to define a profile. Profiles inherit top-level
-settings and override only the keys they set. Set `default_profile` to activate
-a profile automatically. Use `--profile` to override it:
+A `[profile-name]` table defines a profile: it inherits top-level settings
+and overrides only the keys it sets. `default_profile` activates a profile
+automatically. `--profile` overrides that:
```toml
model = "gpt-oss:120b"
alpaca compose [OPTIONS] # edit a prompt in $EDITOR, then send it
```
-A first argument matching a subcommand name is treated as that subcommand. To
-read messages from a file with such a name, separate it with `--`, as in
-`alpaca -- quota`.
+`alpaca` treats a first argument matching a subcommand name as that
+subcommand. Read a file with such a name using `--`, as in `alpaca -- quota`.
---
-## Basic Usage
+## Basic usage
-See `alpaca --help` for documentation
+See `alpaca --help` for documentation.
```sh
# Via stdin
## alpaca compose
`alpaca compose` edits a prompt in `$VISUAL`, `$EDITOR`, or `vim`, sends the
-saved text, then writes the reply to stdout. Piped stdin prefills the buffer.
-The editor runs on `/dev/tty`, keeping editor I/O out of the pipeline. Saving
-an empty buffer sends nothing.
+saved text, then writes the reply to standard output. Piped input fills the
+buffer first. The editor runs on `/dev/tty`, so editor input and output stay
+out of the pipeline. Saving an empty buffer sends nothing.
```sh
# Write a prompt in the editor, send it, see the reply
- `--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; no quote or escape parsing. Use a
-wrapper script for complex editor commands. The temporary file is always
-removed. An editor that exits non-zero sets alpaca's own exit status.
+`$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.
-Each successful request is saved as a JSON transcript in `$XDG_DATA_HOME/alpaca`
-(or `~/.local/share/alpaca`), with mode 0600 in a directory with mode 0700. Run
-`alpaca compose -c` to continue the newest transcript: its final reply opens in
-the editor, and each follow-up is sent with the whole conversation as context.
-The saved model, effort and system prompt are reused unless given on the command
-line. Save an empty buffer to leave the loop.
+`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. `alpaca compose -c` continues the newest transcript: its
+final reply opens in the editor, and each follow-up sends the whole
+conversation as context, reusing the saved model, effort and system prompt
+unless you override them on the command line. Save an empty buffer to leave
+the loop.
See `man/alpaca-compose.1`.
## alpaca quota
-`alpaca quota` shows how much of your quota you have used. It asks the
-provider, then prints each quota window as a bar with a countdown to the next
-reset. If the provider reports per-model counts, it also prints those as a
-table.
+`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.
-Pick the provider with `-p`. It defaults to `ollama`:
+Pick the provider with `-p`. Without it, alpaca reports all providers in one
+output and skips the ones it cannot reach:
```sh
-$ alpaca quota # Ollama Cloud
+$ alpaca quota # all providers
+$ alpaca quota -p synthetic # Synthetic
$ alpaca quota -p anthropic # Claude Code
-$ alpaca quota -p openai # Codex CLI
+$ alpaca quota -p openai # OpenAI organization usage
$ alpaca quota -p deepinfra # DeepInfra
```
+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:
-| provider | credential | base URL |
+| provider | default credential | base URL |
| --- | --- | --- |
-| `ollama` | `$OLLAMA_API_KEY` | `$OLLAMA_API_ENDPOINT`, then `https://ollama.com` |
-| `anthropic` | `$CLAUDE_CODE_OAUTH_TOKEN`, then `~/.claude/.credentials.json` | `https://api.anthropic.com` |
-| `openai` | `$CODEX_ACCESS_TOKEN`, then `~/.codex/auth.json` | `https://chatgpt.com` |
+| `synthetic` | `$SYNTHETIC_API_KEY` | `https://api.synthetic.new` |
+| `anthropic` | none | `https://api.anthropic.com` |
+| `openai` | none | `https://api.openai.com` |
| `deepinfra` | `$DEEPINFRA_API_KEY` | `https://api.deepinfra.com` |
-For Anthropic and OpenAI, alpaca reads the credential file the vendor CLI
-writes when you log in. Log in to that CLI once and it works. Those endpoints
-serve the vendor CLIs, not a documented public API, so they may change without
-notice. Both use a short-lived OAuth token. If the credential file goes stale,
-you get an authentication error until the vendor CLI refreshes it.
+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.
-DeepInfra bills per token or per second, with no fixed quota. So alpaca prints
-no window for it. Instead, it prints the current month's spend as a note, and
-billed units per model as a table.
+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.
-The top-level `apikey` and `base_url` settings apply to Ollama only. To use
-them with Anthropic, OpenAI or DeepInfra, declare them in a profile and select
-it with `--profile`. A profile chosen automatically through `default_profile`
-does not apply to those providers. Command-line options and
-`API_KEY`/`API_ENDPOINT` override any profile.
+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
+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.
-DeepInfra also reads a key from the top-level `deepinfra_apikey` setting, with
-no profile needed. An explicit profile's `apikey` still overrides it.
+DeepInfra bills per token or per second, with no fixed quota, so alpaca
+prints no window for it. Like OpenAI, it instead prints the spend for the
+current month as a note, and billed units per model as a table.
+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 `API_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.
+
Options:
-- `-p, --provider <PROVIDER>`: one of `ollama`, `anthropic`, `openai`, `deepinfra`
+- `-p, --provider <PROVIDER>`: one of `synthetic`, `anthropic`, `openai`,
+ `deepinfra`. 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
-- `--raw`: print the provider response body verbatim
-- `--json`, `--jsonp`: print the normalised view as JSON
-- `--color <auto|always|never>`: colour policy. `auto` colours only when stdout
- is a terminal and `NO_COLOR` is unset
+- `--raw`: print the provider response body verbatim. Requires `-p`
+- `--json`, `--jsonp`: print the normalised view as JSON. Without `-p`, one
+ object keyed by provider
+- `--color <auto|always|never>`: colour policy. `auto` colours only when
+ standard output is a terminal and `NO_COLOR` is unset
See `man/alpaca-quota.1`.
Examples to get you started.
-> :warning: Whatever you feed `alpaca` is sent to the endpoint you configure, so point `API_ENDPOINT` at a local server if the data should not leave your machine.
+Whatever you feed `alpaca` is sent to the endpoint you configure. Point
+`API_ENDPOINT` at a local server if the data should not leave your machine.
-### In the Shell
+### In the shell
```sh
# Creating Summary of Meeting Transcripts
### In Emacs
-Emacs can use `shell-command-on-region` to pipe buffer regions to `alpaca`.
+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:
-For example, the following defines a command that plumbs region to `alpaca`, optionally replacing original contents:
-
```emacs-lisp
(defun leoshimo/alpaca-on-region (start end prompt replace)
"Run alpaca on region. Prefix arg means replace region, instead of separate output buffer"
(global-set-key (kbd "M-c") #'leoshimo/alpaca-on-region)
```
-This binding is useful across a wide range of tasks, for example:
+Use this binding for tasks such as:
-- Normalizing non-uniform text, such as unstructured logs to structured JSON events.
-- Editing or organizing text semantically, such as rewording or grouping by category.
-- Generating summary for an Org Agenda doc.
+- normalising nonuniform text, such as unstructured logs to structured JSON events
+- editing or organising text semantically, such as rewording or grouping by category
+- generating a summary for an Org Agenda document
### In Vim
-Vim can run external shell commands on entire buffer or visual selection to
-power similar workflows possible from Emacs. See `h :!` 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.
-For example, a bulleted list of fruits can be sorted by color by:
+For example, to sort a bulleted list of fruits by colour:
-1. Selecting the list of fruits in visual mode
-2. Type `:!alpaca -s "Sort this list by color"`
+1. Select the list of fruits in visual mode.
+2. Type `:!alpaca -s "Sort this list by color"`.
blob - 968d4d7596eb6197fb4ba69442a9bd3728dafac5
blob + 56d050fc24f29a25656335819b9b6c250794625e
--- config.example.toml
+++ config.example.toml
output_format = "plaintext"
-model = "gpt-oss:120b"
+model = "gpt-4o-mini"
temperature = 0.7
timeout = 60
system = "Answer concisely."
assistant = ["An example assistant message"]
user = ["An example user message"]
apikey = "your-api-key"
-base_url = "https://ollama.com"
+base_url = "https://api.openai.com/v1"
reasoning_effort = "none"
-default_profile = "ollama-cloud"
-deepinfra_apikey = "your-deepinfra-key"
+default_profile = "synthetic"
[ollama-cloud]
apikey = "your-ollama-cloud-key"
base_url = "https://ollama.cloud"
[deepinfra]
+apikey = "your-deepinfra-key"
model = "llama-3.1-70b"
+
+[synthetic]
+apikey = "your-synthetic-key"
+model = "hf:zai-org/GLM-5.3-Flash"
+base_url = "https://api.synthetic.new/openai/v1"
+temperature = 1.0
+reasoning_effort = "none"
+timeout = 120
+
+[anthropic]
+apikey = "your-claude-code-oauth-token"
+
+[openai]
+apikey = "your-openai-admin-key"
blob - a7044c99e7cbb9321178cfefd77a5024cbd0189d
blob + 78087cc46e81c8f13c7ea806cea11e3cf9e9f0f7
--- man/alpaca-compose.1.scd
+++ man/alpaca-compose.1.scd
*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. Piped standard input
-prefills the buffer first, so you can use *alpaca compose* in the middle of a
+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.
The editor runs on the controlling terminal. This keeps editor input and screen
*-m*, *--model* _model_
Model to use. When continuing, defaults to the saved model, else
- *ALPACA_MODEL*, else *gpt-oss:120b*.
+ *ALPACA_MODEL*, else *gpt-4o-mini*.
*-s*, *--system* _msg_
System prompt, sent before the conversation.
*VISUAL*, *EDITOR*
Editor command, split on whitespace. *VISUAL* wins. Quotes and escapes are
- not parsed; use a wrapper script for editor commands that need them. Without
+ not parsed. Use a wrapper script for editor commands that need them. Without
either variable, *vim*(1) is used.
*XDG_DATA_HOME*
# CONFIGURATION
-This command uses applicable settings from the Alpaca config file. A config
-setting overrides a value saved in a transcript. Command-line options and
+This command uses its settings from the Alpaca configuration file. A setting
+there overrides a value saved in a transcript. Command-line options and
environment variables take priority. See *alpaca-config*(5).
# EXIT STATUS
-An editor that exits non-zero sets the exit status of *alpaca compose*, so
+An editor that exits nonzero sets the exit status of *alpaca compose*, so
aborting an edit aborts the request. Usage errors exit 2.
# EXAMPLES
blob - 639f04b1cf41484047b231a54ed56b59103ff9c3
blob + d010b42c06a16888c7d2782ade53f4af29cfa441
--- man/alpaca-config.5.scd
+++ man/alpaca-config.5.scd
*default_profile*
Profile to activate automatically when *--profile* is not given.
-*deepinfra_apikey*
- API key for *alpaca quota -p deepinfra*. Unlike *apikey*, this applies
- with no profile needed. Keep the file private if you set this value.
-
# 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
activate a profile, or set *default_profile* to activate one automatically.
+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
+name with *--profile* to also override the quota endpoint deliberately.
+
# COMMANDS
*alpaca* and *alpaca chat* use every setting.
*alpaca compose* uses *output_format*, *model*, *temperature*, *timeout*,
-*system*, *apikey*, *base_url* and *reasoning_effort*. A config setting
+*system*, *apikey*, *base_url* and *reasoning_effort*. A setting in this file
overrides a value saved in a transcript.
-*alpaca quota* uses *output_format* and *timeout*. Top-level *apikey* and
-*base_url* apply to Ollama only. Anthropic, OpenAI and DeepInfra use those
-settings only when a profile selected with *--profile* declares them. Values
-inherited from the top level, or from an automatic *default_profile*, do not
-replace their provider defaults. DeepInfra also accepts a key from top-level
-*deepinfra_apikey*, which needs no profile. An explicit profile's *apikey*
-still overrides it.
+*alpaca quota* uses *output_format* and *timeout*. Top-level *base_url*
+configures the chat endpoint, so *alpaca quota* never uses it for any
+provider: each provider already has the correct endpoint built in. To
+deliberately override a provider's endpoint, select a profile by name with
+*--profile*. Values inherited from the top level, or from an automatic
+*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.
+
# EXAMPLE
output_format = "plaintext"
- model = "gpt-oss:120b"
+ model = "gpt-4o-mini"
temperature = 0.7
timeout = 60
system = "Answer concisely."
assistant = ["An example assistant message"]
user = ["An example user message"]
apikey = "your-api-key"
- base_url = "https://ollama.com"
+ base_url = "https://api.openai.com/v1"
reasoning_effort = "none"
default_profile = "ollama-cloud"
# FILES
_$XDG_CONFIG_HOME/alpaca/config.toml_
- Config file when *XDG_CONFIG_HOME* is set.
+ Configuration file when *XDG_CONFIG_HOME* is set.
_~/.config/alpaca/config.toml_
- Config file when *XDG_CONFIG_HOME* is not set.
+ Configuration file when *XDG_CONFIG_HOME* is not set.
# SEE ALSO
blob - 9862c8b8562daf49fff8cf13c67f42df4962f50a
blob + e83420e4b85b563261db9175c2f1ece6397ad7dd
--- man/alpaca-quota.1.scd
+++ man/alpaca-quota.1.scd
# NAME
-alpaca quota - show provider session and weekly usage in the terminal
+alpaca quota - show how much provider quota you have used
# SYNOPSIS
# DESCRIPTION
-*alpaca quota* calls the usage endpoint of one provider with your credential.
-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 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.
-Choose the provider with *-p*. It defaults to *ollama*. Each provider has its
-own default credential and base URL. You can override either.
+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
+reported instead.
-*ollama*
- Ollama Cloud. Takes the key from *OLLAMA_API_KEY*, and the base URL from
- *OLLAMA_API_ENDPOINT*, else *https://ollama.com*. Prints the session
- (5 hour) and weekly (7 day) quotas, each with a per-model request table.
- The endpoint reports no reset time, so alpaca computes the countdowns
- itself: session windows align to UTC multiples of 5 hours from the epoch,
- and weekly windows use the same rule with a 4 day offset. This keeps the
- boundary the same for every account.
+Each provider has its own default credential and base URL. You can override
+either. With *--apikey* or *--base-url* and no *-p*, the override applies to
+all providers.
+*synthetic*
+ Synthetic. Takes the key from *SYNTHETIC_API_KEY*. Base URL
+ *https://api.synthetic.new*. Prints the subscription quota, with a
+ countdown to the renewal reported by the endpoint.
+
*anthropic*
- Claude Code. Takes the token from *CLAUDE_CODE_OAUTH_TOKEN*, else
- _~/.claude/.credentials.json_. Base URL *https://api.anthropic.com*.
- Prints whichever windows the account has, plus extra spend when it is
- enabled.
+ Claude Code. 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.
*openai*
- Codex CLI. Takes the token from *CODEX_ACCESS_TOKEN*, else
- _~/.codex/auth.json_. Base URL *https://chatgpt.com*. Prints the plan, its
- rate limit windows, and the credit balance.
+ OpenAI organization usage. 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.
*deepinfra*
DeepInfra. Takes the key from *DEEPINFRA_API_KEY*. Base URL
*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
+ no fixed quota, so alpaca prints no window for it. Instead, it prints the
current month's spend as a note, and billed units per model as a table.
-The *anthropic* and *openai* endpoints are not a documented public API, so
-they may change without notice. Both use a short-lived OAuth token. If the
-credential file goes stale, you get an authentication error until the vendor
-CLI refreshes it.
+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
*-p*, *--provider* _provider_
- One of *ollama*, *anthropic*, *openai* or *deepinfra*.
+ One of *synthetic*, *anthropic*, *openai* or *deepinfra*. 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.
Base URL, replacing the provider default.
*--raw*
- Print the provider response body verbatim.
+ Print the provider response body verbatim. A raw body belongs to one
+ provider, so this requires *-p*.
*--json*, *--jsonp*
- Print the normalised view as JSON, compact or indented.
+ Print the normalised view as JSON, compact or indented. Without *-p*,
+ 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
# CONFIGURATION
-This command uses *output_format* and *timeout* from the Alpaca config file.
-Top-level *apikey* and *base_url* apply to Ollama only. For Anthropic, OpenAI
-or DeepInfra, declare connection values in a profile and select it with
-*--profile*. A profile chosen automatically through *default_profile* does not
-apply to those providers. Command-line options and environment variables take
-priority. See *alpaca-config*(5).
+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
+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).
+
# EXIT STATUS
Usage errors exit 2.
blob - 41405dfe334337b1f94fd1f189b57687c2dd218a
blob + 78dd47e12a23d882368b3908813e456e4f88ec21
--- man/alpaca.1.scd
+++ man/alpaca.1.scd
# DESCRIPTION
*alpaca* sends chat requests to a model and writes the reply to standard
-output. Without a subcommand it chats, so *alpaca* composes with pipes, files,
+output. Without a subcommand it chats, so it works in pipelines with files,
editor buffers and other programs.
Three subcommands are available:
*alpaca chat -u "hello"* are the same command. See the options below.
*quota*
- Report how much provider quota is used. See *alpaca-quota*(1).
+ Show how much provider quota you have used. See *alpaca-quota*(1).
*compose*
Edit a prompt in the editor, then send it. See *alpaca-compose*(1).
-A first argument matching a subcommand name is treated as that subcommand. To
-read messages from a file with such a name, separate it with *--*, as in
-*alpaca -- quota*.
+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*.
# OPTIONS
*-m*, *--model* _model_
- Model to use. Defaults to *ALPACA_MODEL*, else *gpt-oss:120b*.
+ Model to use. Defaults to *ALPACA_MODEL*, else *gpt-4o-mini*.
*-t*, *--temperature* _temp_
Sampling temperature.
*--base-url* _url_
Base URL of the API endpoint. Defaults to *API_ENDPOINT*, else
- *https://ollama.com*. A base URL ending in a version segment, such as
- _/v1_, addresses a chat completions endpoint; any other base URL addresses
- _{base}/api/chat_.
+ *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_.
*--output-format* _format_
One of *plaintext*, *json* or *jsonpretty*.
set *XDG_CONFIG_HOME*, Alpaca uses _~/.config/alpaca/config.toml_.
Settings take priority in this order: command-line options, then environment
-variables, then the config file, then built-in defaults. See *alpaca-config*(5)
+variables, then the configuration file, then built-in defaults. See
+*alpaca-config*(5)
for the file format.
# ENVIRONMENT
blob - b67830cd2c8ba83c8e309142a45e47cd594a0540
blob + 24989b06d332c9fd4ab3769563fea10484047512
--- src/cli/config_tests.rs
+++ src/cli/config_tests.rs
assert_eq!(compose.reasoning_effort, Some(Effort::Medium));
assert_eq!(compose.timeout, Duration::from_secs(19));
+ // 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.
let Invocation::Quota(quota) = parse_args_with_config(&["alpaca", "quota"], &config)? else {
return Err("expected quota invocation".into());
};
assert_eq!(quota.output_format, OutputFormat::JSON);
assert_eq!(quota.timeout, Duration::from_secs(19));
- assert_eq!(quota.api_key.as_deref(), Some("configured-key"));
+ assert_eq!(quota.provider, None);
assert_eq!(
- quota.base_url.as_deref(),
- Some("https://configured.example")
+ quota.api_key, None,
+ "the chat apikey is not sent to all providers"
);
+ assert_eq!(quota.base_url, None);
+ assert_eq!(
+ quota
+ .quota_keys
+ .get(&Provider::Synthetic)
+ .map(String::as_str),
+ Some("configured-key")
+ );
+ assert_eq!(
+ quota
+ .quota_keys
+ .get(&Provider::DeepInfra)
+ .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(())
}
#[test]
-fn generic_connection_config_only_applies_to_ollama_quota() -> Result<()> {
+fn top_level_apikey_is_the_last_quota_fallback_for_dedicated_key_providers() -> Result<()> {
let toml = r#"
- apikey = "ollama-key"
- base_url = "https://ollama.example"
+ apikey = "chat-key"
+ base_url = "https://chat.example"
"#;
- let ollama = configured_quota(toml, &["alpaca", "quota"])?;
- assert_eq!(ollama.api_key.as_deref(), Some("ollama-key"));
- assert_eq!(ollama.base_url.as_deref(), Some("https://ollama.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"] {
+ let quota = configured_quota(toml, &["alpaca", "quota", "-p", provider])?;
+ assert_eq!(
+ quota.api_key.as_deref(),
+ Some("chat-key"),
+ "provider {provider}"
+ );
+ assert_eq!(quota.base_url, None, "provider {provider}");
+ }
- for provider in ["anthropic", "openai", "deepinfra"] {
+ // Anthropic and OpenAI never serve chat, so they never guess the chat key.
+ for provider in ["anthropic", "openai"] {
let quota = configured_quota(toml, &["alpaca", "quota", "-p", provider])?;
- assert_eq!(quota.api_key, None);
- assert_eq!(quota.base_url, None);
+ assert_eq!(quota.api_key, None, "provider {provider}");
+ assert_eq!(quota.base_url, None, "provider {provider}");
}
Ok(())
}
#[test]
-fn deepinfra_apikey_is_used_without_a_profile() -> Result<()> {
+fn an_all_providers_report_resolves_one_key_per_provider() -> Result<()> {
let toml = r#"
- apikey = "ollama-key"
- deepinfra_apikey = "deepinfra-key"
+ apikey = "chat-key"
+
+ [synthetic]
+ apikey = "synthetic-key"
+
+ [codex]
+ apikey = "codex-key"
"#;
+ let quota = configured_quota(toml, &["alpaca", "quota"])?;
+ assert_eq!(quota.provider, None);
+ assert_eq!(
+ quota.api_key, None,
+ "the chat apikey is not sent to all providers"
+ );
+ assert_eq!(
+ quota
+ .quota_keys
+ .get(&Provider::Synthetic)
+ .map(String::as_str),
+ Some("synthetic-key")
+ );
+ assert_eq!(
+ quota
+ .quota_keys
+ .get(&Provider::DeepInfra)
+ .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"
+ );
+ assert_eq!(quota.quota_keys.get(&Provider::OpenAI), None);
+ Ok(())
+}
+
+#[test]
+fn a_provider_named_section_supplies_its_quota_key_without_a_profile_flag() -> Result<()> {
+ let toml = r#"
+ apikey = "chat-key"
+
+ [deepinfra]
+ apikey = "deepinfra-key"
+ base_url = "https://deepinfra.example"
+ "#;
+
let quota = configured_quota(toml, &["alpaca", "quota", "-p", "deepinfra"])?;
assert_eq!(quota.api_key.as_deref(), Some("deepinfra-key"));
- assert_eq!(quota.base_url, None);
-
- let ollama = configured_quota(toml, &["alpaca", "quota"])?;
assert_eq!(
- ollama.api_key.as_deref(),
- Some("ollama-key"),
- "deepinfra_apikey does not leak into other providers"
+ quota.base_url, None,
+ "quota keeps its predefined endpoint from the section's key alone; \
+ only an explicit --profile may override it"
);
+
+ // The section only applies to its own provider.
+ let synthetic = configured_quota(toml, &["alpaca", "quota", "-p", "synthetic"])?;
+ assert_eq!(synthetic.api_key.as_deref(), Some("chat-key"));
+ assert_eq!(synthetic.base_url, None);
Ok(())
}
#[test]
-fn an_explicit_profile_overrides_deepinfra_apikey() -> Result<()> {
+fn an_explicit_profile_may_still_override_the_predefined_quota_endpoint() -> Result<()> {
let toml = r#"
- deepinfra_apikey = "top-level-key"
+ [deepinfra]
+ apikey = "deepinfra-key"
+ base_url = "https://deepinfra.example"
+ "#;
+ // Selecting the same table by name with --profile is a deliberate
+ // choice, unlike the automatic lookup by provider name: it also applies
+ // the table's base_url.
+ let quota = configured_quota(
+ toml,
+ &[
+ "alpaca",
+ "quota",
+ "--profile",
+ "deepinfra",
+ "-p",
+ "deepinfra",
+ ],
+ )?;
+ assert_eq!(quota.api_key.as_deref(), Some("deepinfra-key"));
+ assert_eq!(quota.base_url.as_deref(), Some("https://deepinfra.example"));
+ Ok(())
+}
+
+#[test]
+fn a_provider_named_section_also_supplies_a_key_for_providers_without_a_dedicated_field()
+-> Result<()> {
+ let toml = r#"
+ [anthropic]
+ apikey = "anthropic-key"
+ "#;
+
+ let quota = configured_quota(toml, &["alpaca", "quota", "-p", "anthropic"])?;
+ assert_eq!(quota.api_key.as_deref(), Some("anthropic-key"));
+ Ok(())
+}
+
+#[test]
+fn an_explicit_profile_overrides_a_provider_named_section() -> Result<()> {
+ let toml = r#"
+ [deepinfra]
+ apikey = "section-key"
+
[work]
apikey = "profile-key"
"#;
}
#[test]
-fn non_ollama_quota_only_uses_connection_values_from_explicit_profile() -> Result<()> {
+fn a_provider_named_section_keys_it_in_an_all_providers_report() -> Result<()> {
let toml = r#"
+ [deepinfra]
+ apikey = "deepinfra-key"
+ "#;
+
+ let quota = configured_quota(toml, &["alpaca", "quota"])?;
+ assert_eq!(
+ quota
+ .quota_keys
+ .get(&Provider::DeepInfra)
+ .map(String::as_str),
+ Some("deepinfra-key")
+ );
+ Ok(())
+}
+
+#[test]
+fn an_explicit_profile_keys_all_providers_in_an_all_providers_report() -> Result<()> {
+ let toml = r#"
+ apikey = "top-key"
+
+ [codex]
+ apikey = "codex-key"
+ base_url = "https://codex.example"
+ "#;
+
+ // An explicit profile's apikey applies to all providers, exactly as it
+ // would for an explicit -p. Its base_url does not: no single endpoint
+ // serves all providers.
+ let quota = configured_quota(toml, &["alpaca", "quota", "--profile", "codex"])?;
+ assert_eq!(quota.base_url, None);
+ for provider in Provider::ALL {
+ assert_eq!(
+ quota.quota_keys.get(&provider).map(String::as_str),
+ Some("codex-key"),
+ "provider {provider}"
+ );
+ }
+ Ok(())
+}
+
+#[test]
+fn a_profile_without_its_own_keys_falls_back_to_the_top_level_apikey() -> Result<()> {
+ let toml = r#"
+ apikey = "top-key"
+
+ [work]
+ timeout = 19
+ "#;
+
+ let quota = configured_quota(
+ toml,
+ &["alpaca", "quota", "--profile", "work", "-p", "deepinfra"],
+ )?;
+ assert_eq!(quota.api_key.as_deref(), Some("top-key"));
+ Ok(())
+}
+
+#[test]
+fn non_synthetic_quota_only_uses_connection_values_from_explicit_profile() -> Result<()> {
+ let toml = r#"
apikey = "ollama-key"
base_url = "https://ollama.example"
default_profile = "ollama-cloud"
blob - c8fc471830becd0924c55338e11e820ce9349999
blob + 7977d774e03c2e6080595e4692d71ca5e55e5b5c
--- src/cli.rs
+++ src/cli.rs
use serde::Deserialize;
/// Default model used by every subcommand that talks to a model
-pub const DEFAULT_MODEL: &str = "gpt-oss:120b";
+pub const DEFAULT_MODEL: &str = "gpt-4o-mini";
/// Default request 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 `API_ENDPOINT` or `--base-url` to point elsewhere.
-pub const DEFAULT_BASE_URL: &str = "https://ollama.com";
+pub const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1";
#[derive(Debug, Default, Deserialize, Clone)]
#[serde(deny_unknown_fields)]
struct Settings {
base_url: Option<String>,
reasoning_effort: Option<Effort>,
default_profile: Option<String>,
- deepinfra_apikey: Option<String>,
}
#[derive(Debug, Default, Clone)]
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
+ quota_keys: HashMap<Provider, String>,
}
impl Deref for Config {
bail!("timeout must be greater than zero");
}
- Ok(Self { settings, profiles })
+ Ok(Self {
+ settings,
+ profiles,
+ quota_keys: HashMap::new(),
+ })
}
+ /// 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.
+ fn profile(&self, name: &str) -> Option<&Settings> {
+ self.profiles.get(name)
+ }
+
fn with_profile(&self, name: &str) -> Result<Self> {
let profile = self
- .profiles
- .get(name)
+ .profile(name)
.with_context(|| format!("unknown profile '{name}'"))?;
Ok(Self {
settings: self.settings.merge(profile),
profiles: HashMap::new(),
+ quota_keys: HashMap::new(),
})
}
- /// Resolve an active profile and prevent generic Ollama connection
- /// settings from being reused by another quota provider.
- fn for_invocation(&self, profile: Option<&str>, provider: Option<Provider>) -> Result<Self> {
+ /// Resolve an 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());
let mut config = match active {
Some(name) => self.with_profile(name)?,
None => self.clone(),
};
- if matches!(
- provider,
- Some(Provider::Anthropic | Provider::OpenAI | Provider::DeepInfra)
- ) {
- let explicit = profile.and_then(|name| self.profiles.get(name));
- config.settings.apikey = explicit.and_then(|settings| settings.apikey.clone());
- config.settings.base_url = explicit.and_then(|settings| settings.base_url.clone());
- }
+ match quota {
+ QuotaTarget::None => {}
+ QuotaTarget::One(provider) => {
+ let explicit_profile = profile.and_then(|name| self.profile(name));
- // deepinfra_apikey is a top-level fallback, so it applies even without
- // an explicit profile.
- if provider == Some(Provider::DeepInfra) {
- config.settings.apikey = config
- .settings
- .apikey
- .clone()
- .or_else(|| self.settings.deepinfra_apikey.clone());
+ // Quota already has a correct 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.
+ config.settings.base_url =
+ explicit_profile.and_then(|settings| settings.base_url.clone());
+
+ let provider_table = self.profile(&provider.to_string());
+ let key = explicit_profile
+ .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);
+ config.settings.apikey = if falls_back_to_chat_key {
+ key.or_else(|| config.settings.apikey.clone())
+ } else {
+ key
+ };
+ }
+ QuotaTarget::All => {
+ // No single key or endpoint serves all providers, so the chat
+ // connection settings never apply. Each provider resolves the
+ // key it would get from an explicit `-p`.
+ config.settings.apikey = None;
+ config.settings.base_url = None;
+
+ let mut keys = HashMap::new();
+ for provider in Provider::ALL {
+ let shaped = self.for_invocation(profile, QuotaTarget::One(provider))?;
+ if let Some(key) = shaped.settings.apikey.filter(|key| !key.is_empty()) {
+ keys.insert(provider, key);
+ }
+ }
+ config.quota_keys = keys;
+ }
}
Ok(config)
.default_profile
.clone()
.or_else(|| self.default_profile.clone()),
- deepinfra_apikey: other
- .deepinfra_apikey
- .clone()
- .or_else(|| self.deepinfra_apikey.clone()),
}
}
}
/// Arguments for the quota subcommand
#[derive(Debug, Default, Builder)]
pub struct QuotaArgs {
- pub(crate) provider: Provider,
+ /// Provider to report, or all providers when unset
#[builder(default)]
+ pub(crate) provider: Option<Provider>,
+ #[builder(default)]
pub(crate) api_key: Option<String>,
#[builder(default)]
pub(crate) base_url: Option<String>,
#[builder(default)]
pub(crate) raw: bool,
pub(crate) color: ColorChoice,
+ /// Credential each provider resolves from the configuration for a report
+ /// over all providers
+ #[builder(default)]
+ pub(crate) quota_keys: HashMap<Provider, String>,
}
/// Arguments for the compose subcommand
.try_get_matches_from(&args)
.unwrap_or_else(|err| err.exit());
let profile = pre_matches.get_one::<String>("profile").cloned();
- let provider = quota_provider(&pre_matches);
+ let quota = quota_target(&pre_matches);
let config = Config::load()?;
- let config = config.for_invocation(profile.as_deref(), provider)?;
+ let config = config.for_invocation(profile.as_deref(), quota)?;
let matches = cli(&config).get_matches_from(args);
Ok(invocation_from_matches(&matches, &config))
fn parse_args_with_config(args: &[&str], config: &Config) -> Result<Invocation> {
let pre_matches = cli(config).try_get_matches_from(args)?;
let profile = pre_matches.get_one::<String>("profile").cloned();
- let provider = quota_provider(&pre_matches);
- let config = config.for_invocation(profile.as_deref(), provider)?;
+ let quota = quota_target(&pre_matches);
+ let config = config.for_invocation(profile.as_deref(), quota)?;
let matches = cli(&config).try_get_matches_from(args)?;
Ok(invocation_from_matches(&matches, &config))
}
-fn quota_provider(matches: &ArgMatches) -> Option<Provider> {
+/// Which provider(s) a quota invocation reports
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
+enum QuotaTarget {
+ /// Not a quota invocation
+ #[default]
+ None,
+ /// The provider chosen with `-p`
+ One(Provider),
+ /// All providers, omitting the unavailable ones
+ All,
+}
+
+fn quota_target(matches: &ArgMatches) -> QuotaTarget {
matches
.subcommand_matches("quota")
- .and_then(|quota| quota.get_one::<Provider>("provider"))
- .copied()
+ .map_or(QuotaTarget::None, |quota| {
+ quota
+ .get_one::<Provider>("provider")
+ .copied()
+ .map_or(QuotaTarget::All, QuotaTarget::One)
+ })
}
/// Arguments shared by the default command and `alpaca chat`
config,
)
.arg(
- arg!(provider: -p --provider <PROVIDER> "Sets usage provider")
+ arg!(provider: -p --provider <PROVIDER> "Sets usage provider (default: all providers)")
.value_parser(value_parser!(Provider))
- .default_value("ollama"),
+ .required(false),
)
.arg(api_key_arg(config))
.arg(base_url_arg(config))
fn invocation_from_matches(matches: &ArgMatches, config: &Config) -> Invocation {
match matches.subcommand() {
- Some(("quota", sub)) => Invocation::Quota(QuotaArgs::from(sub)),
+ Some(("quota", sub)) => Invocation::Quota(quota_args_from(sub, config)),
Some(("compose", sub)) => Invocation::Compose(ComposeArgs::from(sub)),
Some(("chat", sub)) => Invocation::Chat(chat_args_from(sub, config)),
_ => Invocation::Chat(chat_args_from(matches, config)),
}
}
-impl From<&ArgMatches> for QuotaArgs {
- fn from(matches: &ArgMatches) -> Self {
- Self {
- provider: *matches
- .get_one::<Provider>("provider")
- .expect("Provider is required"),
- // Quota takes no API key from the environment: each provider
- // resolves its own credential when the flag is absent.
- api_key: api_key_of(matches),
- base_url: base_url_of(matches),
- timeout: timeout_of(matches),
- output_format: output_format_of(matches),
- raw: matches.get_flag("raw"),
- color: *matches
- .get_one::<ColorChoice>("color")
- .expect("Color is required"),
- }
+fn quota_args_from(matches: &ArgMatches, config: &Config) -> QuotaArgs {
+ QuotaArgs {
+ provider: matches.get_one::<Provider>("provider").copied(),
+ // Quota resolves each provider's own credential when the flag is
+ // absent. A report over all providers takes per-provider keys from
+ // the configuration instead of the chat apikey.
+ api_key: api_key_of(matches),
+ base_url: base_url_of(matches),
+ timeout: timeout_of(matches),
+ output_format: output_format_of(matches),
+ raw: matches.get_flag("raw"),
+ color: *matches
+ .get_one::<ColorChoice>("color")
+ .expect("Color is required"),
+ quota_keys: config.quota_keys.clone(),
}
}
impl ValueEnum for Provider {
fn value_variants<'a>() -> &'a [Self] {
- &[Self::Ollama, Self::Anthropic, Self::OpenAI, Self::DeepInfra]
+ &[
+ Self::Synthetic,
+ Self::Anthropic,
+ Self::OpenAI,
+ Self::DeepInfra,
+ ]
}
fn to_possible_value(&self) -> Option<PossibleValue> {
Some(PossibleValue::new(match self {
- Self::Ollama => "ollama",
+ Self::Synthetic => "synthetic",
Self::Anthropic => "anthropic",
Self::OpenAI => "openai",
Self::DeepInfra => "deepinfra",
#[test]
fn quota_defaults() -> Result<()> {
let args = quota_args(&["alpaca", "quota"])?;
- assert_eq!(args.provider, Provider::Ollama);
+ assert_eq!(args.provider, None, "without -p, all providers are asked");
assert_eq!(args.api_key, None);
assert_eq!(args.base_url, None);
assert_eq!(args.timeout, Duration::from_secs(60));
assert_eq!(args.color, ColorChoice::Auto);
assert!(!args.raw);
assert_eq!(args.output_format, OutputFormat::Plaintext);
+ assert!(args.quota_keys.is_empty());
Ok(())
}
#[test]
+ fn quota_explicit_provider() -> Result<()> {
+ let args = quota_args(&["alpaca", "quota", "-p", "anthropic"])?;
+ assert_eq!(args.provider, Some(Provider::Anthropic));
+ Ok(())
+ }
+
+ #[test]
fn quota_provider_and_flags() -> Result<()> {
let args = quota_args(&[
"alpaca",
"5",
])?;
- assert_eq!(args.provider, Provider::OpenAI);
+ assert_eq!(args.provider, Some(Provider::OpenAI));
assert!(args.raw);
assert_eq!(args.color, ColorChoice::Never);
assert_eq!(args.base_url.as_deref(), Some("http://example.com"));
#[test]
fn quota_alias_q() -> Result<()> {
let args = quota_args(&["alpaca", "q"])?;
- assert_eq!(args.provider, Provider::Ollama);
+ assert_eq!(args.provider, None);
Ok(())
}
blob - 0e152265c75285e4811b85aa8115182ecb8c7a35
blob + 20b0a13b09b09c7dbc840633541993bfeba410a6
--- src/exec/quota.rs
+++ src/exec/quota.rs
//! Implements quota subcommand
//!
-//! Asks a provider how much of the account's quota is used, then renders each
-//! window as a bar with a countdown to the next reset.
+//! Asks providers how much of the account's quota is used, then renders 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 std::collections::BTreeMap;
use std::io::{self, IsTerminal, Write};
-use anyhow::{Context, Result};
+use anyhow::{Context, Result, bail};
use crate::cli::{OutputFormat, QuotaArgs};
-use crate::usage::{self, render::Style};
+use crate::usage::{self, Provider, render::Style};
/// Executes `QuotaArgs`
///
/// # Errors
///
+/// Returns an error if the chosen provider has no credential, the request
+/// fails, or the response cannot be shown. A report over all providers
+/// succeeds even when some providers are unavailable.
+pub async fn exec(args: QuotaArgs) -> Result<()> {
+ match args.provider {
+ Some(provider) => exec_provider(&args, provider).await,
+ None => exec_all_providers(&args).await,
+ }
+}
+
+/// Report one provider, surfacing every failure
+///
+/// # Errors
+///
/// Returns an error if no credential is found, the request fails, or the
/// response cannot be shown.
-pub async fn exec(args: QuotaArgs) -> Result<()> {
- let client = usage::Client::new(args.provider, args.api_key, args.base_url, args.timeout)
- .with_context(|| format!("failed to create {} client", args.provider))?;
-
- let body = client
- .fetch()
- .await
- .with_context(|| format!("failed to fetch {} usage", args.provider))?;
-
+async fn exec_provider(args: &QuotaArgs, provider: Provider) -> Result<()> {
let mut stdout = io::stdout();
if args.raw {
+ let body = fetch(args, provider).await?;
stdout.write_all(&body)?;
if !body.ends_with(b"\n") {
stdout.write_all(b"\n")?;
return Ok(());
}
- let view = args.provider.view(&body)?;
+ let (body, extra) = tokio::join!(fetch(args, provider), fetch_extra(args, provider));
+ let view = provider.view(&body?, extra.as_deref())?;
match args.output_format {
OutputFormat::Plaintext => {
Ok(())
}
+
+/// 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.
+///
+/// # Errors
+///
+/// Returns an error when `--raw` is requested, since a raw body belongs to
+/// one provider.
+async fn exec_all_providers(args: &QuotaArgs) -> Result<()> {
+ if args.raw {
+ bail!("--raw prints one provider's response body; choose the provider with --provider");
+ }
+
+ let (synthetic, anthropic, openai, deepinfra) = tokio::join!(
+ fetch_view(args, Provider::Synthetic),
+ fetch_view(args, Provider::Anthropic),
+ fetch_view(args, Provider::OpenAI),
+ fetch_view(args, Provider::DeepInfra),
+ );
+ let reports = [
+ (Provider::Synthetic, synthetic),
+ (Provider::Anthropic, anthropic),
+ (Provider::OpenAI, openai),
+ (Provider::DeepInfra, deepinfra),
+ ];
+
+ 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)?;
+ }
+ 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}")?;
+ }
+ }
+
+ Ok(())
+}
+
+/// Build the client 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
+ // the key the configuration resolves for this provider alone.
+ let api_key = args
+ .api_key
+ .clone()
+ .or_else(|| args.quota_keys.get(&provider).cloned());
+
+ usage::Client::new(provider, api_key, args.base_url.clone(), args.timeout)
+ .with_context(|| format!("failed to create {provider} client"))
+}
+
+/// Fetch the raw usage response body of one provider
+///
+/// # Errors
+///
+/// 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()
+ .await
+ .with_context(|| format!("failed to fetch {provider} usage"))
+}
+
+/// 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.
+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
+///
+/// # Errors
+///
+/// Returns an error if the request fails or the response cannot be
+/// normalised.
+async fn fetch_view(args: &QuotaArgs, provider: Provider) -> Result<usage::View> {
+ let (body, extra) = tokio::join!(fetch(args, provider), fetch_extra(args, provider));
+ Ok(provider.view(&body?, extra.as_deref())?)
+}
blob - a8f714a2f7dfc74599cef9235561839588ae51b1
blob + ff4a9f8d0a9c0f300245c717122595b3c7a477c4
--- src/usage/anthropic.rs
+++ src/usage/anthropic.rs
spend: Spend,
}
-/// The OAuth token Claude Code writes when you log in
-///
-/// # Errors
-///
-/// Returns an error if the credential file exists but cannot be parsed.
-pub fn token_from_file() -> Result<Option<String>, Error> {
- #[derive(Deserialize)]
- struct Credentials {
- #[serde(rename = "claudeAiOauth")]
- oauth: OAuth,
- }
-
- #[derive(Deserialize)]
- struct OAuth {
- #[serde(rename = "accessToken")]
- access_token: String,
- }
-
- let path = crate::home_dir()?.join(".claude").join(".credentials.json");
- let data = match std::fs::read(path) {
- Ok(data) => data,
- Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
- Err(err) => return Err(err.into()),
- };
-
- let credentials: Credentials = serde_json::from_slice(&data)?;
- Ok(Some(credentials.oauth.access_token).filter(|token| !token.is_empty()))
-}
-
/// Normalise the Claude Code usage response
///
/// The flat `limits` list is preferred, since it names whichever windows the
///
/// Returns an error if the body is not a Claude 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 !["five_hour", "seven_day", "limits", "spend"]
- .iter()
- .any(|key| value.get(key).is_some())
- {
- return Err(Error::UnexpectedResponse(
- "response contains no anthropic usage fields".to_string(),
- ));
- }
- let usage: Usage =
- serde_json::from_value(value).map_err(|err| Error::UnexpectedResponse(err.to_string()))?;
+ let usage: Usage = super::parse_usage(
+ body,
+ "anthropic",
+ &["five_hour", "seven_day", "limits", "spend"],
+ )?;
let mut windows: Vec<Window> = usage
.limits
blob - 9340a12fb201054eeaee820e6b34cab833858ce3
blob + 720aff7ada6a318d1d13c1f51fe88239a7484b22
--- 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 endpoint instead reports the
-//! current month's spend, which becomes the trailing note, and a per-model
-//! breakdown of billed units, mirroring Ollama's per-model request table.
+//! current month's spend, and a per-model breakdown of billed units, shown as
+//! a request table like the other providers. Remaining credit is reported on
+//! a separate billing checklist endpoint, so it is fetched independently and
+//! joins the month's spend in the trailing note.
use serde::Deserialize;
months: Vec<UsageMonth>,
}
+/// The billing checklist's account balance
+///
+/// A negative balance is funds ready to spend; a positive one is money owed.
+#[derive(Debug, Default, Deserialize)]
+struct Checklist {
+ #[serde(default)]
+ stripe_balance: f64,
+}
+
/// Normalise the `DeepInfra` billing usage response
///
+/// `credit` is the body of [`Provider::credit_path`](super::Provider::credit_path),
+/// when the caller fetched one; it only ever adds to the trailing note, so a
+/// missing or unparseable credit body is silently left out rather than
+/// failing the whole view.
+///
/// # Errors
///
-/// Returns an error if the body is not a `DeepInfra` 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 !["months", "initial_month"]
- .iter()
- .any(|key| value.get(key).is_some())
- {
- return Err(Error::UnexpectedResponse(
- "response contains no deepinfra usage fields".to_string(),
- ));
- }
- let usage: Usage =
- serde_json::from_value(value).map_err(|err| Error::UnexpectedResponse(err.to_string()))?;
+/// Returns an error if `body` is not a `DeepInfra` usage response.
+pub fn view(body: &[u8], credit: Option<&[u8]>) -> Result<View, Error> {
+ let usage: Usage = super::parse_usage(body, "deepinfra", &["months", "initial_month"])?;
let month = usage.months.first();
let items = month.map_or([].as_slice(), |month| month.items.as_slice());
+ let spend = month.map(|month| format!("total {}", cents(month.total_cost)));
+ let note = match (spend, credit.and_then(credit_note)) {
+ (Some(spend), Some(credit)) => Some(format!("{spend}, {credit}")),
+ (spend, credit) => spend.or(credit),
+ };
+
Ok(View {
title: "deepinfra usage".to_string(),
subtitle: month
.map(|month| month.period.clone())
.filter(|period| !period.is_empty()),
- note: month.map(|month| format!("total {}", cents(month.total_cost))),
+ note,
windows: vec![],
tables: vec![Table {
heading: "models".to_string(),
}
/// Format a cent amount as a dollar figure
+///
+/// `total_cost` can go negative, for example when a discount outweighs a
+/// month's usage, so the sign is carried separately from the magnitude:
+/// integer division truncates towards zero, which would otherwise drop the
+/// sign whenever the whole-dollar part is zero (`-50` cents would read as
+/// `0.50` instead of `-0.50`).
fn cents(cents: i64) -> String {
- let whole = cents / 100;
+ let sign = if cents < 0 { "-" } else { "" };
+ let whole = cents.abs() / 100;
let fraction = cents.abs() % 100;
- format!("{whole}.{fraction:02} USD")
+ format!("{sign}{whole}.{fraction:02} USD")
}
+/// Describe the remaining credit from a billing checklist response
+///
+/// Returns `None` when `body` is not a checklist response, so a broken or
+/// unexpected credit body never blocks the usage report.
+fn credit_note(body: &[u8]) -> Option<String> {
+ let checklist: Checklist = serde_json::from_slice(body).ok()?;
+ Some(if checklist.stripe_balance <= 0.0 {
+ format!("{:.2} USD credit left", -checklist.stripe_balance)
+ } else {
+ format!("owes {:.2} USD", checklist.stripe_balance)
+ })
+}
+
/// Billed units per model, busiest first
///
/// A model can appear in several items when its pricing changed mid-month, so
"initial_month": "2024.01"
}"#;
- let view = view(body)?;
+ let view = view(body, None)?;
assert_eq!(view.title, "deepinfra usage");
assert_eq!(view.subtitle.as_deref(), Some("2026.09"));
#[test]
fn omits_note_and_subtitle_without_months() -> Result<()> {
- let view = view(br#"{"months": [], "initial_month": "2024.01"}"#)?;
+ let view = view(br#"{"months": [], "initial_month": "2024.01"}"#, None)?;
assert_eq!(view.note, None);
assert_eq!(view.subtitle, None);
#[test]
fn rejects_non_usage_bodies() {
- assert!(view(b"not json").is_err());
- assert!(view(b"{}").is_err());
+ assert!(view(b"not json", None).is_err());
+ assert!(view(b"{}", None).is_err());
}
+
+ #[test]
+ fn a_discount_can_take_the_total_negative() -> Result<()> {
+ let view = view(
+ br#"{
+ "months": [{"period": "2026.09", "interval": {}, "total_cost": -50, "items": []}],
+ "initial_month": "2024.01"
+ }"#,
+ None,
+ )?;
+
+ assert_eq!(
+ view.note.as_deref(),
+ Some("total -0.50 USD"),
+ "the sign must not be lost when the whole-dollar part is zero"
+ );
+ Ok(())
+ }
+
+ #[test]
+ fn a_negative_balance_is_credit_left() -> Result<()> {
+ let body = br#"{"months": [{"period": "2026.09", "total_cost": 1234, "items": []}]}"#;
+ let credit = br#"{"stripe_balance": -50.0}"#;
+
+ let view = view(body, Some(credit))?;
+
+ assert_eq!(
+ view.note.as_deref(),
+ Some("total 12.34 USD, 50.00 USD credit left")
+ );
+ Ok(())
+ }
+
+ #[test]
+ fn a_positive_balance_is_money_owed() -> Result<()> {
+ let credit = br#"{"stripe_balance": 12.5}"#;
+
+ let view = view(br#"{"months": []}"#, Some(credit))?;
+
+ assert_eq!(view.note.as_deref(), Some("owes 12.50 USD"));
+ Ok(())
+ }
+
+ #[test]
+ fn an_unparseable_credit_body_is_silently_dropped() -> Result<()> {
+ let body = br#"{"months": [{"period": "2026.09", "total_cost": 1234, "items": []}]}"#;
+
+ let view = view(body, Some(b"not json"))?;
+
+ assert_eq!(view.note.as_deref(), Some("total 12.34 USD"));
+ Ok(())
+ }
}
blob - ca996b9323cfcf11e3b73e8117ef57362229a2c3
blob + 08a2f64747138b04f37b36172c66d6f2004c50d1
--- src/usage/mod.rs
+++ src/usage/mod.rs
pub mod anthropic;
pub mod deepinfra;
-pub mod ollama;
pub mod openai;
pub mod render;
+pub mod synthetic;
use std::fmt;
use std::time::Duration;
-use chrono::{DateTime, Utc};
+use chrono::{DateTime, Datelike, TimeZone, Utc};
use serde::{Deserialize, Serialize};
use crate::Error;
struct Credential {
token: String,
- account_id: Option<String>,
}
/// Largest response body read from a usage endpoint
const MAX_BODY: usize = 1 << 20;
/// A usage provider
-#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Provider {
#[default]
- Ollama,
+ Synthetic,
Anthropic,
OpenAI,
DeepInfra,
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Table {
pub heading: String,
- /// Name of the counted unit, shown as the column header (e.g. "reqs")
+ /// Name of the counted unit, shown as the column header (for example "reqs")
pub unit: &'static str,
pub rows: Vec<ModelCount>,
}
u64::try_from((at - now).num_seconds()).unwrap_or(0)
}
+/// Unix timestamp of the start of the current UTC month
+///
+/// `OpenAI`'s organization usage and costs endpoints have no "current period"
+/// shortcut, unlike `DeepInfra`'s `from=current`: 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)
+ .single()
+ .map_or(0, |start| start.timestamp())
+}
+
impl Provider {
+ /// All providers, in report order
+ pub const ALL: [Provider; 4] = [
+ Provider::Synthetic,
+ Provider::Anthropic,
+ Provider::OpenAI,
+ Provider::DeepInfra,
+ ];
+
/// Default base URL, honouring any environment override
#[must_use]
pub fn base_url(self) -> String {
match self {
- Self::Ollama => std::env::var("OLLAMA_API_ENDPOINT")
- .ok()
- .filter(|url| !url.is_empty())
- .unwrap_or_else(|| "https://ollama.com".to_string()),
+ Self::Synthetic => "https://api.synthetic.new".to_string(),
Self::Anthropic => "https://api.anthropic.com".to_string(),
- Self::OpenAI => "https://chatgpt.com".to_string(),
+ Self::OpenAI => "https://api.openai.com".to_string(),
Self::DeepInfra => "https://api.deepinfra.com".to_string(),
}
}
#[must_use]
pub fn path(self) -> &'static str {
match self {
- Self::Ollama => "/api/usage",
+ Self::Synthetic => "/v2/quotas",
Self::Anthropic => "/api/oauth/usage",
- Self::OpenAI => "/backend-api/codex/usage",
+ // The organization's total spend for the query window. `start_time`
+ // and `limit` are appended per request, since they need today's date.
+ Self::OpenAI => "/v1/organization/costs?bucket_width=1d",
// `from=current` selects the current billing month.
Self::DeepInfra => "/payment/usage?from=current",
}
}
+ /// Path of a second endpoint some providers need alongside the primary
+ /// one at [`path`](Self::path): `DeepInfra`'s remaining credit, reported
+ /// separately from usage, and `OpenAI`'s per-model token breakdown, which
+ /// its costs endpoint lacks
+ #[must_use]
+ pub fn extra_path(self) -> Option<&'static str> {
+ match self {
+ Self::OpenAI => {
+ Some("/v1/organization/usage/completions?bucket_width=1d&group_by=model")
+ }
+ Self::DeepInfra => Some("/payment/checklist"),
+ Self::Synthetic | Self::Anthropic => None,
+ }
+ }
+
/// Whether the credential is sent as a bearer token
#[must_use]
pub fn bearer(self) -> bool {
- matches!(self, Self::Anthropic | Self::OpenAI | Self::DeepInfra)
+ matches!(
+ self,
+ Self::Synthetic | Self::Anthropic | Self::OpenAI | Self::DeepInfra
+ )
}
/// The credential this provider uses when none is given on the command line
///
+ /// `Anthropic` and `OpenAI` have no default credential: `Anthropic`'s
+ /// endpoint serves the Claude Code CLI, so alpaca only asks it with an
+ /// explicit key, and `OpenAI`'s organization API always requires one.
+ ///
/// # Errors
///
- /// Returns [`Error::NoCredentials`] when no environment variable is set and
- /// the vendor CLI's credential file is missing or malformed.
+ /// Returns an error when the provider has no environment variable set.
fn credential(self) -> Result<Credential, Error> {
let from_env = |name: &str| std::env::var(name).ok().filter(|key| !key.is_empty());
- let credential = match self {
- Self::Ollama => from_env("OLLAMA_API_KEY").map(|token| Credential {
- token,
- account_id: None,
- }),
- Self::Anthropic => from_env("CLAUDE_CODE_OAUTH_TOKEN")
- .or_else(|| anthropic::token_from_file().ok().flatten())
- .map(|token| Credential {
- token,
- account_id: None,
- }),
- Self::OpenAI => from_env("CODEX_ACCESS_TOKEN")
- .map(|token| Credential {
- token,
- account_id: None,
- })
- .or_else(|| {
- openai::credential_from_file()
- .ok()
- .flatten()
- .map(|(token, account_id)| Credential { token, account_id })
- }),
- Self::DeepInfra => from_env("DEEPINFRA_API_KEY").map(|token| Credential {
- token,
- account_id: None,
- }),
+ let token = match self {
+ Self::Synthetic => from_env("SYNTHETIC_API_KEY"),
+ Self::DeepInfra => from_env("DEEPINFRA_API_KEY"),
+ Self::Anthropic | Self::OpenAI => None,
};
- credential.ok_or(Error::NoCredentials { provider: self })
+ token
+ .map(|token| Credential { token })
+ .ok_or(Error::NoCredentials { provider: self })
}
- /// Normalise a usage response body
+ /// Normalise a usage response body, plus an extra body from
+ /// [`extra_path`](Self::extra_path) when the caller fetched one
///
/// # Errors
///
- /// Returns an error if the body is not the JSON this provider reports.
- pub fn view(self, body: &[u8]) -> Result<View, Error> {
+ /// Returns an error if `body` is not the JSON this provider reports. An
+ /// extra body that fails to parse is ignored rather than erroring the
+ /// whole view, since it is supplementary to the usage report.
+ pub fn view(self, body: &[u8], extra: Option<&[u8]>) -> Result<View, Error> {
match self {
- Self::Ollama => ollama::view(body),
+ Self::Synthetic => synthetic::view(body),
Self::Anthropic => anthropic::view(body),
- Self::OpenAI => openai::view(body),
- Self::DeepInfra => deepinfra::view(body),
+ Self::OpenAI => openai::view(body, extra),
+ Self::DeepInfra => deepinfra::view(body, extra),
}
}
/// Build the HTTP client this provider needs
- ///
- /// The Codex endpoint sits behind a bot filter that answers Go-style and
- /// modern Rust clients with a challenge page instead of JSON: it rejects
- /// HTTP/2 and TLS 1.3 handshakes. That client therefore speaks HTTP/1.1
- /// over TLS 1.2. No compression feature is enabled for reqwest, so no
- /// `Accept-Encoding` header is sent either, which the same filter requires.
fn http_client(self) -> Result<reqwest::Client, Error> {
let agent = match self {
Self::Anthropic => "claude-cli (external, cli)".to_string(),
_ => user_agent(),
};
- let builder = reqwest::Client::builder().user_agent(agent);
- let builder = match self {
- Self::OpenAI => builder
- .http1_only()
- .tls_version_max(reqwest::tls::Version::TLS_1_2),
- _ => builder,
- };
- Ok(builder.build()?)
+ Ok(reqwest::Client::builder().user_agent(agent).build()?)
}
}
+/// Parse a usage response body as JSON
+///
+/// Shared by every provider module, which otherwise each repeated the same
+/// `map_err` wrapping.
+///
+/// # Errors
+///
+/// Returns [`Error::UnexpectedResponse`] if `body` is not JSON.
+fn parse_json(body: &[u8]) -> Result<serde_json::Value, Error> {
+ serde_json::from_slice(body).map_err(|err| Error::UnexpectedResponse(err.to_string()))
+}
+
+/// Convert a JSON value into `T`
+///
+/// # Errors
+///
+/// Returns [`Error::UnexpectedResponse`] if `value` does not match `T`.
+fn from_json<T: serde::de::DeserializeOwned>(value: serde_json::Value) -> Result<T, Error> {
+ 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
+/// `expected_keys` at the top level
+///
+/// Every provider whose whole response maps onto one struct follows this
+/// shape: parse the body, reject one from the wrong endpoint by checking it
+/// carries a field this provider actually reports, then deserialise it
+/// properly. `provider` names the endpoint in the resulting error.
+///
+/// # Errors
+///
+/// Returns [`Error::UnexpectedResponse`] if `body` is not JSON, if none of
+/// `expected_keys` is present, or if the JSON does not match `T`.
+fn parse_usage<T: serde::de::DeserializeOwned>(
+ body: &[u8],
+ provider: &str,
+ expected_keys: &[&str],
+) -> Result<T, Error> {
+ let value = parse_json(body)?;
+ if !expected_keys.iter().any(|key| value.get(key).is_some()) {
+ return Err(Error::UnexpectedResponse(format!(
+ "response contains no {provider} usage fields"
+ )));
+ }
+ from_json(value)
+}
+
impl fmt::Display for Provider {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let name = match self {
- Self::Ollama => "ollama",
+ Self::Synthetic => "synthetic",
Self::Anthropic => "anthropic",
Self::OpenAI => "openai",
Self::DeepInfra => "deepinfra",
provider: Provider,
base_url: String,
api_key: String,
- account_id: Option<String>,
timeout: Duration,
}
timeout: Duration,
) -> Result<Self, Error> {
let credential = match api_key {
- Some(token) => Credential {
- token,
- account_id: None,
- },
+ Some(token) => Credential { token },
None => provider.credential()?,
};
provider,
base_url: base_url.unwrap_or_else(|| provider.base_url()),
api_key: credential.token,
- account_id: credential.account_id,
timeout,
})
}
/// Returns an error if the request fails or the endpoint answers with a
/// non-success status.
pub async fn fetch(&self) -> Result<Vec<u8>, Error> {
+ self.get(self.provider.path()).await
+ }
+
+ /// Fetch the provider's extra body from [`Provider::extra_path`], if it
+ /// has one
+ ///
+ /// This is supplementary to the usage report rather than its point, so a
+ /// missing endpoint, a failed request, or an unparseable body are never
+ /// an error here: they just leave it unset for the caller.
+ pub async fn fetch_extra(&self) -> Option<Vec<u8>> {
+ self.get(self.provider.extra_path()?).await.ok()
+ }
+
+ async fn get(&self, path: &str) -> Result<Vec<u8>, Error> {
let credential = if self.provider.bearer() {
format!("Bearer {}", self.api_key)
} else {
let mut request = self
.http
- .get(self.endpoint())
+ .get(self.endpoint(path))
.timeout(self.timeout)
.header("Authorization", credential)
.header("Accept", "application/json");
if self.provider == Provider::Anthropic {
request = request.header("anthropic-beta", "oauth-2025-04-20");
}
- if let Some(account_id) = &self.account_id {
- request = request.header("ChatGPT-Account-Id", account_id);
+
+ if self.provider == Provider::OpenAI {
+ // The organization usage and costs endpoints require an explicit
+ // start of the query window; there is no "current period" shortcut
+ // like DeepInfra's, so alpaca computes the start of the current
+ // UTC month itself. `limit` covers every day since then in one
+ // page: 31 is the maximum either endpoint allows per day-bucketed
+ // page, and also the most days a month ever has.
+ request = request.query(&[
+ ("start_time", month_start_unix().to_string()),
+ ("limit", "31".to_string()),
+ ]);
}
let resp = request.send().await?;
}
}
- fn endpoint(&self) -> String {
- format!(
- "{}{}",
- self.base_url.trim_end_matches('/'),
- self.provider.path()
- )
+ fn endpoint(&self, path: &str) -> String {
+ format!("{}{}", self.base_url.trim_end_matches('/'), path)
}
}
#[test]
fn endpoints_join_cleanly() -> Result<(), Error> {
let client = Client::new(
- Provider::Ollama,
+ Provider::Synthetic,
Some("key".to_string()),
Some("http://example.com/".to_string()),
Duration::from_secs(1),
)?;
- assert_eq!(client.endpoint(), "http://example.com/api/usage");
+ assert_eq!(
+ client.endpoint(client.provider.path()),
+ "http://example.com/v2/quotas"
+ );
Ok(())
}
#[test]
fn provider_display_matches_flag_values() {
- assert_eq!(Provider::Ollama.to_string(), "ollama");
+ assert_eq!(Provider::Synthetic.to_string(), "synthetic");
assert_eq!(Provider::Anthropic.to_string(), "anthropic");
assert_eq!(Provider::OpenAI.to_string(), "openai");
assert_eq!(Provider::DeepInfra.to_string(), "deepinfra");
}
#[test]
- fn only_oauth_providers_use_bearer() {
- assert!(!Provider::Ollama.bearer());
+ fn all_providers_use_bearer() {
+ assert!(Provider::Synthetic.bearer());
assert!(Provider::Anthropic.bearer());
assert!(Provider::OpenAI.bearer());
assert!(Provider::DeepInfra.bearer());
blob - 7edd3c721a6b7ae084eb5b2dc8b9b97b1bb07917 (mode 644)
blob + /dev/null
--- src/usage/ollama.rs
+++ /dev/null
-//! 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 5h from the epoch, weekly resets are offset
-/// by 4 days so all accounts share the same boundary. 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),
- },
- ],
- }
-}
-
-/// The next boundary of a window of length `period`, whose boundaries are
-/// 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 - 416d9d517bd6246ab07f8d5dd6d96a468b3fc895
blob + 62d89ff421a16a4ab7e7654aefe8d59a00ec88f2
--- src/usage/openai.rs
+++ src/usage/openai.rs
-//! Codex CLI usage
+//! `OpenAI` organization usage
//!
-//! The endpoint serves the Codex CLI. It is not a documented public API, so it
-//! may change without notice.
+//! 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
+//! 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.
-use chrono::{DateTime, Utc};
use serde::Deserialize;
-use super::{View, Window};
+use super::{ModelCount, Table, View};
use crate::Error;
-/// Seconds in the session window Codex reports
-const SESSION_SECS: i64 = 5 * 3600;
-/// Seconds in the weekly window Codex reports
-const WEEKLY_SECS: i64 = 7 * 86400;
+#[derive(Debug, Default, Deserialize)]
+struct CostAmount {
+ #[serde(default)]
+ value: f64,
+}
-/// One rate limit window, with the used share in percent
-#[derive(Debug, Deserialize)]
-struct RateWindow {
+#[derive(Debug, Default, Deserialize)]
+struct CostResult {
#[serde(default)]
- used_percent: f64,
+ amount: CostAmount,
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct CostBucket {
#[serde(default)]
- limit_window_seconds: i64,
- #[serde(default)]
- reset_at: i64,
+ results: Vec<CostResult>,
}
#[derive(Debug, Default, Deserialize)]
-struct RateLimit {
+struct Costs {
#[serde(default)]
- primary_window: Option<RateWindow>,
- #[serde(default)]
- secondary_window: Option<RateWindow>,
+ data: Vec<CostBucket>,
}
#[derive(Debug, Default, Deserialize)]
-struct Credits {
+struct CompletionsResult {
#[serde(default)]
- balance: String,
+ model: Option<String>,
+ /// Billed input tokens, including cached ones
#[serde(default)]
- unlimited: bool,
+ input_tokens: u64,
+ #[serde(default)]
+ output_tokens: u64,
}
-#[derive(Debug, Deserialize)]
-struct Usage {
- #[serde(rename = "plan_type", default)]
- plan: String,
+#[derive(Debug, Default, Deserialize)]
+struct CompletionsBucket {
#[serde(default)]
- rate_limit: RateLimit,
- #[serde(default)]
- credits: Credits,
+ results: Vec<CompletionsResult>,
}
-/// The OAuth token and account ID the Codex CLI writes when you log in
-///
-/// # Errors
-///
-/// Returns an error if the credential file exists but cannot be parsed.
-pub fn credential_from_file() -> Result<Option<(String, Option<String>)>, Error> {
- #[derive(Deserialize)]
- struct Auth {
- tokens: Tokens,
- }
-
- #[derive(Deserialize)]
- struct Tokens {
- access_token: String,
- #[serde(default)]
- account_id: Option<String>,
- }
-
- let path = crate::home_dir()?.join(".codex").join("auth.json");
- let data = match std::fs::read(path) {
- Ok(data) => data,
- Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
- Err(err) => return Err(err.into()),
- };
-
- let auth: Auth = serde_json::from_slice(&data)?;
- let token = auth.tokens.access_token;
- let account_id = auth.tokens.account_id.filter(|id| !id.is_empty());
- Ok((!token.is_empty()).then_some((token, account_id)))
+#[derive(Debug, Default, Deserialize)]
+struct Completions {
+ #[serde(default)]
+ data: Vec<CompletionsBucket>,
}
-/// Normalise the Codex usage response
+/// 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
+/// a missing or unparseable body is silently left out rather than failing
+/// the whole view.
+///
/// # Errors
///
-/// Returns an error if the body is not a Codex 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 !["plan_type", "rate_limit", "credits"]
+/// Returns an error if `body` is not an `OpenAI` organization costs response.
+pub fn view(body: &[u8], completions: Option<&[u8]>) -> Result<View, Error> {
+ let costs: Costs = super::parse_usage(body, "openai", &["data", "object"])?;
+
+ // `Sum for f64` returns `-0.0` over an empty iterator, which formats as
+ // "-0.00": adding `0.0` normalises that back to a plain zero, since
+ // `-0.0 + 0.0 == 0.0` under IEEE 754.
+ let total: f64 = costs
+ .data
.iter()
- .any(|key| value.get(key).is_some())
- {
- return Err(Error::UnexpectedResponse(
- "response contains no openai usage fields".to_string(),
- ));
- }
- let usage: Usage =
- serde_json::from_value(value).map_err(|err| Error::UnexpectedResponse(err.to_string()))?;
+ .flat_map(|bucket| &bucket.results)
+ .map(|result| result.amount.value)
+ .sum::<f64>()
+ + 0.0;
- let windows = [
- usage.rate_limit.primary_window.as_ref(),
- usage.rate_limit.secondary_window.as_ref(),
- ]
- .into_iter()
- .flatten()
- .map(|window| {
- Window::new(
- window_name(window.limit_window_seconds),
- window.used_percent / 100.0,
- DateTime::<Utc>::from_timestamp(window.reset_at, 0),
- )
- })
- .collect();
+ let rows = completions
+ .and_then(|body| serde_json::from_slice::<Completions>(body).ok())
+ .map(|usage| by_model(&usage))
+ .unwrap_or_default();
- let note = if usage.credits.unlimited {
- Some("credits unlimited".to_string())
- } else if usage.credits.balance.is_empty() {
- None
- } else {
- Some(format!("credits {}", usage.credits.balance))
- };
-
Ok(View {
- title: "codex usage".to_string(),
- subtitle: Some(usage.plan).filter(|plan| !plan.is_empty()),
- note,
- windows,
- tables: vec![],
+ title: "openai usage".to_string(),
+ subtitle: Some(month_label()),
+ note: Some(format!("total {total:.2} USD")),
+ windows: vec![],
+ tables: vec![Table {
+ heading: "models".to_string(),
+ unit: "tokens",
+ rows,
+ }],
})
}
-/// Name a quota window of `secs` seconds
-fn window_name(secs: i64) -> String {
- match secs {
- SESSION_SECS => "session".to_string(),
- WEEKLY_SECS => "weekly".to_string(),
- secs if secs >= 86400 => format!("{}d", secs / 86400),
- secs => format!("{}h", secs / 3600),
+/// The current UTC month, as `YYYY-MM`, matching the query window `alpaca`
+/// asks the endpoints for
+fn month_label() -> String {
+ chrono::Utc::now().format("%Y-%m").to_string()
+}
+
+/// Billed tokens per model, busiest first
+///
+/// A model can appear in several buckets, one per day, so its tokens are
+/// summed across the whole month before sorting.
+fn by_model(usage: &Completions) -> Vec<ModelCount> {
+ let mut totals: Vec<ModelCount> = Vec::new();
+ for result in usage.data.iter().flat_map(|bucket| &bucket.results) {
+ let Some(name) = result.model.clone().filter(|name| !name.is_empty()) else {
+ continue;
+ };
+ let tokens = result.input_tokens + result.output_tokens;
+ match totals.iter_mut().find(|count| count.name == name) {
+ Some(count) => count.requests += tokens,
+ None => totals.push(ModelCount {
+ name,
+ requests: tokens,
+ }),
+ }
}
+ totals.sort_by_key(|count| std::cmp::Reverse(count.requests));
+ totals
}
#[cfg(test)]
use anyhow::Result;
#[test]
- fn reads_windows_and_credits() -> Result<()> {
- let body = br#"{
- "plan_type": "pro",
- "rate_limit": {
- "primary_window": {"used_percent": 12.5, "limit_window_seconds": 18000, "reset_at": 4102444800},
- "secondary_window": {"used_percent": 80, "limit_window_seconds": 604800, "reset_at": 4102444800}
- },
- "credits": {"balance": "3.50"}
+ fn reads_the_months_total_spend_and_model_breakdown() -> Result<()> {
+ let costs = br#"{
+ "object": "page",
+ "data": [
+ {"object": "bucket", "start_time": 0, "end_time": 1, "results": [
+ {"object": "organization.costs.result", "amount": {"value": 1.5, "currency": "usd"}}
+ ]},
+ {"object": "bucket", "start_time": 1, "end_time": 2, "results": [
+ {"object": "organization.costs.result", "amount": {"value": 2.25, "currency": "usd"}}
+ ]}
+ ],
+ "has_more": false,
+ "next_page": null
}"#;
+ let completions = br#"{
+ "object": "page",
+ "data": [
+ {"object": "bucket", "start_time": 0, "end_time": 1, "results": [
+ {"object": "organization.usage.completions.result", "input_tokens": 100, "output_tokens": 20, "num_model_requests": 3, "model": "gpt-4o-mini"}
+ ]},
+ {"object": "bucket", "start_time": 1, "end_time": 2, "results": [
+ {"object": "organization.usage.completions.result", "input_tokens": 50, "output_tokens": 10, "num_model_requests": 1, "model": "gpt-4o-mini"},
+ {"object": "organization.usage.completions.result", "input_tokens": 900, "output_tokens": 100, "num_model_requests": 2, "model": "gpt-5"}
+ ]}
+ ],
+ "has_more": false,
+ "next_page": null
+ }"#;
- let view = view(body)?;
+ let view = view(costs, Some(completions))?;
- assert_eq!(view.title, "codex usage");
- assert_eq!(view.subtitle.as_deref(), Some("pro"));
- assert_eq!(view.windows.len(), 2);
- assert_eq!(view.windows[0].name, "session");
- assert!((view.windows[0].used - 0.125).abs() < f64::EPSILON);
- assert_eq!(view.windows[1].name, "weekly");
- assert_eq!(view.note.as_deref(), Some("credits 3.50"));
- Ok(())
- }
-
- #[test]
- fn unlimited_credits_win_over_a_balance() -> Result<()> {
- let body = br#"{"credits": {"balance": "3.50", "unlimited": true}}"#;
-
- let view = view(body)?;
-
- assert_eq!(view.note.as_deref(), Some("credits unlimited"));
+ assert_eq!(view.title, "openai usage");
+ assert_eq!(
+ view.subtitle,
+ Some(chrono::Utc::now().format("%Y-%m").to_string())
+ );
+ assert_eq!(view.note.as_deref(), Some("total 3.75 USD"));
assert!(view.windows.is_empty());
- assert_eq!(view.subtitle, None, "an empty plan is not rendered");
+ assert_eq!(view.tables[0].heading, "models");
+ assert_eq!(view.tables[0].unit, "tokens");
+ assert_eq!(
+ view.tables[0].rows,
+ vec![
+ ModelCount {
+ name: "gpt-5".to_string(),
+ requests: 1000
+ },
+ ModelCount {
+ name: "gpt-4o-mini".to_string(),
+ requests: 180
+ },
+ ],
+ "tokens for the same model are summed across buckets, then sorted busiest first"
+ );
Ok(())
}
#[test]
- fn omits_note_without_credits() -> Result<()> {
- let view = view(br#"{"plan_type": "plus"}"#)?;
+ fn reports_zero_spend_without_erroring() -> Result<()> {
+ let body = br#"{"object": "page", "data": [], "has_more": false, "next_page": null}"#;
- assert_eq!(view.note, None);
+ let view = view(body, None)?;
+
+ assert_eq!(view.note.as_deref(), Some("total 0.00 USD"));
+ assert!(view.tables[0].rows.is_empty());
Ok(())
}
#[test]
+ fn an_unparseable_completions_body_is_silently_dropped() -> Result<()> {
+ let body = br#"{"object": "page", "data": [], "has_more": false, "next_page": null}"#;
+
+ let view = view(body, Some(b"not json"))?;
+
+ assert!(view.tables[0].rows.is_empty());
+ Ok(())
+ }
+
+ #[test]
fn rejects_non_usage_bodies() {
- assert!(view(b"not json").is_err());
- assert!(view(b"{}").is_err());
+ assert!(view(b"not json", None).is_err());
+ assert!(view(b"{}", None).is_err());
}
-
- #[test]
- fn window_names_cover_known_and_unknown_lengths() {
- assert_eq!(window_name(18000), "session");
- assert_eq!(window_name(604800), "weekly");
- assert_eq!(window_name(2 * 86400), "2d");
- assert_eq!(window_name(3600), "1h");
- }
}
blob - 9792cdefe7306a63fd9505a433b040f9ac390aed
blob + d9688649e8cfe2e980e1bc8aef19cf4f1b79b93a
--- src/usage/render.rs
+++ src/usage/render.rs
Ok(())
}
+/// Write several rendered views, one blank line between them
+///
+/// # Errors
+///
+/// Returns an error if writing to `dest` fails.
+pub fn render_all(mut dest: impl Write, views: &[&View], style: Style) -> Result<(), Error> {
+ let blocks = views
+ .iter()
+ .map(|view| {
+ let mut block = Vec::new();
+ render(&mut block, view, style)?;
+ while block.last() == Some(&b'\n') {
+ block.pop();
+ }
+ Ok(block)
+ })
+ .collect::<Result<Vec<_>, Error>>()?;
+
+ for (index, block) in blocks.iter().enumerate() {
+ if index > 0 {
+ dest.write_all(b"\n\n")?;
+ }
+ dest.write_all(block)?;
+ }
+ if !blocks.is_empty() {
+ dest.write_all(b"\n")?;
+ }
+ Ok(())
+}
+
/// One quota bar, showing the used share of a window
fn window_row(window: &Window, label: usize, style: Style) -> String {
let color = used_color(window.used);
assert!(!out.contains("reqs"), "no table without rows: {out}");
Ok(())
}
+
+ #[test]
+ fn several_views_are_separated_by_one_blank_line() -> Result<()> {
+ let second = View {
+ title: "second".to_string(),
+ ..View::default()
+ };
+ let mut out = Vec::new();
+ render_all(
+ &mut out,
+ &[&view(), &second],
+ Style::new(ColorChoice::Never, false),
+ )?;
+ let out = String::from_utf8(out)?;
+
+ assert!(out.contains("\n\nsecond"), "one blank line between: {out}");
+ assert!(!out.contains("\n\n\nsecond"), "no doubled separator: {out}");
+ Ok(())
+ }
+
+ #[test]
+ fn no_views_write_nothing() -> Result<()> {
+ let mut out = Vec::new();
+ render_all(&mut out, &[], Style::new(ColorChoice::Never, false))?;
+ let out = String::from_utf8(out)?;
+
+ assert_eq!(out, "");
+ Ok(())
+ }
}
blob - /dev/null
blob + 2dbf6f76a7cad374bbbd97bf3eab3707ecfaf17f (mode 644)
--- /dev/null
+++ src/usage/synthetic.rs
+//! Synthetic usage
+
+use serde::Deserialize;
+
+use super::{View, Window, timestamp};
+use crate::Error;
+
+#[derive(Debug, Default, Deserialize)]
+struct Subscription {
+ #[serde(default)]
+ limit: f64,
+ #[serde(default)]
+ requests: f64,
+ #[serde(default, rename = "renewsAt")]
+ renews_at: Option<String>,
+}
+
+/// The weekly credit quota, reported only by subscriptions
+#[derive(Debug, Default, Deserialize)]
+struct WeeklyLimit {
+ /// Share of the weekly credits still available, from 0 to 100
+ #[serde(default = "default_full", rename = "percentRemaining")]
+ percent_remaining: f64,
+ #[serde(rename = "remainingCredits")]
+ remaining_credits: Option<String>,
+ #[serde(rename = "maxCredits")]
+ max_credits: Option<String>,
+ /// When the next batch of credits regenerates
+ #[serde(rename = "nextRegenAt")]
+ next_regen_at: Option<String>,
+}
+
+fn default_full() -> f64 {
+ 100.0
+}
+
+/// Normalise the Synthetic usage response
+///
+/// # Errors
+///
+/// Returns an error if the body is not a Synthetic usage response.
+pub fn view(body: &[u8]) -> Result<View, Error> {
+ let value = super::parse_json(body)?;
+ let Some(subscription) = value.get("subscription") else {
+ return Err(Error::UnexpectedResponse(
+ "response contains no synthetic usage fields".to_string(),
+ ));
+ };
+ let subscription: Subscription = super::from_json(subscription.clone())?;
+ let weekly = value
+ .get("weeklyTokenLimit")
+ .cloned()
+ .and_then(|raw| super::from_json(raw).ok());
+
+ Ok(build(&subscription, weekly))
+}
+
+fn build(subscription: &Subscription, weekly: Option<WeeklyLimit>) -> View {
+ let used = if subscription.limit > 0.0 {
+ subscription.requests / subscription.limit
+ } else {
+ 0.0
+ };
+
+ let mut windows = vec![Window::new(
+ "subscription",
+ used,
+ timestamp(subscription.renews_at.as_deref()),
+ )];
+ let note = weekly.map(|weekly| {
+ // The provider regenerates credits in small increments, so
+ // `nextRegenAt` is the next top-up, not the full reset.
+ windows.push(Window::new(
+ "weekly credits",
+ ((100.0 - weekly.percent_remaining) / 100.0).clamp(0.0, 1.0),
+ timestamp(weekly.next_regen_at.as_deref()),
+ ));
+ match (weekly.remaining_credits, weekly.max_credits) {
+ (Some(remaining), Some(max)) => {
+ format!("{remaining} of {max} weekly credits left")
+ }
+ (Some(remaining), None) => format!("{remaining} weekly credits left"),
+ (None, _) => "weekly credits in use".to_string(),
+ }
+ });
+
+ View {
+ title: "synthetic usage".to_string(),
+ subtitle: Some(format!(
+ "{:.0}/{:.0} requests",
+ subscription.requests, subscription.limit
+ )),
+ note,
+ windows,
+ tables: vec![],
+ }
+}
+
+#[cfg(test)]
+mod test {
+ use super::*;
+ use anyhow::Result;
+
+ const BODY: &[u8] = br#"{
+ "subscription": {
+ "limit": 135,
+ "requests": 34,
+ "renewsAt": "2025-09-21T14:36:14.288Z"
+ }
+ }"#;
+
+ #[test]
+ fn reads_the_subscription_window() -> Result<()> {
+ let view = view(BODY)?;
+
+ assert_eq!(view.title, "synthetic usage");
+ assert_eq!(view.subtitle.as_deref(), Some("34/135 requests"));
+ assert_eq!(view.note, None);
+ assert_eq!(view.windows.len(), 1);
+ assert_eq!(view.windows[0].name, "subscription");
+ assert!((view.windows[0].used - 34.0 / 135.0).abs() < f64::EPSILON);
+ assert!(view.windows[0].resets_in_secs.is_some());
+ assert!(view.tables.is_empty());
+ Ok(())
+ }
+
+ const WEEKLY_BODY: &[u8] = br#"{
+ "subscription": {
+ "limit": 500,
+ "requests": 0,
+ "renewsAt": "2026-09-06T02:39:36.062Z"
+ },
+ "weeklyTokenLimit": {
+ "nextRegenAt": "2026-09-05T21:45:04.000Z",
+ "percentRemaining": 27.877385916666668,
+ "maxCredits": "$24.00",
+ "remainingCredits": "$6.69",
+ "nextRegenCredits": "$0.48"
+ }
+ }"#;
+
+ #[test]
+ fn reads_the_weekly_credit_window() -> Result<()> {
+ let view = view(WEEKLY_BODY)?;
+
+ assert_eq!(view.windows.len(), 2);
+ assert_eq!(view.windows[1].name, "weekly credits");
+ let expected = (100.0 - 27.877_385_916_666_668) / 100.0;
+ assert!((view.windows[1].used - expected).abs() < f64::EPSILON);
+ assert!(view.windows[1].resets_in_secs.is_some());
+ assert_eq!(
+ view.note.as_deref(),
+ Some("$6.69 of $24.00 weekly credits left")
+ );
+ Ok(())
+ }
+
+ #[test]
+ fn weekly_defaults_to_full_and_tolerates_missing_credits() -> Result<()> {
+ let view = view(br#"{"subscription": {}, "weeklyTokenLimit": {}}"#)?;
+
+ assert_eq!(view.windows[1].used, 0.0);
+ assert_eq!(view.windows[1].resets_in_secs, None);
+ assert_eq!(view.note.as_deref(), Some("weekly credits in use"));
+ Ok(())
+ }
+
+ #[test]
+ fn zero_limit_does_not_divide_by_zero() -> Result<()> {
+ let view = view(br#"{"subscription": {"limit": 0, "requests": 0}}"#)?;
+
+ assert!((view.windows[0].used - 0.0).abs() < f64::EPSILON);
+ assert_eq!(view.windows[0].resets_in_secs, None);
+ Ok(())
+ }
+
+ #[test]
+ fn rejects_non_usage_bodies() {
+ assert!(view(b"not json").is_err());
+ assert!(view(b"{}").is_err());
+ }
+}
blob - c0c5d2d74dcc99af46eed8d8e2bda1cea623dc32
blob + 71c9be65f7c2444532d0e3ae1519b8ad61615cf5
--- tests/chat.rs
+++ tests/chat.rs
/// A canned successful native `/api/chat` response body
fn ok_body() -> &'static str {
r#"{
- "model": "gpt-oss:120b",
+ "model": "gpt-4o-mini",
"created_at": "2025-10-17T23:14:07.414671Z",
"message": {
"role": "assistant",
.with_header("content-type", "application/json")
.with_header("authorization", "Bearer ABCDE")
.match_body(mockito::Matcher::PartialJson(json!({
- "model": "gpt-oss:120b",
+ "model": "gpt-4o-mini",
"stream": false,
"messages": [{
"role": "user",
.with_header("content-type", "application/json")
.with_header("authorization", "Bearer ABCDE")
.match_body(mockito::Matcher::PartialJson(json!({
- "model": "gpt-oss:120b",
+ "model": "gpt-4o-mini",
"stream": false,
"messages": [{
"role": "user",
.with_header("content-type", "application/json")
.with_header("authorization", "Bearer ABCDE")
.match_body(mockito::Matcher::PartialJson(json!({
- "model": "gpt-oss:120b",
+ "model": "gpt-4o-mini",
"think": "medium",
"messages": [{
"role": "user",
.with_header("content-type", "application/json")
.with_header("authorization", "Bearer ABCDE")
.match_body(mockito::Matcher::PartialJson(json!({
- "model": "gpt-oss:120b",
+ "model": "gpt-4o-mini",
"messages": [{
"role": "system",
"content": "SYSTEM"
.with_header("content-type", "application/json")
.with_header("authorization", "Bearer ABCDE")
.match_body(mockito::Matcher::PartialJson(json!({
- "model": "gpt-oss:120b",
+ "model": "gpt-4o-mini",
"messages": [{
"role": "user",
"content": "USER"
.mock("POST", "/v1/chat/completions")
.with_header("content-type", "application/json")
.match_body(mockito::Matcher::PartialJson(json!({
- "model": "gpt-oss:120b",
+ "model": "gpt-4o-mini",
"temperature": 0.5,
"reasoning_effort": "high",
"messages": [{
r#"{
"id": "chatcmpl-1",
"created": 1760742847,
- "model": "gpt-oss:120b",
+ "model": "gpt-4o-mini",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "ASSISTANT REPLY"},
.with_header("content-type", "application/json")
.with_header("authorization", "Bearer ABCDE")
.match_body(mockito::Matcher::PartialJson(json!({
- "model": "gpt-oss:120b",
+ "model": "gpt-4o-mini",
"messages": [{
"role": "user",
"content": "Hello from file"
blob - 4b686f032d9b144b55be19d40cf878adfedcc433
blob + 1c35062955e4bf8db101bab3e0c3fbaac60d6367
--- tests/quota.rs
+++ tests/quota.rs
static ISOLATED_CONFIG_HOME: LazyLock<TempDir> =
LazyLock::new(|| TempDir::new().expect("failed to create isolated config dir"));
-/// A canned successful Ollama `/api/usage` response body
+/// A canned successful Synthetic `/v2/quotas` response body
fn ok_body() -> &'static str {
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": "gpt-oss:120b", "request_count": 9}]},
- "weekly": {"usage": 0.25, "models": []}
+ "subscription": {
+ "limit": 135,
+ "requests": 34,
+ "renewsAt": "2099-09-21T14:36:14.288Z"
}
}"#
}
cmd
}
+/// Removes every ambient credential and connection setting, so a report over
+/// all providers talk to nothing outside the mock server.
+fn offline(cmd: &mut Command) -> &mut Command {
+ cmd.env_remove("SYNTHETIC_API_KEY")
+ .env_remove("DEEPINFRA_API_KEY")
+ .env_remove("API_KEY")
+ .env_remove("API_ENDPOINT")
+}
+
#[test]
fn quota_renders_windows_and_models() {
let mut server = mockito::Server::new();
let mock = server
- .mock("GET", "/api/usage")
- .match_header("authorization", "KEY")
+ .mock("GET", "/v2/quotas")
+ .match_header("authorization", "Bearer KEY")
.with_body(ok_body())
.create();
- let cmd = alpaca()
- .args(["quota", "--apikey", "KEY", "--base-url", &server.url()])
- .assert();
+ let cmd = offline(alpaca().args([
+ "quota",
+ "-p",
+ "synthetic",
+ "--apikey",
+ "KEY",
+ "--base-url",
+ &server.url(),
+ ]))
+ .assert();
mock.assert();
cmd.success()
.stdout(
- predicate::str::contains("ollama cloud usage")
- .and(predicate::str::contains("extra 1.25 USD"))
- .and(predicate::str::contains("session"))
- .and(predicate::str::contains("50.0%"))
- .and(predicate::str::contains("gpt-oss:120b"))
- .and(predicate::str::contains("reqs")),
+ predicate::str::contains("synthetic usage")
+ .and(predicate::str::contains("subscription"))
+ .and(predicate::str::contains("25.2%"))
+ .and(predicate::str::contains("34/135 requests")),
)
.stdout(predicate::str::contains('\x1b').not());
}
let mut server = mockito::Server::new();
let mock = server
- .mock("GET", "/api/usage")
+ .mock("GET", "/v2/quotas")
.with_body(ok_body())
.create();
- let cmd = alpaca()
- .args([
- "quota",
- "--raw",
- "--apikey",
- "KEY",
- "--base-url",
- &server.url(),
- ])
- .assert();
+ let cmd = offline(alpaca().args([
+ "quota",
+ "-p",
+ "synthetic",
+ "--raw",
+ "--apikey",
+ "KEY",
+ "--base-url",
+ &server.url(),
+ ]))
+ .assert();
mock.assert();
cmd.success()
- .stdout(predicate::str::contains(r#""request_count": 9"#));
+ .stdout(predicate::str::contains(r#""requests": 34"#));
}
#[test]
let mut server = mockito::Server::new();
let mock = server
- .mock("GET", "/api/usage")
+ .mock("GET", "/v2/quotas")
.with_body(ok_body())
.create();
- let cmd = alpaca()
- .args([
- "quota",
- "--json",
- "--apikey",
- "KEY",
- "--base-url",
- &server.url(),
- ])
- .assert();
+ let cmd = offline(alpaca().args([
+ "quota",
+ "-p",
+ "synthetic",
+ "--json",
+ "--apikey",
+ "KEY",
+ "--base-url",
+ &server.url(),
+ ]))
+ .assert();
mock.assert();
cmd.success().stdout(
- predicate::str::starts_with(r#"{"title":"ollama cloud usage""#)
- .and(predicate::str::contains(r#""name":"session""#))
+ predicate::str::starts_with(r#"{"title":"synthetic usage""#)
+ .and(predicate::str::contains(r#""name":"subscription""#))
.and(predicate::str::contains(r#""resets_in_secs""#)),
);
}
let mut server = mockito::Server::new();
let mock = server
- .mock("GET", "/api/usage")
+ .mock("GET", "/v2/quotas")
.with_status(401)
.with_body(r#"{"error":"unauthorized"}"#)
.create();
- let cmd = alpaca()
- .args(["quota", "--apikey", "BAD", "--base-url", &server.url()])
- .assert();
+ let cmd = offline(alpaca().args([
+ "quota",
+ "-p",
+ "synthetic",
+ "--apikey",
+ "BAD",
+ "--base-url",
+ &server.url(),
+ ]))
+ .assert();
mock.assert();
cmd.failure().stderr(
- predicate::str::contains("failed to fetch ollama usage")
+ predicate::str::contains("failed to fetch synthetic usage")
.and(predicate::str::contains("unauthorized")),
);
}
#[test]
-fn quota_without_credentials_fails() {
- alpaca()
- .args(["quota"])
- .env_remove("OLLAMA_API_KEY")
+fn single_provider_quota_without_credentials_fails() {
+ offline(alpaca().args(["quota", "-p", "synthetic"]))
.assert()
.failure()
- .stderr(predicate::str::contains("no credentials for ollama"));
+ .stderr(predicate::str::contains("no credentials for synthetic"));
}
#[test]
+fn anthropic_and_openai_have_no_default_credential() {
+ // Their endpoints serve the vendor CLIs, so alpaca only asks them with an
+ // explicit apikey.
+ offline(alpaca().args(["quota", "-p", "anthropic"]))
+ .assert()
+ .failure()
+ .stderr(predicate::str::contains("no credentials for anthropic"));
+
+ offline(alpaca().args(["quota", "-p", "openai"]))
+ .assert()
+ .failure()
+ .stderr(predicate::str::contains("no credentials for openai"));
+}
+
+#[test]
+fn an_all_providers_report_omits_providers_without_credentials() {
+ // Nothing is configured, so no provider can be drawn: the report draws
+ // nothing and still succeeds.
+ offline(alpaca().args(["quota"]))
+ .assert()
+ .success()
+ .stdout("");
+}
+
+/// Mocks one successful endpoint for each provider on `server`
+fn mock_all_providers(server: &mut mockito::Server) {
+ server
+ .mock("GET", "/v2/quotas")
+ .with_body(ok_body())
+ .create();
+ server
+ .mock("GET", "/api/oauth/usage")
+ .with_body(r#"{"five_hour":{"utilization":25}}"#)
+ .create();
+ server
+ .mock("GET", "/v1/organization/costs")
+ .match_query(mockito::Matcher::Any)
+ .with_body(r#"{"object":"page","data":[],"has_more":false,"next_page":null}"#)
+ .create();
+ server
+ .mock("GET", "/payment/usage?from=current")
+ .with_body(
+ r#"{"months": [{"period": "2026.09", "interval": {}, "total_cost": 1234, "items": []}],
+ "initial_month": "2024.01"}"#,
+ )
+ .create();
+}
+
+#[test]
+fn an_all_providers_report_draws_each_provider_in_one_output() {
+ let mut server = mockito::Server::new();
+ mock_all_providers(&mut server);
+
+ offline(alpaca().args(["quota", "--apikey", "KEY", "--base-url", &server.url()]))
+ .assert()
+ .success()
+ .stdout(
+ predicate::str::contains("synthetic usage")
+ .and(predicate::str::contains("anthropic usage"))
+ .and(predicate::str::contains("openai usage"))
+ .and(predicate::str::contains("deepinfra usage")),
+ );
+}
+
+#[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
+ // deepinfra endpoints are unmocked, so all three are left undrawn.
+ server
+ .mock("GET", "/v2/quotas")
+ .with_body(ok_body())
+ .create();
+ let failing = server
+ .mock("GET", "/api/oauth/usage")
+ .with_status(401)
+ .with_body(r#"{"error":"unauthorized"}"#)
+ .create();
+
+ offline(alpaca().args(["quota", "--apikey", "KEY", "--base-url", &server.url()]))
+ .assert()
+ .success()
+ .stdout(
+ predicate::str::contains("synthetic usage")
+ .and(predicate::str::contains("anthropic").not()),
+ );
+
+ failing.assert();
+}
+
+#[test]
+fn an_all_providers_json_report_keys_each_view_by_provider() {
+ let mut server = mockito::Server::new();
+ mock_all_providers(&mut server);
+
+ offline(alpaca().args([
+ "quota",
+ "--json",
+ "--apikey",
+ "KEY",
+ "--base-url",
+ &server.url(),
+ ]))
+ .assert()
+ .success()
+ .stdout(
+ predicate::str::contains(r#""synthetic":{"title":"synthetic usage""#)
+ .and(predicate::str::contains(r#""anthropic":{"#))
+ .and(predicate::str::contains(r#""openai":{"#))
+ .and(predicate::str::contains(r#""deepinfra":{"#)),
+ );
+}
+
+#[test]
+fn raw_is_a_single_provider_report() {
+ offline(alpaca().args(["quota", "--raw"]))
+ .assert()
+ .failure()
+ .stderr(predicate::str::contains("--provider"));
+}
+
+#[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.
+ let config_home = TempDir::new().unwrap();
+ let config_dir = config_home.path().join("alpaca");
+ std::fs::create_dir(&config_dir).unwrap();
+ std::fs::write(config_dir.join("config.toml"), r#"apikey = "KEY""#).unwrap();
+
+ let mut server = mockito::Server::new();
+ let mock = server
+ .mock("GET", "/v2/quotas")
+ .match_header("authorization", "Bearer KEY")
+ .with_body(ok_body())
+ .create();
+
+ Command::cargo_bin("alpaca")
+ .unwrap()
+ .env("XDG_CONFIG_HOME", config_home.path())
+ .env_remove("API_KEY")
+ .env_remove("API_ENDPOINT")
+ .args(["quota", "--base-url", &server.url()])
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("synthetic usage"));
+
+ mock.assert();
+}
+
+#[test]
fn quota_rejects_oversized_responses() {
let mut server = mockito::Server::new();
let mock = server
- .mock("GET", "/api/usage")
+ .mock("GET", "/v2/quotas")
.with_body(vec![b'x'; (1 << 20) + 1])
.create();
- let cmd = alpaca()
- .args([
- "quota",
- "--raw",
- "--apikey",
- "KEY",
- "--base-url",
- &server.url(),
- ])
- .assert();
+ let cmd = offline(alpaca().args([
+ "quota",
+ "-p",
+ "synthetic",
+ "--raw",
+ "--apikey",
+ "KEY",
+ "--base-url",
+ &server.url(),
+ ]))
+ .assert();
mock.assert();
cmd.failure().stderr(predicate::str::contains(
}
#[test]
-fn openai_quota_sends_account_id_from_codex_credentials() {
- let home = TempDir::new().unwrap();
- let codex = home.path().join(".codex");
- std::fs::create_dir(&codex).unwrap();
- std::fs::write(
- codex.join("auth.json"),
- r#"{"tokens":{"access_token":"TOKEN","account_id":"ACCOUNT"}}"#,
- )
- .unwrap();
+fn deepinfra_quota_reports_remaining_credit() {
+ let mut server = mockito::Server::new();
+ let usage = server
+ .mock("GET", "/payment/usage?from=current")
+ .with_body(
+ r#"{"months": [{"period": "2026.09", "total_cost": 1234, "items": []}],
+ "initial_month": "2024.01"}"#,
+ )
+ .create();
+ let checklist = server
+ .mock("GET", "/payment/checklist")
+ .match_header("authorization", "Bearer KEY")
+ .with_body(r#"{"stripe_balance": -50.0}"#)
+ .create();
+ alpaca()
+ .args([
+ "quota",
+ "-p",
+ "deepinfra",
+ "--apikey",
+ "KEY",
+ "--base-url",
+ &server.url(),
+ ])
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("50.00 USD credit left"));
+
+ usage.assert();
+ checklist.assert();
+}
+
+#[test]
+fn openai_quota_reports_the_months_spend_and_model_breakdown() {
let mut server = mockito::Server::new();
+ let costs = server
+ .mock("GET", "/v1/organization/costs")
+ .match_header("authorization", "Bearer ADMIN_KEY")
+ .match_query(mockito::Matcher::AllOf(vec![
+ mockito::Matcher::UrlEncoded("bucket_width".into(), "1d".into()),
+ mockito::Matcher::Regex("start_time=\\d+".into()),
+ mockito::Matcher::UrlEncoded("limit".into(), "31".into()),
+ ]))
+ .with_body(
+ r#"{"object":"page","data":[
+ {"object":"bucket","start_time":0,"end_time":1,"results":[
+ {"object":"organization.costs.result","amount":{"value":12.34,"currency":"usd"}}
+ ]}
+ ],"has_more":false,"next_page":null}"#,
+ )
+ .create();
+ let completions = server
+ .mock("GET", "/v1/organization/usage/completions")
+ .match_query(mockito::Matcher::AllOf(vec![
+ mockito::Matcher::UrlEncoded("group_by".into(), "model".into()),
+ ]))
+ .with_body(
+ r#"{"object":"page","data":[
+ {"object":"bucket","start_time":0,"end_time":1,"results":[
+ {"object":"organization.usage.completions.result","input_tokens":100,"output_tokens":50,"num_model_requests":1,"model":"gpt-4o-mini"}
+ ]}
+ ],"has_more":false,"next_page":null}"#,
+ )
+ .create();
+
+ alpaca()
+ .args([
+ "quota",
+ "-p",
+ "openai",
+ "--apikey",
+ "ADMIN_KEY",
+ "--base-url",
+ &server.url(),
+ ])
+ .assert()
+ .success()
+ .stdout(
+ predicate::str::contains("openai usage")
+ .and(predicate::str::contains("total 12.34 USD"))
+ .and(predicate::str::contains("gpt-4o-mini"))
+ .and(predicate::str::contains("tokens")),
+ );
+
+ costs.assert();
+ completions.assert();
+}
+
+#[test]
+fn openai_quota_survives_a_missing_completions_endpoint() {
+ let mut server = mockito::Server::new();
+ let costs = server
+ .mock("GET", "/v1/organization/costs")
+ .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.
+
+ alpaca()
+ .args([
+ "quota",
+ "-p",
+ "openai",
+ "--apikey",
+ "ADMIN_KEY",
+ "--base-url",
+ &server.url(),
+ ])
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("total 0.00 USD"));
+
+ costs.assert();
+}
+
+#[test]
+fn deepinfra_quota_survives_a_missing_checklist_endpoint() {
+ let mut server = mockito::Server::new();
let mock = server
- .mock("GET", "/backend-api/codex/usage")
- .match_header("authorization", "Bearer TOKEN")
- .match_header("chatgpt-account-id", "ACCOUNT")
- .with_body(r#"{"plan_type":"plus"}"#)
+ .mock("GET", "/payment/usage?from=current")
+ .with_body(
+ r#"{"months": [{"period": "2026.09", "total_cost": 1234, "items": []}],
+ "initial_month": "2024.01"}"#,
+ )
.create();
+ // /payment/checklist is left unmocked: the usage report still succeeds
+ // without a credit note.
alpaca()
- .env("HOME", home.path())
- .env_remove("CODEX_ACCESS_TOKEN")
- .args(["quota", "-p", "openai", "--base-url", &server.url()])
+ .args([
+ "quota",
+ "-p",
+ "deepinfra",
+ "--apikey",
+ "KEY",
+ "--base-url",
+ &server.url(),
+ ])
.assert()
- .success();
+ .success()
+ .stdout(
+ predicate::str::contains("total 12.34 USD")
+ .and(predicate::str::contains("credit left").not()),
+ );
mock.assert();
}