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
20 changes: 7 additions & 13 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
name: Publish Docker image

# Every v* tag becomes an image on Docker Hub: coyl/ktms-admin:<version>, also
# under the old name coyl/llm-proxy-admin so existing deployments keep pulling.
# Every v* tag becomes an image on Docker Hub: coyl/ktms-admin:<version>.
# Needs the repository secrets DOCKER_USERNAME and DOCKER_PASSWORD (an access token).
on:
push:
Expand Down Expand Up @@ -44,9 +43,7 @@ jobs:
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: |
coyl/ktms-admin
coyl/llm-proxy-admin
images: coyl/ktms-admin
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.newest.outputs.line }}
Expand All @@ -71,14 +68,11 @@ jobs:
with:
cosign-release: v3.1.3

# Signatures are stored per repository, so each name is signed; each is
# retried on its own so a Rekor or Fulcio blip leaves no tag unsigned.
# Retried so a Rekor or Fulcio blip does not leave the tag unsigned.
- name: Sign the image
run: |
for image in coyl/ktms-admin coyl/llm-proxy-admin; do
for i in 1 2 3; do
cosign sign --yes --recursive "$image@${{ steps.build.outputs.digest }}" && break
[ "$i" = 3 ] && exit 1
sleep $((i * 15))
done
for i in 1 2 3; do
cosign sign --yes --recursive "coyl/ktms-admin@${{ steps.build.outputs.digest }}" && exit 0
sleep $((i * 15))
done
exit 1
32 changes: 32 additions & 0 deletions .github/workflows/e2e-main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: E2E against main

# KTMS and light-tokenmeter main move on their own; this daily run notices
# before the pinned pair in scripts/e2e.refs is moved forward.
on:
schedule:
- cron: "23 5 * * *"
workflow_dispatch:

permissions:
contents: read

jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: bufbuild/buf-action@v1
with:
setup_only: true
version: 1.69.0
- name: proto/ktms/v1 matches KTMS main
env:
KTMS_REF: main
run: ./scripts/proto-check.sh
- name: End-to-end with KTMS and light-tokenmeter main
if: success() || failure()
env:
KTMS_REF: main
TOKENMETER_REF: main
run: ./scripts/e2e.sh
23 changes: 23 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,29 @@ jobs:
- name: Check JavaScript syntax
run: for f in internal/web/static/js/*.js internal/web/static/js/pages/*.js internal/web/static/vendor/*.mjs; do node --check "$f"; done

# KTMS owns ktms.v1: the copy here must match it at the ref the e2e test
# runs against, and internal/gen must be generated from the copy.
proto:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: bufbuild/buf-action@v1
with:
setup_only: true
version: 1.69.0
- name: proto/ktms/v1 matches KTMS, internal/gen matches the copy
run: ./scripts/proto-check.sh

# The console against KTMS and light-tokenmeter built from the refs in
# scripts/e2e.refs, with light-tokenmeter's authentication on: README "Compatibility".
e2e:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: End-to-end with KTMS and light-tokenmeter
run: ./scripts/e2e.sh

docker:
runs-on: ubuntu-latest
steps:
Expand Down
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/bin/
/llm-proxy-admin
/ktms-admin
.env
.DS_Store
.idea/
Expand Down
38 changes: 27 additions & 11 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,34 +6,50 @@ Notable changes to ktms-admin. The format follows

## [Unreleased]

## [1.0.0] - 2026-10-23

The first stable release, paired with Keystone. The Model Switchboard. (KTMS) 1.0: ktms-admin
1.x works with every KTMS 1.x release (see [Compatibility](README.md#compatibility)).

### Added
- `TOKENMETER_TOKEN`: a light-tokenmeter bearer token with the `read` scope, for its authentication.
The usage dot turns red and names the setting when the token is missing, rejected or lacks the scope;
`examples/compose.yaml` wires tokens through the whole stack.
- Any OpenID Connect provider by its generic settings (`OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_ISSUER`,
`OIDC_NAME`), several at once through `OIDC_PROVIDERS`, and GitHub sign-in (`GITHUB_CLIENT_ID`,
`GITHUB_CLIENT_SECRET`, `GITHUB_URL` for Enterprise Server).
- `TOKENMETER_TOKEN`: a light-tokenmeter 1.0 bearer token with the `read` scope, for its
authentication. The usage status in the sidebar says "refused" and names the setting when the token
is missing, rejected or lacks the scope; `examples/compose.yaml` wires tokens through the whole stack.
- Password sign-in: `ADMIN_USERS` or `ADMIN_USERS_FILE` hold `email:hash` entries made by the new
`ktms-admin hash-password` command (argon2id; bcrypt from `htpasswd -nB` works too). Failed attempts
back off per account and per client address.
- Any OpenID Connect provider by its generic settings (`OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_ISSUER`,
`OIDC_NAME`), several at once through `OIDC_PROVIDERS`, and GitHub sign-in (`GITHUB_CLIENT_ID`,
`GITHUB_CLIENT_SECRET`, `GITHUB_URL` for Enterprise Server).
- Sign-in through an access proxy: an e-mail header from `TRUSTED_PROXY_CIDRS` (oauth2-proxy, Authelia,
Authentik, Pomerium) or a verified Cloudflare Access token (`CF_ACCESS_TEAM_DOMAIN`, `CF_ACCESS_AUD`).
Google sign-in is now optional when one of these is set.
Authentik, Pomerium, Tailscale) or a verified Cloudflare Access token (`CF_ACCESS_TEAM_DOMAIN`,
`CF_ACCESS_AUD`).
- Key policies on the Keys page: allowed models and requests and tokens per minute, set when minting
or later (team leads for their team's keys), and each key's spend against its caps.
- Prompt-cache prices (cached input, cache write) and the `prompt_cache` capability in the model
editor.

### Changed
- Google sign-in is optional: configure any of the methods above, alone or together. Sign-in methods
are pluggable adapters behind one core that owns sessions, roles and the sign-in page; Google works as
before, with the same redirect URI.
- The console and sign-in page use the ai-process.dev palette and logo. The dark theme runs orange to
yellow on warm near-black, and the light theme has orange accents. Warnings are yellow and errors red,
so they no longer look like the accent; the third chart and rule colour is blue.
- Sign-in methods are pluggable adapters behind one core that owns sessions, roles and the
sign-in page. Google sign-in works as before, with the same redirect URI.
- The image is published only as `coyl/ktms-admin`. `coyl/llm-proxy-admin` stays at 0.4.0: switch
your deployments to the new name.
- The binary and Go module are named `ktms-admin`; the console's internal CSRF header value
changed with them (the server and its UI ship together).
- The README names the gateway Keystone. The Model Switchboard. (KTMS).
- The license is Apache 2.0, with a `NOTICE` file.

### Deprecated
- `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`: use `OIDC_CLIENT_ID` and `OIDC_CLIENT_SECRET`. The old names
keep working with a startup warning and are removed in 2.0.

### Fixed
- A profile picture the CSP blocks no longer shows as a broken image.
- Sign-in error pages are styled again: the CSP blocked their inline styles.
- A profile picture the CSP blocks no longer shows as a broken image.

## [0.4.0] - 2026-10-22

Expand Down
6 changes: 3 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@ 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" -o /out/llm-proxy-admin ./cmd/llm-proxy-admin
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -trimpath -ldflags="-s -w" -o /out/ktms-admin ./cmd/ktms-admin

FROM alpine:3.21
# CA certificates are needed to reach identity providers for sign-in.
RUN apk add --no-cache ca-certificates && adduser -D -u 10001 appuser
COPY --from=builder /out/llm-proxy-admin /llm-proxy-admin
COPY --from=builder /out/ktms-admin /ktms-admin
USER appuser
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://127.0.0.1:8080/healthz >/dev/null || exit 1
ENTRYPOINT ["/llm-proxy-admin"]
ENTRYPOINT ["/ktms-admin"]
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
.PHONY: build run test proto fmt dev

build:
go build -trimpath -ldflags="-s -w" -o bin/llm-proxy-admin ./cmd/llm-proxy-admin
go build -trimpath -ldflags="-s -w" -o bin/ktms-admin ./cmd/ktms-admin

test:
go test -race ./...
Expand All @@ -15,4 +15,4 @@ fmt:

# Local run without sign-in (localhost only). Needs KTMS_ADDR + KTMS_ADMIN_KEY and/or TOKENMETER_URL.
dev:
PUBLIC_URL=http://localhost:8080 INSECURE_NO_AUTH=true go run ./cmd/llm-proxy-admin
PUBLIC_URL=http://localhost:8080 INSECURE_NO_AUTH=true go run ./cmd/ktms-admin
43 changes: 27 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# KTMS admin (llm-proxy-admin)
# Keystone. The Model Switchboard. (KTMS): admin console

[![Test](https://github.com/ai-process/ktms-admin/actions/workflows/test.yml/badge.svg)](https://github.com/ai-process/ktms-admin/actions/workflows/test.yml)
[![Docker image](https://img.shields.io/docker/v/coyl/ktms-admin?label=docker%20hub&sort=semver)](https://hub.docker.com/r/coyl/ktms-admin)
[![License: Apache 2.0](https://img.shields.io/badge/license-Apache_2.0-blue.svg)](LICENSE)

A web console for [**KTMS**](https://github.com/ai-process/ktms) (the LLM gateway, formerly llm-proxy) and
A web console for [**Keystone**](https://github.com/ai-process/ktms), the LLM gateway (KTMS, formerly llm-proxy), and
[**light-tokenmeter**](https://github.com/ai-process/light-tokenmeter) (the usage meter). Use it with both,
or with either one alone.

Expand Down Expand Up @@ -207,13 +207,19 @@ through the gateway's stable `ktms.v1` admin API. Within 1.x the console's setti
below) keep their names and meaning; renamed ones keep working with a startup warning until 2.0.
The console's own `/api` serves its web UI only and is not a public API.

| ktms-admin | KTMS gateway |
| --- | --- |
| 1.x | 1.x |
| 0.4.x | 0.14.x |
| 0.3.x and earlier | 0.13.x |
| ktms-admin | KTMS gateway | light-tokenmeter |
| --- | --- | --- |
| 1.x | 1.x | 1.x; with its authentication on, set `TOKENMETER_TOKEN` to a `read` token |
| 0.4.x | 0.14.x | 0.1.x |
| 0.3.x and earlier | 0.13.x | 0.1.x |

Before 1.0 there is no compatibility promise: use the pairs above. The Usage page reads
light-tokenmeter's HTTP summary API, which stays compatible within its 1.x.

Before 1.0 there is no compatibility promise: use the pairs above.
`scripts/e2e.sh` checks this on every pull request, against the KTMS and light-tokenmeter refs in
`scripts/e2e.refs`, and daily against their `main`: it turns light-tokenmeter's authentication on and
drives the console's API through models, rules, vendor keys, client keys and their policies, spend
limits and spend, tenants, a playground call through a mock vendor and its usage report.

## Running with one backend or both

Expand All @@ -222,7 +228,7 @@ The console shows only the pages whose backend you configured.
| You set | You get |
| --- | --- |
| `TOKENMETER_URL` (+ `TOKENMETER_TOKEN` when light-tokenmeter has auth on) | **Usage** page. Nothing else. |
| `KTMS_ADDR` + `KTMS_ADMIN_KEY` | **Models, Rules, Keys, Nodes, Playground, Judge**. |
| `KTMS_ADDR` + `KTMS_ADMIN_KEY` | **Models, Rules, Limits, Keys, Tenants, Team, Nodes, Playground, Judge**. |
| both | Everything, plus **estimated cost** on the usage page (tokens × the prices on your models; estimates use standard in/out prices and do not account for peak or batch rates). |

The sidebar shows a green or red dot per backend, so a wrong address or a revoked key is visible at once:
Expand All @@ -247,7 +253,7 @@ All settings are environment variables; see [`env.example`](env.example).
| `AUTH_HEADER_EMAIL` | `X-Forwarded-Email` | The header the proxy names the user's e-mail in. |
| `AUTH_HEADER_NAME` | empty | Optional header with the user's display name. |
| `CF_ACCESS_TEAM_DOMAIN`, `CF_ACCESS_AUD` | empty | Turn on [Cloudflare Access](#sign-in-behind-an-access-proxy) sign-in. |
| `ALLOWED_EMAILS` | – | Admin patterns. At least one of the two lists must be non-empty. |
| `ALLOWED_EMAILS` | – | Admin patterns. At least one of `ALLOWED_EMAILS`, `VIEWER_EMAILS` or `TEAM_MEMBERSHIPS` must be non-empty. |
| `VIEWER_EMAILS` | empty | Read-only patterns. |
| `TEAM_MEMBERSHIPS` | empty | Team workspaces: `email:team_id[:teamlead\|member[:org_id]]`, see [Who may sign in](#who-may-sign-in). |
| `DEV_USER_ROLE`, `DEV_USER_TEAM_ID`, `DEV_USER_ORG_ID` | admin | With `INSECURE_NO_AUTH`: the role and team of the local dev user. |
Expand Down Expand Up @@ -310,8 +316,10 @@ own validation.
**Limits.** Per-user rate limits and daily token budgets, and spend caps (USD per UTC day or month) for keys
and users.

**Keys.** A wizard mints client API keys: name and team, access, optional daily and monthly spend caps; the
secret is shown once with a copy button. Revoke keys, assign vendor credentials to clients (write-only:
**Keys.** A wizard mints client API keys: name and team, access, optional daily and monthly spend caps and
request and token rates; the secret is shown once with a copy button. Each key's policy (allowed models,
requests and tokens per minute) is edited in place, and its spend shows against its caps as KTMS counts it,
wildcard caps included; team leads see and edit their team's keys. Revoke keys, assign vendor credentials to clients (write-only:
stored encrypted by the gateway and never shown again) and re-encrypt all credentials after adding a new
encryption key version.

Expand Down Expand Up @@ -355,16 +363,19 @@ make proto # regenerate internal/gen from proto/ (needs buf)
`js/lib.js` (shared components), `js/pages/*` (one module per page), and `vendor/` (Preact 10 and htm 3,
unmodified except that hooks import `./preact.mjs`). Restart the server after editing them: they are embedded
at build time. `proto/` is a copy of KTMS's
protocol, so the console talks to it over gRPC without importing its code.
protocol, so the console talks to it over gRPC without importing its code. CI fails when the copy differs
from KTMS at the ref in `scripts/e2e.refs`, or `internal/gen` from the copy: copy the files over and run
`make proto`.

Layout: `cmd/llm-proxy-admin` (entrypoint), `internal/auth` (sessions, allowlists, sign-in page; one package per sign-in method below it),
Layout: `cmd/ktms-admin` (entrypoint), `internal/auth` (sessions, allowlists, sign-in page; one package per sign-in method below it),
`internal/api` (JSON API and guards), `internal/usage` (light-tokenmeter client and the greedy/worthy
analysis), `internal/config`, `internal/web` (embedded UI).

### Releasing

Pushing a `vX.Y.Z` tag publishes a multi-arch image to Docker Hub as `coyl/ktms-admin:<version>`, also
under the old name `coyl/llm-proxy-admin`. `latest` moves only with the newest stable release. The
Pushing a `vX.Y.Z` tag publishes a multi-arch image to Docker Hub as `coyl/ktms-admin:<version>`.
`latest` moves only with the newest stable release. The old name `coyl/llm-proxy-admin` gets no new
releases after 0.4.0. The
image is signed with keyless cosign and carries an SBOM and provenance (verify like the gateway's
images, see [KTMS SECURITY.md](https://github.com/ai-process/ktms/blob/main/SECURITY.md#verifying-a-release),
with `docker-publish.yml` of `ai-process/ktms-admin`). The repository needs the secrets
Expand Down
31 changes: 28 additions & 3 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,44 @@
# Security policy

## Supported versions

| Version | Supported |
| ------- | --------- |
| `1.x`, latest minor release | :white_check_mark: |
| `0.x` | :x: upgrade to 1.0 |

## Reporting a vulnerability

Please do **not** open a public issue. Use GitHub's
[private vulnerability reporting](https://github.com/ai-process/ktms-admin/security/advisories/new)
for this repository. You will get a first answer within a few days.

## Verifying a release

From 1.0.0 on, images are signed with keyless [Sigstore cosign](https://docs.sigstore.dev/) and carry an
SBOM and SLSA provenance. Use cosign 3 and pin the identity to the tag you verify:

```bash
V=v1.0.0
cosign verify coyl/ktms-admin:${V#v} \
--certificate-identity "https://github.com/ai-process/ktms-admin/.github/workflows/docker-publish.yml@refs/tags/$V" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
docker buildx imagetools inspect coyl/ktms-admin:${V#v} --format '{{ json .SBOM }}'
```

## What this console protects

The console holds an **admin key for the KTMS gateway**, which can read and change routing and mint API
keys. Treat access to it accordingly:

- Only people matching `ALLOWED_EMAILS` (full access) or `VIEWER_EMAILS` (read-only) can sign in, and
only with a Google-verified address. The lists are re-checked on every request.
- Only verified e-mails that match `ALLOWED_EMAILS` (admins), `VIEWER_EMAILS` (read-only) or
`TEAM_MEMBERSHIPS` (one team's keys and spend) can sign in, and the lists are re-checked on every
request. Sign-in is a password (argon2id or bcrypt hashes, with back-off per account and client
address), OpenID Connect, GitHub, or an access proxy.
- An access proxy's e-mail header is trusted only from `TRUSTED_PROXY_CIDRS`; Cloudflare Access tokens
are verified against the team's keys. Never expose the console directly when relying on a header.
- The admin key never reaches the browser; vendor API keys are write-only through the console.
- Mutating requests need the admin role, a custom `X-Requested-With` header and a same-origin `Origin`.
- Mutating requests need a role allowed to make them, a custom `X-Requested-With` header and a
same-origin `Origin`.
- Run it behind HTTPS (`PUBLIC_URL=https://…`); cookies are then `Secure`, `HttpOnly` and `SameSite=Lax`.
- `INSECURE_NO_AUTH` disables sign-in and is refused unless `PUBLIC_URL` is localhost.
7 changes: 4 additions & 3 deletions buf.gen.yaml
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
version: v2
plugins:
- remote: buf.build/protocolbuffers/go
# Pinned: a new plugin release must not turn the generated-code check red.
- remote: buf.build/protocolbuffers/go:v1.36.12
out: internal/gen
opt:
- paths=source_relative
- remote: buf.build/grpc/go
- remote: buf.build/grpc/go:v1.6.2
out: internal/gen
opt:
- paths=source_relative
managed:
enabled: true
override:
- file_option: go_package_prefix
value: github.com/ai-process/llm-proxy-admin/internal/gen
value: github.com/ai-process/ktms-admin/internal/gen
2 changes: 1 addition & 1 deletion cmd/llm-proxy-admin/hashpw.go → cmd/ktms-admin/hashpw.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import (

"golang.org/x/term"

"github.com/ai-process/llm-proxy-admin/internal/auth/password"
"github.com/ai-process/ktms-admin/internal/auth/password"
)

const hashUsage = `usage: ktms-admin hash-password [email]
Expand Down
16 changes: 8 additions & 8 deletions cmd/llm-proxy-admin/main.go → cmd/ktms-admin/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@ import (
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"

"github.com/ai-process/llm-proxy-admin/internal/api"
"github.com/ai-process/llm-proxy-admin/internal/auth"
"github.com/ai-process/llm-proxy-admin/internal/auth/cfaccess"
"github.com/ai-process/llm-proxy-admin/internal/auth/github"
"github.com/ai-process/llm-proxy-admin/internal/auth/header"
"github.com/ai-process/llm-proxy-admin/internal/auth/oidc"
"github.com/ai-process/llm-proxy-admin/internal/auth/password"
"github.com/ai-process/llm-proxy-admin/internal/config"
"github.com/ai-process/ktms-admin/internal/api"
"github.com/ai-process/ktms-admin/internal/auth"
"github.com/ai-process/ktms-admin/internal/auth/cfaccess"
"github.com/ai-process/ktms-admin/internal/auth/github"
"github.com/ai-process/ktms-admin/internal/auth/header"
"github.com/ai-process/ktms-admin/internal/auth/oidc"
"github.com/ai-process/ktms-admin/internal/auth/password"
"github.com/ai-process/ktms-admin/internal/config"
)

func main() {
Expand Down
Loading
Loading