Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 20 additions & 6 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
name: Publish Docker image

# Every vX.Y.Z release becomes a signed multi-arch image coyl/light-tokenmeter:X.Y.Z
# with SBOM and provenance; X.Y follows the newest release of its line, latest the newest of all.
# Every vX.Y.Z release becomes signed multi-arch images coyl/light-tokenmeter:X.Y.Z (the
# service) and coyl/tokenmeter-cli:X.Y.Z (the CLI) with SBOM and provenance; X.Y follows the
# newest release of its line, latest the newest of all.
# Called by release.yml. Needs the secrets DOCKER_USERNAME and DOCKER_PASSWORD.
on:
workflow_call:
Expand All @@ -18,6 +19,18 @@ permissions:
jobs:
publish:
runs-on: ubuntu-latest
strategy:
# Each image signs itself; one leg failing must not cancel another
# between its push and its signature.
fail-fast: false
matrix:
include:
- image: coyl/light-tokenmeter
dockerfile: Dockerfile
scope: service
- image: coyl/tokenmeter-cli
dockerfile: Dockerfile.cli
scope: cli
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
Expand Down Expand Up @@ -48,7 +61,7 @@ jobs:
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: coyl/light-tokenmeter
images: ${{ matrix.image }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.newest.outputs.line }}
Expand All @@ -60,13 +73,14 @@ jobs:
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: .
file: ${{ matrix.dockerfile }}
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: VERSION=${{ steps.meta.outputs.version }}
cache-from: type=gha
cache-to: type=gha,mode=max
cache-from: type=gha,scope=${{ matrix.scope }}
cache-to: type=gha,mode=max,scope=${{ matrix.scope }}
provenance: mode=max
sbom: true

Expand All @@ -80,7 +94,7 @@ jobs:
- name: Sign the image
run: |
for i in 1 2 3; do
cosign sign --yes --recursive "coyl/light-tokenmeter@${{ steps.build.outputs.digest }}" && exit 0
cosign sign --yes --recursive "${{ matrix.image }}@${{ steps.build.outputs.digest }}" && exit 0
[ "$i" -lt 3 ] && sleep $((i * 15))
done
exit 1
31 changes: 30 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,35 @@ jobs:
gh release create "$GITHUB_REF_NAME" --draft --title "light-tokenmeter $v" --notes-file notes.md --verify-tag
fi

# The CLI's archives and signed checksums, onto the draft.
binaries:
needs: draft
runs-on: ubuntu-latest
# Every action here is pinned by commit: the job can sign under the
# release identity and write to the release.
permissions:
contents: write
id-token: write # keyless cosign signature of checksums.txt
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
fetch-depth: 0
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
with:
go-version: '1.27.3'
cache: true
- uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
with:
cosign-release: v3.1.3
- uses: anchore/sbom-action/download-syft@3ad7283483fc7af8ff2b4ea19663c2d5ca935e26 # v0.24.2, syft for the SBOMs
- uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0
with:
version: '~> v2'
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}

images:
needs: draft
permissions:
Expand All @@ -58,7 +87,7 @@ jobs:
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}

publish:
needs: images
needs: [images, binaries]
runs-on: ubuntu-latest
permissions:
contents: write
Expand Down
24 changes: 22 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,18 @@ jobs:
CUR: ${{ github.ref_type == 'tag' && github.ref_name || '' }} # a release tag is not its own predecessor
run: ./scripts/upgrade-test.sh

# The tokenmeter CLI against a built service, with ClickHouse and without.
cli-e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.27.3'
cache: true
- name: CLI end to end
run: ./scripts/cli-e2e.sh

docker:
runs-on: ubuntu-latest
steps:
Expand All @@ -89,5 +101,13 @@ jobs:
with:
context: .
push: false
cache-from: type=gha
cache-to: type=gha,mode=max
cache-from: type=gha,scope=service
cache-to: type=gha,mode=max,scope=service
- name: Build CLI image (no push)
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.cli
push: false
cache-from: type=gha,scope=cli
cache-to: type=gha,mode=max,scope=cli
63 changes: 63 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# The tokenmeter CLI on the GitHub release that release.yml drafted: archives,
# a signed checksums.txt and SBOMs. The service itself ships as an image.
# The Homebrew tap switches on only when HOMEBREW_TAP_TOKEN exists.
version: 2
project_name: tokenmeter

builds:
- id: cli
main: ./cmd/tokenmeter
binary: tokenmeter
env: [CGO_ENABLED=0]
goos: [darwin, linux]
goarch: [amd64, arm64]
flags: [-trimpath]
ldflags: ["-s -w -X main.version={{ .Version }}"]

