Commit Diff


commit - 48182fda9a11903d8e5bc1df49e2a6f00deea33b
commit + ab17466fc0927d8fb701d37f553e79fc4b4dec07
blob - 095f3684503a9f5661db7b6e347fca256f267d93
blob + 862d1d32009acf1d27983f63a03d9b15c92958b9
--- Cargo.lock
+++ Cargo.lock
@@ -13,7 +13,7 @@ dependencies = [
 
 [[package]]
 name = "alpaca"
-version = "0.6.0"
+version = "0.7.0"
 dependencies = [
  "anyhow",
  "assert_cmd",
@@ -353,9 +353,9 @@ dependencies = [
 
 [[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",
@@ -363,18 +363,18 @@ dependencies = [
 
 [[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"
@@ -928,9 +928,9 @@ dependencies = [
 
 [[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"
@@ -1431,6 +1431,7 @@ dependencies = [
  "rustls-platform-verifier",
  "serde",
  "serde_json",
+ "serde_urlencoded",
  "sync_wrapper",
  "tokio",
  "tokio-rustls",
blob - 2778703e010dc80903516e05ec2ed144be3103f0
blob + 30eb3dd475fc9fa578f4cecec02a68b52d434810
--- Cargo.toml
+++ Cargo.toml
@@ -1,7 +1,7 @@
 [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"
@@ -13,7 +13,7 @@ anyhow = "1.0.104"
 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
@@ -13,7 +13,7 @@ MAN1PAGES := $(patsubst %.scd,%,$(wildcard man/*.1.scd
 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
 
@@ -26,6 +26,9 @@ man/%: man/%.scd
 test:
 	$(CARGO) test --locked
 
+fmt:
+	$(CARGO) fmt
+
 check:
 	$(CARGO) check
 	$(CARGO) clippy -- -W clippy::pedantic
blob - 8068673f35d24b281739faae2d7a9d74c764730b
blob + a92f7d0b2d7d92517d7c327c56d364640319e696
--- README.md
+++ README.md
@@ -7,29 +7,32 @@ Unix native interface for interacting with LLMs.
 
 ## 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
 
@@ -41,33 +44,35 @@ $ make test
 $ 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:
@@ -75,29 +80,28 @@ 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"
@@ -125,15 +129,14 @@ alpaca quota [OPTIONS]         # provider quota usage
 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
@@ -175,9 +178,9 @@ $ echo "50 + 50" | alpaca --system "Solve the followin
 ## alpaca compose
 
 `alpaca compose` edits a prompt in `$VISUAL`, `$EDITOR`, or `vim`, sends the
-saved text, then 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
@@ -207,16 +210,18 @@ Options:
 - `--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`.
 
@@ -224,58 +229,81 @@ 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`.
 
@@ -285,9 +313,10 @@ 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
@@ -312,10 +341,10 @@ $ git diff --staged \
 
 ### 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"
@@ -327,18 +356,18 @@ For example, the following defines a command that plum
 (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
@@ -1,19 +1,33 @@
 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
@@ -15,7 +15,7 @@ alpaca compose - edit a prompt in the editor, then sen
 
 *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
@@ -37,7 +37,7 @@ unless given on the command line. Save an empty buffer
 
 *-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.
@@ -70,7 +70,7 @@ unless given on the command line. Save an empty buffer
 
 *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*
@@ -78,13 +78,13 @@ unless given on the command line. Save an empty buffer
 
 # 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
@@ -54,53 +54,61 @@ The file uses TOML. Unknown keys and invalid values ar
 *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
@@ -2,7 +2,7 @@ alpaca-quota(1)
 
 # NAME
 
-alpaca quota - show provider session and weekly usage in the terminal
+alpaca quota - show how much provider quota you have used
 
 # SYNOPSIS
 
@@ -12,48 +12,57 @@ alpaca quota - show provider session and weekly usage 
 
 # 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.
@@ -65,10 +74,12 @@ CLI refreshes it.
 	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
@@ -79,13 +90,24 @@ CLI refreshes it.
 
 # 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
@@ -20,7 +20,7 @@ alpaca - Unix native interface for LLMs
 # 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:
@@ -30,19 +30,19 @@ 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.
@@ -70,9 +70,9 @@ read messages from a file with such a name, separate i
 
 *--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*.
@@ -95,7 +95,8 @@ Use _$XDG_CONFIG_HOME/alpaca/config.toml_ for shared d
 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
@@ -121,63 +121,181 @@ fn config_applies_to_compose_and_quota() -> Result<()>
     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"
     "#;
@@ -191,8 +309,68 @@ fn an_explicit_profile_overrides_deepinfra_apikey() ->
 }
 
 #[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
@@ -18,14 +18,14 @@ use derive_builder::Builder;
 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 {
@@ -42,13 +42,15 @@ 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 {
@@ -104,46 +106,89 @@ impl 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)
@@ -175,10 +220,6 @@ impl Settings {
                 .default_profile
                 .clone()
                 .or_else(|| self.default_profile.clone()),
-            deepinfra_apikey: other
-                .deepinfra_apikey
-                .clone()
-                .or_else(|| self.deepinfra_apikey.clone()),
         }
     }
 }
@@ -238,8 +279,10 @@ pub struct ChatArgs {
 /// 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>,
@@ -249,6 +292,10 @@ pub struct QuotaArgs {
     #[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
@@ -351,10 +398,10 @@ pub fn parse() -> Result<Invocation> {
         .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))
@@ -378,17 +425,33 @@ fn cli(config: &Config) -> Command {
 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`
@@ -429,9 +492,9 @@ fn quota_command(config: &Config) -> Command {
         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))
@@ -552,7 +615,7 @@ fn output_format_args(cmd: Command, config: &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)),
@@ -588,23 +651,21 @@ fn chat_args_from(matches: &ArgMatches, config: &Confi
     }
 }
 
-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(),
     }
 }
 
@@ -758,12 +819,17 @@ impl ValueEnum for ColorChoice {
 
 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",
@@ -1018,17 +1084,25 @@ mod test {
     #[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",
@@ -1044,7 +1118,7 @@ mod test {
             "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"));
@@ -1063,7 +1137,7 @@ mod test {
     #[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
@@ -1,33 +1,43 @@
 //! 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")?;
@@ -35,7 +45,8 @@ pub async fn exec(args: QuotaArgs) -> Result<()> {
         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 => {
@@ -48,3 +59,112 @@ pub async fn exec(args: QuotaArgs) -> Result<()> {
 
     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
@@ -84,35 +84,6 @@ struct Usage {
     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
@@ -123,18 +94,11 @@ pub fn token_from_file() -> Result<Option<String>, Err
 ///
 /// 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
@@ -2,8 +2,10 @@
 //!
 //! `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;
 
@@ -42,34 +44,43 @@ struct Usage {
     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(),
@@ -80,12 +91,32 @@ pub fn view(body: &[u8]) -> Result<View, Error> {
 }
 
 /// 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
@@ -127,7 +158,7 @@ mod test {
             "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"));
@@ -154,7 +185,7 @@ mod test {
 
     #[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);
@@ -164,7 +195,59 @@ mod test {
 
     #[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
@@ -6,32 +6,31 @@
 
 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,
@@ -66,7 +65,7 @@ pub struct Window {
 #[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>,
 }
@@ -96,17 +95,34 @@ fn remaining_secs(at: DateTime<Utc>, now: DateTime<Utc
     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(),
         }
     }
@@ -115,101 +131,141 @@ impl Provider {
     #[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",
@@ -228,7 +284,6 @@ pub struct Client {
     provider: Provider,
     base_url: String,
     api_key: String,
-    account_id: Option<String>,
     timeout: Duration,
 }
 
@@ -247,10 +302,7 @@ impl Client {
         timeout: Duration,
     ) -> Result<Self, Error> {
         let credential = match api_key {
-            Some(token) => Credential {
-                token,
-                account_id: None,
-            },
+            Some(token) => Credential { token },
             None => provider.credential()?,
         };
 
@@ -259,7 +311,6 @@ impl Client {
             provider,
             base_url: base_url.unwrap_or_else(|| provider.base_url()),
             api_key: credential.token,
-            account_id: credential.account_id,
             timeout,
         })
     }
@@ -271,6 +322,20 @@ impl Client {
     /// 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 {
@@ -279,7 +344,7 @@ impl Client {
 
         let mut request = self
             .http
-            .get(self.endpoint())
+            .get(self.endpoint(path))
             .timeout(self.timeout)
             .header("Authorization", credential)
             .header("Accept", "application/json");
@@ -287,8 +352,18 @@ impl Client {
         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?;
@@ -306,12 +381,8 @@ impl Client {
         }
     }
 
-    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)
     }
 }
 
@@ -350,27 +421,30 @@ mod test {
     #[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
@@ -1,249 +0,0 @@
-//! 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
@@ -1,146 +1,134 @@
-//! 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)]
@@ -149,59 +137,87 @@ mod 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
@@ -123,6 +123,36 @@ pub fn render(mut dest: impl Write, view: &View, style
     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);
@@ -327,4 +357,33 @@ mod test {
         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
@@ -0,0 +1,182 @@
+//! 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
@@ -19,7 +19,7 @@ fn alpaca() -> Command {
 /// 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",
@@ -60,7 +60,7 @@ fn chat_user_message_from_flag() {
         .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",
@@ -91,7 +91,7 @@ fn chat_user_message_from_stdin() {
         .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",
@@ -123,7 +123,7 @@ fn chat_with_reasoning_effort() {
         .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",
@@ -159,7 +159,7 @@ fn chat_multiple_messages() {
         .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"
@@ -209,7 +209,7 @@ fn chat_api_error() {
         .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"
@@ -244,7 +244,7 @@ fn chat_against_a_completions_endpoint() {
         .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": [{
@@ -256,7 +256,7 @@ fn chat_against_a_completions_endpoint() {
             r#"{
                  "id": "chatcmpl-1",
                  "created": 1760742847,
-                 "model": "gpt-oss:120b",
+                 "model": "gpt-4o-mini",
                  "choices": [{
                      "index": 0,
                      "message": {"role": "assistant", "content": "ASSISTANT REPLY"},
@@ -300,7 +300,7 @@ fn chat_user_message_from_file() {
         .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
@@ -8,16 +8,13 @@ use tempfile::TempDir;
 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"
         }
     }"#
 }
@@ -28,30 +25,44 @@ fn alpaca() -> Command {
     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());
 }
@@ -61,25 +72,26 @@ fn quota_raw_prints_the_provider_body() {
     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]
@@ -87,26 +99,27 @@ fn quota_json_emits_the_normalized_view() {
     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""#)),
     );
 }
@@ -116,51 +129,210 @@ fn quota_reports_provider_errors() {
     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(
@@ -233,30 +405,152 @@ fn deepinfra_quota_reports_the_current_months_spend() 
 }
 
 #[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();
 }