Commit Diff


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