commit 545a5aa90590e928e51d84609a2c7eb6a3af7fc1 from: mtmn date: Fri Sep 4 22:38:09 2026 UTC merge quota and compose to core commit - b5fc4794c6055d415e4584d6b75b0e5be51daf36 commit + 545a5aa90590e928e51d84609a2c7eb6a3af7fc1 blob - b4050dedcd52a2cc5d85c657d451b8aa1113ce97 blob + c4973ffaf80eca846cbbaa45f34f34c9f5bd5426 --- .gitignore +++ .gitignore @@ -1,3 +1,4 @@ /target -/alpacu/alpacu -/alpaci/alpaci +/man/*.1 +/man/*.5 +/.allium-loop/ blob - fe59632020171cd46b8a52976b1a02e116838afc blob + 9e2f5038f693e9b8c62113dfba94d309c68e764d --- Cargo.lock +++ Cargo.lock @@ -26,8 +26,10 @@ dependencies = [ "reqwest", "serde", "serde_json", + "tempfile", "thiserror", "tokio", + "toml", ] [[package]] @@ -148,9 +150,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0 [[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", @@ -158,9 +160,9 @@ dependencies = [ [[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", @@ -206,9 +208,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da7 [[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", @@ -454,7 +456,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308c dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -496,9 +498,9 @@ checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97 [[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" @@ -1065,9 +1067,9 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac [[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", @@ -1650,7 +1652,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b09 dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -1667,6 +1669,15 @@ dependencies = [ ] [[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" @@ -1724,9 +1735,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479 [[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" @@ -1769,9 +1780,9 @@ dependencies = [ [[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", @@ -1826,6 +1837,7 @@ source = "registry+https://github.com/rust-lang/crates checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -1854,7 +1866,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -1869,9 +1881,9 @@ dependencies = [ [[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", ] @@ -1907,14 +1919,14 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8 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", @@ -1935,6 +1947,45 @@ dependencies = [ ] [[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" @@ -2329,6 +2380,12 @@ source = "registry+https://github.com/rust-lang/crates 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" @@ -2440,7 +2497,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e6 dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] blob - 2b58a49e4ffe847e61148e9646708dcd2907b00f blob + 7062b3127ad43d75835a3c86b1d24bb453548b85 --- Cargo.toml +++ Cargo.toml @@ -11,13 +11,15 @@ keywords = ["unix", "cli", "command-line", "ai", "llm" [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 @@ -3,8 +3,7 @@ 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 @@ -13,7 +12,7 @@ environment by focusing on: - 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 @@ -21,130 +20,104 @@ tools without bespoke integrations. ## 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 -) 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 - 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 @@ -188,11 +161,105 @@ $ echo "50 + 50" | alpaca --system "Solve the followin --- +## alpaca compose + +`alpaca compose` edits a prompt in `$VISUAL`, `$EDITOR`, or `vim`, sends the +saved text, then writes the reply to stdout. Piped stdin prefills the buffer. +The editor runs on `/dev/tty`, keeping editor I/O out of the pipeline. Saving +an empty buffer sends nothing. + +```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 to use +- `-s, --system `: system prompt +- `-t, --temperature `: sampling temperature +- `-T, --timeout `: request timeout in seconds +- `-c, --continue`: continue the newest saved conversation +- `--reasoning-effort `: reasoning effort +- `--apikey `, `--base-url `: endpoint settings +- `--json`, `--jsonp`: print the response as JSON instead of the reply text + +`$VISUAL` and `$EDITOR` split on whitespace; no quote or escape parsing. Use a +wrapper script for complex editor commands. The temporary file is always +removed. An editor that exits non-zero sets alpaca's own exit status. + +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 `: one of `ollama`, `anthropic`, `openai` +- `-T, --timeout `: request timeout in seconds +- `--apikey `: API key or OAuth token, replacing the provider default +- `--base-url `: base URL, replacing the provider default +- `--raw`: print the provider response body verbatim +- `--json`, `--jsonp`: print the normalised view as JSON +- `--color `: colour policy. `auto` colours only when stdout + is a terminal and `NO_COLOR` is unset + +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 @@ -0,0 +1,37 @@ +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 @@ -1,2 +0,0 @@ -/alpaci -*.log blob - ebbb6f56f1f80870e7cad47707cc8d77033dbd15 (mode 644) blob + /dev/null --- alpaci/Makefile +++ /dev/null @@ -1,38 +0,0 @@ -.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 @@ -1,167 +0,0 @@ -.\" 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 @@ -1,3 +0,0 @@ -module alpaci - -go 1.22 blob - 2fdc1645a62c645ec01cc6a1def7cb9fc5e5b823 (mode 644) blob + /dev/null --- alpaci/main.go +++ /dev/null @@ -1,540 +0,0 @@ -// 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 @@ -1,287 +0,0 @@ -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 @@ -1,35 +0,0 @@ -.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 @@ -1,87 +0,0 @@ -.\" 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 @@ -1,3 +0,0 @@ -module alpacu - -go 1.26 blob - 5cef3c11a7e246561d6ddc5c081a845f23925afc (mode 644) blob + /dev/null --- alpacu/main.go +++ /dev/null @@ -1,243 +0,0 @@ -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 @@ -0,0 +1,98 @@ +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 @@ -0,0 +1,121 @@ +.\" 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 @@ -0,0 +1,88 @@ +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 @@ -0,0 +1,81 @@ +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 @@ -0,0 +1,129 @@ +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 @@ -1,17 +1,111 @@ //! 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, + model: Option, + temperature: Option, + timeout: Option, + system: Option, + #[serde(default)] + assistant: Vec, + #[serde(default)] + user: Vec, + apikey: Option, + base_url: Option, + reasoning_effort: Option, +} + +impl Config { + fn load() -> Result { + 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 { + 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 { + 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, home: Option) -> Option { + 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) -> 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, pub(crate) messages: Vec, pub(crate) model: String, @@ -22,10 +116,55 @@ pub struct Invocation { pub(crate) timeout: Duration, #[builder(default)] pub(crate) reasoning_effort: Option, + #[builder(default)] + pub(crate) base_url: Option, } +/// Arguments for the quota subcommand +#[derive(Debug, Default, Builder)] +pub struct QuotaArgs { + pub(crate) provider: Provider, + #[builder(default)] + pub(crate) api_key: Option, + #[builder(default)] + pub(crate) base_url: Option, + 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, + #[builder(default)] + pub(crate) model: Option, + #[builder(default)] + pub(crate) system: Option, + #[builder(default)] + pub(crate) temperature: Option, + pub(crate) timeout: Duration, + #[builder(default)] + pub(crate) reasoning_effort: Option, + pub(crate) output_format: OutputFormat, + #[builder(default)] + pub(crate) base_url: Option, + /// 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, @@ -33,139 +172,411 @@ pub enum OutputFormat { 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 for Option { + 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> for Effort { + fn from(effort: Option) -> 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 { + let args: Vec = 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 "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 { + 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 "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 "Sets system prompt").required(false), + config.system.clone(), + )) .arg( - arg!(timeout: -T --timeout "Sets timeout duration in seconds") - .value_parser(value_parser!(u64)) - .default_value("60") - ) - .arg(arg!(system_message: -s --system "Sets system prompt").required(false)) - .arg( arg!(assistant_messages: -a --assistant ... "Appends assistant message") .required(false), ) .arg(arg!(user_messages: -u --user ... "Appends user message").required(false)) - .arg( - arg!(api_key: --apikey "Sets API Key to use") - .env("OLLAMA_API_KEY") - .hide_env_values(true), - ) - .arg( - arg!(output_format: --output_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" "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 "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 "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 "Sets temperature") + .value_parser(value_parser!(f32)), + config.temperature.map(|value| value.to_string()), + )) + .arg(default_value_opt( + arg!(system_message: -s --system "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 "Sets model. Identifiers are whatever the endpoint serves.") + .env("ALPACA_MODEL") +} + +fn timeout_arg(config: &Config) -> Arg { + arg!(timeout: -T --timeout "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" "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 "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" "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 "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 for Invocation { fn from(matches: ArgMatches) -> Self { - let api_key = matches.get_one::("api_key").cloned(); - let messages = Invocation::messages_from_matches(&matches); - let model = matches - .get_one::("model") - .expect("Model is required") - .clone(); + invocation_from_matches(matches, &Config::default()) + } +} - let temperature = matches.get_one::("temperature").copied(); +impl From<&ArgMatches> for ChatArgs { + fn from(matches: &ArgMatches) -> Self { + chat_args_from(matches, &Config::default()) + } +} - let timeout = matches - .get_one::("timeout") - .map(|t| Duration::from_secs(*t)) - .expect("Timeout is required"); - - let output_format = *matches - .get_one::("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::("temperature").copied(), + output_format: output_format_of(matches), + file: matches .get_one::("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::("reasoning_effort") - .copied(); +impl From<&ArgMatches> for QuotaArgs { + fn from(matches: &ArgMatches) -> Self { + Self { + provider: *matches + .get_one::("provider") + .expect("Provider is required"), + // Quota takes no API key from the environment: each provider + // resolves its own credential when the flag is absent. + api_key: api_key_of(matches), + base_url: base_url_of(matches), + timeout: timeout_of(matches), + output_format: output_format_of(matches), + raw: matches.get_flag("raw"), + color: *matches + .get_one::("color") + .expect("Color is required"), + } + } +} +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::("model").cloned(), + system: matches.get_one::("system_message").cloned(), + temperature: matches.get_one::("temperature").copied(), + timeout: timeout_of(matches), + reasoning_effort: matches.get_one::("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 { + matches + .get_one::("api_key") + .filter(|key| !key.is_empty()) + .cloned() +} + +fn base_url_of(matches: &ArgMatches) -> Option { + matches + .get_one::("base_url") + .filter(|url| !url.is_empty()) + .cloned() +} + +fn model_of(matches: &ArgMatches) -> String { + matches + .get_one::("model") + .expect("Model is required") + .clone() +} + +fn timeout_of(matches: &ArgMatches) -> Duration { + matches + .get_one::("timeout") + .copied() + .map(Duration::from_secs) + .expect("Timeout is required") +} + +fn output_format_of(matches: &ArgMatches) -> OutputFormat { + *matches + .get_one::("output_format") + .expect("Output format is required") +} + +fn effort_of(matches: &ArgMatches) -> Option { + matches + .get_one::("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 { + let mut messages = config + .assistant + .iter() + .map(|msg| Message::assistant(msg)) + .chain(config.user.iter().map(|msg| Message::user(msg))) + .collect::>(); + let mut command_line_messages = vec![]; + + if let Some(user_msgs) = matches.get_many::("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::("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 { - let mut messages = vec![]; + // System message is always first + if let Some(system_msg) = matches.get_one::("system_message") { + messages.insert(0, Message::system(system_msg)); + } - if let Some(user_msgs) = matches.get_many::("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::("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::>(); + messages +} - // System message is always first - if let Some(system_msg) = matches.get_one::("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] @@ -180,6 +591,49 @@ impl ValueEnum for OutputFormat { } } +impl ValueEnum for ColorChoice { + fn value_variants<'a>() -> &'a [Self] { + &[Self::Auto, Self::Always, Self::Never] + } + + fn to_possible_value(&self) -> Option { + 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 { + 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 { + 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] @@ -195,26 +649,74 @@ impl ValueEnum for ReasoningEffort { } #[cfg(test)] +mod config_tests; + +#[cfg(test)] mod test { use super::*; type Result = std::result::Result>; + /// 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 { + std::sync::LazyLock::force(&CLEAN_ENV); + Ok(cli(&Config::default()) + .try_get_matches_from(args) + .map(Invocation::from)?) + } + + fn chat_args(args: &[&str]) -> Result { + match parse_args(args)? { + Invocation::Chat(args) => Ok(args), + other => Err(format!("expected chat invocation, got {other:?}").into()), + } + } + + fn quota_args(args: &[&str]) -> Result { + match parse_args(args)? { + Invocation::Quota(args) => Ok(args), + other => Err(format!("expected quota invocation, got {other:?}").into()), + } + } + + fn compose_args(args: &[&str]) -> Result { + 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, @@ -230,21 +732,26 @@ mod test { #[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, @@ -263,11 +770,9 @@ mod test { 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, @@ -285,9 +790,7 @@ mod test { #[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, @@ -299,51 +802,192 @@ mod test { #[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 @@ -0,0 +1,741 @@ +//! 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, + /// 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, + #[builder(default)] + temperature: Option, + timeout: Duration, + #[builder(default)] + think: Option, +} + +/// 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, + pub choices: Vec, + 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, base_url: String) -> Result { + 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 { + 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::(&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::>(); + + 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, + model: String, + message: Option, + #[serde(default)] + done_reason: Option, + #[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, + #[serde(default)] + usage: CompletionsUsage, +} + +#[derive(Debug, Deserialize)] +struct CompletionsChoice { + message: Option, + #[serde(default)] + finish_reason: Option, +} + +#[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 +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 { + 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 { + 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 for Response { + type Error = ResponseError; + + fn try_from(value: APIResponse) -> Result { + match value { + APIResponse::Native(native) => Self::try_from(native), + APIResponse::Completions(completions) => Self::try_from(completions), + } + } +} + +impl TryFrom for Response { + type Error = ResponseError; + + fn try_from(value: NativeResponse) -> Result { + 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 for Response { + type Error = ResponseError; + + fn try_from(value: CompletionsResponse) -> Result { + let choices = value + .choices + .into_iter() + .map(|c| { + let message = c.message.ok_or(ResponseError::NoMessage)?; + choice(message, c.finish_reason.as_deref()) + }) + .collect::, _>>()?; + + 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::(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::(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::(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::(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::(r#"{"error": "model 'nope' not found"}"#)?; + assert_eq!(flat.message, "model 'nope' not found"); + + let nested = + serde_json::from_str::(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::(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::(&data)?; + assert!(matches!( + Response::try_from(response), + Err(ResponseError::TokenCountOverflow) + )); + Ok(()) + } +} blob - 0ce669871f6ed18118041e575e2365acbe166cf8 blob + b20af6da546f55a2bfdae7f9966b03b92be31691 --- src/error.rs +++ src/error.rs @@ -1,10 +1,9 @@ //! 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), @@ -20,6 +19,31 @@ pub enum 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 @@ -0,0 +1,149 @@ +use super::*; + +type Result = std::result::Result>; + +fn configured_chat(toml: &str, args: &[&str]) -> Result { + 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 @@ -1,26 +1,23 @@ //! 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) @@ -36,20 +33,12 @@ pub async fn exec(args: Invocation) -> Result<()> { 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")?; @@ -58,7 +47,7 @@ pub async fn exec(args: Invocation) -> Result<()> { .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(()) } @@ -82,39 +71,6 @@ fn read_messages_from_file(file: &Path) -> Result 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; @@ -124,12 +80,11 @@ mod test { 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] @@ -199,8 +154,28 @@ mod 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 @@ -1,14 +1,75 @@ //! 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 { + 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 @@ -0,0 +1,119 @@ +//! 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::::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, 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 { + 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 @@ -0,0 +1,50 @@ +//! 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 @@ -1,11 +1,28 @@ +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 = std::result::Result; + +/// 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::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 @@ -0,0 +1,107 @@ +//! 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 { + 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 { + ["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 { + 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::::new()); + } +} blob - 59e0a7d39bdce3d2be395b411d5bfd78e79b1368 blob + d8e362f6322c76e64833576733b471154a9964de --- src/main.rs +++ src/main.rs @@ -1,9 +1,23 @@ -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::() { + std::process::exit(*code); + } + + eprintln!("alpaca: {err:#}"); + std::process::exit(1); + } } blob - 34137e38327a1922d4715acfe772acec97a76ebf (mode 644) blob + /dev/null --- src/ollama.rs +++ /dev/null @@ -1,417 +0,0 @@ -//! Interactions with the Ollama Cloud API -//! -//! Reference: - -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, - /// Base URL for API Endpoint - base_url: String, -} - -/// Requests for the Chat API -/// Reference: -#[derive(Builder, Default)] -pub struct ChatRequest { - model: String, - messages: Vec, - #[builder(default)] - temperature: Option, - timeout: Duration, - #[builder(default)] - think: Option, -} - -/// 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, - pub choices: Vec, - 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, base_url: String) -> Result { - 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 { - 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::().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::>(); - - 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, - model: String, - message: Option, - #[serde(default)] - done_reason: Option, - #[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 for Response { - type Error = ResponseError; - - fn try_from(value: ChatAPIResponse) -> Result { - 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::(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::(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::(data)?; - - assert_eq!(resp.message, "model 'nope' not found"); - - Ok(()) - } -} blob - 378e86478140600edf16cd0af18cc2c48cb55185 blob + c1604d171e0ee1f15c8c6d7a32981f06bdb0695d --- src/parse.rs +++ src/parse.rs @@ -3,7 +3,7 @@ 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 @@ -0,0 +1,442 @@ +//! 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub system: Option, + /// Alternating user and assistant messages, starting with a user message + pub messages: Vec, +} + +const fn default_version() -> u32 { + VERSION +} + +impl Transcript { + /// Start a transcript with no messages yet + #[must_use] + pub fn new(model: String, effort: Option, system: Option) -> 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 { + 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 { + 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 { + 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, 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 { + 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 @@ -0,0 +1,281 @@ +//! 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, +} + +/// 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, +} + +/// 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, + #[serde(default)] + seven_day: Option, + #[serde(default)] + limits: Vec, + #[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, 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 { + 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 = 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 @@ -0,0 +1,345 @@ +//! 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, + /// Trailing note, typically extra spend + #[serde(skip_serializing_if = "Option::is_none")] + pub note: Option, + pub windows: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub tables: Vec, +} + +/// 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, +} + +/// One table of per-model request counts +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct Table { + pub heading: String, + pub rows: Vec, +} + +/// 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, used: f64, resets_at: Option>) -> 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, now: DateTime) -> 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 { + 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 { + 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 { + 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, + base_url: Option, + timeout: Duration, + ) -> Result { + 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, 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, 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> { + 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 @@ -0,0 +1,247 @@ +//! Ollama Cloud usage + +use chrono::{DateTime, TimeDelta, Utc}; +use serde::Deserialize; + +use super::{ModelCount, Table, View, Window}; +use crate::Error; + +/// Length of the session window +const SESSION: TimeDelta = TimeDelta::hours(5); +/// Length of the weekly window +const WEEKLY: TimeDelta = TimeDelta::days(7); +/// Offset of the weekly boundary from the Unix epoch, so all accounts share it +const WEEKLY_OFFSET: TimeDelta = TimeDelta::days(4); + +#[derive(Debug, Default, Deserialize)] +struct Limit { + #[serde(default)] + usage: f64, + #[serde(default)] + models: Vec, +} + +#[derive(Debug, Default, Deserialize)] +struct Period { + #[serde(rename = "type", default)] + kind: String, + #[serde(rename = "starting_at", default)] + from: String, + #[serde(rename = "ending_at", default)] + to: String, +} + +#[derive(Debug, Default, Deserialize)] +struct Activity { + #[serde(default)] + cost: String, + #[serde(default)] + period: Period, +} + +#[derive(Debug, Default, Deserialize)] +struct Limits { + #[serde(default)] + session: Limit, + #[serde(default)] + weekly: Limit, +} + +#[derive(Debug, Default, Deserialize)] +struct Usage { + #[serde(default)] + activity: Activity, + #[serde(default)] + limits: Limits, +} + +/// Normalise the Ollama Cloud usage response +/// +/// Ollama reports no reset time, so the windows are computed locally: session +/// resets align to UTC multiples of 5h from the epoch, weekly resets are offset +/// by 4 days so all accounts share the same boundary. Matches the formula in +/// ollama/ollama issue #12532. +/// +/// # Errors +/// +/// Returns an error if the body is not an Ollama usage response. +pub fn view(body: &[u8]) -> Result { + let value: serde_json::Value = + serde_json::from_slice(body).map_err(|err| Error::UnexpectedResponse(err.to_string()))?; + if !["activity", "limits"] + .iter() + .any(|key| value.get(key).is_some()) + { + return Err(Error::UnexpectedResponse( + "response contains no ollama usage fields".to_string(), + )); + } + let usage: Usage = + serde_json::from_value(value).map_err(|err| Error::UnexpectedResponse(err.to_string()))?; + Ok(build(&usage, Utc::now())) +} + +fn build(usage: &Usage, now: DateTime) -> View { + let period = &usage.activity.period; + + View { + title: "ollama cloud usage".to_string(), + subtitle: Some(format!( + "{} . {} to {}", + period.kind, period.from, period.to + )), + note: (!usage.activity.cost.is_empty()) + .then(|| format!("extra {} USD", usage.activity.cost)), + windows: vec![ + Window::new( + "session", + usage.limits.session.usage, + Some(next_boundary(now, SESSION, TimeDelta::zero())), + ), + Window::new( + "weekly", + usage.limits.weekly.usage, + Some(next_boundary(now, WEEKLY, WEEKLY_OFFSET)), + ), + ], + tables: vec![ + Table { + heading: "session models".to_string(), + 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, period: TimeDelta, offset: TimeDelta) -> DateTime { + let period_secs = period.num_seconds(); + let since_epoch = now.timestamp() - offset.num_seconds(); + let elapsed = since_epoch.rem_euclid(period_secs); + now + TimeDelta::seconds(period_secs - elapsed) +} + +/// Model counts, busiest first +fn sorted(models: &[ModelCount]) -> Vec { + let mut sorted = models.to_vec(); + sorted.sort_by_key(|model| std::cmp::Reverse(model.requests)); + sorted +} + +#[cfg(test)] +mod test { + use super::*; + use anyhow::Result; + + const BODY: &[u8] = br#"{ + "activity": { + "cost": "1.25", + "period": {"type": "monthly", "starting_at": "2025-10-01", "ending_at": "2025-10-31"} + }, + "limits": { + "session": {"usage": 0.5, "models": [ + {"name": "small", "request_count": 2}, + {"name": "big", "request_count": 9} + ]}, + "weekly": {"usage": 0.25, "models": []} + } + }"#; + + #[test] + fn reads_windows_models_and_cost() -> Result<()> { + let view = view(BODY)?; + + assert_eq!(view.title, "ollama cloud usage"); + assert_eq!( + view.subtitle.as_deref(), + Some("monthly . 2025-10-01 to 2025-10-31") + ); + assert_eq!(view.note.as_deref(), Some("extra 1.25 USD")); + assert_eq!(view.windows.len(), 2); + assert_eq!(view.windows[0].name, "session"); + assert!((view.windows[0].used - 0.5).abs() < f64::EPSILON); + assert_eq!(view.windows[1].name, "weekly"); + assert_eq!( + view.tables[0].rows, + vec![ + ModelCount { + name: "big".to_string(), + requests: 9 + }, + ModelCount { + name: "small".to_string(), + requests: 2 + } + ], + "models are sorted busiest first" + ); + assert!(view.tables[1].rows.is_empty()); + Ok(()) + } + + #[test] + fn omits_note_without_cost() -> Result<()> { + let view = view(br#"{"limits": {"session": {"usage": 0}, "weekly": {"usage": 0}}}"#)?; + + assert_eq!(view.note, None); + Ok(()) + } + + #[test] + fn rejects_non_usage_bodies() { + assert!(view(b"not json").is_err()); + assert!(view(b"{}").is_err()); + } + + #[test] + fn session_boundaries_are_epoch_aligned() { + let now = DateTime::parse_from_rfc3339("2025-10-17T23:14:07Z") + .unwrap() + .with_timezone(&Utc); + + let next = next_boundary(now, SESSION, TimeDelta::zero()); + + assert!(next > now, "boundary is in the future"); + assert!(next - now <= SESSION, "boundary is within one window"); + assert_eq!( + next.timestamp() % SESSION.num_seconds(), + 0, + "session boundaries are multiples of 5h from the epoch" + ); + } + + #[test] + fn weekly_boundaries_use_the_four_day_offset() { + let now = DateTime::parse_from_rfc3339("2025-10-17T23:14:07Z") + .unwrap() + .with_timezone(&Utc); + + let next = next_boundary(now, WEEKLY, WEEKLY_OFFSET); + + assert!(next > now); + assert!(next - now <= WEEKLY); + assert_eq!( + (next.timestamp() - WEEKLY_OFFSET.num_seconds()) % WEEKLY.num_seconds(), + 0 + ); + } + + #[test] + fn boundaries_never_land_in_the_past_before_the_epoch() { + let before_epoch = DateTime::parse_from_rfc3339("1969-01-01T00:00:00Z") + .unwrap() + .with_timezone(&Utc); + + let next = next_boundary(before_epoch, SESSION, TimeDelta::zero()); + + assert!( + next > before_epoch, + "negative timestamps still move forward" + ); + } +} blob - /dev/null blob + af79856fd6db58534020742a9dcedcf3f79bdd04 (mode 644) --- /dev/null +++ src/usage/openai.rs @@ -0,0 +1,203 @@ +//! 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, + #[serde(default)] + secondary_window: Option, +} + +#[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, 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 { + 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::::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 @@ -0,0 +1,323 @@ +//! 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!("{:COUNT_WIDTH$}", table.heading, "reqs")) + .collect::>() + .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::>() + .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!("{: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: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) -> Option { + 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 { + 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 @@ -5,7 +5,7 @@ use assert_fs::prelude::*; 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", @@ -64,8 +64,8 @@ fn chat_user_message_from_flag() { 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(); @@ -96,8 +96,8 @@ fn chat_user_message_from_stdin() { 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(); @@ -128,8 +128,8 @@ fn chat_with_reasoning_effort() { 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(); @@ -182,8 +182,8 @@ fn chat_multiple_messages() { "-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(); @@ -219,8 +219,8 @@ fn chat_api_error() { .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(); @@ -230,6 +230,59 @@ fn chat_api_error() { )); } +/// 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() { @@ -255,8 +308,8 @@ 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 @@ -0,0 +1,162 @@ +//! 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", + )); +}