archives:
- id: cli
ids: [cli]
formats: [tar.gz]
name_template: "tokenmeter_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
files: [LICENSE, NOTICE]

checksum:
name_template: checksums.txt

# One SPDX SBOM per archive, built by syft.
sboms:
- artifacts: archive

# A keyless signature of checksums.txt covers every archive listed in it.
signs:
- cmd: cosign
artifacts: checksum
signature: "${artifact}.sigstore.json"
args: ["sign-blob", "--yes", "--bundle=${signature}", "${artifact}"]

# The release, its notes and its publishing belong to release.yml.
release:
draft: true
use_existing_draft: true
mode: keep-existing
replace_existing_artifacts: true

changelog:
disable: true

brews:
- name: tokenmeter
ids: [cli]
homepage: https://github.com/ai-process/light-tokenmeter
description: Usage reports and tokens for light-tokenmeter
license: Apache-2.0
repository:
owner: ai-process
name: homebrew-tap
token: '{{ if isEnvSet "HOMEBREW_TAP_TOKEN" }}{{ .Env.HOMEBREW_TAP_TOKEN }}{{ end }}'
skip_upload: '{{ if isEnvSet "HOMEBREW_TAP_TOKEN" }}false{{ else }}true{{ end }}'
install: |
bin.install "tokenmeter"
generate_completions_from_executable(bin/"tokenmeter", "completion")
test: |
system "#{bin}/tokenmeter", "version"
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ All notable changes to light-tokenmeter are listed here. The format follows

## [Unreleased]

### Added
- The `tokenmeter` CLI: `usage summary|top|by-meta|export` with totals in USD and windows that
the server caps fetched in parts, `token new|hash` for `TOKENMETER_TOKENS`, `event send`,
`health`, contexts with the token from an environment variable, a 0600 file or the system
keychain, `-o json|csv`, exit codes by error class and completion for bash, zsh and fish.
Released as archives with a signed `checksums.txt`, the `coyl/tokenmeter-cli` image and a
Homebrew formula (published once the tap token is set).
- `pkg/client`, a Go client for the gRPC API.
- `/healthz` reports the service's `version`.

## [1.0.0] - 2026-10-23

