commit ab17466fc0927d8fb701d37f553e79fc4b4dec07 from: mtmn date: Sun Sep 6 14:45:59 2026 UTC remove local apikey handlers, quota prints all, etc. 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 `, `--base-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 `: one of `ollama`, `anthropic`, `openai`, `deepinfra` +- `-p, --provider `: one of `synthetic`, `anthropic`, `openai`, + `deepinfra`. Without it, all providers are reported and the unreachable ones + are omitted - `-T, --timeout `: request timeout in seconds - `--apikey `: API key or OAuth token, replacing the provider default - `--base-url `: base URL, replacing the provider default -- `--raw`: print the provider response body verbatim -- `--json`, `--jsonp`: print the normalised view as JSON -- `--color `: 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 `: 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, reasoning_effort: Option, default_profile: Option, - deepinfra_apikey: Option, } #[derive(Debug, Default, Clone)] struct Config { settings: Settings, profiles: HashMap, + /// Credential each provider resolves from the configuration for a report + /// over all providers, where one `apikey` cannot serve them all + quota_keys: HashMap, } 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 { 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) -> Result { + /// Resolve an active profile and decide which credentials a quota + /// invocation may use. + fn for_invocation(&self, profile: Option<&str>, quota: QuotaTarget) -> Result { let active = profile.or(self.default_profile.as_deref()); 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, + #[builder(default)] pub(crate) api_key: Option, #[builder(default)] pub(crate) base_url: Option, @@ -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, } /// Arguments for the compose subcommand @@ -351,10 +398,10 @@ pub fn parse() -> Result { .try_get_matches_from(&args) .unwrap_or_else(|err| err.exit()); let profile = pre_matches.get_one::("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 { let pre_matches = cli(config).try_get_matches_from(args)?; let profile = pre_matches.get_one::("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 { +/// 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")) - .copied() + .map_or(QuotaTarget::None, |quota| { + quota + .get_one::("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 "Sets usage provider") + arg!(provider: -p --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") - .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::("color") - .expect("Color is required"), - } +fn quota_args_from(matches: &ArgMatches, config: &Config) -> QuotaArgs { + QuotaArgs { + provider: matches.get_one::("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::("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 { 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 = 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 { + // 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> { + 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> { + 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 { + 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, 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, Err /// /// Returns an error if the body is not a Claude usage response. pub fn view(body: &[u8]) -> Result { - let value: serde_json::Value = - serde_json::from_slice(body).map_err(|err| Error::UnexpectedResponse(err.to_string()))?; - if !["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 = 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, } +/// 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 { - 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 { + 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 { } /// 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 { + 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, } /// 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, } @@ -96,17 +95,34 @@ fn remaining_secs(at: DateTime, now: DateTime i64 { + let now = Utc::now(); + Utc.with_ymd_and_hms(now.year(), now.month(), 1, 0, 0, 0) + .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 { 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 { + /// 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 { 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 { 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::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(value: serde_json::Value) -> Result { + 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( + body: &[u8], + provider: &str, + expected_keys: &[&str], +) -> Result { + 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, timeout: Duration, } @@ -247,10 +302,7 @@ impl Client { timeout: Duration, ) -> Result { 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, 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> { + self.get(self.provider.extra_path()?).await.ok() + } + + async fn get(&self, path: &str) -> Result, 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, -} - -#[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 { - let value: serde_json::Value = - serde_json::from_slice(body).map_err(|err| Error::UnexpectedResponse(err.to_string()))?; - if !["activity", "limits"] - .iter() - .any(|key| value.get(key).is_some()) - { - return Err(Error::UnexpectedResponse( - "response contains no ollama usage fields".to_string(), - )); - } - let usage: Usage = - serde_json::from_value(value).map_err(|err| Error::UnexpectedResponse(err.to_string()))?; - Ok(build(&usage, Utc::now())) -} - -fn build(usage: &Usage, now: DateTime) -> View { - let period = &usage.activity.period; - - View { - title: "ollama cloud usage".to_string(), - subtitle: Some(format!( - "{} . {} to {}", - period.kind, period.from, period.to - )), - note: (!usage.activity.cost.is_empty()) - .then(|| format!("extra {} USD", usage.activity.cost)), - windows: vec![ - Window::new( - "session", - usage.limits.session.usage, - Some(next_boundary(now, SESSION, TimeDelta::zero())), - ), - Window::new( - "weekly", - usage.limits.weekly.usage, - Some(next_boundary(now, WEEKLY, WEEKLY_OFFSET)), - ), - ], - tables: vec![ - Table { - heading: "session models".to_string(), - unit: "reqs", - rows: sorted(&usage.limits.session.models), - }, - Table { - heading: "weekly models".to_string(), - unit: "reqs", - rows: sorted(&usage.limits.weekly.models), - }, - ], - } -} - -/// The next boundary of a window of length `period`, whose boundaries are -/// offset by `offset` from the Unix epoch -fn next_boundary(now: DateTime, period: TimeDelta, offset: TimeDelta) -> DateTime { - let period_secs = period.num_seconds(); - let since_epoch = now.timestamp() - offset.num_seconds(); - let elapsed = since_epoch.rem_euclid(period_secs); - now + TimeDelta::seconds(period_secs - elapsed) -} - -/// Model counts, busiest first -fn sorted(models: &[ModelCount]) -> Vec { - let mut sorted = models.to_vec(); - sorted.sort_by_key(|model| std::cmp::Reverse(model.requests)); - sorted -} - -#[cfg(test)] -mod test { - use super::*; - use anyhow::Result; - - const BODY: &[u8] = br#"{ - "activity": { - "cost": "1.25", - "period": {"type": "monthly", "starting_at": "2025-10-01", "ending_at": "2025-10-31"} - }, - "limits": { - "session": {"usage": 0.5, "models": [ - {"name": "small", "request_count": 2}, - {"name": "big", "request_count": 9} - ]}, - "weekly": {"usage": 0.25, "models": []} - } - }"#; - - #[test] - fn reads_windows_models_and_cost() -> Result<()> { - let view = view(BODY)?; - - assert_eq!(view.title, "ollama cloud usage"); - assert_eq!( - view.subtitle.as_deref(), - Some("monthly . 2025-10-01 to 2025-10-31") - ); - assert_eq!(view.note.as_deref(), Some("extra 1.25 USD")); - assert_eq!(view.windows.len(), 2); - assert_eq!(view.windows[0].name, "session"); - assert!((view.windows[0].used - 0.5).abs() < f64::EPSILON); - assert_eq!(view.windows[1].name, "weekly"); - assert_eq!( - view.tables[0].rows, - vec![ - ModelCount { - name: "big".to_string(), - requests: 9 - }, - ModelCount { - name: "small".to_string(), - requests: 2 - } - ], - "models are sorted busiest first" - ); - assert!(view.tables[1].rows.is_empty()); - Ok(()) - } - - #[test] - fn omits_note_without_cost() -> Result<()> { - let view = view(br#"{"limits": {"session": {"usage": 0}, "weekly": {"usage": 0}}}"#)?; - - assert_eq!(view.note, None); - Ok(()) - } - - #[test] - fn rejects_non_usage_bodies() { - assert!(view(b"not json").is_err()); - assert!(view(b"{}").is_err()); - } - - #[test] - fn session_boundaries_are_epoch_aligned() { - let now = DateTime::parse_from_rfc3339("2025-10-17T23:14:07Z") - .unwrap() - .with_timezone(&Utc); - - let next = next_boundary(now, SESSION, TimeDelta::zero()); - - assert!(next > now, "boundary is in the future"); - assert!(next - now <= SESSION, "boundary is within one window"); - assert_eq!( - next.timestamp() % SESSION.num_seconds(), - 0, - "session boundaries are multiples of 5h from the epoch" - ); - } - - #[test] - fn weekly_boundaries_use_the_four_day_offset() { - let now = DateTime::parse_from_rfc3339("2025-10-17T23:14:07Z") - .unwrap() - .with_timezone(&Utc); - - let next = next_boundary(now, WEEKLY, WEEKLY_OFFSET); - - assert!(next > now); - assert!(next - now <= WEEKLY); - assert_eq!( - (next.timestamp() - WEEKLY_OFFSET.num_seconds()) % WEEKLY.num_seconds(), - 0 - ); - } - - #[test] - fn boundaries_never_land_in_the_past_before_the_epoch() { - let before_epoch = DateTime::parse_from_rfc3339("1969-01-01T00:00:00Z") - .unwrap() - .with_timezone(&Utc); - - let next = next_boundary(before_epoch, SESSION, TimeDelta::zero()); - - assert!( - next > before_epoch, - "negative timestamps still move forward" - ); - } -} blob - 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, } #[derive(Debug, Default, Deserialize)] -struct RateLimit { +struct Costs { #[serde(default)] - primary_window: Option, - #[serde(default)] - secondary_window: Option, + data: Vec, } #[derive(Debug, Default, Deserialize)] -struct Credits { +struct CompletionsResult { #[serde(default)] - balance: String, + model: Option, + /// 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, } -/// 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)>, Error> { - #[derive(Deserialize)] - struct Auth { - tokens: Tokens, - } - - #[derive(Deserialize)] - struct Tokens { - access_token: String, - #[serde(default)] - account_id: Option, - } - - 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, } -/// 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 { - 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 { + 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::() + + 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::::from_timestamp(window.reset_at, 0), - ) - }) - .collect(); + let rows = completions + .and_then(|body| serde_json::from_slice::(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 { + let mut totals: Vec = 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::, 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, +} + +/// 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, + #[serde(rename = "maxCredits")] + max_credits: Option, + /// When the next batch of credits regenerates + #[serde(rename = "nextRegenAt")] + next_regen_at: Option, +} + +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 { + 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) -> 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 = 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(); }