commit - b5fc4794c6055d415e4584d6b75b0e5be51daf36
commit + 545a5aa90590e928e51d84609a2c7eb6a3af7fc1
blob - b4050dedcd52a2cc5d85c657d451b8aa1113ce97
blob + c4973ffaf80eca846cbbaa45f34f34c9f5bd5426
--- .gitignore
+++ .gitignore
/target
-/alpacu/alpacu
-/alpaci/alpaci
+/man/*.1
+/man/*.5
+/.allium-loop/
blob - fe59632020171cd46b8a52976b1a02e116838afc
blob + 9e2f5038f693e9b8c62113dfba94d309c68e764d
--- Cargo.lock
+++ Cargo.lock
"reqwest",
"serde",
"serde_json",
+ "tempfile",
"thiserror",
"tokio",
+ "toml",
]
[[package]]
[[package]]
name = "aws-lc-rs"
-version = "1.18.0"
+version = "1.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e"
+checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
dependencies = [
"aws-lc-sys",
"zeroize",
[[package]]
name = "aws-lc-sys"
-version = "0.44.0"
+version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483"
+checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
dependencies = [
"cc",
"cmake",
[[package]]
name = "cc"
-version = "1.4.4"
+version = "1.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273"
+checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80"
dependencies = [
"find-msvc-tools",
"jobserver",
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.4",
+ "syn 3.0.5",
]
[[package]]
[[package]]
name = "find-msvc-tools"
-version = "0.1.11"
+version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
+checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
[[package]]
name = "float-cmp"
[[package]]
name = "mio"
-version = "1.2.2"
+version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
+checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
dependencies = [
"libc",
"wasi",
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.4",
+ "syn 3.0.5",
]
[[package]]
]
[[package]]
+name = "serde_spanned"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "smallvec"
-version = "1.15.2"
+version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"
[[package]]
name = "socket2"
[[package]]
name = "syn"
-version = "3.0.4"
+version = "3.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
+checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
dependencies = [
"proc-macro2",
"quote",
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
+ "getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.4",
+ "syn 3.0.5",
]
[[package]]
[[package]]
name = "tinyvec"
-version = "1.12.0"
+version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
+checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b"
dependencies = [
"tinyvec_macros",
]
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.4",
+ "syn 3.0.5",
]
[[package]]
name = "tokio-rustls"
-version = "0.26.4"
+version = "0.26.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
+checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67"
dependencies = [
"rustls",
"tokio",
]
[[package]]
+name = "toml"
+version = "1.1.5+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785"
+dependencies = [
+ "indexmap",
+ "serde_core",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_parser",
+ "toml_writer",
+ "winnow",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "1.1.1+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_parser"
+version = "1.1.3+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
+dependencies = [
+ "winnow",
+]
+
+[[package]]
+name = "toml_writer"
+version = "1.1.2+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
+
+[[package]]
name = "tower"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
+name = "winnow"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
+
+[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.4",
+ "syn 3.0.5",
]
[[package]]
blob - 2b58a49e4ffe847e61148e9646708dcd2907b00f
blob + 7062b3127ad43d75835a3c86b1d24bb453548b85
--- Cargo.toml
+++ Cargo.toml
[dependencies]
anyhow = "1.0.104"
chrono = { version = "0.4.45", features = ["serde"] }
-clap = { version = "4.6.4", features = ["default", "cargo", "env"] }
+clap = { version = "4.6.6", features = ["default", "cargo", "env", "string"] }
derive_builder = "0.20.2"
reqwest = { version = "0.13.4", features = ["json"] }
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
-thiserror = "2.0.18"
+tempfile = "3.27.0"
+thiserror = "2.0.20"
tokio = { version = "1.53.1", features = ["full"] }
+toml = "1.1.5"
[dev-dependencies]
assert_cmd = "2.2.2"
blob - 32b13b2ec6d040b6dc591434a8ac38a3323d4b84
blob + 8781b752986756b3bc92eb2cd2d7e19fee945c5d
--- README.md
+++ README.md
Unix native interface for interacting with LLMs.
`alpaca` is a fork of [`cogni`](https://github.com/leoshimo/cogni) by
-[leoshimo](https://github.com/leoshimo) that utilizes
-[Ollama Cloud](https://docs.ollama.com/cloud) rather than OpenAI.
+[leoshimo](https://github.com/leoshimo).
## Focus
- Ergonomics and accessibility in Unix shell
- Composability and interop with other programs - including `alpaca` itself
-- Ease of language model programming in both ad-hoc and repeatable manner
+- Easy language model programming, both ad-hoc and repeatable
For example, designing for IO redirection (`stdin`, `stdout`) allows `alpaca` to
work with files, editor buffers, clipboards, syslogs, sockets, and many external
## Features
-- Unix-minded Design (IO redirection, composability, interop)
-- Ad-hoc Language Model Scripting
-- Flexible input and output formats (Text, JSON, NDJSON, Transcript)
-- Standalone binary - No Python required
-- Repeatable Scripts via Templates
+- Unix-minded design (IO redirection, composability, interop)
+- Ad-hoc language model scripting
+- Flexible input and output formats (text, JSON, transcript)
+- Standalone binary, with no Python required
+- Works against any compatible chat endpoint, hosted or local
+- Editor-backed prompting and provider quota reporting in the same binary
-## Non-Features
+## Non-features
-- Interactive use - instead, invoke `alpaca` from within interactive environments (REPLs, emacs, etc)
+- Interactive use. Instead, invoke `alpaca` from within interactive environments, such as REPLs and emacs.
## Installation
+Building requires Rust and `scdoc`:
+
```sh
-$ cargo install --path .
+$ make
+$ make test
+$ sudo make install
```
+`PREFIX` defaults to `/usr/local`; `DESTDIR`, `BINDIR`, and `MANDIR` may be
+overridden for packaging.
+
## Setup
-`alpaca` talks to the [Ollama Cloud API](https://docs.ollama.com/cloud). It
-expects an Ollama API Key (create one at
-<https://ollama.com/settings/keys>) supplied via the `--apikey` option or more
-conveniently the `OLLAMA_API_KEY` environment variable:
+`alpaca` expects an API key supplied with the `--apikey` option or, more
+conveniently, the `API_KEY` environment variable:
```sh
# in shell configuration
-export OLLAMA_API_KEY=your-api-key
+export API_KEY=your-api-key
```
-By default requests are sent to `https://ollama.com`. To target a different
-host, for example a local Ollama server, set `OLLAMA_API_ENDPOINT`:
+Requests go to `https://ollama.com` unless told otherwise. To target a
+different host, for example a local server, set `API_ENDPOINT` or pass
+`--base-url`:
```sh
-export OLLAMA_API_ENDPOINT=http://localhost:11434
+export API_ENDPOINT=http://localhost:11434
```
-Pick a model with `-m/--model` (default `gpt-oss:120b`). See
-<https://ollama.com/library> for available model identifiers.
+The endpoint's dialect follows from the base URL. A base URL ending in a
+version segment, such as `https://host/v1`, is sent to `/chat/completions`;
+any other base URL is sent to `/api/chat`. Authorization is only sent when a
+key is known, so endpoints that need no authentication also work.
----
+Pick a model with `-m/--model` (default `gpt-oss:120b`). Model identifiers are
+whatever your endpoint serves.
-## Extra tools
+Shared settings, honoured by every subcommand:
-### alpacu
+| variable | meaning | default |
+| --- | --- | --- |
+| `API_KEY` | API key | none |
+| `API_ENDPOINT` | base URL | `https://ollama.com` |
+| `ALPACA_MODEL` | model | `gpt-oss:120b` |
+| `ALPACA_EFFORT` | reasoning effort | `none` |
+| `ALPACA_TIMEOUT` | request timeout, in seconds | `60` |
-`alpacu` is a small Go program in the `alpacu/` directory. It calls the Ollama
-Cloud usage endpoint and prints your session quota (5 hour window) and weekly
-quota (7 day window) as colored bars, with a countdown to the next reset and a
-per-model request table with request counts.
+## Configuration
-It shares the same `OLLAMA_API_KEY` setup as `alpaca`. The key can also be
-passed with `-k`. Build and install with make (Go 1.26, no dependencies):
+Put shared settings in `$XDG_CONFIG_HOME/alpaca/config.toml`. Alpaca uses
+`~/.config/alpaca/config.toml` when `XDG_CONFIG_HOME` is not set.
-```sh
-$ cd alpacu
-$ make # build ./alpacu
-$ make vet # vet
-$ sudo make install # installs alpacu(1) and alpacu.1 to /usr/local
-```
+Command-line options override environment variables. Environment variables
+override the config file. The config file overrides built-in defaults.
-Options:
-
-- `-k key`: API key. Falls back to `OLLAMA_API_KEY`
-- `-b url`: base URL. Defaults to `$OLLAMA_API_ENDPOINT`, then `https://ollama.com`
-- `-j`: print the raw JSON response instead of the rendered output
-- `-h`: show help
-
-See `alpacu.1` for the full man page.
-
-### alpaci
-
-`alpaci` edits prompt in `$VISUAL`, `$EDITOR`, or `vim`, sends saved text
-to Ollama `POST /api/chat`, then writes reply to stdout. Piped stdin
-prefills prompt. Editor uses `/dev/tty`, keeping editor I/O out of pipeline.
-
-Uses `OLLAMA_API_KEY` or `-k`. Build needs Go 1.26:
-
-```sh
-$ cd alpaci
-$ make # build ./alpaci
-$ make vet # vet
-$ sudo make install # installs alpaci(1) and alpaci.1 to /usr/local
+```toml
+output_format = "plaintext"
+model = "gpt-oss:120b"
+temperature = 0.7
+timeout = 60
+system = "Answer concisely."
+assistant = ["An example assistant message"]
+user = ["An example user message"]
+apikey = "your-api-key"
+base_url = "https://ollama.com"
+reasoning_effort = "none"
```
-```sh
-# Write a prompt in the editor, send it, see the reply
-$ alpaci
+The config file applies to chat, compose and quota where they share an option.
+See `alpaca-config(5)` for all fields and command-specific behavior. Keep the
+file private if it contains `apikey`.
-# Pick a model and reasoning effort
-$ alpaci -m gpt-oss:120b -e high
+## Commands
-# Set a system prompt
-$ alpaci -s "Answer in one sentence"
-
-# Edit piped input in the editor before sending
-$ git diff --staged | alpaci -s "Write a conventional commit message"
-
-# Sit in the middle of a pipeline: pipe in, edit, pipe out
-$ curl -s "wttr.in/?1" | alpaci -s "Summarize this weather in 1 sentence" | say
```
+alpaca [OPTIONS] [FILE] # chat, the default command
+alpaca chat [OPTIONS] [FILE] # the same thing, named
+alpaca quota [OPTIONS] # provider quota usage
+alpaca compose [OPTIONS] # edit a prompt in $EDITOR, then send it
+```
-Options:
+A first argument matching a subcommand name is treated as that subcommand. To
+read messages from a file with such a name, separate it with `--`, as in
+`alpaca -- quota`.
-- `-m model`: model to use. Defaults to `$ALPACA_MODEL`, then `glm-5.3-flash`
-- `-e effort`: reasoning effort, one of `low`, `medium`, `high`, `none`. Defaults to `$ALPACA_EFFORT`, then `low`. With `none`, the request omits the think field
-- `-s system`: system prompt
-- `-T secs`: request timeout in seconds. Defaults to 300
-- `-k key`: API key. Falls back to `OLLAMA_API_KEY`
-- `-b url`: base URL. Defaults to `$OLLAMA_API_ENDPOINT`, then `https://ollama.com`
-- `-j`: print the raw JSON response instead of the reply
-- `-h`: show help
-
-`$VISUAL` and `$EDITOR` split on whitespace; no quote or escape parsing.
-Use wrapper script for complex editor command. `-s` sends system message before
-user prompt. Temporary file is removed. `-T` must be positive.
-
-Each successful request is saved as a readable, length-prefixed transcript in
-`$XDG_DATA_HOME/alpaca/` (or `~/.local/share/alpaca/`). Run `alpaci c` to
-continue the newest transcript: its final assistant reply opens in the editor,
-and each saved follow-up is sent with the entire conversation as context. The
-saved model, effort and system prompt are reused unless explicitly overridden.
-Save an empty editor buffer to leave the continuation loop.
-
-See `alpaci.1`.
-
---
## Basic Usage
---
+## alpaca compose
+
+`alpaca compose` edits a prompt in `$VISUAL`, `$EDITOR`, or `vim`, sends the
+saved text, then writes the reply to stdout. Piped stdin prefills the buffer.
+The editor runs on `/dev/tty`, keeping editor I/O out of the pipeline. Saving
+an empty buffer sends nothing.
+
+```sh
+# Write a prompt in the editor, send it, see the reply
+$ alpaca compose
+
+# Pick a model and reasoning effort
+$ alpaca compose -m gpt-oss:120b --reasoning-effort high
+
+# Set a system prompt
+$ alpaca compose -s "Answer in one sentence"
+
+# Edit piped input in the editor before sending
+$ git diff --staged | alpaca compose -s "Write a conventional commit message"
+
+# Sit in the middle of a pipeline: pipe in, edit, pipe out
+$ curl -s "wttr.in/?1" | alpaca compose -s "Summarize this weather in 1 sentence" | say
+```
+
+Options:
+
+- `-m, --model <MODEL>`: model to use
+- `-s, --system <MSG>`: system prompt
+- `-t, --temperature <TEMP>`: sampling temperature
+- `-T, --timeout <SECS>`: request timeout in seconds
+- `-c, --continue`: continue the newest saved conversation
+- `--reasoning-effort <low|medium|high|none>`: reasoning effort
+- `--apikey <KEY>`, `--base-url <URL>`: endpoint settings
+- `--json`, `--jsonp`: print the response as JSON instead of the reply text
+
+`$VISUAL` and `$EDITOR` split on whitespace; no quote or escape parsing. Use a
+wrapper script for complex editor commands. The temporary file is always
+removed. An editor that exits non-zero sets alpaca's own exit status.
+
+Each successful request is saved as a JSON transcript in `$XDG_DATA_HOME/alpaca`
+(or `~/.local/share/alpaca`), with mode 0600 in a directory with mode 0700. Run
+`alpaca compose -c` to continue the newest transcript: its final reply opens in
+the editor, and each follow-up is sent with the whole conversation as context.
+The saved model, effort and system prompt are reused unless given on the command
+line. Save an empty buffer to leave the loop.
+
+See `man/alpaca-compose.1`.
+
+---
+
+## alpaca quota
+
+`alpaca quota` asks a provider how much of your quota you have used. It prints
+each quota window as a bar showing the used share, with a countdown to the next
+reset. Where the provider reports per-model request counts, it prints those as a
+table.
+
+Pick the provider with `-p` (default `ollama`):
+
+```sh
+$ alpaca quota # Ollama Cloud
+$ alpaca quota -p anthropic # Claude Code
+$ alpaca quota -p openai # Codex CLI
+```
+
+Each provider has its own default credential and base URL:
+
+| provider | credential | base URL |
+| --- | --- | --- |
+| `ollama` | `$OLLAMA_API_KEY` | `$OLLAMA_API_ENDPOINT`, then `https://ollama.com` |
+| `anthropic` | `$CLAUDE_CODE_OAUTH_TOKEN`, then `~/.claude/.credentials.json` | `https://api.anthropic.com` |
+| `openai` | `$CODEX_ACCESS_TOKEN`, then `~/.codex/auth.json` | `https://chatgpt.com` |
+
+The last two read the credential file the vendor CLI writes when you log in.
+They work as soon as you have logged into that CLI. Those endpoints serve the
+vendor CLIs and are not documented public APIs, so they may change without
+notice. Both expect a short lived OAuth token: if the credential file is stale,
+you get an authentication error until the vendor CLI refreshes it.
+
+Options:
+
+- `-p, --provider <PROVIDER>`: one of `ollama`, `anthropic`, `openai`
+- `-T, --timeout <SECS>`: request timeout in seconds
+- `--apikey <KEY>`: API key or OAuth token, replacing the provider default
+- `--base-url <URL>`: base URL, replacing the provider default
+- `--raw`: print the provider response body verbatim
+- `--json`, `--jsonp`: print the normalised view as JSON
+- `--color <auto|always|never>`: colour policy. `auto` colours only when stdout
+ is a terminal and `NO_COLOR` is unset
+
+See `man/alpaca-quota.1`.
+
+---
+
## Tour of alpaca
-An gallery of examples to get the inspiration flowing
+Examples to get you started.
-> :warning: `alpaca` uses the [Ollama Cloud API](https://docs.ollama.com/cloud), thus *any data fed into program will be sent to their servers* (unless you point `OLLAMA_API_ENDPOINT` at a local server).
+> :warning: Whatever you feed `alpaca` is sent to the endpoint you configure, so point `API_ENDPOINT` at a local server if the data should not leave your machine.
### In the Shell
blob - /dev/null
blob + c3793e637044e49f4d39587c1a04fe7293ca7661 (mode 644)
--- /dev/null
+++ Makefile
+PREFIX ?= /usr/local
+BINDIR ?= $(PREFIX)/bin
+MANDIR ?= $(PREFIX)/share/man
+DESTDIR ?=
+
+SUDO ?= sudo
+CARGO ?= cargo
+INSTALL ?= install
+CARGO_TARGET_DIR ?= target
+
+PROGRAM := alpaca
+MAN1PAGES := $(patsubst %.scd,%,$(wildcard man/*.1.scd))
+MAN5PAGES := $(patsubst %.scd,%,$(wildcard man/*.5.scd))
+MANPAGES := $(MAN1PAGES) $(MAN5PAGES)
+
+.PHONY: all build test install clean
+
+all: build
+
+build: $(MANPAGES)
+ $(CARGO) build --release --locked
+
+man/%: man/%.scd
+ scdoc < "$<" > "$@"
+
+test:
+ $(CARGO) test --locked
+
+install: build
+ $(SUDO) $(INSTALL) -d "$(DESTDIR)$(BINDIR)" "$(DESTDIR)$(MANDIR)/man1" "$(DESTDIR)$(MANDIR)/man5"
+ $(SUDO) $(INSTALL) -m 0755 "$(CARGO_TARGET_DIR)/release/$(PROGRAM)" "$(DESTDIR)$(BINDIR)/$(PROGRAM)"
+ $(SUDO) $(INSTALL) -m 0644 $(MAN1PAGES) "$(DESTDIR)$(MANDIR)/man1/"
+ $(SUDO) $(INSTALL) -m 0644 $(MAN5PAGES) "$(DESTDIR)$(MANDIR)/man5/"
+
+clean:
+ $(CARGO) clean
+ $(RM) $(MANPAGES)
blob - a4767e340e731b6243f69e1f64ca0489bf00e828 (mode 644)
blob + /dev/null
--- alpaci/.gitignore
+++ /dev/null
-/alpaci
-*.log
blob - ebbb6f56f1f80870e7cad47707cc8d77033dbd15 (mode 644)
blob + /dev/null
--- alpaci/Makefile
+++ /dev/null
-.POSIX:
-
-SHELL = /bin/sh
-GO ?= go
-GOCACHE ?= $(HOME)/.cache/go-build
-BIN ?= alpaci
-PREFIX ?= /usr/local
-BINDIR ?= $(PREFIX)/bin
-MANDIR ?= $(PREFIX)/share/man
-DESTDIR ?=
-DOAS ?= sudo
-GOFLAGS ?=
-
-.PHONY: all build clean install uninstall vet test
-
-all: build
-
-build:
- GOFLAGS='$(GOFLAGS)' CGO_ENABLED=0 GOCACHE=$(GOCACHE) $(GO) build -trimpath -buildvcs=false -o $(BIN) .
-
-vet:
- CGO_ENABLED=0 GOCACHE=$(GOCACHE) $(GO) vet ./...
-
-test:
- CGO_ENABLED=0 GOCACHE=$(GOCACHE) $(GO) test ./...
-
-install: build
- $(DOAS) mkdir -p $(DESTDIR)$(BINDIR) $(DESTDIR)$(MANDIR)/man1
- $(DOAS) cp $(BIN) $(DESTDIR)$(BINDIR)/$(BIN)
- $(DOAS) chmod 0755 $(DESTDIR)$(BINDIR)/$(BIN)
- $(DOAS) cp $(BIN).1 $(DESTDIR)$(MANDIR)/man1/$(BIN).1
- $(DOAS) chmod 0644 $(DESTDIR)$(MANDIR)/man1/$(BIN).1
-
-uninstall:
- $(DOAS) rm -f $(DESTDIR)$(BINDIR)/$(BIN) $(DESTDIR)$(MANDIR)/man1/$(BIN).1
-
-clean:
- rm -f $(BIN)
blob - 511661c65cce7b14f0f4c58f6fb801f19d5c3939 (mode 644)
blob + /dev/null
--- alpaci/alpaci.1
+++ /dev/null
-.\" alpaci . edit a prompt, then send it to the Ollama chat API
-.Dd September 2, 2025
-.Dt ALPACI 1
-.Os
-.Sh NAME
-.Nm alpaci
-.Nd edit a prompt in the editor, then send it to the Ollama chat API
-.Sh SYNOPSIS
-.Nm
-.Op Fl m Ar model
-.Op Fl e Ar effort
-.Op Fl s Ar system
-.Op Fl T Ar secs
-.Op Fl k Ar key
-.Op Fl b Ar url
-.Op Fl j
-.Op Fl h
-.Op Cm c
-.Sh DESCRIPTION
-.Nm
-edits a temporary prompt file, sends saved text as user prompt to
-.Pa {base}/api/chat ,
-and writes reply to standard output.
-Piped standard input prefills prompt file.
-Each successful request is saved as a length-prefixed transcript in
-.Pa $XDG_DATA_HOME/alpaca
-or, when that variable is unset,
-.Pa ~/.local/share/alpaca .
-.Pp
-With operand
-.Cm c ,
-.Nm
-loads the newest transcript, opens its final assistant reply in the editor,
-and continues prompting until the edited buffer is empty. Each continuation
-request includes the recorded conversation as context. The saved model, effort,
-and system prompt are reused unless their options are explicitly supplied.
-Editor always uses
-.Pa /dev/tty .
-Editor input and output never enter pipeline.
-.Nm
-fails when no controlling terminal exists.
-.Pp
-Temporary file is removed on exit.
-Request is non-streaming
-.Pq Cm stream=false .
-.Sh OPTIONS
-.Bl -tag -width Ds
-.It Fl m Ar model
-Model to use.
-Defaults to
-.Ev ALPACA_MODEL ,
-then
-.Cm glm-5.3-flash .
-.It Fl e Ar effort
-Reasoning effort, one of
-.Cm low ,
-.Cm medium ,
-.Cm high ,
-or
-.Cm none .
-Defaults to
-.Ev ALPACA_EFFORT ,
-then
-.Cm low .
-With
-.Cm none ,
-the request omits the
-.Sy think
-field.
-.It Fl s Ar system
-System prompt.
-.It Fl T Ar secs
-Request timeout in seconds.
-Defaults to 300.
-.It Fl k Ar key
-API key.
-Falls back to
-.Ev OLLAMA_API_KEY .
-May be omitted for unauthenticated local endpoints.
-.It Fl b Ar url
-Base URL.
-Defaults to
-.Ev OLLAMA_API_ENDPOINT ,
-then
-.Cm https://ollama.com .
-Useful for local or staging instances.
-.It Fl j
-Print the raw JSON response instead of the reply.
-.It Cm c
-Continue the newest saved conversation.
-.It Fl h
-Show help and exit.
-.El
-.Sh ENVIRONMENT
-.Bl -tag -width Ds
-.It Ev OLLAMA_API_KEY
-API key used when
-.Fl k
-is not given.
-.It Ev OLLAMA_API_ENDPOINT
-Base URL used when
-.Fl b
-is not given.
-.It Ev ALPACA_MODEL
-Default model when
-.Fl m
-is not given.
-.It Ev ALPACA_EFFORT
-Default reasoning effort when
-.Fl e
-is not given.
-.It Ev XDG_DATA_HOME
-Base directory for saved transcripts. When unset, transcripts use
-.Pa ~/.local/share/alpaca .
-.It Ev VISUAL , Ev EDITOR
-Editor command for prompt buffer.
-Values split on whitespace; quotes and escapes are not parsed.
-Use wrapper script for complex commands.
-Defaults to
-.Xr vim 1 .
-.El
-.Sh EXIT STATUS
-.Ex -std
-.Nm
-exits 2 for invalid options, missing API key, non-positive timeout, or
-operands.
-It exits with editor exit status when editor fails.
-.Sh EXAMPLES
-Write a prompt in the editor, send it, see the reply:
-.Bd -literal -offset indent
-$ alpaci
-.Ed
-.Pp
-Pick a model and reasoning effort:
-.Bd -literal -offset indent
-$ alpaci -m gpt-oss:120b -e high
-.Ed
-.Pp
-Set a system prompt:
-.Bd -literal -offset indent
-$ git diff --staged | alpaci -s \&"Write a conventional commit message\&"
-.Ed
-.Pp
-Sit in the middle of a pipeline:
-.Bd -literal -offset indent
-$ curl -s \&"wttr.in/?1\&" | alpaci -s \&"Summarize this weather in 1 sentence\&" | say
-.Ed
-.Pp
-Print the raw JSON response:
-.Bd -literal -offset indent
-$ alpaci -j
-.Ed
-.Pp
-Continue the newest saved conversation:
-.Bd -literal -offset indent
-$ alpaci c
-.Ed
-.Sh SEE ALSO
-.Xr alpaca 1 ,
-.Xr alpacu 1 ,
-.Xr vim 1
-.Pp
-.Lk https://docs.ollama.com/api/chat Ollama Chat API
-.Lk https://ollama.com/library Ollama model library
-.Sh AUTHORS
-.An m
-.Aq Mt miro@local
\ No newline at end of file
blob - 68e9a41d837a763b332460756a1d591590912e66 (mode 644)
blob + /dev/null
--- alpaci/go.mod
+++ /dev/null
-module alpaci
-
-go 1.22
blob - 2fdc1645a62c645ec01cc6a1def7cb9fc5e5b823 (mode 644)
blob + /dev/null
--- alpaci/main.go
+++ /dev/null
-// alpaci - edit a prompt in the editor, then send it to the Ollama chat API
-package main
-
-import (
- "bufio"
- "bytes"
- "context"
- "encoding/json"
- "errors"
- "flag"
- "fmt"
- "io"
- "net/http"
- "os"
- "os/exec"
- "path/filepath"
- "sort"
- "strconv"
- "strings"
- "time"
-)
-
-const usage = `usage: alpaci [-m model] [-e effort] [-s system] [-T secs] [-k key] [-b url] [-j] [-h] [c]
-
- -m model model to use (else $ALPACA_MODEL, else glm-5.3-flash)
- -e effort reasoning effort: low, medium, high or none
- (else $ALPACA_EFFORT, else low)
- -s system system prompt
- -T secs request timeout in seconds (default 300)
- -k key API key (else $OLLAMA_API_KEY; omit for unauthenticated endpoints)
- -b url base URL (else $OLLAMA_API_ENDPOINT, else https://ollama.com)
- -j print the raw JSON response instead of the reply
- -h show help
- c continue the newest saved conversation
-
-Edit a prompt in the editor, then send it to the Ollama chat API and
-print the reply. Piped stdin prefills the editor buffer first, so alpaci
-can sit in the middle of a pipeline: pipe data in, edit it, pipe the
-reply onward.
-
-With c, alpaci opens the previous assistant reply in the editor and keeps
-prompting. Conversations are saved under $XDG_DATA_HOME/alpaca (or
-~/.local/share/alpaca) and sent as context on each continuation turn.
-`
-
-const (
- defaultBase = "https://ollama.com"
- maxErrorBody = 64 << 10
- maxTimeoutSec = int64(1<<63-1) / int64(time.Second)
- transcriptMagic = "alpaci-transcript-v1"
-)
-
-type httpClient interface {
- Do(*http.Request) (*http.Response, error)
-}
-
-// client is the default HTTP client. Each request carries its own context
-// timeout via -T, so no global Timeout is set. We do set a timeout for
-// reading response headers so a stalled connection fails quickly.
-var client httpClient = &http.Client{
- Transport: &http.Transport{
- ResponseHeaderTimeout: 30 * time.Second,
- },
-}
-
-type message struct {
- Role string `json:"role"`
- Content string `json:"content"`
-}
-
-type chatReq struct {
- Model string `json:"model"`
- Messages []message `json:"messages"`
- Stream bool `json:"stream"`
- Think string `json:"think,omitempty"`
-}
-
-type transcript struct {
- Model string
- Effort string
- System string
- Messages []message
-}
-
-// validEffort reports whether an -e value is accepted. "none" means the
-// request omits the think field.
-func validEffort(e string) bool {
- switch e {
- case "low", "medium", "high", "none":
- return true
- }
- return false
-}
-
-// buildReq assembles the chat request. A non-empty system prompt becomes
-// a system message before the user prompt; "none" drops the think field.
-func buildReq(model, effort, system, prompt string) chatReq {
- return buildReqMessages(model, effort, system, []message{{Role: "user", Content: prompt}})
-}
-
-func buildReqMessages(model, effort, system string, messages []message) chatReq {
- var m []message
- if system != "" {
- m = append(m, message{Role: "system", Content: system})
- }
- m = append(m, messages...)
- think := effort
- if effort == "none" {
- think = ""
- }
- return chatReq{Model: model, Messages: m, Stream: false, Think: think}
-}
-
-func dataDir() (string, error) {
- if dir := os.Getenv("XDG_DATA_HOME"); dir != "" {
- return filepath.Join(dir, "alpaca"), nil
- }
- home, err := os.UserHomeDir()
- if err != nil {
- return "", fmt.Errorf("find home directory: %w", err)
- }
- return filepath.Join(home, ".local", "share", "alpaca"), nil
-}
-
-// writeField writes a length-prefixed field so arbitrary message content is safe.
-func writeField(w io.Writer, name, value string) error {
- _, err := fmt.Fprintf(w, "%s %d\n%s\n", name, len(value), value)
- return err
-}
-
-func writeTranscript(path string, t transcript) error {
- var b bytes.Buffer
- b.WriteString(transcriptMagic + "\n")
- for _, f := range []struct{ name, value string }{{"model", t.Model}, {"effort", t.Effort}, {"system", t.System}} {
- if err := writeField(&b, f.name, f.value); err != nil {
- return err
- }
- }
- for _, m := range t.Messages {
- if m.Role != "user" && m.Role != "assistant" {
- return fmt.Errorf("invalid transcript role %q", m.Role)
- }
- if err := writeField(&b, "message-"+m.Role, m.Content); err != nil {
- return err
- }
- }
- return os.WriteFile(path, b.Bytes(), 0600)
-}
-
-func readField(r *bufio.Reader) (string, string, error) {
- line, err := r.ReadString('\n')
- if err != nil {
- return "", "", err
- }
- parts := strings.Split(strings.TrimSuffix(line, "\n"), " ")
- if len(parts) != 2 || parts[0] == "" {
- return "", "", errors.New("invalid field header")
- }
- n, err := strconv.Atoi(parts[1])
- if err != nil || n < 0 {
- return "", "", errors.New("invalid field length")
- }
- data := make([]byte, n)
- if _, err := io.ReadFull(r, data); err != nil {
- return "", "", err
- }
- if end, err := r.ReadByte(); err != nil || end != '\n' {
- return "", "", errors.New("missing field terminator")
- }
- return parts[0], string(data), nil
-}
-
-func readTranscript(path string) (transcript, error) {
- f, err := os.Open(path)
- if err != nil {
- return transcript{}, err
- }
- defer f.Close()
- r := bufio.NewReader(f)
- magic, err := r.ReadString('\n')
- if err != nil || strings.TrimSuffix(magic, "\n") != transcriptMagic {
- return transcript{}, errors.New("not an alpaci transcript")
- }
- var t transcript
- for _, expected := range []string{"model", "effort", "system"} {
- name, value, err := readField(r)
- if err != nil || name != expected {
- return transcript{}, fmt.Errorf("invalid %s field", expected)
- }
- switch name {
- case "model":
- t.Model = value
- case "effort":
- t.Effort = value
- case "system":
- t.System = value
- }
- }
- for {
- name, value, err := readField(r)
- if errors.Is(err, io.EOF) {
- break
- }
- if err != nil || (name != "message-user" && name != "message-assistant") {
- return transcript{}, errors.New("invalid message field")
- }
- t.Messages = append(t.Messages, message{Role: strings.TrimPrefix(name, "message-"), Content: value})
- }
- if len(t.Messages) == 0 || len(t.Messages)%2 != 0 {
- return transcript{}, errors.New("transcript must end with an assistant reply")
- }
- for i, m := range t.Messages {
- want := "user"
- if i%2 == 1 {
- want = "assistant"
- }
- if m.Role != want {
- return transcript{}, errors.New("transcript messages are out of order")
- }
- }
- return t, nil
-}
-
-func newTranscript(t transcript) (string, error) {
- dir, err := dataDir()
- if err != nil {
- return "", err
- }
- if err := os.MkdirAll(dir, 0700); err != nil {
- return "", err
- }
- f, err := os.CreateTemp(dir, strconv.FormatInt(time.Now().UnixNano(), 10)+"-*.log")
- if err != nil {
- return "", err
- }
- f.Close()
- path := f.Name()
- return path, writeTranscript(path, t)
-}
-
-func newestTranscript() (string, transcript, error) {
- dir, err := dataDir()
- if err != nil {
- return "", transcript{}, err
- }
- entries, err := os.ReadDir(dir)
- if errors.Is(err, os.ErrNotExist) {
- return "", transcript{}, errors.New("no saved conversations")
- }
- if err != nil {
- return "", transcript{}, err
- }
- sort.Slice(entries, func(i, j int) bool {
- a, _ := entries[i].Info()
- b, _ := entries[j].Info()
- return a.ModTime().After(b.ModTime())
- })
- var lastErr error
- for _, entry := range entries {
- if entry.IsDir() || filepath.Ext(entry.Name()) != ".log" {
- continue
- }
- path := filepath.Join(dir, entry.Name())
- t, err := readTranscript(path)
- if err != nil {
- lastErr = fmt.Errorf("read conversation %s: %w", entry.Name(), err)
- continue
- }
- return path, t, nil
- }
- if lastErr != nil {
- return "", transcript{}, fmt.Errorf("no usable conversation: %w", lastErr)
- }
- return "", transcript{}, errors.New("no saved conversations")
-}
-
-// parseChatRes extracts the reply text from the chat response body.
-func parseChatRes(data []byte) (string, error) {
- var r struct {
- Message *struct {
- Content string `json:"content"`
- } `json:"message"`
- DoneReason string `json:"done_reason"`
- }
- if err := json.Unmarshal(data, &r); err != nil {
- return "", err
- }
- if r.Message == nil {
- return "", errors.New("response missing message field")
- }
- return r.Message.Content, nil
-}
-
-func errBody(data []byte) (string, error) {
- var e struct {
- Error string `json:"error"`
- }
- if err := json.Unmarshal(data, &e); err != nil {
- return "", err
- }
- return e.Error, nil
-}
-
-// send posts the request to base/api/chat. Authorization is sent only when
-// key is non-empty, so local endpoints without authentication still work.
-func send(ctx context.Context, base, key string, r chatReq) ([]byte, error) {
- body, err := json.Marshal(r)
- if err != nil {
- return nil, err
- }
- url := strings.TrimRight(base, "/") + "/api/chat"
- req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
- if err != nil {
- return nil, err
- }
- if key != "" {
- req.Header.Set("Authorization", "Bearer "+key)
- }
- req.Header.Set("Content-Type", "application/json")
- res, err := client.Do(req)
- if err != nil {
- return nil, err
- }
- defer res.Body.Close()
- if res.StatusCode != http.StatusOK {
- data, err := io.ReadAll(io.LimitReader(res.Body, maxErrorBody))
- if err != nil {
- return nil, err
- }
- msg, err := errBody(data)
- if err != nil {
- msg = strings.TrimSpace(string(data))
- }
- return nil, fmt.Errorf("status %d: %s", res.StatusCode, msg)
- }
- return io.ReadAll(res.Body)
-}
-
-func envOr(name, def string) string {
- if v := os.Getenv(name); v != "" {
- return v
- }
- return def
-}
-
-// editorCmd splits $VISUAL or $EDITOR on whitespace. VISUAL wins.
-// Quotes and escapes are not parsed; use a wrapper script for them.
-func editorCmd() []string {
- for _, name := range []string{"VISUAL", "EDITOR"} {
- if v := os.Getenv(name); v != "" {
- if fields := strings.Fields(v); len(fields) > 0 {
- return fields
- }
- }
- }
- return []string{"vim"}
-}
-
-// runEditor runs the editor on controlling terminal. This keeps editor
-// input and screen output out of stdin/stdout pipelines.
-func runEditor(file string) error {
- ed := editorCmd()
- if len(ed) == 0 {
- return errors.New("empty editor command")
- }
- tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
- if err != nil {
- return fmt.Errorf("open controlling terminal: %w", err)
- }
- defer tty.Close()
- cmd := exec.Command(ed[0], append(ed[1:], file)...)
- cmd.Stdin, cmd.Stdout, cmd.Stderr = tty, tty, tty
- return cmd.Run()
-}
-
-// editPrompt writes the initial text (possibly empty) into a temp file,
-// opens the editor on it, and returns the saved contents. The file is
-// always removed. An editor exit error is returned as *exec.ExitError so
-// the caller can exit with the same status without bypassing cleanup.
-func editPrompt(initial string) (string, error) {
- tmp, err := os.CreateTemp("", "alpaci-")
- if err != nil {
- return "", err
- }
- name := tmp.Name()
- defer os.Remove(name)
- if initial != "" {
- if _, err := tmp.WriteString(initial); err != nil {
- tmp.Close()
- return "", err
- }
- }
- tmp.Close()
-
- err = runEditor(name)
- var ee *exec.ExitError
- if errors.As(err, &ee) {
- return "", ee
- }
- if err != nil {
- return "", err
- }
-
- data, err := os.ReadFile(name)
- if err != nil {
- return "", err
- }
- return string(data), nil
-}
-
-func must(err error) {
- if err != nil {
- fmt.Fprintf(os.Stderr, "alpaci: %v\n", err)
- os.Exit(1)
- }
-}
-
-func usageError(format string, args ...any) {
- fmt.Fprintf(os.Stderr, "alpaci: "+format+"\n", args...)
- fmt.Fprint(os.Stderr, usage)
- os.Exit(2)
-}
-
-func flagWasSet(name string) bool {
- set := false
- flag.Visit(func(f *flag.Flag) {
- if f.Name == name {
- set = true
- }
- })
- return set
-}
-
-func printResponse(body []byte, raw bool) {
- if raw {
- if !json.Valid(body) {
- must(fmt.Errorf("response is not valid JSON"))
- }
- var out bytes.Buffer
- must(json.Indent(&out, body, "", " "))
- out.WriteByte('\n')
- _, _ = os.Stdout.Write(out.Bytes())
- return
- }
- reply, err := parseChatRes(body)
- must(err)
- fmt.Print(reply)
-}
-
-func sendTurn(model, effort, system, base, key string, timeout int, t *transcript, prompt string) (string, []byte) {
- t.Messages = append(t.Messages, message{Role: "user", Content: prompt})
- ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
- defer cancel()
- body, err := send(ctx, base, key, buildReqMessages(model, effort, system, t.Messages))
- must(err)
- reply, err := parseChatRes(body)
- must(err)
- t.Messages = append(t.Messages, message{Role: "assistant", Content: reply})
- return reply, body
-}
-
-func main() {
- model := flag.String("m", envOr("ALPACA_MODEL", "glm-5.3-flash"), "model")
- effort := flag.String("e", envOr("ALPACA_EFFORT", "low"), "reasoning effort")
- system := flag.String("s", "", "system prompt")
- timeout := flag.Int("T", 300, "timeout seconds")
- key := flag.String("k", os.Getenv("OLLAMA_API_KEY"), "API key")
- base := flag.String("b", envOr("OLLAMA_API_ENDPOINT", defaultBase), "base URL")
- jsn := flag.Bool("j", false, "raw JSON")
- help := flag.Bool("h", false, "help")
- flag.Usage = func() { fmt.Fprint(os.Stderr, usage) }
- flag.Parse()
- if *help {
- fmt.Print(usage)
- return
- }
- continueMode := flag.NArg() == 1 && flag.Arg(0) == "c"
- if flag.NArg() != 0 && !continueMode {
- usageError("unexpected argument %q", flag.Arg(0))
- }
- if *timeout <= 0 || int64(*timeout) > maxTimeoutSec {
- usageError("timeout must be between 1 and %d seconds", maxTimeoutSec)
- }
-
- if continueMode {
- path, t, err := newestTranscript()
- must(err)
- if !flagWasSet("m") {
- *model = t.Model
- }
- if !flagWasSet("e") {
- *effort = t.Effort
- }
- if !flagWasSet("s") {
- *system = t.System
- }
- if !validEffort(*effort) {
- usageError("invalid effort %q", *effort)
- }
- initial := t.Messages[len(t.Messages)-1].Content
- for {
- prompt, err := editPrompt(initial)
- var ee *exec.ExitError
- if errors.As(err, &ee) {
- os.Exit(ee.ExitCode())
- }
- must(err)
- if prompt == "" {
- return
- }
- reply, body := sendTurn(*model, *effort, *system, *base, *key, *timeout, &t, prompt)
- must(writeTranscript(path, t))
- printResponse(body, *jsn)
- initial = reply
- }
- }
- if !validEffort(*effort) {
- usageError("invalid effort %q", *effort)
- }
- initial := ""
- if st, statErr := os.Stdin.Stat(); statErr == nil && st.Mode()&os.ModeCharDevice == 0 {
- b, err := io.ReadAll(os.Stdin)
- must(err)
- initial = string(b)
- }
- prompt, err := editPrompt(initial)
- var ee *exec.ExitError
- if errors.As(err, &ee) {
- os.Exit(ee.ExitCode())
- }
- must(err)
- if prompt == "" {
- return
- }
- t := transcript{Model: *model, Effort: *effort, System: *system}
- _, body := sendTurn(*model, *effort, *system, *base, *key, *timeout, &t, prompt)
- _, err = newTranscript(t)
- must(err)
- printResponse(body, *jsn)
-}
blob - f5b85699b33d174f53d9eaeb3e76e5a815d41a63 (mode 644)
blob + /dev/null
--- alpaci/main_test.go
+++ /dev/null
-package main
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "io"
- "net/http"
- "os"
- "path/filepath"
- "reflect"
- "strings"
- "testing"
- "time"
-)
-
-func TestTranscriptRoundTrip(t *testing.T) {
- path := filepath.Join(t.TempDir(), "session.log")
- want := transcript{
- Model: "model", Effort: "none", System: "system\ntext",
- Messages: []message{{Role: "user", Content: "hello\nmessage-user 4\nevil"}, {Role: "assistant", Content: "reply"}},
- }
- if err := writeTranscript(path, want); err != nil {
- t.Fatal(err)
- }
- got, err := readTranscript(path)
- if err != nil {
- t.Fatal(err)
- }
- if !reflect.DeepEqual(got, want) {
- t.Fatalf("got %#v, want %#v", got, want)
- }
-}
-
-func TestNewestTranscript(t *testing.T) {
- t.Setenv("XDG_DATA_HOME", t.TempDir())
- dir, err := dataDir()
- if err != nil {
- t.Fatal(err)
- }
- if err := os.MkdirAll(dir, 0700); err != nil {
- t.Fatal(err)
- }
- old := filepath.Join(dir, "old.log")
- newer := filepath.Join(dir, "new.log")
- if err := writeTranscript(old, transcript{Model: "old", Effort: "low", Messages: []message{{Role: "user", Content: "old question"}, {Role: "assistant", Content: "old"}}}); err != nil {
- t.Fatal(err)
- }
- if err := writeTranscript(newer, transcript{Model: "new", Effort: "high", Messages: []message{{Role: "user", Content: "new question"}, {Role: "assistant", Content: "new"}}}); err != nil {
- t.Fatal(err)
- }
- oldTime := time.Now().Add(-time.Second)
- if err := os.Chtimes(old, oldTime, oldTime); err != nil {
- t.Fatal(err)
- }
- path, got, err := newestTranscript()
- if err != nil {
- t.Fatal(err)
- }
- if path != newer || got.Model != "new" {
- t.Fatalf("got %q %#v", path, got)
- }
-}
-
-func TestContinuationRequestIncludesHistory(t *testing.T) {
- req := buildReqMessages("m", "low", "s", []message{{Role: "user", Content: "first"}, {Role: "assistant", Content: "answer"}, {Role: "user", Content: "next"}})
- want := []message{{Role: "system", Content: "s"}, {Role: "user", Content: "first"}, {Role: "assistant", Content: "answer"}, {Role: "user", Content: "next"}}
- if !reflect.DeepEqual(req.Messages, want) {
- t.Fatalf("got %#v, want %#v", req.Messages, want)
- }
-}
-
-func TestNewestTranscriptWithoutHistory(t *testing.T) {
- t.Setenv("XDG_DATA_HOME", t.TempDir())
- if _, _, err := newestTranscript(); err == nil {
- t.Fatal("expected no-history error")
- }
-}
-
-func TestParseChatRes(t *testing.T) {
- cases := []struct {
- name string
- body string
- want string
- wantErr bool
- }{
- {"reply", `{"message":{"content":"hello"},"done_reason":"stop"}`, "hello", false},
- {"empty reply", `{"message":{"content":""},"done_reason":"stop"}`, "", false},
- {"missing message", `{"done_reason":"stop"}`, "", true},
- {"not json", `not json`, "", true},
- }
- for _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
- got, err := parseChatRes([]byte(tc.body))
- if (err != nil) != tc.wantErr {
- t.Fatalf("err=%v, wantErr=%v", err, tc.wantErr)
- }
- if got != tc.want {
- t.Fatalf("got %q, want %q", got, tc.want)
- }
- })
- }
-}
-
-func TestErrBody(t *testing.T) {
- msg, err := errBody([]byte(`{"error":"bad request"}`))
- if err != nil {
- t.Fatal(err)
- }
- if msg != "bad request" {
- t.Fatalf("got %q, want %q", msg, "bad request")
- }
- if _, err := errBody([]byte(`not json`)); err == nil {
- t.Fatal("expected error for non-JSON")
- }
-}
-
-type fakeClient struct {
- req *http.Request
- res *http.Response
- err error
-}
-
-func (c *fakeClient) Do(req *http.Request) (*http.Response, error) {
- c.req = req
- if c.err != nil {
- return nil, c.err
- }
- return c.res, nil
-}
-
-func TestSendSuccess(t *testing.T) {
- body := []byte(`{"message":{"content":"hi"},"done_reason":"stop"}`)
- old := client
- defer func() { client = old }()
- fc := &fakeClient{res: &http.Response{
- StatusCode: http.StatusOK,
- Body: io.NopCloser(bytes.NewReader(body)),
- Header: http.Header{"Content-Type": []string{"application/json"}},
- }}
- client = fc
-
- got, err := send(context.Background(), "https://example.com", "secret", buildReq("m", "low", "s", "p"))
- if err != nil {
- t.Fatal(err)
- }
- if !bytes.Equal(got, body) {
- t.Fatalf("got %s, want %s", got, body)
- }
- if got := fc.req.Header.Get("Authorization"); got != "Bearer secret" {
- t.Fatalf("Authorization=%q, want Bearer secret", got)
- }
- if !strings.HasSuffix(fc.req.URL.Path, "/api/chat") {
- t.Fatalf("URL path=%q", fc.req.URL.Path)
- }
-}
-
-func TestSendOmitsAuthWithoutKey(t *testing.T) {
- body := []byte(`{"message":{"content":"hi"},"done_reason":"stop"}`)
- old := client
- defer func() { client = old }()
- fc := &fakeClient{res: &http.Response{
- StatusCode: http.StatusOK,
- Body: io.NopCloser(bytes.NewReader(body)),
- }}
- client = fc
-
- if _, err := send(context.Background(), "http://localhost", "", buildReq("m", "low", "", "p")); err != nil {
- t.Fatal(err)
- }
- if fc.req.Header.Get("Authorization") != "" {
- t.Fatalf("Authorization header sent unexpectedly")
- }
-}
-
-func TestSendErrorStatus(t *testing.T) {
- old := client
- defer func() { client = old }()
- fc := &fakeClient{res: &http.Response{
- StatusCode: http.StatusBadRequest,
- Body: io.NopCloser(bytes.NewReader([]byte(`{"error":"nope"}`))),
- }}
- client = fc
-
- _, err := send(context.Background(), "https://example.com", "secret", buildReq("m", "low", "", "p"))
- if err == nil {
- t.Fatal("expected error")
- }
- if !strings.Contains(err.Error(), "nope") {
- t.Fatalf("error=%q, want it to contain nope", err)
- }
-}
-
-func TestInvalidTranscript(t *testing.T) {
- cases := []struct {
- name string
- write func(string) error
- }{
- {"bad magic", func(p string) error { return os.WriteFile(p, []byte("wrong\n"), 0600) }},
- {"odd message count", func(p string) error {
- return writeTranscript(p, transcript{Model: "m", Effort: "low", Messages: []message{{Role: "user", Content: "x"}}})
- }},
- {"out of order", func(p string) error {
- return writeTranscript(p, transcript{Model: "m", Effort: "low", Messages: []message{{Role: "assistant", Content: "x"}, {Role: "user", Content: "y"}}})
- }},
- }
- for _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
- p := filepath.Join(t.TempDir(), "bad.log")
- if err := tc.write(p); err != nil {
- t.Fatal(err)
- }
- if _, err := readTranscript(p); err == nil {
- t.Fatal("expected error")
- }
- })
- }
-}
-
-func TestNewestTranscriptSkipsCorrupt(t *testing.T) {
- t.Setenv("XDG_DATA_HOME", t.TempDir())
- dir, err := dataDir()
- if err != nil {
- t.Fatal(err)
- }
- if err := os.MkdirAll(dir, 0700); err != nil {
- t.Fatal(err)
- }
- corrupt := filepath.Join(dir, "corrupt.log")
- good := filepath.Join(dir, "good.log")
- if err := os.WriteFile(corrupt, []byte("bad\n"), 0600); err != nil {
- t.Fatal(err)
- }
- if err := writeTranscript(good, transcript{Model: "good", Effort: "low", Messages: []message{{Role: "user", Content: "q"}, {Role: "assistant", Content: "a"}}}); err != nil {
- t.Fatal(err)
- }
- goodTime := time.Now().Add(-time.Second)
- if err := os.Chtimes(good, goodTime, goodTime); err != nil {
- t.Fatal(err)
- }
- path, got, err := newestTranscript()
- if err != nil {
- t.Fatal(err)
- }
- if path != good || got.Model != "good" {
- t.Fatalf("got %q %#v", path, got)
- }
-}
-
-func TestRawJSONRequiresValidJSON(t *testing.T) {
- // Capture must's os.Exit by running in a subprocess would be heavy; instead
- // exercise parseChatRes path separately and trust raw mode validates first.
- if json.Valid([]byte(`not json`)) {
- t.Fatal("test setup wrong")
- }
- // The printResponse raw branch uses json.Valid before Indent; parsing the
- // same body via parseChatRes also fails, which is the non-raw path.
- if _, err := parseChatRes([]byte(`not json`)); err == nil {
- t.Fatal("expected parse error for non-JSON")
- }
-}
-
-func TestBuildReqOmitsThinkForNone(t *testing.T) {
- req := buildReq("m", "none", "", "p")
- if req.Think != "" {
- t.Fatalf("Think=%q, want empty", req.Think)
- }
- req = buildReq("m", "high", "", "p")
- if req.Think != "high" {
- t.Fatalf("Think=%q, want high", req.Think)
- }
-}
-
-func TestNewTranscriptUniquePaths(t *testing.T) {
- t.Setenv("XDG_DATA_HOME", t.TempDir())
- p1, err := newTranscript(transcript{Model: "m", Effort: "low", Messages: []message{{Role: "user", Content: "x"}, {Role: "assistant", Content: "y"}}})
- if err != nil {
- t.Fatal(err)
- }
- p2, err := newTranscript(transcript{Model: "m", Effort: "low", Messages: []message{{Role: "user", Content: "x"}, {Role: "assistant", Content: "y"}}})
- if err != nil {
- t.Fatal(err)
- }
- if p1 == p2 {
- t.Fatal("newTranscript produced duplicate paths")
- }
-}
blob - 1023dee492803d6cdb3c8bbef6f5114b3a9f7f85 (mode 644)
blob + /dev/null
--- alpacu/Makefile
+++ /dev/null
-.POSIX:
-
-SHELL = /bin/sh
-GO ?= go
-GOCACHE ?= $(HOME)/.cache/go-build
-BIN ?= alpacu
-PREFIX ?= /usr/local
-BINDIR ?= $(PREFIX)/bin
-MANDIR ?= $(PREFIX)/share/man
-DESTDIR ?=
-DOAS ?= sudo
-GOFLAGS ?=
-
-.PHONY: all build clean install uninstall vet
-
-all: build
-
-build:
- GOFLAGS='$(GOFLAGS)' CGO_ENABLED=0 GOCACHE=$(GOCACHE) $(GO) build -trimpath -buildvcs=false -o $(BIN) .
-
-vet:
- CGO_ENABLED=0 GOCACHE=$(GOCACHE) $(GO) vet ./...
-
-install: build
- $(DOAS) mkdir -p $(DESTDIR)$(BINDIR) $(DESTDIR)$(MANDIR)/man1
- $(DOAS) cp $(BIN) $(DESTDIR)$(BINDIR)/$(BIN)
- $(DOAS) chmod 0755 $(DESTDIR)$(BINDIR)/$(BIN)
- $(DOAS) cp $(BIN).1 $(DESTDIR)$(MANDIR)/man1/$(BIN).1
- $(DOAS) chmod 0644 $(DESTDIR)$(MANDIR)/man1/$(BIN).1
-
-uninstall:
- $(DOAS) rm -f $(DESTDIR)$(BINDIR)/$(BIN) $(DESTDIR)$(MANDIR)/man1/$(BIN).1
-
-clean:
- rm -f $(BIN)
blob - 0af891cd07617f550c4dc94f4997139702d6160f (mode 644)
blob + /dev/null
--- alpacu/alpacu.1
+++ /dev/null
-.\" alpacu . show Ollama Cloud account usage
-.Dd August 31, 2026
-.Dt ALPACU 1
-.Os
-.Sh NAME
-.Nm alpacu
-.Nd show Ollama Cloud session and weekly usage in the terminal
-.Sh SYNOPSIS
-.Nm
-.Op Fl k Ar key
-.Op Fl b Ar url
-.Op Fl j
-.Op Fl h
-.Sh DESCRIPTION
-.Nm
-calls the Ollama Cloud usage endpoint with your API key.
-It then prints your session quota (5 hour window) and weekly quota
-(7 day window) as ANSI bars showing the used share, a live countdown
-to the next reset, and a per-model request table.
-.Pp
-API key comes from
-.Fl k
-or
-.Ev OLLAMA_API_KEY .
-Base URL comes from
-.Fl b
-or
-.Ev OLLAMA_API_ENDPOINT .
-.Pp
-The reset countdown is computed locally from the current UTC time.
-Session resets align to multiples of 5 hours from the Unix epoch.
-Weekly resets are offset by 4 days from the epoch so all accounts share
-the same boundary.
-This matches the formula published in the
-.Lk https://github.com/ollama/ollama/issues/12532 ollama/ollama issue 12532 .
-.Sh OPTIONS
-.Bl -tag -width Ds
-.It Fl k Ar key
-API key.
-Falls back to
-.Ev OLLAMA_API_KEY .
-.It Fl b Ar url
-Base URL.
-Defaults to
-.Ev OLLAMA_API_ENDPOINT ,
-then
-.Cm https://ollama.com .
-Useful for local or staging instances.
-.It Fl j
-Print the raw JSON response instead of the rendered output.
-.It Fl h
-Show help and exit.
-.El
-.Sh ENVIRONMENT
-.Bl -tag -width Ds
-.It Ev OLLAMA_API_KEY
-API key used when
-.Fl k
-is not given.
-.It Ev OLLAMA_API_ENDPOINT
-Base URL used when
-.Fl b
-is not given.
-.El
-.Sh EXIT STATUS
-.Ex -std
-.Nm
-exits 2 for invalid options, missing API key, or operands.
-.Sh EXAMPLES
-Print the current usage:
-.Bd -literal -offset indent
-alpacu -k '$OLLAMA_API_KEY'
-.Ed
-.Pp
-Print the raw JSON:
-.Bd -literal -offset indent
-alpacu -j
-.Ed
-.Sh SEE ALSO
-.Xr curl 1
-.Pp
-.Lk https://docs.ollama.com/api/introduction Ollama API introduction
-.Lk https://docs.ollama.com/api/usage Ollama Cloud usage endpoint
-.Lk https://ollama.com/pricing Ollama Cloud pricing and reset windows
-.Sh AUTHORS
-.An m
-.Aq Mt miro@local
\ No newline at end of file
blob - 483cb7744fb59ddbc2fea3e42d1eaaabff3c5ff1 (mode 644)
blob + /dev/null
--- alpacu/go.mod
+++ /dev/null
-module alpacu
-
-go 1.26
blob - 5cef3c11a7e246561d6ddc5c081a845f23925afc (mode 644)
blob + /dev/null
--- alpacu/main.go
+++ /dev/null
-package main
-
-import (
- "cmp"
- "encoding/json"
- "flag"
- "fmt"
- "io"
- "net/http"
- "os"
- "slices"
- "strings"
- "time"
-)
-
-const (
- maxErrorBody = 64 << 10
- usage = `usage: alpacu [-k key] [-b url] [-j] [-h]
-
- -k key API key (else $OLLAMA_API_KEY)
- -b url base URL (default https://ollama.com)
- -j print raw JSON
- -h help
-`
-)
-
-var client = &http.Client{Timeout: 15 * time.Second}
-
-type Mcount struct {
- Name string `json:"name"`
- N int `json:"request_count"`
-}
-
-type Limit struct {
- Usage float64 `json:"usage"`
- Models []Mcount `json:"models"`
-}
-
-type Resp struct {
- Activity struct {
- Cost string `json:"cost"`
- Period struct {
- Type string `json:"type"`
- From string `json:"starting_at"`
- To string `json:"ending_at"`
- } `json:"period"`
- } `json:"activity"`
- Limits struct {
- Session Limit `json:"session"`
- Weekly Limit `json:"weekly"`
- } `json:"limits"`
-}
-
-func main() {
- key := flag.String("k", os.Getenv("OLLAMA_API_KEY"), "API key")
- base := flag.String("b", envOr("OLLAMA_API_ENDPOINT", "https://ollama.com"), "base URL")
- jsn := flag.Bool("j", false, "raw JSON")
- help := flag.Bool("h", false, "help")
- flag.Usage = func() { fmt.Fprint(os.Stderr, usage) }
- flag.Parse()
- if *help {
- fmt.Print(usage)
- return
- }
- if flag.NArg() != 0 {
- usageError("unexpected argument %q", flag.Arg(0))
- }
- if *key == "" {
- usageError("no API key")
- }
-
- r, err := fetch(*base, *key)
- must(err)
-
- if *jsn {
- b, err := json.MarshalIndent(r, "", " ")
- must(err)
- fmt.Println(string(b))
- return
- }
-
- render(r)
-}
-
-func fetch(base, key string) (*Resp, error) {
- req, err := http.NewRequest("GET", strings.TrimRight(base, "/")+"/api/usage", nil)
- if err != nil {
- return nil, err
- }
- req.Header.Set("Authorization", key)
- req.Header.Set("Accept", "application/json")
- res, err := client.Do(req)
- if err != nil {
- return nil, err
- }
- defer res.Body.Close()
- if res.StatusCode != http.StatusOK {
- b, err := io.ReadAll(io.LimitReader(res.Body, maxErrorBody))
- if err != nil {
- return nil, err
- }
- return nil, fmt.Errorf("status %d: %s", res.StatusCode, strings.TrimSpace(string(b)))
- }
- var r Resp
- if err := json.NewDecoder(res.Body).Decode(&r); err != nil {
- return nil, err
- }
- return &r, nil
-}
-
-func envOr(name, def string) string {
- if v := os.Getenv(name); v != "" {
- return v
- }
- return def
-}
-
-func must(err error) {
- if err != nil {
- fmt.Fprintf(os.Stderr, "alpacu: %v\n", err)
- os.Exit(1)
- }
-}
-
-func usageError(format string, args ...any) {
- fmt.Fprintf(os.Stderr, "alpacu: "+format+"\n", args...)
- fmt.Fprint(os.Stderr, usage)
- os.Exit(2)
-}
-
-// ANSI TUI
-
-const (
- clr = "\x1b[0m"
- dim = "\x1b[2m"
- bold = "\x1b[1m"
- red = "\x1b[31m"
- ylw = "\x1b[33m"
- grn = "\x1b[32m"
- cyn = "\x1b[36m"
- clrW = "\x1b[38;5;255m"
- bkW = "\x1b[48;5;237m"
- bkD = "\x1b[48;5;235m"
-)
-
-// clrPct colors a used fraction: green when light, red when nearly spent.
-func clrPct(f float64) string {
- switch {
- case f >= 1.0:
- return red + bold
- case f >= 0.9:
- return ylw + bold
- case f >= 0.5:
- return cyn
- default:
- return grn
- }
-}
-
-// countdown returns "resets in Xh Ym" for the next reset of a window
-// whose length is period seconds. Session resets align to UTC multiples
-// of 18000s (5h) from epoch. Weekly resets are offset by 4 days from
-// epoch (so all accounts share the same boundary). Matches the formula
-// in ollama/ollama issue #12532.
-func countdown(period int64, offsetSec int64) string {
- now := time.Now().UTC().Unix()
- left := period - ((now - offsetSec) % period)
- if left <= 0 {
- left = period
- }
- return fmt.Sprintf("resets in %dh %02dm", left/3600, (left%3600)/60)
-}
-
-func bar(f float64, w int) string {
- fill := int(float64(w) * f)
- if fill < 0 {
- fill = 0
- }
- if fill > w {
- fill = w
- }
- return strings.Repeat("█", fill) + strings.Repeat("░", w-fill)
-}
-
-func sorted(m []Mcount) []Mcount {
- s := slices.Clone(m)
- slices.SortFunc(s, func(a, b Mcount) int { return cmp.Compare(b.N, a.N) })
- return s
-}
-
-// row prints one quota bar showing the used share of the window.
-func row(label string, used float64, w int, note string) string {
- c := clrPct(used)
- pct := fmt.Sprintf("%5.1f%%", used*100)
- b := bar(used, w)
- return fmt.Sprintf(" %s%-7s%s %s%s%s %s%s%s %s\n",
- dim, label, clr, c, b, clr, c, pct, clr, note)
-}
-
-func render(r *Resp) {
- // header
- per := r.Activity.Period
- hdr := fmt.Sprintf("%sollama cloud usage%s %s%s . %s to %s%s",
- bold+cyn, clr, dim, per.Type, per.From, per.To, clr)
- if r.Activity.Cost != "" {
- hdr += fmt.Sprintf(" %sextra %s USD%s", dim, r.Activity.Cost, clr)
- }
-
- fmt.Println(hdr)
- fmt.Println()
- fmt.Println(row("session", r.Limits.Session.Usage, 40, dim+countdown(5*3600, 0)+clr))
- fmt.Println(row("weekly ", r.Limits.Weekly.Usage, 40, dim+countdown(7*86400, 4*86400)+clr))
-
- // models side-by-side
- sm := sorted(r.Limits.Session.Models)
- wm := sorted(r.Limits.Weekly.Models)
- h := len(sm)
- if len(wm) > h {
- h = len(wm)
- }
- if h == 0 {
- return
- }
- fmt.Println()
- fmt.Printf(" %s%s%-20s %6s %-20s %6s%s\n",
- bold, dim, "session models", "reqs", "weekly models", "reqs", clr)
- for i := 0; i < h; i++ {
- sl, sr := "", ""
- wl, wr := "", ""
- if i < len(sm) {
- sl, sr = sm[i].Name, fmt.Sprint(sm[i].N)
- }
- if i < len(wm) {
- wl, wr = wm[i].Name, fmt.Sprint(wm[i].N)
- }
- fmt.Printf(" %s%-20s%s %s%6s%s %s%-20s%s %s%6s%s\n",
- clrW+bkW, sl, clr,
- bold, sr, clr,
- clrW+bkD, wl, clr,
- bold, wr, clr,
- )
- }
-}
blob - /dev/null
blob + eb90d48c8d007760adabffc4c782c1355d769c68 (mode 644)
--- /dev/null
+++ man/alpaca-compose.1.scd
+alpaca-compose(1)
+
+# NAME
+
+alpaca compose - edit a prompt in the editor, then send it to the chat API
+
+# SYNOPSIS
+
+*alpaca compose* [*-m* _model_] [*-s* _msg_] [*-t* _temp_]
+\[*-T* _secs_] [*-c*] [*--reasoning-effort* _effort_]
+\[*--apikey* _key_] [*--base-url* _url_] [*--json* | *--jsonp*]
+
+# DESCRIPTION
+
+*alpaca compose* opens a temporary file in the editor, sends the saved text as
+a user prompt, and writes the reply to standard output. Piped standard input
+prefills the buffer first, 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, which keeps editor input and
+screen output out of the pipeline. Where no controlling terminal exists, the
+editor inherits this process's own streams instead. Saving an empty buffer
+sends nothing and exits 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.
+
+With *-c*, *alpaca compose* opens the newest saved conversation's final reply
+in the editor and keeps prompting, sending the whole conversation as context
+on each turn. The saved model, reasoning effort and system prompt are reused
+unless given on the command line. Save an empty buffer to leave the loop.
+
+# OPTIONS
+
+*-m*, *--model* _model_
+ Model to use. When continuing, defaults to the saved model, else
+ *ALPACA_MODEL*, else *gpt-oss:120b*.
+
+*-s*, *--system* _msg_
+ System prompt, sent before the conversation.
+
+*-t*, *--temperature* _temp_
+ Sampling temperature.
+
+*-T*, *--timeout* _secs_
+ Request timeout in seconds. Defaults to *ALPACA_TIMEOUT*, else 60.
+
+*-c*, *--continue*
+ Continue the newest saved conversation.
+
+*--reasoning-effort* _effort_
+ One of *low*, *medium*, *high* or *none*.
+
+*--apikey* _key_
+ API key. Defaults to *API_KEY*.
+
+*--base-url* _url_
+ Base URL of the API endpoint. Defaults to *API_ENDPOINT*.
+
+*--json*, *--jsonp*
+ Print the response as JSON instead of the reply text.
+
+# ENVIRONMENT
+
+*VISUAL*, *EDITOR*
+ Editor command, split on whitespace. *VISUAL* wins. Quotes and escapes are
+ not parsed; use a wrapper script for editor commands that need them. Without
+ either variable, *vim*(1) is used.
+
+*XDG_DATA_HOME*
+ Base directory for saved conversations.
+
+# CONFIGURATION
+
+This command uses applicable settings from the Alpaca config file. A config
+setting overrides a value saved in a transcript. Command-line options and
+environment variables take priority. See *alpaca-config*(5).
+
+# EXIT STATUS
+
+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
+
+Edit piped input before sending it:
+
+ $ git diff --staged | alpaca compose -s "Write a conventional commit message"
+
+Continue the newest conversation:
+
+ $ alpaca compose -c
+
+# SEE ALSO
+
+*alpaca*(1), *alpaca-quota*(1), *alpaca-config*(5)
blob - /dev/null
blob + 040356730015975f570aeb43c15d42fd6b03398e (mode 644)
--- /dev/null
+++ man/alpaca-config.5
+.\" Generated by scdoc 1.11.4
+.\" Complete documentation for this program is not available as a GNU info page
+.ie \n(.g .ds Aq \(aq
+.el .ds Aq '
+.nh
+.ad l
+.\" Begin generated content:
+.TH "alpaca-config" "5" "2026-09-04"
+.PP
+.SH NAME
+.PP
+alpaca-config - configure default alpaca options
+.PP
+.SH DESCRIPTION
+.PP
+Alpaca reads shared defaults from
+\fI$XDG_CONFIG_HOME/alpaca/config.\&toml\fR.\& When \fBXDG_CONFIG_HOME\fR is not set,
+Alpaca reads \fI\(ti/.\&config/alpaca/config.\&toml\fR.\&
+.PP
+The file is optional.\& Alpaca exits with an error if the file exists but cannot
+be read or parsed.\&
+.PP
+Command-line options override environment variables.\& Environment variables
+override this file.\& This file overrides built-in defaults.\&
+.PP
+.SH FORMAT
+.PP
+The file uses TOML.\& Unknown keys and invalid values are errors.\&
+.PP
+\fBoutput_format\fR
+.RS 4
+Output format.\& Use \fBplaintext\fR, \fBjson\fR or \fBjsonpretty\fR.\&
+.PP
+.RE
+\fBmodel\fR
+.RS 4
+Model identifier served by the configured endpoint.\&
+.PP
+.RE
+\fBtemperature\fR
+.RS 4
+Sampling temperature as a number.\&
+.PP
+.RE
+\fBtimeout\fR
+.RS 4
+Request timeout in seconds.\& The value must be greater than zero.\&
+.PP
+.RE
+\fBsystem\fR
+.RS 4
+System message.\&
+.PP
+.RE
+\fBassistant\fR
+.RS 4
+Array of assistant messages.\& These messages come before configured user
+messages and command-line messages.\&
+.PP
+.RE
+\fBuser\fR
+.RS 4
+Array of user messages.\& These messages come after configured assistant
+messages and before command-line messages.\&
+.PP
+.RE
+\fBapikey\fR
+.RS 4
+API key.\& Protect the file from other users if you set this value.\&
+.PP
+.RE
+\fBbase_url\fR
+.RS 4
+Base URL of the API endpoint.\&
+.PP
+.RE
+\fBreasoning_effort\fR
+.RS 4
+Reasoning effort.\& Use \fBnone\fR, \fBlow\fR, \fBmedium\fR or \fBhigh\fR.\&
+.PP
+.RE
+.SH COMMANDS
+.PP
+\fBalpaca\fR and \fBalpaca chat\fR use every setting.\&
+.PP
+\fBalpaca compose\fR uses \fBoutput_format\fR, \fBmodel\fR, \fBtemperature\fR, \fBtimeout\fR,
+\fBsystem\fR, \fBapikey\fR, \fBbase_url\fR and \fBreasoning_effort\fR.\& A config setting
+overrides a value saved in a transcript.\&
+.PP
+\fBalpaca quota\fR uses \fBoutput_format\fR, \fBtimeout\fR, \fBapikey\fR and \fBbase_url\fR.\&
+.PP
+.SH EXAMPLE
+.PP
+.RS 4
+output_format = "plaintext"
+model = "gpt-oss:120b"
+temperature = 0.\&7
+timeout = 60
+system = "Answer concisely.\&"
+assistant = ["An example assistant message"]
+user = ["An example user message"]
+apikey = "your-api-key"
+base_url = "https://ollama.\&com"
+reasoning_effort = "none"
+.PP
+.RE
+.SH FILES
+.PP
+\fI$XDG_CONFIG_HOME/alpaca/config.\&toml\fR
+.RS 4
+Config file when \fBXDG_CONFIG_HOME\fR is set.\&
+.PP
+.RE
+\fI\(ti/.\&config/alpaca/config.\&toml\fR
+.RS 4
+Config file when \fBXDG_CONFIG_HOME\fR is not set.\&
+.PP
+.RE
+.SH SEE ALSO
+.PP
+\fBalpaca\fR(1), \fBalpaca-compose\fR(1), \fBalpaca-quota\fR(1)
blob - /dev/null
blob + a75302c9de031d09cda53bcaba2368e8cce1d6cd (mode 644)
--- /dev/null
+++ man/alpaca-config.5.scd
+alpaca-config(5)
+
+# NAME
+
+alpaca-config - configure default alpaca options
+
+# DESCRIPTION
+
+Alpaca reads shared defaults from
+_$XDG_CONFIG_HOME/alpaca/config.toml_. When *XDG_CONFIG_HOME* is not set,
+Alpaca reads _~/.config/alpaca/config.toml_.
+
+The file is optional. Alpaca exits with an error if the file exists but cannot
+be read or parsed.
+
+Command-line options override environment variables. Environment variables
+override this file. This file overrides built-in defaults.
+
+# FORMAT
+
+The file uses TOML. Unknown keys and invalid values are errors.
+
+*output_format*
+ Output format. Use *plaintext*, *json* or *jsonpretty*.
+
+*model*
+ Model identifier served by the configured endpoint.
+
+*temperature*
+ Sampling temperature as a number.
+
+*timeout*
+ Request timeout in seconds. The value must be greater than zero.
+
+*system*
+ System message.
+
+*assistant*
+ 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
+ messages and before command-line messages.
+
+*apikey*
+ API key. Protect the file from other users if you set this value.
+
+*base_url*
+ Base URL of the API endpoint.
+
+*reasoning_effort*
+ Reasoning effort. Use *none*, *low*, *medium* or *high*.
+
+# COMMANDS
+
+*alpaca* and *alpaca chat* use every setting.
+
+*alpaca compose* uses *output_format*, *model*, *temperature*, *timeout*,
+*system*, *apikey*, *base_url* and *reasoning_effort*. A config setting
+overrides a value saved in a transcript.
+
+*alpaca quota* uses *output_format*, *timeout*, *apikey* and *base_url*.
+
+# EXAMPLE
+
+ output_format = "plaintext"
+ model = "gpt-oss:120b"
+ temperature = 0.7
+ timeout = 60
+ system = "Answer concisely."
+ assistant = ["An example assistant message"]
+ user = ["An example user message"]
+ apikey = "your-api-key"
+ base_url = "https://ollama.com"
+ reasoning_effort = "none"
+
+# FILES
+
+_$XDG_CONFIG_HOME/alpaca/config.toml_
+ Config file when *XDG_CONFIG_HOME* is set.
+
+_~/.config/alpaca/config.toml_
+ Config file when *XDG_CONFIG_HOME* is not set.
+
+# SEE ALSO
+
+*alpaca*(1), *alpaca-compose*(1), *alpaca-quota*(1)
blob - /dev/null
blob + c4abd648e99375f0c06686a610a3c0f5e142308c (mode 644)
--- /dev/null
+++ man/alpaca-quota.1.scd
+alpaca-quota(1)
+
+# NAME
+
+alpaca quota - show provider session and weekly usage in the terminal
+
+# SYNOPSIS
+
+*alpaca quota* [*-p* _provider_] [*-T* _secs_] [*--apikey* _key_]
+\[*--base-url* _url_] [*--raw*] [*--json* | *--jsonp*] [*--color* _when_]
+
+# DESCRIPTION
+
+*alpaca quota* calls the usage endpoint of one provider with your credential.
+It prints each quota window as a bar showing the used share, a countdown to the
+next reset, and, where the provider reports them, a per-model request table.
+
+The provider is chosen with *-p*, and defaults to *ollama*. Each provider has
+its own default credential and base URL, either of which can be overridden.
+
+*ollama*
+ Ollama Cloud. Key from *OLLAMA_API_KEY*, base URL from
+ *OLLAMA_API_ENDPOINT*, else *https://ollama.com*. Prints the session
+ (5 hour) and weekly (7 day) quotas and a per-model request table for each.
+ The endpoint reports no reset time, so the countdowns are computed locally:
+ session windows align to UTC multiples of 5 hours from the epoch, and weekly
+ windows use the same rule with a 4 day offset, so all accounts share the same
+ boundary.
+
+*anthropic*
+ Claude Code. Token from *CLAUDE_CODE_OAUTH_TOKEN*, else
+ _~/.claude/.credentials.json_. Base URL *https://api.anthropic.com*.
+ Prints whichever windows the account has, and extra spend when it is enabled.
+
+*openai*
+ Codex CLI. Token from *CODEX_ACCESS_TOKEN*, else _~/.codex/auth.json_. Base
+ URL *https://chatgpt.com*. Prints the plan, its rate limit windows, and the
+ credit balance.
+
+The *anthropic* and *openai* endpoints serve the vendor CLIs. They are not
+documented public APIs, so they may change without notice. Both expect a short
+lived OAuth token: when the credential file is stale, expect an authentication
+error until the vendor CLI refreshes it.
+
+# OPTIONS
+
+*-p*, *--provider* _provider_
+ One of *ollama*, *anthropic* or *openai*.
+
+*-T*, *--timeout* _secs_
+ Request timeout in seconds. Defaults to *ALPACA_TIMEOUT*, else 60.
+
+*--apikey* _key_
+ API key or OAuth token, replacing the provider default.
+
+*--base-url* _url_
+ Base URL, replacing the provider default.
+
+*--raw*
+ Print the provider response body verbatim.
+
+*--json*, *--jsonp*
+ Print the normalised view as JSON, compact or indented.
+
+*--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.
+
+# CONFIGURATION
+
+This command uses *output_format*, *timeout*, *apikey* and *base_url* from the
+Alpaca config file. Command-line options and environment variables take
+priority. See *alpaca-config*(5).
+
+# EXIT STATUS
+
+Usage errors exit 2.
+
+# SEE ALSO
+
+*alpaca*(1), *alpaca-compose*(1), *alpaca-config*(5)
blob - /dev/null
blob + 4dff79521121c2cd41d1f2512d254a6e19375479 (mode 644)
--- /dev/null
+++ man/alpaca.1.scd
+alpaca(1)
+
+# NAME
+
+alpaca - Unix native interface for LLMs
+
+# SYNOPSIS
+
+*alpaca* [*-m* _model_] [*-t* _temp_] [*-T* _secs_] [*-s* _msg_]
+\[*-u* _msg_] [*-a* _msg_] [*--reasoning-effort* _effort_]
+\[*--apikey* _key_] [*--base-url* _url_] [*--json* | *--jsonp*] [_file_]
+
+*alpaca chat* [_..._]
+
+*alpaca quota* [_..._]
+
+*alpaca compose* [_..._]
+
+# DESCRIPTION
+
+*alpaca* sends chat requests to a model and writes the reply to standard
+output. Without a subcommand it chats, so *alpaca* composes with pipes, files,
+editor buffers and other programs.
+
+Three subcommands are available:
+
+*chat*
+ Send a chat request. This is the default, so *alpaca -u "hello"* and
+ *alpaca chat -u "hello"* are the same command. See the options below.
+
+*quota*
+ Report how much provider quota is used. See *alpaca-quota*(1).
+
+*compose*
+ Edit a prompt in the editor, then send it. See *alpaca-compose*(1).
+
+A first argument matching a subcommand name is treated as that subcommand. To
+read messages from a file with such a name, separate it with *--*, as in
+*alpaca -- quota*.
+
+# OPTIONS
+
+*-m*, *--model* _model_
+ Model to use. Defaults to *ALPACA_MODEL*, else *gpt-oss:120b*.
+
+*-t*, *--temperature* _temp_
+ Sampling temperature.
+
+*-T*, *--timeout* _secs_
+ Request timeout in seconds. Defaults to *ALPACA_TIMEOUT*, else 60.
+
+*-s*, *--system* _msg_
+ System prompt. Always sent first.
+
+*-u*, *--user* _msg_
+ Append a user message. May be repeated.
+
+*-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.
+
+*--reasoning-effort* _effort_
+ One of *low*, *medium*, *high* or *none*. Defaults to *ALPACA_EFFORT*,
+ else *none*, which omits the field from the request.
+
+*--apikey* _key_
+ API key. Defaults to *API_KEY*. Authorization is only sent when a key is
+ known, so endpoints that need no authentication also work.
+
+*--base-url* _url_
+ Base URL of the API endpoint. Defaults to *API_ENDPOINT*, else
+ *https://ollama.com*. A base URL ending in a version segment, such as
+ _/v1_, addresses a chat completions endpoint; any other base URL addresses
+ _{base}/api/chat_.
+
+*--output-format* _format_
+ One of *plaintext*, *json* or *jsonpretty*.
+
+*--json*, *--jsonp*
+ Shorthands for the two JSON formats.
+
+# OPERANDS
+
+_file_
+ File providing a message to append to the chat log. Defaults to *-*, which
+ reads non-tty standard input.
+
+# CONFIGURATION
+
+Use _$XDG_CONFIG_HOME/alpaca/config.toml_ for shared defaults. Alpaca uses
+_~/.config/alpaca/config.toml_ when *XDG_CONFIG_HOME* is not set.
+
+Command-line options override environment variables. Environment variables
+override the config file. See *alpaca-config*(5) for the file format.
+
+# ENVIRONMENT
+
+*API_KEY*
+ Default API key.
+
+*API_ENDPOINT*
+ Default base URL.
+
+*ALPACA_MODEL*
+ Default model.
+
+*ALPACA_EFFORT*
+ Default reasoning effort.
+
+*ALPACA_TIMEOUT*
+ Default request timeout, in seconds.
+
+# EXIT STATUS
+
+Usage errors exit 2.
+
+# EXAMPLES
+
+Answer a question from standard input:
+
+ $ echo "What is 50 + 50?" | alpaca
+
+Write a commit message for staged changes:
+
+ $ git diff --staged | alpaca -s "Write a conventional commit message" | git commit -F -
+
+# SEE ALSO
+
+*alpaca-compose*(1), *alpaca-quota*(1), *alpaca-config*(5)
blob - 2fd12d6e6ab5743b8a83fbb57d80fd7a9a2a0ef1
blob + e62d0ec386b951e70ab6bb729059f565bb7bd780
--- src/cli.rs
+++ src/cli.rs
//! Command line interface for alpaca
+use std::ffi::OsString;
+use std::fs;
use std::path::PathBuf;
use std::time::Duration;
-use crate::ollama::{Message, ReasoningEffort};
+use crate::api::{Message, ReasoningEffort};
+use crate::usage::Provider;
+use anyhow::{Context, Result, bail};
use clap::{
- ArgGroup, ArgMatches, Command, ValueEnum, arg, builder::PossibleValue, command, value_parser,
+ Arg, ArgGroup, ArgMatches, Command, ValueEnum, arg, builder::PossibleValue, command,
+ value_parser,
};
use derive_builder::Builder;
+use serde::Deserialize;
-/// CLI invocations that can be launched
+/// Default model used by every subcommand that talks to a model
+pub const DEFAULT_MODEL: &str = "gpt-oss:120b";
+/// Default request timeout, in seconds
+pub const DEFAULT_TIMEOUT_SECS: &str = "60";
+/// Default API endpoint
+///
+/// Any host serving a compatible chat endpoint works, so this is only a
+/// starting point: set `API_ENDPOINT` or `--base-url` to point elsewhere.
+pub const DEFAULT_BASE_URL: &str = "https://ollama.com";
+#[derive(Debug, Default, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct Config {
+ output_format: Option<OutputFormat>,
+ model: Option<String>,
+ temperature: Option<f32>,
+ timeout: Option<u64>,
+ system: Option<String>,
+ #[serde(default)]
+ assistant: Vec<String>,
+ #[serde(default)]
+ user: Vec<String>,
+ apikey: Option<String>,
+ base_url: Option<String>,
+ reasoning_effort: Option<Effort>,
+}
+
+impl Config {
+ fn load() -> Result<Self> {
+ let Some(path) = config_path() else {
+ return Ok(Self::default());
+ };
+
+ let contents = match fs::read_to_string(&path) {
+ Ok(contents) => contents,
+ Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Self::default()),
+ Err(err) => {
+ return Err(err).with_context(|| format!("failed to read {}", path.display()));
+ }
+ };
+
+ Self::from_toml(&contents)
+ .with_context(|| format!("invalid configuration {}", path.display()))
+ }
+
+ fn from_toml(contents: &str) -> Result<Self> {
+ let config: Self = toml::from_str(contents).context("failed to parse TOML")?;
+ if config.timeout == Some(0) {
+ bail!("timeout must be greater than zero");
+ }
+ Ok(config)
+ }
+}
+
+fn config_path() -> Option<PathBuf> {
+ config_path_from(
+ std::env::var_os("XDG_CONFIG_HOME").filter(|path| !path.is_empty()),
+ crate::home_dir().ok(),
+ )
+}
+
+fn config_path_from(xdg_config_home: Option<OsString>, home: Option<PathBuf>) -> Option<PathBuf> {
+ xdg_config_home
+ .map(PathBuf::from)
+ .or_else(|| home.map(|path| path.join(".config")))
+ .map(|path| path.join("alpaca").join("config.toml"))
+}
+
+/// Applies `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 {
+ arg.default_value(value)
+ } else {
+ arg
+ }
+}
+
+/// A parsed command line invocation
+#[derive(Debug)]
+pub enum Invocation {
+ /// Send a chat request built from flags, a file or stdin
+ Chat(ChatArgs),
+ /// Report provider quota usage
+ Quota(QuotaArgs),
+ /// Edit a prompt in `$EDITOR`, then send it
+ Compose(ComposeArgs),
+}
+
+/// Arguments for the chat subcommand, which is also the default command
#[derive(Debug, Default, Builder)]
-pub struct Invocation {
+pub struct ChatArgs {
pub(crate) api_key: Option<String>,
pub(crate) messages: Vec<Message>,
pub(crate) model: String,
pub(crate) timeout: Duration,
#[builder(default)]
pub(crate) reasoning_effort: Option<ReasoningEffort>,
+ #[builder(default)]
+ pub(crate) base_url: Option<String>,
}
+/// Arguments for the quota subcommand
+#[derive(Debug, Default, Builder)]
+pub struct QuotaArgs {
+ pub(crate) provider: Provider,
+ #[builder(default)]
+ pub(crate) api_key: Option<String>,
+ #[builder(default)]
+ pub(crate) base_url: Option<String>,
+ pub(crate) timeout: Duration,
+ pub(crate) output_format: OutputFormat,
+ /// Print the provider response body verbatim
+ #[builder(default)]
+ pub(crate) raw: bool,
+ pub(crate) color: ColorChoice,
+}
+
+/// Arguments for the compose subcommand
+///
+/// Model, effort and system prompt are optional here: when a saved
+/// conversation is continued, values left unset are taken from the transcript
+/// rather than from the defaults.
+#[derive(Debug, Default, Builder)]
+pub struct ComposeArgs {
+ #[builder(default)]
+ pub(crate) api_key: Option<String>,
+ #[builder(default)]
+ pub(crate) model: Option<String>,
+ #[builder(default)]
+ pub(crate) system: Option<String>,
+ #[builder(default)]
+ pub(crate) temperature: Option<f32>,
+ pub(crate) timeout: Duration,
+ #[builder(default)]
+ pub(crate) reasoning_effort: Option<Effort>,
+ pub(crate) output_format: OutputFormat,
+ #[builder(default)]
+ pub(crate) base_url: Option<String>,
+ /// Continue the newest saved conversation
+ #[builder(default)]
+ pub(crate) continue_conversation: bool,
+}
+
/// The format that invocation's results are in
-#[derive(Debug, Default, PartialEq, Clone, Copy)]
+#[derive(Debug, Default, PartialEq, Eq, Clone, Copy, Deserialize)]
+#[serde(rename_all = "lowercase")]
pub enum OutputFormat {
#[default]
Plaintext,
JSONPretty,
}
+/// When to emit ANSI colour
+#[derive(Debug, Default, PartialEq, Eq, Clone, Copy)]
+pub enum ColorChoice {
+ /// Colour when stdout is a terminal and `NO_COLOR` is unset
+ #[default]
+ Auto,
+ Always,
+ Never,
+}
+
+/// Reasoning effort as accepted on the command line, where `none` omits the
+/// `think` field from the request
+#[derive(Debug, Default, PartialEq, Eq, Clone, Copy, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Effort {
+ #[default]
+ None,
+ Low,
+ Medium,
+ High,
+}
+
+impl From<Effort> for Option<ReasoningEffort> {
+ fn from(effort: Effort) -> Self {
+ match effort {
+ Effort::None => None,
+ Effort::Low => Some(ReasoningEffort::Low),
+ Effort::Medium => Some(ReasoningEffort::Medium),
+ Effort::High => Some(ReasoningEffort::High),
+ }
+ }
+}
+
+impl From<Option<ReasoningEffort>> for Effort {
+ fn from(effort: Option<ReasoningEffort>) -> Self {
+ match effort {
+ None => Effort::None,
+ Some(ReasoningEffort::Low) => Effort::Low,
+ Some(ReasoningEffort::Medium) => Effort::Medium,
+ Some(ReasoningEffort::High) => Effort::High,
+ }
+ }
+}
+
/// Parse commandline arguments into `Invocation`. May exit with help or error message
-#[must_use]
-pub fn parse() -> Invocation {
- cli().get_matches().into()
+pub fn parse() -> Result<Invocation> {
+ let args: Vec<OsString> = std::env::args_os().collect();
+
+ // Preserve clap's help, version and usage-error exits without consulting a
+ // potentially malformed optional configuration file.
+ cli(&Config::default())
+ .try_get_matches_from(&args)
+ .unwrap_or_else(|err| err.exit());
+
+ let config = Config::load()?;
+ let matches = cli(&config).get_matches_from(args);
+ Ok(invocation_from_matches(matches, &config))
}
-/// Top-level command
-fn cli() -> Command {
- command!()
- .arg(arg!(model: -m --model <MODEL> "Sets model. See https://ollama.com/library for model identifiers.").default_value("gpt-oss:120b"))
- .arg(
+/// Top-level command. Without a subcommand, alpaca chats
+fn cli(config: &Config) -> Command {
+ chat_args(command!(), config)
+ .subcommand(chat_args(
+ Command::new("chat").about("Send a chat request (default command)"),
+ config,
+ ))
+ .subcommand(quota_command(config))
+ .subcommand(compose_command(config))
+ .args_conflicts_with_subcommands(true)
+ .subcommand_negates_reqs(true)
+}
+
+#[cfg(test)]
+fn parse_args_with_config(args: &[&str], config: &Config) -> Result<Invocation> {
+ let matches = cli(config).try_get_matches_from(args)?;
+ Ok(invocation_from_matches(matches, config))
+}
+
+/// Arguments shared by the default command and `alpaca chat`
+fn chat_args(cmd: Command, config: &Config) -> Command {
+ output_format_args(cmd, config)
+ .arg(model_arg().default_value(
+ config.model.clone().unwrap_or_else(|| DEFAULT_MODEL.to_string()),
+ ))
+ .arg(default_value_opt(
arg!(temperature: -t --temperature <TEMP> "Sets temperature")
.value_parser(value_parser!(f32)),
- )
+ config.temperature.map(|value| value.to_string()),
+ ))
+ .arg(timeout_arg(config))
+ .arg(default_value_opt(
+ arg!(system_message: -s --system <MSG> "Sets system prompt").required(false),
+ config.system.clone(),
+ ))
.arg(
- arg!(timeout: -T --timeout <DURATION> "Sets timeout duration in seconds")
- .value_parser(value_parser!(u64))
- .default_value("60")
- )
- .arg(arg!(system_message: -s --system <MSG> "Sets system prompt").required(false))
- .arg(
arg!(assistant_messages: -a --assistant <MSG> ... "Appends assistant message")
.required(false),
)
.arg(arg!(user_messages: -u --user <MSG> ... "Appends user message").required(false))
- .arg(
- arg!(api_key: --apikey <API_KEY> "Sets API Key to use")
- .env("OLLAMA_API_KEY")
- .hide_env_values(true),
- )
- .arg(
- arg!(output_format: --output_format <FORMAT> "Sets output format")
- .value_parser(value_parser!(OutputFormat))
- .conflicts_with("output_format_short")
- .default_value_ifs([
- ("json", "true", Some("json")),
- ("jsonp", "true", Some("jsonpretty")),
- ])
- .default_value("plaintext"),
- )
- .arg(
- arg!(reasoning_effort: --"reasoning-effort" <EFFORT> "Sets reasoning effort (low, medium, high)")
- .value_parser(value_parser!(ReasoningEffort))
- .required(false),
- )
- .arg(arg!(--json "Shorthand for --output_format json"))
- .arg(arg!(--jsonp "Shorthand for --output_format jsonpretty"))
- .group(ArgGroup::new("output_format_short").args(["json", "jsonp"]))
+ .arg(api_key_arg(config))
+ .arg(base_url_arg(config))
+ .arg(default_value_opt(
+ effort_arg(),
+ config.reasoning_effort.map(effort_name),
+ ))
.arg(arg!(file: [FILE] "File providing messages to append to chat log. If \"-\", reads from non-tty stdin").default_value("-"))
}
+fn quota_command(config: &Config) -> Command {
+ output_format_args(
+ Command::new("quota").about("Report how much provider quota is used"),
+ config,
+ )
+ .arg(
+ arg!(provider: -p --provider <PROVIDER> "Sets usage provider")
+ .value_parser(value_parser!(Provider))
+ .default_value("ollama"),
+ )
+ .arg(api_key_arg(config))
+ .arg(base_url_arg(config))
+ .arg(timeout_arg(config))
+ .arg(arg!(raw: --raw "Prints the provider response body verbatim"))
+ .arg(
+ arg!(color: --color <WHEN> "Sets when to colour output")
+ .value_parser(value_parser!(ColorChoice))
+ .default_value("auto"),
+ )
+}
+
+fn compose_command(config: &Config) -> Command {
+ output_format_args(
+ Command::new("compose").about("Edit a prompt in $EDITOR, then send it"),
+ config,
+ )
+ .arg(default_value_opt(model_arg(), config.model.clone()))
+ .arg(default_value_opt(
+ arg!(temperature: -t --temperature <TEMP> "Sets temperature")
+ .value_parser(value_parser!(f32)),
+ config.temperature.map(|value| value.to_string()),
+ ))
+ .arg(default_value_opt(
+ arg!(system_message: -s --system <MSG> "Sets system prompt").required(false),
+ config.system.clone(),
+ ))
+ .arg(timeout_arg(config))
+ .arg(api_key_arg(config))
+ .arg(base_url_arg(config))
+ .arg(default_value_opt(
+ effort_arg(),
+ config.reasoning_effort.map(effort_name),
+ ))
+ .arg(arg!(continue_conversation: -c --"continue" "Continues the newest saved conversation"))
+}
+
+fn model_arg() -> Arg {
+ arg!(model: -m --model <MODEL> "Sets model. Identifiers are whatever the endpoint serves.")
+ .env("ALPACA_MODEL")
+}
+
+fn timeout_arg(config: &Config) -> Arg {
+ arg!(timeout: -T --timeout <DURATION> "Sets timeout duration in seconds")
+ .value_parser(value_parser!(u64).range(1..))
+ .env("ALPACA_TIMEOUT")
+ .default_value(config.timeout.map_or_else(
+ || DEFAULT_TIMEOUT_SECS.to_string(),
+ |value| value.to_string(),
+ ))
+}
+
+fn effort_arg() -> Arg {
+ arg!(reasoning_effort: --"reasoning-effort" <EFFORT> "Sets reasoning effort")
+ .value_parser(value_parser!(Effort))
+ .env("ALPACA_EFFORT")
+ .required(false)
+}
+
+fn effort_name(effort: Effort) -> String {
+ effort
+ .to_possible_value()
+ .expect("effort has a value")
+ .get_name()
+ .to_string()
+}
+
+fn api_key_arg(config: &Config) -> Arg {
+ default_value_opt(
+ arg!(api_key: --apikey <API_KEY> "Sets the API key to use")
+ .env("API_KEY")
+ .hide_env_values(true)
+ .hide_default_value(true),
+ config.apikey.clone(),
+ )
+}
+
+fn base_url_arg(config: &Config) -> Arg {
+ default_value_opt(
+ arg!(base_url: --"base-url" <URL> "Sets base URL of the API endpoint")
+ .env("API_ENDPOINT")
+ .required(false),
+ config.base_url.clone(),
+ )
+}
+
+fn output_format_args(cmd: Command, config: &Config) -> Command {
+ let default = config
+ .output_format
+ .unwrap_or_default()
+ .to_possible_value()
+ .expect("output format has a value")
+ .get_name()
+ .to_string();
+
+ cmd.arg(
+ arg!(output_format: --output_format <FORMAT> "Sets output format")
+ .value_parser(value_parser!(OutputFormat))
+ .conflicts_with("output_format_short")
+ .default_value_ifs([
+ ("json", "true", Some("json")),
+ ("jsonp", "true", Some("jsonpretty")),
+ ])
+ .default_value(default),
+ )
+ .arg(arg!(--json "Shorthand for --output_format json"))
+ .arg(arg!(--jsonp "Shorthand for --output_format jsonpretty"))
+ .group(ArgGroup::new("output_format_short").args(["json", "jsonp"]))
+}
+
+fn invocation_from_matches(matches: ArgMatches, config: &Config) -> Invocation {
+ match matches.subcommand() {
+ Some(("quota", sub)) => Invocation::Quota(QuotaArgs::from(sub)),
+ Some(("compose", sub)) => Invocation::Compose(ComposeArgs::from(sub)),
+ Some(("chat", sub)) => Invocation::Chat(chat_args_from(sub, config)),
+ _ => Invocation::Chat(chat_args_from(&matches, config)),
+ }
+}
+
impl From<ArgMatches> for Invocation {
fn from(matches: ArgMatches) -> Self {
- let api_key = matches.get_one::<String>("api_key").cloned();
- let messages = Invocation::messages_from_matches(&matches);
- let model = matches
- .get_one::<String>("model")
- .expect("Model is required")
- .clone();
+ invocation_from_matches(matches, &Config::default())
+ }
+}
- let temperature = matches.get_one::<f32>("temperature").copied();
+impl From<&ArgMatches> for ChatArgs {
+ fn from(matches: &ArgMatches) -> Self {
+ chat_args_from(matches, &Config::default())
+ }
+}
- let timeout = matches
- .get_one::<u64>("timeout")
- .map(|t| Duration::from_secs(*t))
- .expect("Timeout is required");
-
- let output_format = *matches
- .get_one::<OutputFormat>("output_format")
- .expect("Output format is required");
-
- let file = matches
+fn chat_args_from(matches: &ArgMatches, config: &Config) -> ChatArgs {
+ ChatArgs {
+ api_key: api_key_of(matches),
+ messages: messages_from_matches(matches, config),
+ model: model_of(matches),
+ temperature: matches.get_one::<f32>("temperature").copied(),
+ output_format: output_format_of(matches),
+ file: matches
.get_one::<String>("file")
.expect("File is required")
- .clone()
- .into();
+ .into(),
+ timeout: timeout_of(matches),
+ reasoning_effort: effort_of(matches),
+ base_url: base_url_of(matches),
+ }
+}
- let reasoning_effort = matches
- .get_one::<ReasoningEffort>("reasoning_effort")
- .copied();
+impl From<&ArgMatches> for QuotaArgs {
+ fn from(matches: &ArgMatches) -> Self {
+ Self {
+ provider: *matches
+ .get_one::<Provider>("provider")
+ .expect("Provider is required"),
+ // Quota takes no API key from the environment: each provider
+ // resolves its own credential when the flag is absent.
+ api_key: api_key_of(matches),
+ base_url: base_url_of(matches),
+ timeout: timeout_of(matches),
+ output_format: output_format_of(matches),
+ raw: matches.get_flag("raw"),
+ color: *matches
+ .get_one::<ColorChoice>("color")
+ .expect("Color is required"),
+ }
+ }
+}
+impl From<&ArgMatches> for ComposeArgs {
+ fn from(matches: &ArgMatches) -> Self {
Self {
- api_key,
- messages,
- model,
- temperature,
- output_format,
- file,
- timeout,
- reasoning_effort,
+ api_key: api_key_of(matches),
+ model: matches.get_one::<String>("model").cloned(),
+ system: matches.get_one::<String>("system_message").cloned(),
+ temperature: matches.get_one::<f32>("temperature").copied(),
+ timeout: timeout_of(matches),
+ reasoning_effort: matches.get_one::<Effort>("reasoning_effort").copied(),
+ output_format: output_format_of(matches),
+ base_url: base_url_of(matches),
+ continue_conversation: matches.get_flag("continue_conversation"),
}
}
}
-impl Invocation {
- /// Builder
- #[must_use]
- pub fn builder() -> InvocationBuilder {
- InvocationBuilder::default()
+fn api_key_of(matches: &ArgMatches) -> Option<String> {
+ matches
+ .get_one::<String>("api_key")
+ .filter(|key| !key.is_empty())
+ .cloned()
+}
+
+fn base_url_of(matches: &ArgMatches) -> Option<String> {
+ matches
+ .get_one::<String>("base_url")
+ .filter(|url| !url.is_empty())
+ .cloned()
+}
+
+fn model_of(matches: &ArgMatches) -> String {
+ matches
+ .get_one::<String>("model")
+ .expect("Model is required")
+ .clone()
+}
+
+fn timeout_of(matches: &ArgMatches) -> Duration {
+ matches
+ .get_one::<u64>("timeout")
+ .copied()
+ .map(Duration::from_secs)
+ .expect("Timeout is required")
+}
+
+fn output_format_of(matches: &ArgMatches) -> OutputFormat {
+ *matches
+ .get_one::<OutputFormat>("output_format")
+ .expect("Output format is required")
+}
+
+fn effort_of(matches: &ArgMatches) -> Option<ReasoningEffort> {
+ matches
+ .get_one::<Effort>("reasoning_effort")
+ .copied()
+ .unwrap_or_default()
+ .into()
+}
+
+/// Given `clap::ArgMatches`, creates a vector of `Message` with assigned roles and ordering
+fn messages_from_matches(matches: &ArgMatches, config: &Config) -> Vec<Message> {
+ let mut messages = config
+ .assistant
+ .iter()
+ .map(|msg| Message::assistant(msg))
+ .chain(config.user.iter().map(|msg| Message::user(msg)))
+ .collect::<Vec<_>>();
+ let mut command_line_messages = vec![];
+
+ if let Some(user_msgs) = matches.get_many::<String>("user_messages") {
+ let indices = matches.indices_of("user_messages").unwrap();
+ command_line_messages.extend(
+ user_msgs
+ .zip(indices)
+ .map(|(c, idx)| (Message::user(c), idx)),
+ );
}
+ if let Some(asst_msgs) = matches.get_many::<String>("assistant_messages") {
+ let indices = matches.indices_of("assistant_messages").unwrap();
+ command_line_messages.extend(
+ asst_msgs
+ .zip(indices)
+ .map(|(c, idx)| (Message::assistant(c), idx)),
+ );
+ }
+ command_line_messages.sort_by_key(|(_, idx)| *idx);
+ messages.extend(command_line_messages.into_iter().map(|(msg, _)| msg));
- /// Given `clap::ArgMatches`, creates a vector of `Message` with assigned roles and ordering
- fn messages_from_matches(matches: &ArgMatches) -> Vec<Message> {
- let mut messages = vec![];
+ // System message is always first
+ if let Some(system_msg) = matches.get_one::<String>("system_message") {
+ messages.insert(0, Message::system(system_msg));
+ }
- if let Some(user_msgs) = matches.get_many::<String>("user_messages") {
- let indices = matches.indices_of("user_messages").unwrap();
- messages.extend(
- user_msgs
- .zip(indices)
- .map(|(c, idx)| (Message::user(c), idx)),
- );
- }
- if let Some(asst_msgs) = matches.get_many::<String>("assistant_messages") {
- let indices = matches.indices_of("assistant_messages").unwrap();
- messages.extend(
- asst_msgs
- .zip(indices)
- .map(|(c, idx)| (Message::assistant(c), idx)),
- );
- }
- messages.sort_by_key(|(_, idx)| *idx);
- let mut messages = messages.into_iter().map(|(msg, _)| msg).collect::<Vec<_>>();
+ messages
+}
- // System message is always first
- if let Some(system_msg) = matches.get_one::<String>("system_message") {
- messages.insert(0, Message::system(system_msg));
- }
+impl ChatArgs {
+ /// Builder
+ #[must_use]
+ pub fn builder() -> ChatArgsBuilder {
+ ChatArgsBuilder::default()
+ }
+}
- messages
+impl QuotaArgs {
+ /// Builder
+ #[must_use]
+ pub fn builder() -> QuotaArgsBuilder {
+ QuotaArgsBuilder::default()
}
}
+impl ComposeArgs {
+ /// Builder
+ #[must_use]
+ pub fn builder() -> ComposeArgsBuilder {
+ ComposeArgsBuilder::default()
+ }
+}
+
impl ValueEnum for OutputFormat {
fn value_variants<'a>() -> &'a [Self] {
&[Self::Plaintext, Self::JSON, Self::JSONPretty]
}
}
+impl ValueEnum for ColorChoice {
+ fn value_variants<'a>() -> &'a [Self] {
+ &[Self::Auto, Self::Always, Self::Never]
+ }
+
+ fn to_possible_value(&self) -> Option<PossibleValue> {
+ Some(match self {
+ Self::Auto => PossibleValue::new("auto"),
+ Self::Always => PossibleValue::new("always"),
+ Self::Never => PossibleValue::new("never"),
+ })
+ }
+}
+
+impl ValueEnum for Provider {
+ fn value_variants<'a>() -> &'a [Self] {
+ &[Self::Ollama, Self::Anthropic, Self::OpenAI]
+ }
+
+ fn to_possible_value(&self) -> Option<PossibleValue> {
+ Some(PossibleValue::new(match self {
+ Self::Ollama => "ollama",
+ Self::Anthropic => "anthropic",
+ Self::OpenAI => "openai",
+ }))
+ }
+}
+
+impl ValueEnum for Effort {
+ fn value_variants<'a>() -> &'a [Self] {
+ &[Self::None, Self::Low, Self::Medium, Self::High]
+ }
+
+ fn to_possible_value(&self) -> Option<PossibleValue> {
+ Some(match self {
+ Self::None => PossibleValue::new("none"),
+ Self::Low => PossibleValue::new("low"),
+ Self::Medium => PossibleValue::new("medium"),
+ Self::High => PossibleValue::new("high"),
+ })
+ }
+}
+
impl ValueEnum for ReasoningEffort {
fn value_variants<'a>() -> &'a [Self] {
&[Self::Low, Self::Medium, Self::High]
}
#[cfg(test)]
+mod config_tests;
+
+#[cfg(test)]
mod test {
use super::*;
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
+ /// Clears the environment variables the CLI reads, so a developer's own
+ /// ALPACA_* or API_* settings cannot change parsing results. Runs once,
+ /// before any test in this module parses arguments.
+ static CLEAN_ENV: std::sync::LazyLock<()> = std::sync::LazyLock::new(|| {
+ for key in [
+ "ALPACA_MODEL",
+ "ALPACA_EFFORT",
+ "ALPACA_TIMEOUT",
+ "API_KEY",
+ "API_ENDPOINT",
+ ] {
+ // SAFETY: this runs exactly once and every parse in this module
+ // waits on it, so no other thread reads the environment meanwhile.
+ unsafe { std::env::remove_var(key) };
+ }
+ });
+
+ fn parse_args(args: &[&str]) -> Result<Invocation> {
+ std::sync::LazyLock::force(&CLEAN_ENV);
+ Ok(cli(&Config::default())
+ .try_get_matches_from(args)
+ .map(Invocation::from)?)
+ }
+
+ fn chat_args(args: &[&str]) -> Result<ChatArgs> {
+ match parse_args(args)? {
+ Invocation::Chat(args) => Ok(args),
+ other => Err(format!("expected chat invocation, got {other:?}").into()),
+ }
+ }
+
+ fn quota_args(args: &[&str]) -> Result<QuotaArgs> {
+ match parse_args(args)? {
+ Invocation::Quota(args) => Ok(args),
+ other => Err(format!("expected quota invocation, got {other:?}").into()),
+ }
+ }
+
+ fn compose_args(args: &[&str]) -> Result<ComposeArgs> {
+ match parse_args(args)? {
+ Invocation::Compose(args) => Ok(args),
+ other => Err(format!("expected compose invocation, got {other:?}").into()),
+ }
+ }
+
#[test]
+ fn verify_cli() {
+ cli(&Config::default()).debug_assert();
+ }
+
+ #[test]
fn chat_one_msgs() -> Result<()> {
- let args = cli()
- .try_get_matches_from(vec!["alpaca", "-u", "USER"])
- .map(Invocation::from)?;
-
+ let args = chat_args(&["alpaca", "-u", "USER"])?;
assert_eq!(args.messages, vec![Message::user("USER")]);
Ok(())
}
#[test]
fn chat_many_msgs() -> Result<()> {
- let args = cli()
- .try_get_matches_from(vec!["alpaca", "-u", "USER1", "-a", "ROBOT", "-u", "USER2"])
- .map(Invocation::from)?;
+ let args = chat_args(&["alpaca", "-u", "USER1", "-a", "ROBOT", "-u", "USER2"])?;
assert_eq!(
args.messages,
#[test]
fn chat_reasoning_effort_flag() -> Result<()> {
- let args = cli()
- .try_get_matches_from(vec!["alpaca", "-u", "USER", "--reasoning-effort", "high"])
- .map(Invocation::from)?;
-
+ let args = chat_args(&["alpaca", "-u", "USER", "--reasoning-effort", "high"])?;
assert_eq!(args.reasoning_effort, Some(ReasoningEffort::High));
Ok(())
}
#[test]
+ fn chat_reasoning_effort_none_is_unset() -> Result<()> {
+ let args = chat_args(&["alpaca", "-u", "USER", "--reasoning-effort", "none"])?;
+ assert_eq!(args.reasoning_effort, None);
+
+ let args = chat_args(&["alpaca", "-u", "USER"])?;
+ assert_eq!(args.reasoning_effort, None, "effort defaults to unset");
+ Ok(())
+ }
+
+ #[test]
fn chat_many_msgs_with_system_prompt() -> Result<()> {
- let args = cli()
- .try_get_matches_from(vec![
- "alpaca", "-s", "SYSTEM", "-u", "USER1", "-a", "ROBOT", "-u", "USER2",
- ])
- .map(Invocation::from)?;
+ let args = chat_args(&[
+ "alpaca", "-s", "SYSTEM", "-u", "USER1", "-a", "ROBOT", "-u", "USER2",
+ ])?;
assert_eq!(
args.messages,
fn chat_many_msgs_with_system_prompt_last() -> Result<()> {
// System message should always be brought to front, even if
// it appears after user/assistant messages on the command line.
- let args = cli()
- .try_get_matches_from(vec![
- "alpaca", "-u", "USER1", "-a", "ROBOT", "-s", "SYSTEM", "-u", "USER2",
- ])
- .map(Invocation::from)?;
+ let args = chat_args(&[
+ "alpaca", "-u", "USER1", "-a", "ROBOT", "-s", "SYSTEM", "-u", "USER2",
+ ])?;
assert_eq!(
args.messages,
#[test]
fn chat_output_format_default() -> Result<()> {
- let args = cli()
- .try_get_matches_from(vec!["alpaca", "-u", "ABC"])
- .map(Invocation::from)?;
+ let args = chat_args(&["alpaca", "-u", "ABC"])?;
assert_eq!(
args.output_format,
#[test]
fn chat_output_format_explicit_json() -> Result<()> {
- let args = cli()
- .try_get_matches_from(vec!["alpaca", "-u", "ABC", "--output_format", "json"])
- .map(Invocation::from)?;
-
+ let args = chat_args(&["alpaca", "-u", "ABC", "--output_format", "json"])?;
assert_eq!(args.output_format, OutputFormat::JSON);
Ok(())
}
#[test]
fn chat_output_format_shorthand_json() -> Result<()> {
- let args = cli()
- .try_get_matches_from(vec!["alpaca", "-u", "ABC", "--json"])
- .map(Invocation::from)?;
-
+ let args = chat_args(&["alpaca", "-u", "ABC", "--json"])?;
assert_eq!(args.output_format, OutputFormat::JSON);
Ok(())
}
#[test]
fn chat_output_format_shorthand_jsonp() -> Result<()> {
- let args = cli()
- .try_get_matches_from(vec!["alpaca", "-u", "ABC", "--jsonp"])
- .map(Invocation::from)?;
-
+ let args = chat_args(&["alpaca", "-u", "ABC", "--jsonp"])?;
assert_eq!(args.output_format, OutputFormat::JSONPretty);
Ok(())
}
#[test]
fn chat_file_default() -> Result<()> {
- let args = cli()
- .try_get_matches_from(vec!["alpaca"])
- .map(Invocation::from)?;
-
+ let args = chat_args(&["alpaca"])?;
assert_eq!(args.file, PathBuf::from("-"));
Ok(())
}
#[test]
fn chat_file_positional() -> Result<()> {
- let args = cli()
- .try_get_matches_from(vec!["alpaca", "dialog_log"])
- .map(Invocation::from)?;
+ let args = chat_args(&["alpaca", "dialog_log"])?;
+ assert_eq!(args.file, PathBuf::from("dialog_log"));
+ Ok(())
+ }
+ #[test]
+ fn chat_defaults() -> Result<()> {
+ let args = chat_args(&["alpaca", "-u", "ABC"])?;
+ assert_eq!(args.model, DEFAULT_MODEL);
+ assert_eq!(args.timeout, Duration::from_secs(60));
+ assert_eq!(args.base_url, None);
+ Ok(())
+ }
+
+ #[test]
+ fn chat_explicit_subcommand() -> Result<()> {
+ let args = chat_args(&["alpaca", "chat", "-u", "USER", "dialog_log"])?;
+ assert_eq!(args.messages, vec![Message::user("USER")]);
assert_eq!(args.file, PathBuf::from("dialog_log"));
Ok(())
}
+
+ #[test]
+ fn chat_file_named_like_subcommand_after_escape() -> Result<()> {
+ let args = chat_args(&["alpaca", "--", "quota"])?;
+ assert_eq!(
+ args.file,
+ PathBuf::from("quota"),
+ "after --, a subcommand name is a file"
+ );
+ Ok(())
+ }
+
+ #[test]
+ fn quota_defaults() -> Result<()> {
+ let args = quota_args(&["alpaca", "quota"])?;
+ assert_eq!(args.provider, Provider::Ollama);
+ assert_eq!(args.api_key, None);
+ assert_eq!(args.base_url, None);
+ assert_eq!(args.timeout, Duration::from_secs(60));
+ assert_eq!(args.color, ColorChoice::Auto);
+ assert!(!args.raw);
+ assert_eq!(args.output_format, OutputFormat::Plaintext);
+ Ok(())
+ }
+
+ #[test]
+ fn quota_provider_and_flags() -> Result<()> {
+ let args = quota_args(&[
+ "alpaca",
+ "quota",
+ "-p",
+ "openai",
+ "--raw",
+ "--color",
+ "never",
+ "--base-url",
+ "http://example.com",
+ "-T",
+ "5",
+ ])?;
+
+ assert_eq!(args.provider, Provider::OpenAI);
+ assert!(args.raw);
+ assert_eq!(args.color, ColorChoice::Never);
+ assert_eq!(args.base_url.as_deref(), Some("http://example.com"));
+ assert_eq!(args.timeout, Duration::from_secs(5));
+ Ok(())
+ }
+
+ #[test]
+ fn quota_unknown_provider_is_error() {
+ assert!(
+ quota_args(&["alpaca", "quota", "-p", "nope"]).is_err(),
+ "unknown provider should fail to parse"
+ );
+ }
+
+ #[test]
+ fn quota_json_shorthand() -> Result<()> {
+ let args = quota_args(&["alpaca", "quota", "--json"])?;
+ assert_eq!(args.output_format, OutputFormat::JSON);
+ Ok(())
+ }
+
+ #[test]
+ fn compose_leaves_unset_values_open() -> Result<()> {
+ let args = compose_args(&["alpaca", "compose"])?;
+ assert_eq!(
+ args.model, None,
+ "an unset model is taken from the transcript or the default"
+ );
+ assert_eq!(args.timeout, Duration::from_secs(60));
+ assert_eq!(args.reasoning_effort, None);
+ assert_eq!(args.system, None);
+ assert!(!args.continue_conversation);
+ Ok(())
+ }
+
+ #[test]
+ fn compose_takes_model_from_the_command_line() -> Result<()> {
+ let args = compose_args(&["alpaca", "compose", "-m", "other-model"])?;
+ assert_eq!(args.model.as_deref(), Some("other-model"));
+ Ok(())
+ }
+
+ #[test]
+ fn compose_effort_none_is_explicit() -> Result<()> {
+ let args = compose_args(&["alpaca", "compose", "--reasoning-effort", "none"])?;
+ assert_eq!(
+ args.reasoning_effort,
+ Some(Effort::None),
+ "an explicit none overrides a saved effort"
+ );
+ Ok(())
+ }
+
+ #[test]
+ fn compose_continue_and_system() -> Result<()> {
+ let args = compose_args(&[
+ "alpaca",
+ "compose",
+ "-c",
+ "-s",
+ "SYSTEM",
+ "--reasoning-effort",
+ "low",
+ ])?;
+ assert!(args.continue_conversation, "-c is short for --continue");
+
+ let args = compose_args(&[
+ "alpaca",
+ "compose",
+ "--continue",
+ "-s",
+ "SYSTEM",
+ "--reasoning-effort",
+ "low",
+ ])?;
+ assert!(args.continue_conversation);
+ assert_eq!(args.system.as_deref(), Some("SYSTEM"));
+ assert_eq!(args.reasoning_effort, Some(Effort::Low));
+ Ok(())
+ }
+
+ #[test]
+ fn compose_invalid_effort_is_error() {
+ assert!(
+ compose_args(&["alpaca", "compose", "--reasoning-effort", "extreme"]).is_err(),
+ "unknown effort should fail to parse"
+ );
+ }
+
+ #[test]
+ fn timeout_must_be_positive() {
+ assert!(
+ chat_args(&["alpaca", "-u", "ABC", "-T", "0"]).is_err(),
+ "zero timeout should fail to parse"
+ );
+ }
}
blob - /dev/null
blob + c384d14199e039afafa3e4435cbfbc87e3b13351 (mode 644)
--- /dev/null
+++ src/api.rs
+//! Interactions with the chat API
+//!
+//! 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`.
+
+use std::fmt;
+use std::time::Duration;
+
+use crate::Error;
+use chrono::serde::ts_seconds;
+use chrono::{DateTime, TimeZone, Utc};
+use derive_builder::Builder;
+use serde::{Deserialize, Serialize};
+use serde_json::{Map, Value, json};
+use thiserror::Error;
+
+/// Convenience client for the chat API
+pub struct Client {
+ /// Inner HTTP client
+ http: reqwest::Client,
+ /// Default API key
+ api_key: Option<String>,
+ /// Base URL for the API endpoint
+ base_url: String,
+ /// Request and response dialect implied by the base URL
+ dialect: Dialect,
+}
+
+/// Wire format a chat endpoint speaks
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Dialect {
+ /// `POST {base}/api/chat`, with a single message in the response
+ Native,
+ /// `POST {base}/chat/completions`, with a list of choices in the response
+ Completions,
+}
+
+/// Requests for the chat API
+#[derive(Builder, Default)]
+pub struct ChatRequest {
+ model: String,
+ messages: Vec<Message>,
+ #[builder(default)]
+ temperature: Option<f32>,
+ timeout: Duration,
+ #[builder(default)]
+ think: Option<ReasoningEffort>,
+}
+
+/// Normalized response surfaced to the rest of the crate
+#[derive(Builder, Default, Debug, Serialize, Deserialize)]
+pub struct Response {
+ #[serde(with = "ts_seconds")]
+ pub created: DateTime<Utc>,
+ pub choices: Vec<Choice>,
+ pub model: String,
+ pub usage: Usage,
+}
+
+/// API errors
+///
+/// Endpoints report errors either as `{"error": "message"}` or as
+/// `{"error": {"message": "..."}}`.
+#[derive(Debug, Deserialize)]
+pub struct APIError {
+ #[serde(rename = "error", deserialize_with = "error_message")]
+ pub message: String,
+}
+
+/// Errors that can occur when converting an API response into a normalized
+/// [`Response`].
+#[derive(Debug, Clone, PartialEq, Eq, Error)]
+pub enum ResponseError {
+ #[error("response did not contain a message")]
+ NoMessage,
+ #[error("response message missing text content")]
+ MissingContent,
+ #[error("unsupported finish reason: {0}")]
+ UnsupportedFinishReason(String),
+ #[error("token count overflow")]
+ TokenCountOverflow,
+}
+
+/// Messages in chat API requests and responses
+#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
+pub struct Message {
+ pub role: Role,
+ pub content: String,
+}
+
+#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
+#[serde(rename_all = "lowercase")]
+pub enum Role {
+ System,
+ Assistant,
+ User,
+ Tool,
+}
+
+#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
+pub struct Usage {
+ pub input_tokens: u32,
+ pub output_tokens: u32,
+ pub total_tokens: u32,
+}
+
+#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "lowercase")]
+pub enum ReasoningEffort {
+ Low,
+ Medium,
+ High,
+}
+
+impl fmt::Display for ReasoningEffort {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ let effort = match self {
+ ReasoningEffort::Low => "low",
+ ReasoningEffort::Medium => "medium",
+ ReasoningEffort::High => "high",
+ };
+ f.write_str(effort)
+ }
+}
+
+/// Reason generation stopped
+#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
+#[serde(rename_all = "snake_case")]
+pub enum FinishReason {
+ Stop,
+ Length,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct Choice {
+ pub message: Message,
+ pub finish_reason: FinishReason,
+}
+
+impl Dialect {
+ /// 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.
+ #[must_use]
+ pub fn from_base_url(base_url: &str) -> Self {
+ let is_version = |segment: &str| {
+ let mut chars = segment.chars();
+ chars.next() == Some('v') && chars.all(|c| c.is_ascii_digit())
+ };
+
+ let last = base_url
+ .trim_end_matches('/')
+ .rsplit('/')
+ .next()
+ .unwrap_or_default();
+
+ if is_version(last) && last.len() > 1 {
+ Self::Completions
+ } else {
+ Self::Native
+ }
+ }
+
+ /// Path of the chat endpoint
+ #[must_use]
+ pub fn path(self) -> &'static str {
+ match self {
+ Self::Native => "/api/chat",
+ Self::Completions => "/chat/completions",
+ }
+ }
+}
+
+impl Client {
+ /// Build a client for `base_url`
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if the underlying 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);
+ Ok(Self {
+ http,
+ api_key,
+ base_url,
+ dialect,
+ })
+ }
+
+ /// Send a chat request and normalize 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.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if the request fails or the response cannot be parsed
+ /// into a [`Response`].
+ pub async fn create_response(&self, request: &ChatRequest) -> Result<Response, Error> {
+ let mut builder = self
+ .http
+ .post(self.chat_endpoint())
+ .timeout(request.timeout)
+ .header("Content-Type", "application/json")
+ .json(&request.to_payload(self.dialect));
+
+ if let Some(api_key) = &self.api_key {
+ builder = builder.bearer_auth(api_key);
+ }
+
+ let resp = builder.send().await?;
+
+ let status = resp.status();
+ if status.is_success() {
+ let body: APIResponse = resp.json().await?;
+ Response::try_from(body).map_err(|e| Error::UnexpectedResponse(e.to_string()))
+ } else {
+ let body = resp.bytes().await?;
+ match serde_json::from_slice::<APIError>(&body) {
+ Ok(error) => Err(Error::API { error }),
+ Err(_) => Err(Error::HttpStatus {
+ status: status.as_u16(),
+ message: String::from_utf8_lossy(&body).trim().to_string(),
+ }),
+ }
+ }
+ }
+
+ fn chat_endpoint(&self) -> String {
+ let base = self.base_url.trim_end_matches('/');
+ format!("{base}{}", self.dialect.path())
+ }
+}
+
+impl Message {
+ #[must_use]
+ pub fn system(content: &str) -> Message {
+ Message {
+ role: Role::System,
+ content: content.to_string(),
+ }
+ }
+ #[must_use]
+ pub fn user(content: &str) -> Message {
+ Message {
+ role: Role::User,
+ content: content.to_string(),
+ }
+ }
+ #[must_use]
+ pub fn assistant(content: &str) -> Message {
+ Message {
+ role: Role::Assistant,
+ content: content.to_string(),
+ }
+ }
+}
+
+impl ChatRequest {
+ #[must_use]
+ pub fn builder() -> ChatRequestBuilder {
+ ChatRequestBuilder::default()
+ }
+
+ fn to_payload(&self, dialect: Dialect) -> Value {
+ let messages = self
+ .messages
+ .iter()
+ .map(|m| serde_json::to_value(m).expect("Message always serializes"))
+ .collect::<Vec<_>>();
+
+ let mut payload = Map::new();
+ payload.insert("model".to_string(), json!(self.model));
+ payload.insert("messages".to_string(), json!(messages));
+ payload.insert("stream".to_string(), json!(false));
+
+ match dialect {
+ Dialect::Native => {
+ if let Some(temperature) = self.temperature {
+ payload.insert("options".to_string(), json!({ "temperature": temperature }));
+ }
+ if let Some(think) = self.think {
+ payload.insert("think".to_string(), json!(think.to_string()));
+ }
+ }
+ Dialect::Completions => {
+ if let Some(temperature) = self.temperature {
+ payload.insert("temperature".to_string(), json!(temperature));
+ }
+ if let Some(effort) = self.think {
+ payload.insert("reasoning_effort".to_string(), json!(effort.to_string()));
+ }
+ }
+ }
+
+ Value::Object(payload)
+ }
+}
+
+impl Response {
+ #[must_use]
+ pub fn builder() -> ResponseBuilder {
+ ResponseBuilder::default()
+ }
+}
+
+/// A response in either dialect
+#[derive(Debug, Deserialize)]
+#[serde(untagged)]
+enum APIResponse {
+ Native(NativeResponse),
+ Completions(CompletionsResponse),
+}
+
+#[derive(Debug, Deserialize)]
+struct NativeResponse {
+ created_at: DateTime<Utc>,
+ model: String,
+ message: Option<ResponseMessage>,
+ #[serde(default)]
+ done_reason: Option<String>,
+ #[serde(default)]
+ prompt_eval_count: u32,
+ #[serde(default)]
+ eval_count: u32,
+}
+
+#[derive(Debug, Deserialize)]
+struct CompletionsResponse {
+ #[serde(default)]
+ created: i64,
+ #[serde(default)]
+ model: String,
+ #[serde(default)]
+ choices: Vec<CompletionsChoice>,
+ #[serde(default)]
+ usage: CompletionsUsage,
+}
+
+#[derive(Debug, Deserialize)]
+struct CompletionsChoice {
+ message: Option<ResponseMessage>,
+ #[serde(default)]
+ finish_reason: Option<String>,
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct CompletionsUsage {
+ #[serde(default, rename = "prompt_tokens")]
+ prompt: u32,
+ #[serde(default, rename = "completion_tokens")]
+ completion: u32,
+ #[serde(default, rename = "total_tokens")]
+ total: u32,
+}
+
+#[derive(Debug, Deserialize)]
+struct ResponseMessage {
+ #[serde(default = "default_role")]
+ role: Role,
+ #[serde(default)]
+ content: String,
+}
+
+fn default_role() -> Role {
+ Role::Assistant
+}
+
+/// Read an error message from either a bare string or an object with a
+/// `message` field
+fn error_message<'de, D>(deserializer: D) -> Result<String, D::Error>
+where
+ D: serde::Deserializer<'de>,
+{
+ #[derive(Deserialize)]
+ #[serde(untagged)]
+ enum ErrorBody {
+ Message(String),
+ Object { message: String },
+ }
+
+ Ok(match ErrorBody::deserialize(deserializer)? {
+ ErrorBody::Message(message) | ErrorBody::Object { message } => message,
+ })
+}
+
+/// Interpret a finish reason, defaulting to a clean stop
+fn finish_reason(reason: Option<&str>) -> Result<FinishReason, ResponseError> {
+ match reason {
+ Some("length") => Ok(FinishReason::Length),
+ None | Some("stop") => Ok(FinishReason::Stop),
+ Some(reason) => Err(ResponseError::UnsupportedFinishReason(reason.to_string())),
+ }
+}
+
+fn choice(message: ResponseMessage, reason: Option<&str>) -> Result<Choice, ResponseError> {
+ if message.content.is_empty() {
+ return Err(ResponseError::MissingContent);
+ }
+
+ Ok(Choice {
+ message: Message {
+ role: message.role,
+ content: message.content,
+ },
+ finish_reason: finish_reason(reason)?,
+ })
+}
+
+impl TryFrom<APIResponse> for Response {
+ type Error = ResponseError;
+
+ fn try_from(value: APIResponse) -> Result<Self, Self::Error> {
+ match value {
+ APIResponse::Native(native) => Self::try_from(native),
+ APIResponse::Completions(completions) => Self::try_from(completions),
+ }
+ }
+}
+
+impl TryFrom<NativeResponse> for Response {
+ type Error = ResponseError;
+
+ fn try_from(value: NativeResponse) -> Result<Self, Self::Error> {
+ let message = value.message.ok_or(ResponseError::NoMessage)?;
+ let choice = choice(message, value.done_reason.as_deref())?;
+
+ Ok(Response {
+ created: value.created_at,
+ choices: vec![choice],
+ model: value.model,
+ usage: Usage {
+ input_tokens: value.prompt_eval_count,
+ output_tokens: value.eval_count,
+ total_tokens: value
+ .prompt_eval_count
+ .checked_add(value.eval_count)
+ .ok_or(ResponseError::TokenCountOverflow)?,
+ },
+ })
+ }
+}
+
+impl TryFrom<CompletionsResponse> for Response {
+ type Error = ResponseError;
+
+ fn try_from(value: CompletionsResponse) -> Result<Self, Self::Error> {
+ let choices = value
+ .choices
+ .into_iter()
+ .map(|c| {
+ let message = c.message.ok_or(ResponseError::NoMessage)?;
+ choice(message, c.finish_reason.as_deref())
+ })
+ .collect::<Result<Vec<_>, _>>()?;
+
+ if choices.is_empty() {
+ return Err(ResponseError::NoMessage);
+ }
+
+ let total = if value.usage.total == 0 {
+ value
+ .usage
+ .prompt
+ .checked_add(value.usage.completion)
+ .ok_or(ResponseError::TokenCountOverflow)?
+ } else {
+ value.usage.total
+ };
+
+ Ok(Response {
+ created: Utc
+ .timestamp_opt(value.created, 0)
+ .single()
+ .unwrap_or_default(),
+ choices,
+ model: value.model,
+ usage: Usage {
+ input_tokens: value.usage.prompt,
+ output_tokens: value.usage.completion,
+ total_tokens: total,
+ },
+ })
+ }
+}
+
+#[cfg(test)]
+mod test {
+
+ use super::*;
+ use anyhow::Result;
+ use std::time::Duration;
+
+ #[test]
+ fn parse_response_payload() -> Result<()> {
+ let data = r#"{
+ "model": "gpt-oss:120b",
+ "created_at": "2025-10-17T23:14:07.414671Z",
+ "message": {
+ "role": "assistant",
+ "content": "Hello! How can I assist you today?"
+ },
+ "done": true,
+ "done_reason": "stop",
+ "prompt_eval_count": 8,
+ "eval_count": 9
+ }
+ "#;
+
+ let resp = serde_json::from_str::<APIResponse>(data)?;
+ let resp = Response::try_from(resp).map_err(|e| anyhow::anyhow!(e))?;
+
+ assert_eq!(
+ resp.choices,
+ vec![Choice {
+ message: Message {
+ role: Role::Assistant,
+ content: "Hello! How can I assist you today?".to_string()
+ },
+ finish_reason: FinishReason::Stop
+ }]
+ );
+ assert_eq!(resp.model, "gpt-oss:120b");
+ assert_eq!(
+ resp.usage,
+ Usage {
+ input_tokens: 8,
+ output_tokens: 9,
+ total_tokens: 17,
+ }
+ );
+
+ Ok(())
+ }
+
+ #[test]
+ fn parse_completions_response_payload() -> Result<()> {
+ let data = r#"{
+ "id": "chatcmpl-1",
+ "created": 1760742847,
+ "model": "some-model",
+ "choices": [{
+ "index": 0,
+ "message": {"role": "assistant", "content": "Hello there"},
+ "finish_reason": "stop"
+ }],
+ "usage": {"prompt_tokens": 8, "completion_tokens": 9, "total_tokens": 17}
+ }
+ "#;
+
+ let resp = serde_json::from_str::<APIResponse>(data)?;
+ let resp = Response::try_from(resp).map_err(|e| anyhow::anyhow!(e))?;
+
+ assert_eq!(resp.model, "some-model");
+ assert_eq!(resp.choices.len(), 1);
+ assert_eq!(resp.choices[0].message.content, "Hello there");
+ assert_eq!(resp.choices[0].finish_reason, FinishReason::Stop);
+ assert_eq!(resp.usage.total_tokens, 17);
+ assert_eq!(resp.created.timestamp(), 1_760_742_847);
+ Ok(())
+ }
+
+ #[test]
+ fn completions_length_limit_is_reported() -> Result<()> {
+ let data = r#"{
+ "created": 1,
+ "model": "m",
+ "choices": [{"message": {"content": "cut"}, "finish_reason": "length"}]
+ }
+ "#;
+
+ let resp = Response::try_from(serde_json::from_str::<APIResponse>(data)?)
+ .map_err(|e| anyhow::anyhow!(e))?;
+
+ assert_eq!(resp.choices[0].finish_reason, FinishReason::Length);
+ assert_eq!(
+ resp.usage.total_tokens, 0,
+ "absent usage counts as no tokens"
+ );
+ Ok(())
+ }
+
+ #[test]
+ fn dialect_follows_the_base_url() {
+ assert_eq!(
+ Dialect::from_base_url("https://example.com"),
+ Dialect::Native
+ );
+ assert_eq!(
+ Dialect::from_base_url("http://localhost:11434/"),
+ Dialect::Native
+ );
+ assert_eq!(
+ Dialect::from_base_url("https://example.com/v1"),
+ Dialect::Completions
+ );
+ assert_eq!(
+ Dialect::from_base_url("https://example.com/v1/"),
+ Dialect::Completions
+ );
+ assert_eq!(
+ Dialect::from_base_url("https://example.com/api/v2"),
+ Dialect::Completions
+ );
+ }
+
+ #[test]
+ fn endpoints_follow_the_dialect() -> Result<()> {
+ let native = Client::new(None, "https://example.com/".to_string())?;
+ assert_eq!(native.chat_endpoint(), "https://example.com/api/chat");
+
+ let completions = Client::new(None, "https://example.com/v1".to_string())?;
+ assert_eq!(
+ completions.chat_endpoint(),
+ "https://example.com/v1/chat/completions"
+ );
+ Ok(())
+ }
+
+ #[test]
+ fn response_payload_includes_think() -> Result<()> {
+ let request = ChatRequest::builder()
+ .model("gpt-oss:120b".to_string())
+ .messages(vec![Message::user("Hello")])
+ .temperature(Some(0.0))
+ .timeout(Duration::from_secs(30))
+ .think(Some(ReasoningEffort::High))
+ .build()
+ .expect("request builds");
+
+ let payload = request.to_payload(Dialect::Native);
+
+ assert_eq!(payload["think"], "high");
+ assert_eq!(payload["model"], "gpt-oss:120b");
+ assert_eq!(payload["options"]["temperature"], 0.0);
+ assert_eq!(payload["stream"], false);
+
+ Ok(())
+ }
+
+ #[test]
+ fn completions_payload_uses_flat_fields() -> Result<()> {
+ let request = ChatRequest::builder()
+ .model("some-model".to_string())
+ .messages(vec![Message::user("Hello")])
+ .temperature(Some(0.5))
+ .timeout(Duration::from_secs(30))
+ .think(Some(ReasoningEffort::Low))
+ .build()
+ .expect("request builds");
+
+ let payload = request.to_payload(Dialect::Completions);
+
+ assert_eq!(payload["reasoning_effort"], "low");
+ assert_eq!(payload["temperature"], 0.5);
+ assert!(payload.get("options").is_none());
+ assert!(payload.get("think").is_none());
+
+ Ok(())
+ }
+
+ #[test]
+ fn response_payload_omits_think_when_not_set() -> Result<()> {
+ let request = ChatRequest::builder()
+ .model("gpt-oss:120b".to_string())
+ .messages(vec![Message::user("Hello")])
+ .timeout(Duration::from_secs(30))
+ .build()
+ .expect("request builds");
+
+ let payload = request.to_payload(Dialect::Native);
+
+ assert!(payload.get("think").is_none());
+ assert!(payload.get("options").is_none());
+ Ok(())
+ }
+
+ #[test]
+ fn response_try_from_errors_without_message() -> Result<()> {
+ let data = r#"{
+ "model": "gpt-oss:120b",
+ "created_at": "2025-10-17T23:14:07.414671Z",
+ "done": true,
+ "done_reason": "stop",
+ "prompt_eval_count": 1,
+ "eval_count": 1
+ }
+ "#;
+
+ let resp = serde_json::from_str::<APIResponse>(data)?;
+ let err = Response::try_from(resp).expect_err("should error");
+
+ assert!(
+ matches!(err, ResponseError::NoMessage),
+ "unexpected error: {err:?}"
+ );
+
+ Ok(())
+ }
+
+ #[test]
+ fn parse_response_error() -> Result<()> {
+ let flat = serde_json::from_str::<APIError>(r#"{"error": "model 'nope' not found"}"#)?;
+ assert_eq!(flat.message, "model 'nope' not found");
+
+ let nested =
+ serde_json::from_str::<APIError>(r#"{"error": {"message": "invalid api key"}}"#)?;
+ assert_eq!(nested.message, "invalid api key");
+
+ Ok(())
+ }
+ #[test]
+ fn rejects_unknown_finish_reasons() -> Result<()> {
+ let data = r#"{"created": 1, "model": "m", "choices": [{"message": {"role": "assistant", "content": "blocked"}, "finish_reason": "content_filter"}]}"#;
+ let response = serde_json::from_str::<APIResponse>(data)?;
+ assert!(matches!(
+ Response::try_from(response),
+ Err(ResponseError::UnsupportedFinishReason(reason)) if reason == "content_filter"
+ ));
+ Ok(())
+ }
+
+ #[test]
+ fn rejects_token_count_overflow() -> Result<()> {
+ let data = format!(
+ r#"{{"created": 1, "model": "m", "choices": [{{"message": {{"role": "assistant", "content": "ok"}}, "finish_reason": "stop"}}], "usage": {{"prompt_tokens": {}, "completion_tokens": 1}}}}"#,
+ u32::MAX
+ );
+ let response = serde_json::from_str::<APIResponse>(&data)?;
+ assert!(matches!(
+ Response::try_from(response),
+ Err(ResponseError::TokenCountOverflow)
+ ));
+ Ok(())
+ }
+}
blob - 0ce669871f6ed18118041e575e2365acbe166cf8
blob + b20af6da546f55a2bfdae7f9966b03b92be31691
--- src/error.rs
+++ src/error.rs
//! Errors for alpaca library crate
+use std::path::PathBuf;
+
#[derive(Debug, thiserror::Error)]
pub enum Error {
- #[error("no API key provided")]
- NoAPIKey,
-
#[error("failed to fetch - {0}")]
FailedToFetch(#[from] reqwest::Error),
#[error("json serialization error - {0}")]
JSON(#[from] serde_json::Error),
- #[error("ollama api returned error - {}", .error.message)]
- OllamaError { error: crate::ollama::APIError },
+ #[error("api returned error - {}", .error.message)]
+ API { error: crate::api::APIError },
+
+ #[error("status {status} - {message}")]
+ HttpStatus { status: u16, message: String },
+
+ #[error("response body exceeds {limit} bytes")]
+ ResponseTooLarge { limit: usize },
+
+ #[error("no credentials for {provider}")]
+ NoCredentials { provider: crate::usage::Provider },
+
+ #[error("editor failed - {0}")]
+ Editor(String),
+
+ /// Editor exited non-zero. The status is forwarded to alpaca's own exit code.
+ #[error("editor exited with status {0}")]
+ EditorExit(i32),
+
+ #[error("no saved conversations")]
+ NoConversations,
+
+ #[error("invalid transcript {} - {message}", .path.display())]
+ InvalidTranscript { path: PathBuf, message: String },
+
+ #[error("no home directory")]
+ NoHomeDir,
}
blob - /dev/null
blob + 8f636a7ae619df2597f468f358735df3bfe53a04 (mode 644)
--- /dev/null
+++ src/cli/config_tests.rs
+use super::*;
+
+type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
+
+fn configured_chat(toml: &str, args: &[&str]) -> Result<ChatArgs> {
+ let config = Config::from_toml(toml)?;
+ match parse_args_with_config(args, &config)? {
+ Invocation::Chat(args) => Ok(args),
+ other => Err(format!("expected chat invocation, got {other:?}").into()),
+ }
+}
+
+#[test]
+fn config_parses_every_supported_setting() -> Result<()> {
+ let args = configured_chat(
+ r#"
+ output_format = "jsonpretty"
+ model = "configured-model"
+ temperature = 0.25
+ timeout = 17
+ system = "configured system"
+ assistant = ["assistant one", "assistant two"]
+ user = ["user one", "user two"]
+ apikey = "configured-key"
+ base_url = "https://configured.example/v1"
+ reasoning_effort = "high"
+ "#,
+ &["alpaca"],
+ )?;
+
+ assert_eq!(args.output_format, OutputFormat::JSONPretty);
+ assert_eq!(args.model, "configured-model");
+ assert_eq!(args.temperature, Some(0.25));
+ assert_eq!(args.timeout, Duration::from_secs(17));
+ assert_eq!(args.api_key.as_deref(), Some("configured-key"));
+ assert_eq!(
+ args.base_url.as_deref(),
+ Some("https://configured.example/v1")
+ );
+ assert_eq!(args.reasoning_effort, Some(ReasoningEffort::High));
+ assert_eq!(
+ args.messages,
+ vec![
+ Message::system("configured system"),
+ Message::assistant("assistant one"),
+ Message::assistant("assistant two"),
+ Message::user("user one"),
+ Message::user("user two"),
+ ]
+ );
+ Ok(())
+}
+
+#[test]
+fn command_line_overrides_config_and_keeps_message_order() -> Result<()> {
+ let args = configured_chat(
+ r#"
+ output_format = "jsonpretty"
+ model = "configured-model"
+ system = "configured system"
+ assistant = ["configured assistant"]
+ user = ["configured user"]
+ "#,
+ &[
+ "alpaca",
+ "--json",
+ "-m",
+ "cli-model",
+ "-s",
+ "cli system",
+ "-u",
+ "cli user",
+ "-a",
+ "cli assistant",
+ ],
+ )?;
+
+ assert_eq!(args.output_format, OutputFormat::JSON);
+ assert_eq!(args.model, "cli-model");
+ assert_eq!(
+ args.messages,
+ vec![
+ Message::system("cli system"),
+ Message::assistant("configured assistant"),
+ Message::user("configured user"),
+ Message::user("cli user"),
+ Message::assistant("cli assistant"),
+ ]
+ );
+ Ok(())
+}
+
+#[test]
+fn config_applies_to_compose_and_quota() -> Result<()> {
+ let config = Config::from_toml(
+ r#"
+ output_format = "json"
+ model = "configured-model"
+ timeout = 19
+ system = "configured system"
+ apikey = "configured-key"
+ base_url = "https://configured.example"
+ reasoning_effort = "medium"
+ "#,
+ )?;
+
+ let Invocation::Compose(compose) = parse_args_with_config(&["alpaca", "compose"], &config)?
+ else {
+ return Err("expected compose invocation".into());
+ };
+ assert_eq!(compose.model.as_deref(), Some("configured-model"));
+ assert_eq!(compose.system.as_deref(), Some("configured system"));
+ assert_eq!(compose.reasoning_effort, Some(Effort::Medium));
+ assert_eq!(compose.timeout, Duration::from_secs(19));
+
+ let Invocation::Quota(quota) = parse_args_with_config(&["alpaca", "quota"], &config)? else {
+ return Err("expected quota invocation".into());
+ };
+ assert_eq!(quota.output_format, OutputFormat::JSON);
+ assert_eq!(quota.timeout, Duration::from_secs(19));
+ assert_eq!(quota.api_key.as_deref(), Some("configured-key"));
+ assert_eq!(
+ quota.base_url.as_deref(),
+ Some("https://configured.example")
+ );
+ Ok(())
+}
+
+#[test]
+fn config_rejects_unknown_keys_and_non_positive_timeout() {
+ assert!(Config::from_toml("modle = 'typo'").is_err());
+ assert!(Config::from_toml("timeout = 0").is_err());
+}
+
+#[test]
+fn config_path_prefers_xdg_and_falls_back_to_home() {
+ assert_eq!(
+ config_path_from(
+ Some(OsString::from("/xdg")),
+ Some(PathBuf::from("/home/alpaca")),
+ ),
+ Some(PathBuf::from("/xdg/alpaca/config.toml"))
+ );
+ assert_eq!(
+ config_path_from(None, Some(PathBuf::from("/home/alpaca"))),
+ Some(PathBuf::from("/home/alpaca/.config/alpaca/config.toml"))
+ );
+ assert_eq!(config_path_from(None, None), None);
+}
blob - 43b814dc1c239e286cd24712b98a9de8924d150f
blob + 154c6a56e459afd61686001e53fe1234e5113dbb
--- src/exec/chat.rs
+++ src/exec/chat.rs
//! Implements chat subcommand
use crate::Error;
-use crate::cli::{Invocation, OutputFormat};
-use crate::ollama::{self, FinishReason, Message, Response};
+use crate::api::{self, Message};
+use crate::cli::ChatArgs;
use crate::parse;
use anyhow::{Context, Result};
use std::fs::File;
-use std::io::{self, BufWriter, IsTerminal, Read, Write};
+use std::io::{self, IsTerminal, Read};
use std::path::Path;
-/// Executes `Invocation` via given args
+/// Executes `ChatArgs` via given args
///
/// # Errors
///
/// Returns an error if no messages are provided, the request fails, or the
/// response cannot be shown.
-pub async fn exec(args: Invocation) -> Result<()> {
- let base_url =
- std::env::var("OLLAMA_API_ENDPOINT").unwrap_or_else(|_| "https://ollama.com".to_string());
-
- let client = ollama::Client::new(args.api_key.clone(), base_url)
+pub async fn exec(args: ChatArgs) -> Result<()> {
+ let client = api::Client::new(args.api_key.clone(), super::base_url(args.base_url.clone()))
.with_context(|| "failed to create http client")?;
let file_msgs = read_messages_from_file(&args.file)
return Err(Error::NoMessagesProvided.into());
}
- // TODO: Lifetimes for `ChatRequest` fields
- let mut builder = ollama::ChatRequest::builder();
-
- builder
+ let request = api::ChatRequest::builder()
.model(args.model)
.messages(msgs)
.temperature(args.temperature)
- .timeout(args.timeout);
-
- if let Some(effort) = args.reasoning_effort {
- builder.think(Some(effort));
- }
-
- let request = builder
+ .timeout(args.timeout)
+ .think(args.reasoning_effort)
.build()
.with_context(|| "failed to create request")?;
.await
.with_context(|| "failed to fetch request")?;
- show_response(io::stdout(), args.output_format, &res)?;
+ super::show_response(io::stdout(), args.output_format, &res)?;
Ok(())
}
}
}
-/// Show formatted output for a Chat API result
-fn show_response(
- dest: impl Write,
- output_format: OutputFormat,
- resp: &Response,
-) -> Result<(), Error> {
- let mut writer = BufWriter::new(dest);
- let choice = resp.choices.first().ok_or_else(|| {
- Error::UnexpectedResponse(format!(
- "unexpected number of choices in response: {resp:?}"
- ))
- })?;
-
- match choice.finish_reason {
- FinishReason::Stop => {
- let output = match output_format {
- OutputFormat::Plaintext => choice.message.content.clone(),
- OutputFormat::JSON => serde_json::to_string(resp)?,
- OutputFormat::JSONPretty => serde_json::to_string_pretty(resp)?,
- };
- writeln!(writer, "{output}").map_err(Error::IO)?;
- writer.flush().map_err(Error::IO)?;
- }
- FinishReason::Length => {
- return Err(Error::UnexpectedResponse(format!(
- "response truncated due to length limit: {choice:?}"
- )));
- }
- }
-
- Ok(())
-}
-
#[cfg(test)]
mod test {
use std::time::Duration;
use predicates::str;
use crate::{
- cli::{Invocation, InvocationBuilder, OutputFormat},
- ollama::{Choice, FinishReason, Message, Response, ResponseBuilder, Usage},
+ api::{Choice, FinishReason, Message, Response, ResponseBuilder, Usage},
+ cli::{ChatArgs, ChatArgsBuilder, OutputFormat},
+ exec::show_response,
};
- use super::*;
-
use anyhow::Result;
#[test]
Ok(())
}
- fn default_args() -> InvocationBuilder {
- Invocation::builder()
+ #[test]
+ fn show_chat_response_rejects_truncated_reply() -> Result<()> {
+ let mut output = vec![];
+ let resp = default_resp()
+ .choices(vec![Choice {
+ message: Message::assistant("Hello wor"),
+ finish_reason: FinishReason::Length,
+ }])
+ .build()?;
+
+ let err = show_response(&mut output, OutputFormat::Plaintext, &resp)
+ .expect_err("truncated responses should error");
+
+ assert!(
+ err.to_string().contains("truncated"),
+ "unexpected error: {err}"
+ );
+ Ok(())
+ }
+
+ fn default_args() -> ChatArgsBuilder {
+ ChatArgs::builder()
.api_key(Some(String::default()))
.messages(vec![])
.model(String::default())
blob - 75c8c33d2564802d1ec057ab8464e7b6204cd5dc
blob + e518cbee4af064ba36d2679aa89b96131ecb3c17
--- src/exec/mod.rs
+++ src/exec/mod.rs
//! Executor for alpaca
pub mod chat;
+pub mod compose;
+pub mod quota;
-use crate::cli::Invocation;
+use std::io::{BufWriter, Write};
+
+use crate::Error;
+use crate::api::{Choice, FinishReason, Response};
+use crate::cli::{Invocation, OutputFormat};
use anyhow::Result;
/// Execute the invocation
///
/// # Errors
///
-/// Returns an error if the chat request fails or its response cannot be shown.
-pub async fn exec(inv: Invocation) -> Result<()> {
- chat::exec(inv).await
+/// Returns an error if the subcommand fails.
+pub async fn exec(invocation: Invocation) -> Result<()> {
+ match invocation {
+ Invocation::Chat(args) => chat::exec(args).await,
+ Invocation::Quota(args) => quota::exec(args).await,
+ Invocation::Compose(args) => compose::exec(args).await,
+ }
}
+
+/// Resolve the chat API base URL, falling back to the public endpoint
+pub(crate) fn base_url(configured: Option<String>) -> String {
+ configured.unwrap_or_else(|| crate::cli::DEFAULT_BASE_URL.to_string())
+}
+
+/// Show formatted output for a Chat API result
+///
+/// # Errors
+///
+/// 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,
+ resp: &Response,
+) -> Result<(), Error> {
+ let mut writer = BufWriter::new(dest);
+ let choice = reply(resp)?;
+
+ let output = match output_format {
+ OutputFormat::Plaintext => choice.message.content.clone(),
+ OutputFormat::JSON => serde_json::to_string(resp)?,
+ OutputFormat::JSONPretty => serde_json::to_string_pretty(resp)?,
+ };
+ writeln!(writer, "{output}").map_err(Error::IO)?;
+ writer.flush().map_err(Error::IO)?;
+
+ Ok(())
+}
+
+/// The choice carrying the assistant's reply
+///
+/// # Errors
+///
+/// Returns an error if the response holds no choice or was truncated by the
+/// model's length limit.
+pub(crate) fn reply(resp: &Response) -> Result<&Choice, Error> {
+ let choice = resp.choices.first().ok_or_else(|| {
+ Error::UnexpectedResponse(format!(
+ "unexpected number of choices in response: {resp:?}"
+ ))
+ })?;
+
+ match choice.finish_reason {
+ FinishReason::Stop => Ok(choice),
+ FinishReason::Length => Err(Error::UnexpectedResponse(format!(
+ "response truncated due to length limit: {choice:?}"
+ ))),
+ }
+}
blob - /dev/null
blob + ad7dfd7746ce6db96f34473d78e5921c3f24612b (mode 644)
--- /dev/null
+++ src/exec/compose.rs
+//! Implements compose subcommand
+//!
+//! Edits a prompt in the user's editor, sends it, then saves the conversation
+//! so it can be continued later.
+
+use std::io::{self, IsTerminal, Read};
+
+use anyhow::{Context, Result};
+
+use crate::api::{self, Message, ReasoningEffort};
+use crate::cli::{ComposeArgs, DEFAULT_MODEL};
+use crate::transcript::Transcript;
+use crate::{Error, editor};
+
+/// Executes `ComposeArgs`
+///
+/// # Errors
+///
+/// Returns an error if the editor fails, the request fails, or the
+/// conversation cannot be saved.
+pub async fn exec(args: ComposeArgs) -> Result<()> {
+ let client = api::Client::new(args.api_key.clone(), super::base_url(args.base_url.clone()))
+ .with_context(|| "failed to create http client")?;
+
+ let (mut path, mut transcript, mut prefill) = if args.continue_conversation {
+ let (path, transcript) = Transcript::newest()?;
+ let prefill = transcript.last_reply().unwrap_or_default().to_string();
+ (Some(path), transcript, prefill)
+ } else {
+ (
+ None,
+ Transcript::new(String::new(), None, None),
+ read_stdin()?,
+ )
+ };
+
+ transcript.model = resolve(args.model, &transcript.model, DEFAULT_MODEL.to_string());
+ transcript.effort = args
+ .reasoning_effort
+ .map_or(transcript.effort, Option::<ReasoningEffort>::from);
+ transcript.system = args.system.or(transcript.system);
+
+ loop {
+ let prompt = editor::edit(&prefill)?;
+ if prompt.is_empty() {
+ return Ok(());
+ }
+
+ transcript.messages.push(Message::user(&prompt));
+
+ let request = api::ChatRequest::builder()
+ .model(transcript.model.clone())
+ .messages(transcript.request_messages())
+ .temperature(args.temperature)
+ .timeout(args.timeout)
+ .think(transcript.effort)
+ .build()
+ .with_context(|| "failed to create request")?;
+
+ let response = client
+ .create_response(&request)
+ .await
+ .with_context(|| "failed to fetch request")?;
+
+ let reply = super::reply(&response)?.message.content.clone();
+ transcript.messages.push(Message::assistant(&reply));
+
+ match &path {
+ Some(path) => transcript.save(path)?,
+ None => path = Some(transcript.create()?),
+ }
+
+ super::show_response(io::stdout(), args.output_format, &response)?;
+
+ if !args.continue_conversation {
+ return Ok(());
+ }
+ prefill = reply;
+ }
+}
+
+/// Command line value, else the saved value, else the default
+fn resolve(given: Option<String>, saved: &str, default: String) -> String {
+ given.unwrap_or_else(|| {
+ if saved.is_empty() {
+ default
+ } else {
+ saved.to_string()
+ }
+ })
+}
+
+/// 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() {
+ return Ok(String::new());
+ }
+
+ let mut buffer = String::new();
+ stdin.lock().read_to_string(&mut buffer)?;
+ Ok(buffer)
+}
+
+#[cfg(test)]
+mod test {
+ use super::*;
+
+ #[test]
+ fn command_line_value_wins_over_saved_and_default() {
+ assert_eq!(
+ resolve(Some("given".into()), "saved", "default".into()),
+ "given"
+ );
+ assert_eq!(resolve(None, "saved", "default".into()), "saved");
+ assert_eq!(resolve(None, "", "default".into()), "default");
+ }
+}
blob - /dev/null
blob + 0e152265c75285e4811b85aa8115182ecb8c7a35 (mode 644)
--- /dev/null
+++ src/exec/quota.rs
+//! Implements quota subcommand
+//!
+//! Asks a provider how much of the account's quota is used, then renders each
+//! window as a bar with a countdown to the next reset.
+
+use std::io::{self, IsTerminal, Write};
+
+use anyhow::{Context, Result};
+
+use crate::cli::{OutputFormat, QuotaArgs};
+use crate::usage::{self, render::Style};
+
+/// Executes `QuotaArgs`
+///
+/// # Errors
+///
+/// Returns an error if no credential is found, the request fails, or the
+/// response cannot be shown.
+pub async fn exec(args: QuotaArgs) -> Result<()> {
+ let client = usage::Client::new(args.provider, args.api_key, args.base_url, args.timeout)
+ .with_context(|| format!("failed to create {} client", args.provider))?;
+
+ let body = client
+ .fetch()
+ .await
+ .with_context(|| format!("failed to fetch {} usage", args.provider))?;
+
+ let mut stdout = io::stdout();
+
+ if args.raw {
+ stdout.write_all(&body)?;
+ if !body.ends_with(b"\n") {
+ stdout.write_all(b"\n")?;
+ }
+ return Ok(());
+ }
+
+ let view = args.provider.view(&body)?;
+
+ match args.output_format {
+ OutputFormat::Plaintext => {
+ let style = Style::new(args.color, stdout.is_terminal());
+ usage::render::render(&mut stdout, &view, style)?;
+ }
+ OutputFormat::JSON => writeln!(stdout, "{}", serde_json::to_string(&view)?)?,
+ OutputFormat::JSONPretty => writeln!(stdout, "{}", serde_json::to_string_pretty(&view)?)?,
+ }
+
+ Ok(())
+}
blob - 15cf9e339df89215549608b3604bb8874ccd57e8
blob + 4fa8c978528e18d877efdb464292904c13bc55bb
--- src/lib.rs
+++ src/lib.rs
+pub mod api;
pub mod cli;
+pub mod editor;
pub mod error;
pub mod exec;
-pub mod ollama;
pub mod parse;
+pub mod transcript;
+pub mod usage;
pub use error::Error;
pub use exec::exec;
pub use parse::parse_messages;
pub type Result<T> = std::result::Result<T, Error>;
+
+/// The current user's home directory
+///
+/// # Errors
+///
+/// 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())
+ .map(std::path::PathBuf::from)
+ .or_else(std::env::home_dir)
+ .ok_or(Error::NoHomeDir)
+}
blob - /dev/null
blob + 24abb4c683a5ab2b84310b724ab0e9fc552a9334 (mode 644)
--- /dev/null
+++ src/editor.rs
+//! Editing 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.
+
+use std::fs;
+use std::io::Write;
+use std::path::Path;
+use std::process::{Command, Stdio};
+
+use crate::Error;
+
+/// Editor used when neither `VISUAL` nor `EDITOR` is set
+const FALLBACK_EDITOR: &str = "vim";
+
+/// Open `initial` in the user's editor and return the saved contents
+///
+/// The temporary file is removed on every path, including editor failure.
+///
+/// # Errors
+///
+/// Returns [`Error::EditorExit`] when the editor exits non-zero, and
+/// [`Error::Editor`] when it cannot be started or the terminal cannot be
+/// opened.
+pub fn edit(initial: &str) -> Result<String, Error> {
+ let mut file = tempfile::Builder::new()
+ .prefix("alpaca-")
+ .tempfile()
+ .map_err(Error::IO)?;
+ file.write_all(initial.as_bytes())?;
+ file.flush()?;
+
+ run(file.path())?;
+
+ Ok(fs::read_to_string(file.path())?)
+}
+
+/// The 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.
+#[must_use]
+pub fn command() -> Vec<String> {
+ ["VISUAL", "EDITOR"]
+ .iter()
+ .filter_map(|name| std::env::var(name).ok())
+ .map(|value| split(&value))
+ .find(|words| !words.is_empty())
+ .unwrap_or_else(|| vec![FALLBACK_EDITOR.to_string()])
+}
+
+fn split(value: &str) -> Vec<String> {
+ value.split_whitespace().map(String::from).collect()
+}
+
+/// Run the editor on `path`, wired 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.
+fn run(path: &Path) -> Result<(), Error> {
+ let words = command();
+ let (program, args) = words.split_first().expect("command is never empty");
+
+ let mut editor = Command::new(program);
+ editor.args(args).arg(path);
+
+ match fs::OpenOptions::new()
+ .read(true)
+ .write(true)
+ .open("/dev/tty")
+ {
+ Ok(tty) => {
+ editor
+ .stdin(Stdio::from(tty.try_clone().map_err(Error::IO)?))
+ .stdout(Stdio::from(tty.try_clone().map_err(Error::IO)?))
+ .stderr(Stdio::from(tty));
+ }
+ Err(_) => {
+ editor
+ .stdin(Stdio::inherit())
+ .stdout(Stdio::inherit())
+ .stderr(Stdio::inherit());
+ }
+ }
+
+ let status = editor
+ .status()
+ .map_err(|err| Error::Editor(format!("run {program}: {err}")))?;
+
+ if status.success() {
+ Ok(())
+ } else {
+ Err(Error::EditorExit(status.code().unwrap_or(1)))
+ }
+}
+
+#[cfg(test)]
+mod test {
+ use super::*;
+
+ #[test]
+ fn splits_on_whitespace() {
+ assert_eq!(split("emacsclient -nw"), vec!["emacsclient", "-nw"]);
+ assert_eq!(split(" "), Vec::<String>::new());
+ }
+}
blob - 59e0a7d39bdce3d2be395b411d5bfd78e79b1368
blob + d8e362f6322c76e64833576733b471154a9964de
--- src/main.rs
+++ src/main.rs
-use alpaca::cli;
-use anyhow::Result;
+use alpaca::{Error, cli};
#[tokio::main]
-async fn main() -> Result<()> {
- let invocation = cli::parse();
- alpaca::exec(invocation).await?;
- Ok(())
+async fn main() {
+ let invocation = match cli::parse() {
+ Ok(invocation) => invocation,
+ Err(err) => {
+ eprintln!("alpaca: {err:#}");
+ std::process::exit(1);
+ }
+ };
+
+ if let Err(err) = alpaca::exec(invocation).await {
+ // An editor that exits non-zero sets alpaca's own 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);
+ }
+
+ eprintln!("alpaca: {err:#}");
+ std::process::exit(1);
+ }
}
blob - 34137e38327a1922d4715acfe772acec97a76ebf (mode 644)
blob + /dev/null
--- src/ollama.rs
+++ /dev/null
-//! Interactions with the Ollama Cloud API
-//!
-//! Reference: <https://docs.ollama.com/api/chat>
-
-use std::fmt;
-use std::time::Duration;
-
-use crate::Error;
-use chrono::serde::ts_seconds;
-use chrono::{DateTime, Utc};
-use derive_builder::Builder;
-use reqwest::StatusCode;
-use serde::{Deserialize, Serialize};
-use serde_json::{Map, Value, json};
-use thiserror::Error;
-
-/// Convenience Client for the Ollama Chat API
-pub struct Client {
- /// Inner HTTP client
- http: reqwest::Client,
- /// Default API Key
- api_key: Option<String>,
- /// Base URL for API Endpoint
- base_url: String,
-}
-
-/// Requests for the Chat API
-/// Reference: <https://docs.ollama.com/api/chat>
-#[derive(Builder, Default)]
-pub struct ChatRequest {
- model: String,
- messages: Vec<Message>,
- #[builder(default)]
- temperature: Option<f32>,
- timeout: Duration,
- #[builder(default)]
- think: Option<ReasoningEffort>,
-}
-
-/// Normalized response surfaced to the rest of the crate
-#[derive(Builder, Default, Debug, Serialize, Deserialize)]
-pub struct Response {
- #[serde(with = "ts_seconds")]
- pub created: DateTime<Utc>,
- pub choices: Vec<Choice>,
- pub model: String,
- pub usage: Usage,
-}
-
-/// API Errors from Ollama
-///
-/// Ollama returns errors as `{"error": "message"}`.
-#[derive(Debug, Deserialize)]
-pub struct APIError {
- #[serde(rename = "error")]
- pub message: String,
-}
-
-/// Errors that can occur when converting an Ollama API response into a
-/// normalized [`Response`].
-#[derive(Debug, Clone, PartialEq, Eq, Error)]
-pub enum ResponseError {
- #[error("response did not contain a message")]
- NoMessage,
- #[error("response message missing text content")]
- MissingContent,
-}
-
-/// Messages in Chat API request and response
-#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
-pub struct Message {
- pub role: Role,
- pub content: String,
-}
-
-#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
-#[serde(rename_all = "lowercase")]
-pub enum Role {
- System,
- Assistant,
- User,
- Tool,
-}
-
-#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
-pub struct Usage {
- pub input_tokens: u32,
- pub output_tokens: u32,
- pub total_tokens: u32,
-}
-
-#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
-#[serde(rename_all = "lowercase")]
-pub enum ReasoningEffort {
- Low,
- Medium,
- High,
-}
-
-impl fmt::Display for ReasoningEffort {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- match self {
- ReasoningEffort::Low => write!(f, "low"),
- ReasoningEffort::Medium => write!(f, "medium"),
- ReasoningEffort::High => write!(f, "high"),
- }
- }
-}
-
-/// Reason generation stopped, derived from Ollama's `done_reason`
-#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
-#[serde(rename_all = "snake_case")]
-pub enum FinishReason {
- Stop,
- Length,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
-pub struct Choice {
- pub message: Message,
- pub finish_reason: FinishReason,
-}
-
-impl Client {
- /// # Errors
- ///
- /// Returns an error if the underlying HTTP client cannot be built.
- pub fn new(api_key: Option<String>, base_url: String) -> Result<Self, Error> {
- let http = reqwest::Client::builder().build()?;
- Ok(Self {
- http,
- api_key,
- base_url,
- })
- }
-
- /// # Errors
- ///
- /// Returns an error if no API key is set, the request fails, or the
- /// response cannot be parsed into a [`Response`].
- pub async fn create_response(&self, request: &ChatRequest) -> Result<Response, Error> {
- let api_key = self.api_key.as_ref().ok_or(Error::NoAPIKey)?;
-
- let resp = self
- .http
- .post(self.chat_endpoint())
- .bearer_auth(api_key)
- .timeout(request.timeout)
- .header("Content-Type", "application/json")
- .json(&request.to_payload())
- .send()
- .await?;
-
- if resp.status() == StatusCode::OK {
- let chat: ChatAPIResponse = resp.json().await?;
- Response::try_from(chat)
- .map_err(|e| Error::UnexpectedResponse(e.to_string()))
- } else {
- let error = resp.json::<APIError>().await?;
- Err(Error::OllamaError { error })
- }
- }
-
- fn chat_endpoint(&self) -> String {
- let base = self.base_url.trim_end_matches('/');
- format!("{}/api/chat", base)
- }
-}
-
-impl Message {
- #[must_use]
- pub fn system(content: &str) -> Message {
- Message {
- role: Role::System,
- content: content.to_string(),
- }
- }
- #[must_use]
- pub fn user(content: &str) -> Message {
- Message {
- role: Role::User,
- content: content.to_string(),
- }
- }
- #[must_use]
- pub fn assistant(content: &str) -> Message {
- Message {
- role: Role::Assistant,
- content: content.to_string(),
- }
- }
-}
-
-impl ChatRequest {
- #[must_use]
- pub fn builder() -> ChatRequestBuilder {
- ChatRequestBuilder::default()
- }
-
- fn to_payload(&self) -> Value {
- let messages = self
- .messages
- .iter()
- .map(|m| serde_json::to_value(m).expect("Message always serializes"))
- .collect::<Vec<_>>();
-
- let mut payload = Map::new();
- payload.insert("model".to_string(), json!(self.model));
- payload.insert("messages".to_string(), json!(messages));
- payload.insert("stream".to_string(), json!(false));
-
- if let Some(temperature) = self.temperature {
- payload.insert(
- "options".to_string(),
- json!({ "temperature": temperature }),
- );
- }
-
- if let Some(think) = self.think {
- payload.insert("think".to_string(), json!(think.to_string()));
- }
-
- Value::Object(payload)
- }
-}
-
-impl Response {
- #[must_use]
- pub fn builder() -> ResponseBuilder {
- ResponseBuilder::default()
- }
-}
-
-#[derive(Debug, Deserialize)]
-struct ChatAPIResponse {
- created_at: DateTime<Utc>,
- model: String,
- message: Option<ChatMessage>,
- #[serde(default)]
- done_reason: Option<String>,
- #[serde(default)]
- prompt_eval_count: u32,
- #[serde(default)]
- eval_count: u32,
-}
-
-#[derive(Debug, Deserialize)]
-struct ChatMessage {
- #[serde(default = "default_role")]
- role: Role,
- #[serde(default)]
- content: String,
-}
-
-fn default_role() -> Role {
- Role::Assistant
-}
-
-impl TryFrom<ChatAPIResponse> for Response {
- type Error = ResponseError;
-
- fn try_from(value: ChatAPIResponse) -> Result<Self, Self::Error> {
- let message = value.message.ok_or(ResponseError::NoMessage)?;
-
- if message.content.is_empty() {
- return Err(ResponseError::MissingContent);
- }
-
- let finish_reason = match value.done_reason.as_deref() {
- Some("length") => FinishReason::Length,
- _ => FinishReason::Stop,
- };
-
- let choice = Choice {
- message: Message {
- role: message.role,
- content: message.content,
- },
- finish_reason,
- };
-
- Ok(Response {
- created: value.created_at,
- choices: vec![choice],
- model: value.model,
- usage: Usage {
- input_tokens: value.prompt_eval_count,
- output_tokens: value.eval_count,
- total_tokens: value.prompt_eval_count + value.eval_count,
- },
- })
- }
-}
-
-#[cfg(test)]
-mod test {
-
- use super::*;
- use anyhow::Result;
- use std::time::Duration;
-
- #[test]
- fn parse_response_payload() -> Result<()> {
- let data = r#"{
- "model": "gpt-oss:120b",
- "created_at": "2025-10-17T23:14:07.414671Z",
- "message": {
- "role": "assistant",
- "content": "Hello! How can I assist you today?"
- },
- "done": true,
- "done_reason": "stop",
- "prompt_eval_count": 8,
- "eval_count": 9
- }
- "#;
-
- let resp = serde_json::from_str::<ChatAPIResponse>(data)?;
- let resp = Response::try_from(resp).map_err(|e| anyhow::anyhow!(e))?;
-
- assert_eq!(
- resp.choices,
- vec![Choice {
- message: Message {
- role: Role::Assistant,
- content: "Hello! How can I assist you today?".to_string()
- },
- finish_reason: FinishReason::Stop
- }]
- );
- assert_eq!(resp.model, "gpt-oss:120b");
- assert_eq!(
- resp.usage,
- Usage {
- input_tokens: 8,
- output_tokens: 9,
- total_tokens: 17,
- }
- );
-
- Ok(())
- }
-
- #[test]
- fn response_payload_includes_think() -> Result<()> {
- let request = ChatRequest::builder()
- .model("gpt-oss:120b".to_string())
- .messages(vec![Message::user("Hello")])
- .temperature(Some(0.0))
- .timeout(Duration::from_secs(30))
- .think(Some(ReasoningEffort::High))
- .build()
- .expect("request builds");
-
- let payload = request.to_payload();
-
- assert_eq!(payload["think"], "high");
- assert_eq!(payload["model"], "gpt-oss:120b");
- assert_eq!(payload["options"]["temperature"], 0.0);
- assert_eq!(payload["stream"], false);
-
- Ok(())
- }
-
- #[test]
- fn response_payload_omits_think_when_not_set() -> Result<()> {
- let request = ChatRequest::builder()
- .model("gpt-oss:120b".to_string())
- .messages(vec![Message::user("Hello")])
- .timeout(Duration::from_secs(30))
- .build()
- .expect("request builds");
-
- let payload = request.to_payload();
-
- assert!(payload.get("think").is_none());
- assert!(payload.get("options").is_none());
- Ok(())
- }
-
- #[test]
- fn response_try_from_errors_without_message() -> Result<()> {
- let data = r#"{
- "model": "gpt-oss:120b",
- "created_at": "2025-10-17T23:14:07.414671Z",
- "done": true,
- "done_reason": "stop",
- "prompt_eval_count": 1,
- "eval_count": 1
- }
- "#;
-
- let resp = serde_json::from_str::<ChatAPIResponse>(data)?;
- let err = Response::try_from(resp).expect_err("should error");
-
- assert!(
- matches!(err, ResponseError::NoMessage),
- "unexpected error: {err:?}"
- );
-
- Ok(())
- }
-
- #[test]
- fn parse_response_error() -> Result<()> {
- let data = r#"{
- "error": "model 'nope' not found"
- }
- "#;
-
- let resp = serde_json::from_str::<APIError>(data)?;
-
- assert_eq!(resp.message, "model 'nope' not found");
-
- Ok(())
- }
-}
blob - 378e86478140600edf16cd0af18cc2c48cb55185
blob + c1604d171e0ee1f15c8c6d7a32981f06bdb0695d
--- src/parse.rs
+++ src/parse.rs
use std::io::Read;
use crate::Error;
-use crate::ollama::Message;
+use crate::api::Message;
/// Read from `std::io::Read` into a vector of messages
///
blob - /dev/null
blob + d42a73bd6246f28d199db6f8473e2feab8d66b7d (mode 644)
--- /dev/null
+++ src/transcript.rs
+//! Saved conversations
+//!
+//! Conversations are stored one JSON document per file 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.
+
+use std::fs;
+use std::io::Write;
+use std::os::unix::fs::PermissionsExt;
+use std::path::{Path, PathBuf};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use serde::{Deserialize, Serialize};
+
+use crate::Error;
+use crate::api::{Message, ReasoningEffort, Role};
+
+/// Version of the on-disk transcript document
+const VERSION: u32 = 1;
+
+/// A saved conversation
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Transcript {
+ /// Format version, so future readers can tell formats apart
+ #[serde(default = "default_version")]
+ pub version: u32,
+ pub model: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub effort: Option<ReasoningEffort>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub system: Option<String>,
+ /// Alternating user and assistant messages, starting with a user message
+ pub messages: Vec<Message>,
+}
+
+const fn default_version() -> u32 {
+ VERSION
+}
+
+impl Transcript {
+ /// Start a transcript with no messages yet
+ #[must_use]
+ pub fn new(model: String, effort: Option<ReasoningEffort>, system: Option<String>) -> Self {
+ Self {
+ version: VERSION,
+ model,
+ effort,
+ system,
+ messages: vec![],
+ }
+ }
+
+ /// Messages to send for the next turn, with the system prompt first
+ #[must_use]
+ pub fn request_messages(&self) -> Vec<Message> {
+ let system = self.system.iter().map(|s| Message::system(s));
+ system.chain(self.messages.iter().cloned()).collect()
+ }
+
+ /// The most recent assistant reply, if there is one
+ #[must_use]
+ pub fn last_reply(&self) -> Option<&str> {
+ self.messages
+ .iter()
+ .rev()
+ .find(|m| m.role == Role::Assistant)
+ .map(|m| m.content.as_str())
+ }
+
+ /// Read a transcript from `path`
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if the file cannot be read, is not a transcript
+ /// document, or does not hold a complete alternating conversation.
+ pub fn load(path: &Path) -> Result<Self, Error> {
+ let data = fs::read(path)?;
+ let transcript: Self =
+ serde_json::from_slice(&data).map_err(|err| Error::InvalidTranscript {
+ path: path.to_path_buf(),
+ message: err.to_string(),
+ })?;
+ transcript.validate(path)?;
+ Ok(transcript)
+ }
+
+ /// Write the transcript to `path`, replacing any previous contents
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if the transcript is malformed or the file cannot be
+ /// written.
+ pub fn save(&self, path: &Path) -> Result<(), Error> {
+ let data = self.serialized(path)?;
+ let parent = path.parent().unwrap_or_else(|| Path::new("."));
+ let mut file = tempfile::NamedTempFile::new_in(parent)?;
+ fs::set_permissions(file.path(), fs::Permissions::from_mode(0o600))?;
+ file.write_all(&data)?;
+ file.write_all(b"\n")?;
+ file.flush()?;
+ file.persist(path).map_err(|err| Error::IO(err.error))?;
+ Ok(())
+ }
+
+ /// Save the transcript to a new file in the transcript directory, returning
+ /// its path
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if the directory cannot be created or the file cannot
+ /// be written.
+ pub fn create(&self) -> Result<PathBuf, Error> {
+ let dir = dir()?;
+ fs::create_dir_all(&dir)?;
+ fs::set_permissions(&dir, fs::Permissions::from_mode(0o700))?;
+ let data = self.serialized(&dir)?;
+ let mut file = tempfile::NamedTempFile::new_in(&dir)?;
+ fs::set_permissions(file.path(), fs::Permissions::from_mode(0o600))?;
+ file.write_all(&data)?;
+ file.write_all(b"\n")?;
+ file.flush()?;
+
+ let stamp = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map_or(0, |duration| duration.as_nanos());
+ for seq in 0.. {
+ let path = dir.join(format!("{stamp}-{seq}.json"));
+ match file.persist_noclobber(&path) {
+ Ok(_) => return Ok(path),
+ Err(err) if err.error.kind() == std::io::ErrorKind::AlreadyExists => {
+ file = err.file;
+ }
+ Err(err) => return Err(Error::IO(err.error)),
+ }
+ }
+ unreachable!("the counter yields an unused name")
+ }
+
+ fn serialized(&self, path: &Path) -> Result<Vec<u8>, Error> {
+ self.validate(path)?;
+ Ok(serde_json::to_vec_pretty(self)?)
+ }
+
+ /// Find the most recently modified usable transcript
+ ///
+ /// Unreadable files are skipped. Their error is only reported when no
+ /// transcript in the directory can be read.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Error::NoConversations`] when the directory holds no
+ /// transcripts, or the last parse error when none of them are usable.
+ pub fn newest() -> Result<(PathBuf, Self), Error> {
+ let dir = dir()?;
+ let entries = match fs::read_dir(&dir) {
+ Ok(entries) => entries,
+ Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
+ return Err(Error::NoConversations);
+ }
+ Err(err) => return Err(err.into()),
+ };
+
+ let mut candidates = vec![];
+ for entry in entries {
+ let entry = entry?;
+ let path = entry.path();
+ if path.extension().is_none_or(|ext| ext != "json") {
+ continue;
+ }
+ let metadata = entry.metadata()?;
+ if !metadata.is_file() {
+ continue;
+ }
+ candidates.push((metadata.modified()?, path));
+ }
+
+ candidates.sort_by(|(a, _), (b, _)| b.cmp(a));
+
+ let mut last_error = None;
+ for (_, path) in candidates {
+ match Self::load(&path) {
+ Ok(transcript) => return Ok((path, transcript)),
+ Err(err) => last_error = Some(err),
+ }
+ }
+
+ Err(last_error.unwrap_or(Error::NoConversations))
+ }
+
+ /// Check that messages alternate user, assistant, and end with a reply
+ fn validate(&self, path: &Path) -> Result<(), Error> {
+ let invalid = |message: &str| Error::InvalidTranscript {
+ path: path.to_path_buf(),
+ message: message.to_string(),
+ };
+
+ if self.messages.is_empty() || !self.messages.len().is_multiple_of(2) {
+ return Err(invalid("transcript must end with an assistant reply"));
+ }
+
+ let expected = [Role::User, Role::Assistant];
+ if self
+ .messages
+ .iter()
+ .zip(expected.iter().cycle())
+ .any(|(message, role)| message.role != *role)
+ {
+ return Err(invalid("transcript messages are out of order"));
+ }
+
+ Ok(())
+ }
+}
+
+/// Directory holding saved conversations
+///
+/// # Errors
+///
+/// Returns an error if neither `XDG_DATA_HOME` nor a home directory is known.
+pub fn dir() -> Result<PathBuf, Error> {
+ if let Some(data_home) = std::env::var_os("XDG_DATA_HOME")
+ && !data_home.is_empty()
+ {
+ return Ok(PathBuf::from(data_home).join("alpaca"));
+ }
+ Ok(crate::home_dir()?
+ .join(".local")
+ .join("share")
+ .join("alpaca"))
+}
+
+#[cfg(test)]
+mod test {
+ use super::*;
+ use anyhow::Result;
+ use std::time::Duration;
+
+ /// Point the transcript directory at a temporary tree for the duration of
+ /// the guard. Tests that use it run serially, guarded by a mutex, because
+ /// the environment is process wide.
+ struct DataHome {
+ _dir: tempfile::TempDir,
+ _guard: std::sync::MutexGuard<'static, ()>,
+ }
+
+ static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
+
+ impl DataHome {
+ fn new() -> Self {
+ let guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
+ let dir = tempfile::tempdir().expect("temp dir");
+ // SAFETY: the mutex makes this the only thread touching the
+ // environment while the guard is alive.
+ unsafe { std::env::set_var("XDG_DATA_HOME", dir.path()) };
+ Self {
+ _dir: dir,
+ _guard: guard,
+ }
+ }
+ }
+
+ impl Drop for DataHome {
+ fn drop(&mut self) {
+ // SAFETY: see `DataHome::new`.
+ unsafe { std::env::remove_var("XDG_DATA_HOME") };
+ }
+ }
+
+ fn transcript(model: &str) -> Transcript {
+ Transcript {
+ version: VERSION,
+ model: model.to_string(),
+ effort: Some(ReasoningEffort::Low),
+ system: None,
+ messages: vec![Message::user("question"), Message::assistant("reply")],
+ }
+ }
+
+ #[test]
+ fn round_trip_preserves_awkward_content() -> Result<()> {
+ let dir = tempfile::tempdir()?;
+ let path = dir.path().join("session.json");
+ let want = Transcript {
+ version: VERSION,
+ model: "model".to_string(),
+ effort: None,
+ system: Some("system\ntext".to_string()),
+ messages: vec![
+ Message::user("hello\nmessage-user 4\nevil\"quoted\""),
+ Message::assistant("reply"),
+ ],
+ };
+
+ want.save(&path)?;
+ let got = Transcript::load(&path)?;
+
+ assert_eq!(got, want);
+ Ok(())
+ }
+
+ #[test]
+ fn saved_files_are_private() -> Result<()> {
+ let _home = DataHome::new();
+ let path = transcript("m").create()?;
+
+ let file_mode = fs::metadata(&path)?.permissions().mode() & 0o777;
+ let dir_mode = fs::metadata(dir()?)?.permissions().mode() & 0o777;
+
+ assert_eq!(file_mode, 0o600, "transcripts should not be world readable");
+ assert_eq!(dir_mode, 0o700, "transcript directory should be private");
+ Ok(())
+ }
+
+ #[test]
+ fn create_produces_unique_paths() -> Result<()> {
+ let _home = DataHome::new();
+ let first = transcript("m").create()?;
+ let second = transcript("m").create()?;
+
+ assert_ne!(first, second);
+ Ok(())
+ }
+
+ #[test]
+ fn newest_picks_most_recent() -> Result<()> {
+ let _home = DataHome::new();
+ let old = transcript("old").create()?;
+ let new = transcript("new").create()?;
+
+ let past = SystemTime::now() - Duration::from_secs(60);
+ fs::File::open(&old)?.set_modified(past)?;
+
+ let (path, found) = Transcript::newest()?;
+
+ assert_eq!(path, new);
+ assert_eq!(found.model, "new");
+ Ok(())
+ }
+
+ #[test]
+ fn newest_skips_unreadable_transcripts() -> Result<()> {
+ let _home = DataHome::new();
+ let good = transcript("good").create()?;
+ fs::write(dir()?.join("corrupt.json"), b"not json")?;
+
+ let past = SystemTime::now() - Duration::from_secs(60);
+ fs::File::open(&good)?.set_modified(past)?;
+
+ let (path, found) = Transcript::newest()?;
+
+ assert_eq!(path, good);
+ assert_eq!(found.model, "good");
+ Ok(())
+ }
+
+ #[test]
+ fn newest_without_conversations_errors() {
+ let _home = DataHome::new();
+
+ let err = Transcript::newest().expect_err("should have no conversations");
+
+ assert!(
+ matches!(err, Error::NoConversations),
+ "unexpected error: {err:?}"
+ );
+ }
+
+ #[test]
+ fn newest_reports_parse_error_when_nothing_is_usable() -> Result<()> {
+ let _home = DataHome::new();
+ fs::create_dir_all(dir()?)?;
+ fs::write(dir()?.join("corrupt.json"), b"not json")?;
+
+ let err = Transcript::newest().expect_err("should fail");
+
+ assert!(
+ matches!(err, Error::InvalidTranscript { .. }),
+ "unexpected error: {err:?}"
+ );
+ Ok(())
+ }
+
+ #[test]
+ fn request_messages_puts_system_first() {
+ let transcript = Transcript {
+ version: VERSION,
+ model: "m".to_string(),
+ effort: None,
+ system: Some("s".to_string()),
+ messages: vec![
+ Message::user("first"),
+ Message::assistant("answer"),
+ Message::user("next"),
+ ],
+ };
+
+ assert_eq!(
+ transcript.request_messages(),
+ vec![
+ Message::system("s"),
+ Message::user("first"),
+ Message::assistant("answer"),
+ Message::user("next"),
+ ]
+ );
+ }
+
+ #[test]
+ fn incomplete_conversations_are_rejected() -> Result<()> {
+ let dir = tempfile::tempdir()?;
+ let path = dir.path().join("bad.json");
+
+ let odd = Transcript {
+ messages: vec![Message::user("x")],
+ ..transcript("m")
+ };
+ assert!(odd.save(&path).is_err(), "odd message count is invalid");
+ assert!(!path.exists(), "invalid save must not leave a file");
+
+ let out_of_order = Transcript {
+ messages: vec![Message::assistant("x"), Message::user("y")],
+ ..transcript("m")
+ };
+ assert!(
+ out_of_order.save(&path).is_err(),
+ "assistant first is invalid"
+ );
+
+ fs::write(&path, br#"{"model":"m","messages":[]}"#)?;
+ assert!(
+ Transcript::load(&path).is_err(),
+ "empty conversation is invalid"
+ );
+ Ok(())
+ }
+
+ #[test]
+ fn last_reply_returns_final_assistant_message() {
+ assert_eq!(transcript("m").last_reply(), Some("reply"));
+ }
+}
blob - /dev/null
blob + a8f714a2f7dfc74599cef9235561839588ae51b1 (mode 644)
--- /dev/null
+++ src/usage/anthropic.rs
+//! Claude Code usage
+//!
+//! The endpoint serves the Claude Code CLI. It is not a documented public API,
+//! so it may change without notice.
+
+use std::fmt;
+
+use serde::Deserialize;
+
+use super::{View, Window, timestamp};
+use crate::Error;
+
+/// One quota window, with utilization reported in percent
+#[derive(Debug, Deserialize)]
+struct Utilization {
+ #[serde(default)]
+ utilization: f64,
+ #[serde(default)]
+ resets_at: Option<String>,
+}
+
+/// One entry of the flat limits list
+#[derive(Debug, Deserialize)]
+struct Limit {
+ #[serde(default)]
+ kind: String,
+ #[serde(default)]
+ percent: f64,
+ #[serde(default)]
+ resets_at: Option<String>,
+}
+
+/// An amount in minor units, as reported in the spend block
+#[derive(Debug, Default, Deserialize)]
+struct Money {
+ #[serde(rename = "amount_minor", default)]
+ minor: i64,
+ #[serde(rename = "currency", default)]
+ currency: String,
+ #[serde(rename = "exponent", default)]
+ exponent: u32,
+}
+
+impl fmt::Display for Money {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ let minor = i128::from(self.minor);
+ let Some(scale) = 10_i128.checked_pow(self.exponent) else {
+ return write!(f, "{}e-{} {}", self.minor, self.exponent, self.currency);
+ };
+ let whole = minor / scale;
+ let fraction = minor.abs() % scale;
+ if self.exponent == 0 {
+ write!(f, "{whole} {}", self.currency)
+ } else {
+ let digits = usize::try_from(self.exponent).map_err(|_| fmt::Error)?;
+ if minor < 0 && whole == 0 {
+ write!(f, "-0.{fraction:0digits$} {}", self.currency)
+ } else {
+ write!(f, "{whole}.{fraction:0digits$} {}", self.currency)
+ }
+ }
+ }
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct Spend {
+ #[serde(default)]
+ used: Money,
+ #[serde(default)]
+ limit: Money,
+ #[serde(default)]
+ enabled: bool,
+}
+
+#[derive(Debug, Deserialize)]
+struct Usage {
+ #[serde(default)]
+ five_hour: Option<Utilization>,
+ #[serde(default)]
+ seven_day: Option<Utilization>,
+ #[serde(default)]
+ limits: Vec<Limit>,
+ #[serde(default)]
+ spend: Spend,
+}
+
+/// The OAuth token Claude Code writes when you log in
+///
+/// # Errors
+///
+/// Returns an error if the credential file exists but cannot be parsed.
+pub fn token_from_file() -> Result<Option<String>, Error> {
+ #[derive(Deserialize)]
+ struct Credentials {
+ #[serde(rename = "claudeAiOauth")]
+ oauth: OAuth,
+ }
+
+ #[derive(Deserialize)]
+ struct OAuth {
+ #[serde(rename = "accessToken")]
+ access_token: String,
+ }
+
+ let path = crate::home_dir()?.join(".claude").join(".credentials.json");
+ let data = match std::fs::read(path) {
+ Ok(data) => data,
+ Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
+ Err(err) => return Err(err.into()),
+ };
+
+ let credentials: Credentials = serde_json::from_slice(&data)?;
+ Ok(Some(credentials.oauth.access_token).filter(|token| !token.is_empty()))
+}
+
+/// Normalise the Claude Code usage response
+///
+/// The flat `limits` list is preferred, since it names whichever windows the
+/// account has. Accounts without it fall back to the fixed five hour and seven
+/// day fields.
+///
+/// # Errors
+///
+/// Returns an error if the body is not a Claude usage response.
+pub fn view(body: &[u8]) -> Result<View, Error> {
+ let value: serde_json::Value =
+ serde_json::from_slice(body).map_err(|err| Error::UnexpectedResponse(err.to_string()))?;
+ if !["five_hour", "seven_day", "limits", "spend"]
+ .iter()
+ .any(|key| value.get(key).is_some())
+ {
+ return Err(Error::UnexpectedResponse(
+ "response contains no anthropic usage fields".to_string(),
+ ));
+ }
+ let usage: Usage =
+ serde_json::from_value(value).map_err(|err| Error::UnexpectedResponse(err.to_string()))?;
+
+ let mut windows: Vec<Window> = usage
+ .limits
+ .iter()
+ .map(|limit| {
+ Window::new(
+ limit.kind.clone(),
+ limit.percent / 100.0,
+ timestamp(limit.resets_at.as_deref()),
+ )
+ })
+ .collect();
+
+ if windows.is_empty() {
+ let fallback = [
+ ("session", usage.five_hour.as_ref()),
+ ("weekly", usage.seven_day.as_ref()),
+ ];
+ windows = fallback
+ .into_iter()
+ .filter_map(|(name, window)| {
+ window.map(|w| {
+ Window::new(
+ name,
+ w.utilization / 100.0,
+ timestamp(w.resets_at.as_deref()),
+ )
+ })
+ })
+ .collect();
+ }
+
+ Ok(View {
+ title: "anthropic usage".to_string(),
+ subtitle: None,
+ note: usage
+ .spend
+ .enabled
+ .then(|| format!("extra {} of {}", usage.spend.used, usage.spend.limit)),
+ windows,
+ tables: vec![],
+ })
+}
+
+#[cfg(test)]
+mod test {
+ use super::*;
+ use anyhow::Result;
+
+ #[test]
+ fn prefers_the_flat_limits_list() -> Result<()> {
+ let body = br#"{
+ "five_hour": {"utilization": 10, "resets_at": "2030-01-01T00:00:00Z"},
+ "limits": [
+ {"kind": "session", "percent": 42.5, "resets_at": "2030-01-01T00:00:00Z"},
+ {"kind": "opus weekly", "percent": 90}
+ ],
+ "spend": {"enabled": true,
+ "used": {"amount_minor": 1234, "currency": "USD", "exponent": 2},
+ "limit": {"amount_minor": 5000, "currency": "USD", "exponent": 2}}
+ }"#;
+
+ let view = view(body)?;
+
+ assert_eq!(view.title, "anthropic usage");
+ assert_eq!(view.windows.len(), 2);
+ assert_eq!(view.windows[0].name, "session");
+ assert!((view.windows[0].used - 0.425).abs() < f64::EPSILON);
+ assert!(view.windows[0].resets_in_secs.is_some());
+ assert_eq!(
+ view.windows[1].resets_in_secs, None,
+ "a missing reset time is not rendered"
+ );
+ assert_eq!(view.note.as_deref(), Some("extra 12.34 USD of 50.00 USD"));
+ Ok(())
+ }
+
+ #[test]
+ fn falls_back_to_fixed_windows() -> Result<()> {
+ let body = br#"{
+ "five_hour": {"utilization": 20, "resets_at": "2030-01-01T00:00:00Z"},
+ "seven_day": {"utilization": 60}
+ }"#;
+
+ let view = view(body)?;
+
+ assert_eq!(view.windows.len(), 2);
+ assert_eq!(view.windows[0].name, "session");
+ assert_eq!(view.windows[1].name, "weekly");
+ assert!((view.windows[1].used - 0.6).abs() < f64::EPSILON);
+ Ok(())
+ }
+
+ #[test]
+ fn omits_spend_when_disabled() -> Result<()> {
+ let body = br#"{"seven_day": {"utilization": 1}, "spend": {"enabled": false}}"#;
+
+ let view = view(body)?;
+
+ assert_eq!(view.note, None);
+ assert_eq!(view.windows.len(), 1);
+ Ok(())
+ }
+
+ #[test]
+ fn rejects_non_usage_bodies() {
+ assert!(view(b"not json").is_err());
+ assert!(view(b"{}").is_err());
+ }
+
+ #[test]
+ fn money_renders_minor_units() {
+ let money = Money {
+ minor: 1234,
+ currency: "USD".to_string(),
+ exponent: 2,
+ };
+ assert_eq!(money.to_string(), "12.34 USD");
+
+ let whole = Money {
+ minor: 7,
+ currency: "JPY".to_string(),
+ exponent: 0,
+ };
+ assert_eq!(whole.to_string(), "7 JPY");
+ }
+
+ #[test]
+ fn money_preserves_negative_fraction_and_large_exponent() {
+ let negative = Money {
+ minor: -5,
+ currency: "USD".to_string(),
+ exponent: 2,
+ };
+ assert_eq!(negative.to_string(), "-0.05 USD");
+
+ let large = Money {
+ minor: 1,
+ currency: "USD".to_string(),
+ exponent: u32::MAX,
+ };
+ assert_eq!(large.to_string(), format!("1e-{} USD", u32::MAX));
+ }
+}
blob - /dev/null
blob + 858707aa6886d8401f46bafc36d226f20f923c4f (mode 644)
--- /dev/null
+++ src/usage/mod.rs
+//! Provider quota usage
+//!
+//! Each provider exposes a different usage endpoint with a different response
+//! shape. They are normalised into a [`View`] so the renderer only has to know
+//! about quota windows and per-model request counts.
+
+pub mod anthropic;
+pub mod ollama;
+pub mod openai;
+pub mod render;
+
+use std::fmt;
+use std::time::Duration;
+
+use chrono::{DateTime, Utc};
+use serde::{Deserialize, Serialize};
+
+use crate::Error;
+
+/// Largest response body read from a usage endpoint
+const MAX_BODY: usize = 1 << 20;
+
+/// A usage provider
+#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Provider {
+ #[default]
+ Ollama,
+ Anthropic,
+ OpenAI,
+}
+
+/// Provider usage, normalised for rendering
+#[derive(Debug, Default, Clone, PartialEq, Serialize)]
+pub struct View {
+ pub title: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub subtitle: Option<String>,
+ /// Trailing note, typically extra spend
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub note: Option<String>,
+ pub windows: Vec<Window>,
+ #[serde(skip_serializing_if = "Vec::is_empty")]
+ pub tables: Vec<Table>,
+}
+
+/// One quota window
+#[derive(Debug, Clone, PartialEq, Serialize)]
+pub struct Window {
+ pub name: String,
+ /// Used share of the window, where 1.0 is spent
+ pub used: f64,
+ /// Seconds until the window resets, when the provider reports a reset
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub resets_in_secs: Option<u64>,
+}
+
+/// One table of per-model request counts
+#[derive(Debug, Clone, PartialEq, Serialize)]
+pub struct Table {
+ pub heading: String,
+ pub rows: Vec<ModelCount>,
+}
+
+/// Requests made against one model
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ModelCount {
+ pub name: String,
+ #[serde(rename = "request_count")]
+ pub requests: u64,
+}
+
+impl Window {
+ /// A 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 {
+ name: name.into(),
+ used,
+ resets_in_secs: resets_at.map(|at| remaining_secs(at, Utc::now())),
+ }
+ }
+}
+
+/// Whole seconds between `now` and `at`, clamped at zero for past instants
+fn remaining_secs(at: DateTime<Utc>, now: DateTime<Utc>) -> u64 {
+ u64::try_from((at - now).num_seconds()).unwrap_or(0)
+}
+
+impl Provider {
+ /// Default base URL, honouring any environment override
+ #[must_use]
+ pub fn base_url(self) -> String {
+ match self {
+ Self::Ollama => std::env::var("OLLAMA_API_ENDPOINT")
+ .ok()
+ .filter(|url| !url.is_empty())
+ .unwrap_or_else(|| "https://ollama.com".to_string()),
+ Self::Anthropic => "https://api.anthropic.com".to_string(),
+ Self::OpenAI => "https://chatgpt.com".to_string(),
+ }
+ }
+
+ /// Path of the usage endpoint
+ #[must_use]
+ pub fn path(self) -> &'static str {
+ match self {
+ Self::Ollama => "/api/usage",
+ Self::Anthropic => "/api/oauth/usage",
+ Self::OpenAI => "/backend-api/codex/usage",
+ }
+ }
+
+ /// Whether the credential is sent as a bearer token
+ #[must_use]
+ pub fn bearer(self) -> bool {
+ matches!(self, Self::Anthropic | Self::OpenAI)
+ }
+
+ /// The credential this provider uses when none is given on the command line
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Error::NoCredentials`] when no environment variable is set and
+ /// the vendor CLI's credential file is missing or malformed.
+ pub fn credential(self) -> Result<String, Error> {
+ let from_env = |name: &str| std::env::var(name).ok().filter(|key| !key.is_empty());
+
+ let key = match self {
+ Self::Ollama => from_env("OLLAMA_API_KEY"),
+ Self::Anthropic => from_env("CLAUDE_CODE_OAUTH_TOKEN")
+ .or_else(|| anthropic::token_from_file().ok().flatten()),
+ Self::OpenAI => {
+ from_env("CODEX_ACCESS_TOKEN").or_else(|| openai::token_from_file().ok().flatten())
+ }
+ };
+
+ key.ok_or(Error::NoCredentials { provider: self })
+ }
+
+ /// Normalise a usage response body
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if the body is not the JSON this provider reports.
+ pub fn view(self, body: &[u8]) -> Result<View, Error> {
+ match self {
+ Self::Ollama => ollama::view(body),
+ Self::Anthropic => anthropic::view(body),
+ Self::OpenAI => openai::view(body),
+ }
+ }
+
+ /// Build the HTTP client this provider needs
+ ///
+ /// The Codex endpoint sits behind a bot filter that answers Go-style and
+ /// modern Rust clients with a challenge page instead of JSON: it rejects
+ /// HTTP/2 and TLS 1.3 handshakes. That client therefore speaks HTTP/1.1
+ /// over TLS 1.2. No compression feature is enabled for reqwest, so no
+ /// `Accept-Encoding` header is sent either, which the same filter requires.
+ fn http_client(self) -> Result<reqwest::Client, Error> {
+ let builder = reqwest::Client::builder().user_agent(user_agent());
+ let builder = match self {
+ Self::OpenAI => builder
+ .http1_only()
+ .tls_version_max(reqwest::tls::Version::TLS_1_2),
+ _ => builder,
+ };
+ Ok(builder.build()?)
+ }
+}
+
+impl fmt::Display for Provider {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ let name = match self {
+ Self::Ollama => "ollama",
+ Self::Anthropic => "anthropic",
+ Self::OpenAI => "openai",
+ };
+ f.write_str(name)
+ }
+}
+
+fn user_agent() -> String {
+ format!("alpaca/{}", env!("CARGO_PKG_VERSION"))
+}
+
+/// Client for provider usage endpoints
+pub struct Client {
+ http: reqwest::Client,
+ provider: Provider,
+ base_url: String,
+ api_key: String,
+ timeout: Duration,
+}
+
+impl Client {
+ /// Build a client for `provider`, resolving the credential and base URL
+ /// from the provider defaults when they are not given
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if no credential can be found or the HTTP client
+ /// cannot be built.
+ pub fn new(
+ provider: Provider,
+ api_key: Option<String>,
+ base_url: Option<String>,
+ timeout: Duration,
+ ) -> Result<Self, Error> {
+ let api_key = match api_key {
+ Some(key) => key,
+ None => provider.credential()?,
+ };
+
+ Ok(Self {
+ http: provider.http_client()?,
+ provider,
+ base_url: base_url.unwrap_or_else(|| provider.base_url()),
+ api_key,
+ timeout,
+ })
+ }
+
+ /// Fetch the raw usage response body
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if the request fails or the endpoint answers with a
+ /// non-success status.
+ pub async fn fetch(&self) -> Result<Vec<u8>, Error> {
+ let credential = if self.provider.bearer() {
+ format!("Bearer {}", self.api_key)
+ } else {
+ self.api_key.clone()
+ };
+
+ let resp = self
+ .http
+ .get(self.endpoint())
+ .timeout(self.timeout)
+ .header("Authorization", credential)
+ .header("Accept", "application/json")
+ .send()
+ .await?;
+
+ let status = resp.status();
+ let body = read_body(resp).await?;
+
+ if status.is_success() {
+ Ok(body)
+ } else {
+ Err(Error::HttpStatus {
+ status: status.as_u16(),
+ message: String::from_utf8_lossy(&body).trim().to_string(),
+ })
+ }
+ }
+
+ fn endpoint(&self) -> String {
+ format!(
+ "{}{}",
+ self.base_url.trim_end_matches('/'),
+ self.provider.path()
+ )
+ }
+}
+
+/// Read at most [`MAX_BODY`] bytes of a response body
+async fn read_body(mut resp: reqwest::Response) -> Result<Vec<u8>, Error> {
+ let mut body = Vec::new();
+ while let Some(chunk) = resp.chunk().await? {
+ let room = MAX_BODY - body.len();
+ if chunk.len() > room {
+ return Err(Error::ResponseTooLarge { limit: MAX_BODY });
+ }
+ if chunk.len() == room {
+ body.extend_from_slice(&chunk);
+ if resp.chunk().await?.is_some() {
+ return Err(Error::ResponseTooLarge { limit: MAX_BODY });
+ }
+ return Ok(body);
+ }
+ body.extend_from_slice(&chunk);
+ }
+ Ok(body)
+}
+
+/// Parse an RFC 3339 timestamp, ignoring absent or malformed values
+fn timestamp(value: Option<&str>) -> Option<DateTime<Utc>> {
+ let value = value?;
+ DateTime::parse_from_rfc3339(value)
+ .ok()
+ .map(|at| at.with_timezone(&Utc))
+}
+
+#[cfg(test)]
+mod test {
+ use super::*;
+
+ #[test]
+ fn endpoints_join_cleanly() -> Result<(), Error> {
+ let client = Client::new(
+ Provider::Ollama,
+ Some("key".to_string()),
+ Some("http://example.com/".to_string()),
+ Duration::from_secs(1),
+ )?;
+
+ assert_eq!(client.endpoint(), "http://example.com/api/usage");
+ Ok(())
+ }
+
+ #[test]
+ fn provider_display_matches_flag_values() {
+ assert_eq!(Provider::Ollama.to_string(), "ollama");
+ assert_eq!(Provider::Anthropic.to_string(), "anthropic");
+ assert_eq!(Provider::OpenAI.to_string(), "openai");
+ }
+
+ #[test]
+ fn only_oauth_providers_use_bearer() {
+ assert!(!Provider::Ollama.bearer());
+ assert!(Provider::Anthropic.bearer());
+ assert!(Provider::OpenAI.bearer());
+ }
+
+ #[test]
+ fn remaining_is_clamped_at_zero() {
+ let now = Utc::now();
+ assert_eq!(remaining_secs(now - chrono::TimeDelta::hours(1), now), 0);
+ assert_eq!(
+ remaining_secs(now + chrono::TimeDelta::minutes(90), now),
+ 5400
+ );
+ }
+
+ #[test]
+ fn timestamps_tolerate_missing_and_malformed_values() {
+ assert!(timestamp(None).is_none());
+ assert!(timestamp(Some("")).is_none());
+ assert!(timestamp(Some("not a time")).is_none());
+ assert!(timestamp(Some("2025-01-01T00:00:00Z")).is_some());
+ }
+}
blob - /dev/null
blob + f430e5638ccc6d2517cdc38eed5d1a76ca3d56f2 (mode 644)
--- /dev/null
+++ src/usage/ollama.rs
+//! Ollama Cloud usage
+
+use chrono::{DateTime, TimeDelta, Utc};
+use serde::Deserialize;
+
+use super::{ModelCount, Table, View, Window};
+use crate::Error;
+
+/// Length of the session window
+const SESSION: TimeDelta = TimeDelta::hours(5);
+/// Length of the weekly window
+const WEEKLY: TimeDelta = TimeDelta::days(7);
+/// Offset of the weekly boundary from the Unix epoch, so all accounts share it
+const WEEKLY_OFFSET: TimeDelta = TimeDelta::days(4);
+
+#[derive(Debug, Default, Deserialize)]
+struct Limit {
+ #[serde(default)]
+ usage: f64,
+ #[serde(default)]
+ models: Vec<ModelCount>,
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct Period {
+ #[serde(rename = "type", default)]
+ kind: String,
+ #[serde(rename = "starting_at", default)]
+ from: String,
+ #[serde(rename = "ending_at", default)]
+ to: String,
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct Activity {
+ #[serde(default)]
+ cost: String,
+ #[serde(default)]
+ period: Period,
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct Limits {
+ #[serde(default)]
+ session: Limit,
+ #[serde(default)]
+ weekly: Limit,
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct Usage {
+ #[serde(default)]
+ activity: Activity,
+ #[serde(default)]
+ limits: Limits,
+}
+
+/// Normalise the Ollama Cloud usage response
+///
+/// Ollama reports no reset time, so the windows are computed locally: session
+/// resets align to UTC multiples of 5h from the epoch, weekly resets are offset
+/// by 4 days so all accounts share the same boundary. Matches the formula in
+/// ollama/ollama issue #12532.
+///
+/// # Errors
+///
+/// Returns an error if the body is not an Ollama usage response.
+pub fn view(body: &[u8]) -> Result<View, Error> {
+ let value: serde_json::Value =
+ serde_json::from_slice(body).map_err(|err| Error::UnexpectedResponse(err.to_string()))?;
+ if !["activity", "limits"]
+ .iter()
+ .any(|key| value.get(key).is_some())
+ {
+ return Err(Error::UnexpectedResponse(
+ "response contains no ollama usage fields".to_string(),
+ ));
+ }
+ let usage: Usage =
+ serde_json::from_value(value).map_err(|err| Error::UnexpectedResponse(err.to_string()))?;
+ Ok(build(&usage, Utc::now()))
+}
+
+fn build(usage: &Usage, now: DateTime<Utc>) -> View {
+ let period = &usage.activity.period;
+
+ View {
+ title: "ollama cloud usage".to_string(),
+ subtitle: Some(format!(
+ "{} . {} to {}",
+ period.kind, period.from, period.to
+ )),
+ note: (!usage.activity.cost.is_empty())
+ .then(|| format!("extra {} USD", usage.activity.cost)),
+ windows: vec![
+ Window::new(
+ "session",
+ usage.limits.session.usage,
+ Some(next_boundary(now, SESSION, TimeDelta::zero())),
+ ),
+ Window::new(
+ "weekly",
+ usage.limits.weekly.usage,
+ Some(next_boundary(now, WEEKLY, WEEKLY_OFFSET)),
+ ),
+ ],
+ tables: vec![
+ Table {
+ heading: "session models".to_string(),
+ rows: sorted(&usage.limits.session.models),
+ },
+ Table {
+ heading: "weekly models".to_string(),
+ rows: sorted(&usage.limits.weekly.models),
+ },
+ ],
+ }
+}
+
+/// The next boundary of a window of length `period`, whose boundaries are
+/// offset by `offset` from the Unix epoch
+fn next_boundary(now: DateTime<Utc>, period: TimeDelta, offset: TimeDelta) -> DateTime<Utc> {
+ let period_secs = period.num_seconds();
+ let since_epoch = now.timestamp() - offset.num_seconds();
+ let elapsed = since_epoch.rem_euclid(period_secs);
+ now + TimeDelta::seconds(period_secs - elapsed)
+}
+
+/// Model counts, busiest first
+fn sorted(models: &[ModelCount]) -> Vec<ModelCount> {
+ let mut sorted = models.to_vec();
+ sorted.sort_by_key(|model| std::cmp::Reverse(model.requests));
+ sorted
+}
+
+#[cfg(test)]
+mod test {
+ use super::*;
+ use anyhow::Result;
+
+ const BODY: &[u8] = br#"{
+ "activity": {
+ "cost": "1.25",
+ "period": {"type": "monthly", "starting_at": "2025-10-01", "ending_at": "2025-10-31"}
+ },
+ "limits": {
+ "session": {"usage": 0.5, "models": [
+ {"name": "small", "request_count": 2},
+ {"name": "big", "request_count": 9}
+ ]},
+ "weekly": {"usage": 0.25, "models": []}
+ }
+ }"#;
+
+ #[test]
+ fn reads_windows_models_and_cost() -> Result<()> {
+ let view = view(BODY)?;
+
+ assert_eq!(view.title, "ollama cloud usage");
+ assert_eq!(
+ view.subtitle.as_deref(),
+ Some("monthly . 2025-10-01 to 2025-10-31")
+ );
+ assert_eq!(view.note.as_deref(), Some("extra 1.25 USD"));
+ assert_eq!(view.windows.len(), 2);
+ assert_eq!(view.windows[0].name, "session");
+ assert!((view.windows[0].used - 0.5).abs() < f64::EPSILON);
+ assert_eq!(view.windows[1].name, "weekly");
+ assert_eq!(
+ view.tables[0].rows,
+ vec![
+ ModelCount {
+ name: "big".to_string(),
+ requests: 9
+ },
+ ModelCount {
+ name: "small".to_string(),
+ requests: 2
+ }
+ ],
+ "models are sorted busiest first"
+ );
+ assert!(view.tables[1].rows.is_empty());
+ Ok(())
+ }
+
+ #[test]
+ fn omits_note_without_cost() -> Result<()> {
+ let view = view(br#"{"limits": {"session": {"usage": 0}, "weekly": {"usage": 0}}}"#)?;
+
+ assert_eq!(view.note, None);
+ Ok(())
+ }
+
+ #[test]
+ fn rejects_non_usage_bodies() {
+ assert!(view(b"not json").is_err());
+ assert!(view(b"{}").is_err());
+ }
+
+ #[test]
+ fn session_boundaries_are_epoch_aligned() {
+ let now = DateTime::parse_from_rfc3339("2025-10-17T23:14:07Z")
+ .unwrap()
+ .with_timezone(&Utc);
+
+ let next = next_boundary(now, SESSION, TimeDelta::zero());
+
+ assert!(next > now, "boundary is in the future");
+ assert!(next - now <= SESSION, "boundary is within one window");
+ assert_eq!(
+ next.timestamp() % SESSION.num_seconds(),
+ 0,
+ "session boundaries are multiples of 5h from the epoch"
+ );
+ }
+
+ #[test]
+ fn weekly_boundaries_use_the_four_day_offset() {
+ let now = DateTime::parse_from_rfc3339("2025-10-17T23:14:07Z")
+ .unwrap()
+ .with_timezone(&Utc);
+
+ let next = next_boundary(now, WEEKLY, WEEKLY_OFFSET);
+
+ assert!(next > now);
+ assert!(next - now <= WEEKLY);
+ assert_eq!(
+ (next.timestamp() - WEEKLY_OFFSET.num_seconds()) % WEEKLY.num_seconds(),
+ 0
+ );
+ }
+
+ #[test]
+ fn boundaries_never_land_in_the_past_before_the_epoch() {
+ let before_epoch = DateTime::parse_from_rfc3339("1969-01-01T00:00:00Z")
+ .unwrap()
+ .with_timezone(&Utc);
+
+ let next = next_boundary(before_epoch, SESSION, TimeDelta::zero());
+
+ assert!(
+ next > before_epoch,
+ "negative timestamps still move forward"
+ );
+ }
+}
blob - /dev/null
blob + af79856fd6db58534020742a9dcedcf3f79bdd04 (mode 644)
--- /dev/null
+++ src/usage/openai.rs
+//! Codex CLI usage
+//!
+//! The endpoint serves the Codex CLI. It is not a documented public API, so it
+//! may change without notice.
+
+use chrono::{DateTime, Utc};
+use serde::Deserialize;
+
+use super::{View, Window};
+use crate::Error;
+
+/// Seconds in the session window Codex reports
+const SESSION_SECS: i64 = 5 * 3600;
+/// Seconds in the weekly window Codex reports
+const WEEKLY_SECS: i64 = 7 * 86400;
+
+/// One rate limit window, with the used share in percent
+#[derive(Debug, Deserialize)]
+struct RateWindow {
+ #[serde(default)]
+ used_percent: f64,
+ #[serde(default)]
+ limit_window_seconds: i64,
+ #[serde(default)]
+ reset_at: i64,
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct RateLimit {
+ #[serde(default)]
+ primary_window: Option<RateWindow>,
+ #[serde(default)]
+ secondary_window: Option<RateWindow>,
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct Credits {
+ #[serde(default)]
+ balance: String,
+ #[serde(default)]
+ unlimited: bool,
+}
+
+#[derive(Debug, Deserialize)]
+struct Usage {
+ #[serde(rename = "plan_type", default)]
+ plan: String,
+ #[serde(default)]
+ rate_limit: RateLimit,
+ #[serde(default)]
+ credits: Credits,
+}
+
+/// The OAuth token the Codex CLI writes when you log in
+///
+/// # Errors
+///
+/// Returns an error if the credential file exists but cannot be parsed.
+pub fn token_from_file() -> Result<Option<String>, Error> {
+ #[derive(Deserialize)]
+ struct Auth {
+ tokens: Tokens,
+ }
+
+ #[derive(Deserialize)]
+ struct Tokens {
+ access_token: String,
+ }
+
+ let path = crate::home_dir()?.join(".codex").join("auth.json");
+ let data = match std::fs::read(path) {
+ Ok(data) => data,
+ Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
+ Err(err) => return Err(err.into()),
+ };
+
+ let auth: Auth = serde_json::from_slice(&data)?;
+ Ok(Some(auth.tokens.access_token).filter(|token| !token.is_empty()))
+}
+
+/// Normalise the Codex usage response
+///
+/// # Errors
+///
+/// Returns an error if the body is not a Codex usage response.
+pub fn view(body: &[u8]) -> Result<View, Error> {
+ let value: serde_json::Value =
+ serde_json::from_slice(body).map_err(|err| Error::UnexpectedResponse(err.to_string()))?;
+ if !["plan_type", "rate_limit", "credits"]
+ .iter()
+ .any(|key| value.get(key).is_some())
+ {
+ return Err(Error::UnexpectedResponse(
+ "response contains no openai usage fields".to_string(),
+ ));
+ }
+ let usage: Usage =
+ serde_json::from_value(value).map_err(|err| Error::UnexpectedResponse(err.to_string()))?;
+
+ let windows = [
+ usage.rate_limit.primary_window.as_ref(),
+ usage.rate_limit.secondary_window.as_ref(),
+ ]
+ .into_iter()
+ .flatten()
+ .map(|window| {
+ Window::new(
+ window_name(window.limit_window_seconds),
+ window.used_percent / 100.0,
+ DateTime::<Utc>::from_timestamp(window.reset_at, 0),
+ )
+ })
+ .collect();
+
+ let note = if usage.credits.unlimited {
+ Some("credits unlimited".to_string())
+ } else if usage.credits.balance.is_empty() {
+ None
+ } else {
+ Some(format!("credits {}", usage.credits.balance))
+ };
+
+ Ok(View {
+ title: "codex usage".to_string(),
+ subtitle: Some(usage.plan).filter(|plan| !plan.is_empty()),
+ note,
+ windows,
+ tables: vec![],
+ })
+}
+
+/// Name a quota window of `secs` seconds
+fn window_name(secs: i64) -> String {
+ match secs {
+ SESSION_SECS => "session".to_string(),
+ WEEKLY_SECS => "weekly".to_string(),
+ secs if secs >= 86400 => format!("{}d", secs / 86400),
+ secs => format!("{}h", secs / 3600),
+ }
+}
+
+#[cfg(test)]
+mod test {
+ use super::*;
+ use anyhow::Result;
+
+ #[test]
+ fn reads_windows_and_credits() -> Result<()> {
+ let body = br#"{
+ "plan_type": "pro",
+ "rate_limit": {
+ "primary_window": {"used_percent": 12.5, "limit_window_seconds": 18000, "reset_at": 4102444800},
+ "secondary_window": {"used_percent": 80, "limit_window_seconds": 604800, "reset_at": 4102444800}
+ },
+ "credits": {"balance": "3.50"}
+ }"#;
+
+ let view = view(body)?;
+
+ assert_eq!(view.title, "codex usage");
+ assert_eq!(view.subtitle.as_deref(), Some("pro"));
+ assert_eq!(view.windows.len(), 2);
+ assert_eq!(view.windows[0].name, "session");
+ assert!((view.windows[0].used - 0.125).abs() < f64::EPSILON);
+ assert_eq!(view.windows[1].name, "weekly");
+ assert_eq!(view.note.as_deref(), Some("credits 3.50"));
+ Ok(())
+ }
+
+ #[test]
+ fn unlimited_credits_win_over_a_balance() -> Result<()> {
+ let body = br#"{"credits": {"balance": "3.50", "unlimited": true}}"#;
+
+ let view = view(body)?;
+
+ assert_eq!(view.note.as_deref(), Some("credits unlimited"));
+ assert!(view.windows.is_empty());
+ assert_eq!(view.subtitle, None, "an empty plan is not rendered");
+ Ok(())
+ }
+
+ #[test]
+ fn omits_note_without_credits() -> Result<()> {
+ let view = view(br#"{"plan_type": "plus"}"#)?;
+
+ 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 window_names_cover_known_and_unknown_lengths() {
+ assert_eq!(window_name(18000), "session");
+ assert_eq!(window_name(604800), "weekly");
+ assert_eq!(window_name(2 * 86400), "2d");
+ assert_eq!(window_name(3600), "1h");
+ }
+}
blob - /dev/null
blob + 22ae73796ea44d2c93c71930324702ae2e3b28ee (mode 644)
--- /dev/null
+++ src/usage/render.rs
+//! Rendering usage views for a terminal
+
+use std::io::Write;
+
+use super::{Table, View, Window};
+use crate::Error;
+use crate::cli::ColorChoice;
+
+/// Width of a quota bar, in cells
+const BAR_WIDTH: usize = 40;
+/// Width of a model name column, in cells
+const NAME_WIDTH: usize = 20;
+/// Width of a request count column, in cells
+const COUNT_WIDTH: usize = 6;
+
+const RESET: &str = "\x1b[0m";
+const DIM: &str = "\x1b[2m";
+const BOLD: &str = "\x1b[1m";
+const RED_BOLD: &str = "\x1b[31m\x1b[1m";
+const YELLOW_BOLD: &str = "\x1b[33m\x1b[1m";
+const GREEN: &str = "\x1b[32m";
+const CYAN: &str = "\x1b[36m";
+const WHITE: &str = "\x1b[38;5;255m";
+const BG_LIGHT: &str = "\x1b[48;5;237m";
+const BG_DARK: &str = "\x1b[48;5;235m";
+
+/// Whether to emit ANSI escapes
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct Style {
+ color: bool,
+}
+
+impl Style {
+ /// Resolve the colour choice against the terminal and `NO_COLOR`
+ #[must_use]
+ pub fn new(choice: ColorChoice, is_terminal: bool) -> Self {
+ let color = match choice {
+ ColorChoice::Always => true,
+ ColorChoice::Never => false,
+ ColorChoice::Auto => is_terminal && std::env::var_os("NO_COLOR").is_none(),
+ };
+ Self { color }
+ }
+
+ /// Wrap `text` in escape codes, or return it unchanged without colour
+ fn paint(self, codes: &str, text: &str) -> String {
+ if self.color {
+ format!("{codes}{text}{RESET}")
+ } else {
+ text.to_string()
+ }
+ }
+}
+
+/// Write a rendered view
+///
+/// # Errors
+///
+/// Returns an error if writing to `dest` fails.
+pub fn render(mut dest: impl Write, view: &View, style: Style) -> Result<(), Error> {
+ let mut header = style.paint(&format!("{BOLD}{CYAN}"), &view.title);
+ for note in [view.subtitle.as_deref(), view.note.as_deref()]
+ .into_iter()
+ .flatten()
+ {
+ header.push_str(" ");
+ header.push_str(&style.paint(DIM, note));
+ }
+ writeln!(dest, "{header}")?;
+ writeln!(dest)?;
+
+ let label = view
+ .windows
+ .iter()
+ .map(|window| window.name.chars().count())
+ .max()
+ .unwrap_or(0);
+ for window in &view.windows {
+ // A blank line after each bar, matching the spacing of the tool this
+ // subcommand replaces.
+ writeln!(dest, "{}\n", window_row(window, label, style))?;
+ }
+
+ let height = view
+ .tables
+ .iter()
+ .map(|table| table.rows.len())
+ .max()
+ .unwrap_or(0);
+ if height == 0 {
+ return Ok(());
+ }
+
+ writeln!(dest)?;
+ let headings = view
+ .tables
+ .iter()
+ .map(|table| format!("{:<NAME_WIDTH$} {:>COUNT_WIDTH$}", table.heading, "reqs"))
+ .collect::<Vec<_>>()
+ .join(" ");
+ writeln!(
+ dest,
+ " {}",
+ style.paint(&format!("{BOLD}{DIM}"), &headings)
+ )?;
+
+ for row in 0..height {
+ let cells = view
+ .tables
+ .iter()
+ .enumerate()
+ .map(|(column, table)| table_cell(table, row, column, style))
+ .collect::<Vec<_>>()
+ .join(" ");
+ writeln!(dest, " {cells}")?;
+ }
+
+ Ok(())
+}
+
+/// One quota bar, showing the used share of a window
+fn window_row(window: &Window, label: usize, style: Style) -> String {
+ let color = used_color(window.used);
+ let name = style.paint(DIM, &format!("{:<label$}", window.name));
+ let bar = style.paint(color, &bar(window.used));
+ let percent = style.paint(color, &format!("{:>5.1}%", window.used * 100.0));
+ let note = match resets_in(window.resets_in_secs) {
+ Some(note) => style.paint(DIM, ¬e),
+ None => String::new(),
+ };
+
+ // Trailing whitespace is kept out of the line when there is no note.
+ format!(" {name} {bar} {percent} {note}")
+ .trim_end()
+ .to_string()
+}
+
+/// One model table cell, with alternating column backgrounds
+fn table_cell(table: &Table, row: usize, column: usize, style: Style) -> String {
+ let (name, count) = table
+ .rows
+ .get(row)
+ .map_or((String::new(), String::new()), |r| {
+ (r.name.clone(), r.requests.to_string())
+ });
+
+ let background = if column.is_multiple_of(2) {
+ BG_LIGHT
+ } else {
+ BG_DARK
+ };
+ let name = style.paint(
+ &format!("{WHITE}{background}"),
+ &format!("{name:<NAME_WIDTH$}"),
+ );
+ let count = style.paint(BOLD, &format!("{count:>COUNT_WIDTH$}"));
+
+ format!("{name} {count}")
+}
+
+/// Colour a used share: green when light, red when nearly spent
+fn used_color(used: f64) -> &'static str {
+ if used >= 1.0 {
+ RED_BOLD
+ } else if used >= 0.9 {
+ YELLOW_BOLD
+ } else if used >= 0.5 {
+ CYAN
+ } else {
+ GREEN
+ }
+}
+
+fn bar(used: f64) -> String {
+ #[allow(
+ clippy::cast_possible_truncation,
+ clippy::cast_sign_loss,
+ clippy::cast_precision_loss
+ )]
+ // used is a share clamped to [0, 1], so the product always fits in BAR_WIDTH
+ let filled = (BAR_WIDTH as f64 * used.clamp(0.0, 1.0)) as usize;
+ let filled = filled.min(BAR_WIDTH);
+ "█".repeat(filled) + &"░".repeat(BAR_WIDTH - filled)
+}
+
+/// Time left before a window resets
+fn resets_in(secs: Option<u64>) -> Option<String> {
+ let secs = secs?;
+ let (hours, minutes) = (secs / 3600, (secs % 3600) / 60);
+ Some(if secs >= 86400 {
+ format!("resets in {}d {:02}h", secs / 86400, hours % 24)
+ } else {
+ format!("resets in {hours}h {minutes:02}m")
+ })
+}
+
+#[cfg(test)]
+mod test {
+ use super::*;
+ use crate::usage::ModelCount;
+ use anyhow::Result;
+
+ fn view() -> View {
+ View {
+ title: "usage".to_string(),
+ subtitle: Some("plan".to_string()),
+ note: Some("credits 1".to_string()),
+ windows: vec![
+ Window {
+ name: "session".to_string(),
+ used: 0.5,
+ resets_in_secs: Some(3660),
+ },
+ Window {
+ name: "weekly".to_string(),
+ used: 0.0,
+ resets_in_secs: None,
+ },
+ ],
+ tables: vec![Table {
+ heading: "session models".to_string(),
+ rows: vec![ModelCount {
+ name: "big".to_string(),
+ requests: 9,
+ }],
+ }],
+ }
+ }
+
+ fn rendered(style: Style) -> Result<String> {
+ let mut out = vec![];
+ render(&mut out, &view(), style)?;
+ Ok(String::from_utf8(out)?)
+ }
+
+ #[test]
+ fn plain_output_has_no_escapes() -> Result<()> {
+ let out = rendered(Style::new(ColorChoice::Never, true))?;
+
+ assert!(!out.contains('\x1b'), "plain output should have no escapes");
+ assert!(out.contains("usage plan credits 1"));
+ assert!(out.contains("session"));
+ assert!(out.contains("50.0%"));
+ assert!(out.contains("resets in 1h 01m"));
+ assert!(out.contains("big"));
+ assert!(out.contains("reqs"));
+ Ok(())
+ }
+
+ #[test]
+ fn colour_output_has_escapes() -> Result<()> {
+ let out = rendered(Style::new(ColorChoice::Always, false))?;
+
+ assert!(out.contains('\x1b'), "always should colour a non-terminal");
+ Ok(())
+ }
+
+ #[test]
+ fn auto_is_plain_off_a_terminal() -> Result<()> {
+ let out = rendered(Style::new(ColorChoice::Auto, false))?;
+
+ assert!(!out.contains('\x1b'));
+ Ok(())
+ }
+
+ #[test]
+ fn window_names_are_padded_to_the_widest() -> Result<()> {
+ let out = rendered(Style::new(ColorChoice::Never, false))?;
+
+ assert!(
+ out.contains(" session "),
+ "widest name sets the label width: {out}"
+ );
+ assert!(out.contains(" weekly "), "shorter names are padded");
+ Ok(())
+ }
+
+ #[test]
+ fn bars_are_clamped() {
+ assert_eq!(bar(-1.0), "░".repeat(BAR_WIDTH));
+ assert_eq!(bar(0.0), "░".repeat(BAR_WIDTH));
+ assert_eq!(bar(2.0), "█".repeat(BAR_WIDTH));
+ assert_eq!(bar(0.5), "█".repeat(20) + &"░".repeat(20));
+ }
+
+ #[test]
+ fn colors_track_thresholds() {
+ assert_eq!(used_color(0.49), GREEN);
+ assert_eq!(used_color(0.5), CYAN);
+ assert_eq!(used_color(0.89), CYAN);
+ assert_eq!(used_color(0.9), YELLOW_BOLD);
+ assert_eq!(used_color(1.0), RED_BOLD);
+ assert_eq!(used_color(1.5), RED_BOLD);
+ }
+
+ #[test]
+ fn resets_switch_to_days() {
+ assert_eq!(resets_in(None), None);
+ assert_eq!(resets_in(Some(0)).as_deref(), Some("resets in 0h 00m"));
+ assert_eq!(resets_in(Some(3660)).as_deref(), Some("resets in 1h 01m"));
+ assert_eq!(
+ resets_in(Some(2 * 86400 + 3 * 3600)).as_deref(),
+ Some("resets in 2d 03h")
+ );
+ }
+
+ #[test]
+ fn tables_are_skipped_when_empty() -> Result<()> {
+ let mut out = vec![];
+ let view = View {
+ tables: vec![Table {
+ heading: "models".to_string(),
+ rows: vec![],
+ }],
+ ..view()
+ };
+ render(&mut out, &view, Style::new(ColorChoice::Never, false))?;
+ let out = String::from_utf8(out)?;
+
+ assert!(!out.contains("reqs"), "no table without rows: {out}");
+ Ok(())
+ }
+}
blob - 2b8ca1f8eb055057810e29221fac12fb38bc6237
blob + d6f294690066d520973b497d65fc2f1ee06a2399
--- tests/chat.rs
+++ tests/chat.rs
use predicates::prelude::*;
use serde_json::json;
-/// A canned successful Ollama `/api/chat` response body
+/// A canned successful native `/api/chat` response body
fn ok_body() -> &'static str {
r#"{
"model": "gpt-oss:120b",
let cmd = Command::cargo_bin("alpaca")
.unwrap()
.args(["-u", "Hello"])
- .env("OLLAMA_API_ENDPOINT", server.url())
- .env("OLLAMA_API_KEY", "ABCDE")
+ .env("API_ENDPOINT", server.url())
+ .env("API_KEY", "ABCDE")
.assert();
mock.assert();
let cmd = Command::cargo_bin("alpaca")
.unwrap()
.write_stdin("Hello")
- .env("OLLAMA_API_ENDPOINT", server.url())
- .env("OLLAMA_API_KEY", "ABCDE")
+ .env("API_ENDPOINT", server.url())
+ .env("API_KEY", "ABCDE")
.assert();
mock.assert();
let cmd = Command::cargo_bin("alpaca")
.unwrap()
.args(["-u", "Hello", "--reasoning-effort", "medium"])
- .env("OLLAMA_API_ENDPOINT", server.url())
- .env("OLLAMA_API_KEY", "ABCDE")
+ .env("API_ENDPOINT", server.url())
+ .env("API_KEY", "ABCDE")
.assert();
mock.assert();
"-s", "SYSTEM", "-u", "USER_1", "-a", "ASSI_1", "-u", "USER_2", "-a", "ASSI_2",
])
.write_stdin("USER_STDIN")
- .env("OLLAMA_API_ENDPOINT", server.url())
- .env("OLLAMA_API_KEY", "ABCDE")
+ .env("API_ENDPOINT", server.url())
+ .env("API_KEY", "ABCDE")
.assert();
mock.assert();
.unwrap()
.args(["-u", "USER", "-t", "1000"])
.write_stdin("USER_STDIN")
- .env("OLLAMA_API_ENDPOINT", server.url())
- .env("OLLAMA_API_KEY", "ABCDE")
+ .env("API_ENDPOINT", server.url())
+ .env("API_KEY", "ABCDE")
.assert();
mock.assert();
));
}
+/// A base URL ending in a version segment speaks the chat completions dialect
+#[test]
+fn chat_against_a_completions_endpoint() {
+ let mut server = mockito::Server::new();
+
+ let mock = server
+ .mock("POST", "/v1/chat/completions")
+ .with_header("content-type", "application/json")
+ .match_body(mockito::Matcher::PartialJson(json!({
+ "model": "gpt-oss:120b",
+ "temperature": 0.5,
+ "reasoning_effort": "high",
+ "messages": [{
+ "role": "user",
+ "content": "Hello"
+ }]
+ })))
+ .with_body(
+ r#"{
+ "id": "chatcmpl-1",
+ "created": 1760742847,
+ "model": "gpt-oss:120b",
+ "choices": [{
+ "index": 0,
+ "message": {"role": "assistant", "content": "ASSISTANT REPLY"},
+ "finish_reason": "stop"
+ }],
+ "usage": {"prompt_tokens": 8, "completion_tokens": 9, "total_tokens": 17}
+ }"#,
+ )
+ .create();
+
+ let cmd = Command::cargo_bin("alpaca")
+ .unwrap()
+ .args([
+ "-u",
+ "Hello",
+ "-t",
+ "0.5",
+ "--reasoning-effort",
+ "high",
+ "--base-url",
+ &format!("{}/v1", server.url()),
+ ])
+ .env("API_KEY", "ABCDE")
+ .assert();
+
+ mock.assert();
+
+ cmd.success()
+ .stdout(predicate::str::contains("ASSISTANT REPLY"));
+}
+
/// Test messages from file
#[test]
fn chat_user_message_from_file() {
let cmd = Command::cargo_bin("alpaca")
.unwrap()
.args([infile.path().to_str().unwrap()])
- .env("OLLAMA_API_ENDPOINT", server.url())
- .env("OLLAMA_API_KEY", "ABCDE")
+ .env("API_ENDPOINT", server.url())
+ .env("API_KEY", "ABCDE")
.assert();
mock.assert();
blob - /dev/null
blob + 9736052666c25a9bc0b82b75fc7c01fbd428aec4 (mode 644)
--- /dev/null
+++ tests/quota.rs
+//! Integration tests for quota subcommand
+
+use assert_cmd::Command;
+use predicates::prelude::*;
+
+/// A canned successful Ollama `/api/usage` response body
+fn ok_body() -> &'static str {
+ r#"{
+ "activity": {
+ "cost": "1.25",
+ "period": {"type": "monthly", "starting_at": "2025-10-01", "ending_at": "2025-10-31"}
+ },
+ "limits": {
+ "session": {"usage": 0.5, "models": [{"name": "gpt-oss:120b", "request_count": 9}]},
+ "weekly": {"usage": 0.25, "models": []}
+ }
+ }"#
+}
+
+fn alpaca() -> Command {
+ Command::cargo_bin("alpaca").unwrap()
+}
+
+#[test]
+fn quota_renders_windows_and_models() {
+ let mut server = mockito::Server::new();
+
+ let mock = server
+ .mock("GET", "/api/usage")
+ .match_header("authorization", "KEY")
+ .with_body(ok_body())
+ .create();
+
+ let cmd = alpaca()
+ .args(["quota", "--apikey", "KEY", "--base-url", &server.url()])
+ .assert();
+
+ mock.assert();
+
+ cmd.success()
+ .stdout(
+ predicate::str::contains("ollama cloud usage")
+ .and(predicate::str::contains("extra 1.25 USD"))
+ .and(predicate::str::contains("session"))
+ .and(predicate::str::contains("50.0%"))
+ .and(predicate::str::contains("gpt-oss:120b"))
+ .and(predicate::str::contains("reqs")),
+ )
+ .stdout(predicate::str::contains('\x1b').not());
+}
+
+#[test]
+fn quota_raw_prints_the_provider_body() {
+ let mut server = mockito::Server::new();
+
+ let mock = server
+ .mock("GET", "/api/usage")
+ .with_body(ok_body())
+ .create();
+
+ let cmd = alpaca()
+ .args([
+ "quota",
+ "--raw",
+ "--apikey",
+ "KEY",
+ "--base-url",
+ &server.url(),
+ ])
+ .assert();
+
+ mock.assert();
+
+ cmd.success()
+ .stdout(predicate::str::contains(r#""request_count": 9"#));
+}
+
+#[test]
+fn quota_json_emits_the_normalized_view() {
+ let mut server = mockito::Server::new();
+
+ let mock = server
+ .mock("GET", "/api/usage")
+ .with_body(ok_body())
+ .create();
+
+ let cmd = alpaca()
+ .args([
+ "quota",
+ "--json",
+ "--apikey",
+ "KEY",
+ "--base-url",
+ &server.url(),
+ ])
+ .assert();
+
+ mock.assert();
+
+ cmd.success().stdout(
+ predicate::str::starts_with(r#"{"title":"ollama cloud usage""#)
+ .and(predicate::str::contains(r#""name":"session""#))
+ .and(predicate::str::contains(r#""resets_in_secs""#)),
+ );
+}
+
+#[test]
+fn quota_reports_provider_errors() {
+ let mut server = mockito::Server::new();
+
+ let mock = server
+ .mock("GET", "/api/usage")
+ .with_status(401)
+ .with_body(r#"{"error":"unauthorized"}"#)
+ .create();
+
+ let cmd = alpaca()
+ .args(["quota", "--apikey", "BAD", "--base-url", &server.url()])
+ .assert();
+
+ mock.assert();
+
+ cmd.failure().stderr(
+ predicate::str::contains("failed to fetch ollama usage")
+ .and(predicate::str::contains("unauthorized")),
+ );
+}
+
+#[test]
+fn quota_without_credentials_fails() {
+ alpaca()
+ .args(["quota"])
+ .env_remove("OLLAMA_API_KEY")
+ .assert()
+ .failure()
+ .stderr(predicate::str::contains("no credentials for ollama"));
+}
+
+#[test]
+fn quota_rejects_oversized_responses() {
+ let mut server = mockito::Server::new();
+ let mock = server
+ .mock("GET", "/api/usage")
+ .with_body(vec![b'x'; (1 << 20) + 1])
+ .create();
+
+ let cmd = alpaca()
+ .args([
+ "quota",
+ "--raw",
+ "--apikey",
+ "KEY",
+ "--base-url",
+ &server.url(),
+ ])
+ .assert();
+
+ mock.assert();
+ cmd.failure().stderr(predicate::str::contains(
+ "response body exceeds 1048576 bytes",
+ ));
+}