The first stable release, together with Keystone. The Model Switchboard. (KTMS) 1.0 and
Expand Down
17 changes: 17 additions & 0 deletions Dockerfile.cli
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
FROM golang:1.27.3-alpine3.23 AS builder
RUN apk add --no-cache 'ca-certificates>=20261020-r0'
ARG TARGETOS=linux
ARG TARGETARCH
ARG VERSION=dev
WORKDIR /app
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" -o /out/tokenmeter ./cmd/tokenmeter

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /out/tokenmeter /tokenmeter
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
USER 65532:65532
ENTRYPOINT ["/tokenmeter"]
49 changes: 48 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ who is worthy.
- [Installation](#installation)
- [Sending events](#sending-events)
- [Finding the greedy and the worthy](#finding-the-greedy-and-the-worthy)
- [Command-line tool](#command-line-tool)
- [API reference](#api-reference)
- [API stability](#api-stability)
- [Configuration](#configuration)
Expand Down Expand Up @@ -214,6 +215,46 @@ grpcurl -plaintext -d '{"meta_key":"job_id","meta_value":"job-42","group_by_meta
gRPC reflection is on, so `grpcurl` needs no `.proto` files (with auth on, pass a token with
`-H 'authorization: Bearer …'`).

## Command-line tool

`tokenmeter` reads the reports without hand-built query strings or dividing by 1e9, makes tokens
without `openssl`, and sends a test event. It talks gRPC to `:9090`; `health` uses HTTP `:8080`.

```bash
# a release archive (darwin/linux, amd64/arm64, signed checksums), or:
go install github.com/ai-process/light-tokenmeter/cmd/tokenmeter@latest
docker run --rm -e TOKENMETER_ADDR -e TOKENMETER_TOKEN coyl/tokenmeter-cli:latest usage summary
```

```bash
export TOKENMETER_ADDR=localhost:9090 TOKENMETER_TOKEN=… # a read token for reports
tokenmeter usage summary --last 7d --group-by project # totals in USD; --daily per day
tokenmeter usage summary --from 2026-09-01 --to 2026-10-01 --model gpt-4o --group-by user
tokenmeter usage top --by cost --dimension action --limit 5
tokenmeter usage by-meta --key job_id --value job-42 --group-by task_id
tokenmeter usage export --last 30d --format csv --out usage.csv # daily rows, every dimension
tokenmeter token new --name ktms --scope write # the token, then its entry
tokenmeter event send --project p1 --user u1 --action check --vendor openai --model gpt-4o-mini
tokenmeter health
tokenmeter completion zsh > "${fpath[1]}/_tokenmeter" # also bash and fish
```

- Windows are `--last` (`24h`, `7d`, `4w`; 7 days by default) or `--from`/`--to` in RFC 3339 or
`YYYY-MM-DD`, all UTC. A window the server caps at 10,000 rows (`truncated`) is fetched in
smaller parts and added up, so totals are complete; only an hour that is still capped is
reported as incomplete.
- `-o json` keeps the API's fields (`cost_nanousd` as a string), `-o csv` gives exact dollars.
- Contexts work as in `ktms`: `tokenmeter context set prod --addr tm.example.com:443 --tls
--token keychain`, then `tokenmeter context store-token prod` reads the token without echo.
They live in `~/.config/tokenmeter/config.yaml` (`TOKENMETER_CONFIG`); a token source is
`env:VAR`, `file:PATH` or `keychain`. Flags win over `TOKENMETER_ADDR`, `TOKENMETER_TLS`,
`TOKENMETER_HTTP` and `TOKENMETER_TOKEN`, which win over the current context; a context named
with `--context` or `TOKENMETER_CONTEXT` wins over them, so its token never goes elsewhere.
- Exit codes: 0 done, 1 failed, 2 bad usage, 4 not authenticated or not allowed, 5 not found,
6 rejected as invalid, 7 unavailable (retry later).
- [`pkg/client`](pkg/client) is the Go client underneath: `Record` in batches of 500, `Summary`
with the window splitting, `ByMeta`.

## API reference

### HTTP (`:8080`)
Expand All @@ -223,7 +264,7 @@ gRPC reflection is on, so `grpcurl` needs no `.proto` files (with auth on, pass
| `POST` | `/v1/usage/events` | Record one event. |
| `POST` | `/v1/usage/events:batch` | Record up to 500 events. |
| `GET` | `/v1/usage/summary` | Aggregated usage, see above. |
| `GET` | `/healthz` | Liveness. |
| `GET` | `/healthz` | Liveness, with the service's `version`. |
| `GET` | `/readyz` | Readiness (database ping). |

Errors from `/v1/usage/*` are JSON `{"error": "<message>", "code": "<code>", "details": "<optional>"}`,
Expand Down Expand Up @@ -266,6 +307,10 @@ releases fix bugs.
Not public, and free to change in any release: log message text (other than the prefix above),
the `internal/` Go packages, the outbox's lease and retry timings, and resource numbers.

The `tokenmeter` CLI (commands, flags, exit codes, `-o json|csv` output) and `pkg/client` are new
in 1.1 and Experimental until 1.2: they may still change in a minor release, with a changelog
note.

### Compatibility with Keystone. The Model Switchboard. (KTMS) and ktms-admin

| light-tokenmeter | Keystone (KTMS) | ktms-admin | Authentication |
Expand Down Expand Up @@ -345,6 +390,8 @@ The service stores only SHA-256 hashes. Each entry is `name:scope[+scope]:sha256
are separated by commas or newlines, and in a file `#` starts a comment:

```bash
tokenmeter token new --name ktms --scope write # prints the token, then ktms:write:<hash>
# or by hand:
TOKEN=$(openssl rand -hex 32) # give this to the client
printf %s "$TOKEN" | openssl dgst -sha256 -r | cut -d' ' -f1 # put this in the config
TOKENMETER_TOKENS="ktms:write:<hash>,console:read:<hash>"
Expand Down
10 changes: 10 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ docker buildx imagetools inspect coyl/light-tokenmeter:${V#v} --format '{{ json
docker buildx imagetools inspect coyl/light-tokenmeter:${V#v} --format '{{ json .Provenance }}'
```

The CLI image `coyl/tokenmeter-cli` is signed the same way. The CLI's archives on the GitHub
release (`checksums.txt` is signed; each archive has an `.sbom.json`), from 1.1.0 on:

```bash
cosign verify-blob checksums.txt --bundle checksums.txt.sigstore.json \
--certificate-identity "https://github.com/ai-process/light-tokenmeter/.github/workflows/release.yml@refs/tags/$V" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
sha256sum --ignore-missing -c checksums.txt
```

Every pull request and every release runs [`govulncheck`](https://go.dev/doc/security/vuln/): a
known vulnerability that light-tokenmeter's code can reach fails the build, and a release does not
go out.
Expand Down
1 change: 1 addition & 0 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ func main() {

// Before migrations, which can be long: a scrape must not see the service down.
metrics.BuildInfo.WithLabelValues(version).Set(1)
server.Version = version
if cfg.ClickHouseEnabled {
metrics.ClickHouseEnabled.Set(1)
}
Expand Down
Loading
Loading