From 4a86ef27f9630b399d5b75184b1121d16b3aabff Mon Sep 17 00:00:00 2001 From: Vasil Date: Thu, 22 Oct 2026 19:54:06 +0200 Subject: [PATCH 1/8] chore(release): 1.0.0 - images publish only as coyl/ktms-admin; the legacy coyl/llm-proxy-admin repository gets no new releases after 0.4.0 - the Go module, the binary and the console's CSRF header value are named ktms-admin - README names the gateway Keystone. The Model Switchboard. (KTMS); proto synced with ktms main - CHANGELOG 1.0.0 --- .github/workflows/docker-publish.yml | 20 +++++------- .gitignore | 2 +- CHANGELOG.md | 12 ++++++++ Dockerfile | 6 ++-- Makefile | 4 +-- README.md | 11 ++++--- buf.gen.yaml | 2 +- cmd/{llm-proxy-admin => ktms-admin}/main.go | 6 ++-- go.mod | 2 +- internal/api/api.go | 12 ++++---- internal/api/api_test.go | 12 ++++---- internal/api/proxy.go | 8 ++--- internal/api/tenancy_access_test.go | 6 ++-- internal/api/usage.go | 4 +-- internal/gen/ktms/v1/admin.pb.go | 34 ++++++++++++--------- internal/gen/ktms/v1/gateway.pb.go | 32 ++++++++++--------- internal/gen/ktms/v1/gateway_grpc.pb.go | 10 ++++-- internal/gen/ktms/v1/node.pb.go | 4 +-- internal/web/static/js/lib.js | 2 +- internal/web/static/js/main.js | 2 +- internal/web/static/js/pages/keys.js | 2 +- proto/ktms/v1/admin.proto | 14 ++++++--- proto/ktms/v1/gateway.proto | 25 +++++++++------ 23 files changed, 134 insertions(+), 98 deletions(-) rename cmd/{llm-proxy-admin => ktms-admin}/main.go (94%) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index d0299fc..3b91eaa 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -1,7 +1,6 @@ name: Publish Docker image -# Every v* tag becomes an image on Docker Hub: coyl/ktms-admin:, 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:. # Needs the repository secrets DOCKER_USERNAME and DOCKER_PASSWORD (an access token). on: push: @@ -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 }} @@ -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 diff --git a/.gitignore b/.gitignore index ba05414..8fdc0a3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ /bin/ -/llm-proxy-admin +/ktms-admin .env .DS_Store .idea/ diff --git a/CHANGELOG.md b/CHANGELOG.md index e1bda76..03cbaef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ Notable changes to ktms-admin. The format follows ## [Unreleased] +## [1.0.0] - 2026-10-22 + +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)). + +### Changed +- 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). + ## [0.4.0] - 2026-10-22 Needs KTMS 0.14.0 or later for the tenant and team pages. diff --git a/Dockerfile b/Dockerfile index 37131fe..1064182 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 Google 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"] diff --git a/Makefile b/Makefile index 4448570..fcabbb1 100644 --- a/Makefile +++ b/Makefile @@ -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 ./... @@ -15,4 +15,4 @@ fmt: # Local run without Google 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 diff --git a/README.md b/README.md index 626afc8..6f62589 100644 --- a/README.md +++ b/README.md @@ -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: MIT](https://img.shields.io/badge/license-MIT-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. @@ -240,14 +240,15 @@ unmodified except that hooks import `./preact.mjs`). Restart the server after ed at build time. `proto/` is a copy of KTMS's protocol, so the console talks to it over gRPC without importing its code. -Layout: `cmd/llm-proxy-admin` (entrypoint), `internal/auth` (Google sign-in, sessions, allowlists), +Layout: `cmd/ktms-admin` (entrypoint), `internal/auth` (Google sign-in, sessions, allowlists), `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:`, 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:`. +`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 diff --git a/buf.gen.yaml b/buf.gen.yaml index f32bd92..83ad4a2 100644 --- a/buf.gen.yaml +++ b/buf.gen.yaml @@ -12,4 +12,4 @@ 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 diff --git a/cmd/llm-proxy-admin/main.go b/cmd/ktms-admin/main.go similarity index 94% rename from cmd/llm-proxy-admin/main.go rename to cmd/ktms-admin/main.go index 5a7ceba..4ee2c1d 100644 --- a/cmd/llm-proxy-admin/main.go +++ b/cmd/ktms-admin/main.go @@ -12,9 +12,9 @@ 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/config" + "github.com/ai-process/ktms-admin/internal/api" + "github.com/ai-process/ktms-admin/internal/auth" + "github.com/ai-process/ktms-admin/internal/config" ) func main() { diff --git a/go.mod b/go.mod index b67c034..ed04c86 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/ai-process/llm-proxy-admin +module github.com/ai-process/ktms-admin go 1.26.0 diff --git a/internal/api/api.go b/internal/api/api.go index fb1b6ef..1257a33 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -22,15 +22,15 @@ import ( "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" - "github.com/ai-process/llm-proxy-admin/internal/auth" - "github.com/ai-process/llm-proxy-admin/internal/config" - pb "github.com/ai-process/llm-proxy-admin/internal/gen/ktms/v1" - "github.com/ai-process/llm-proxy-admin/internal/usage" - "github.com/ai-process/llm-proxy-admin/internal/web" + "github.com/ai-process/ktms-admin/internal/auth" + "github.com/ai-process/ktms-admin/internal/config" + pb "github.com/ai-process/ktms-admin/internal/gen/ktms/v1" + "github.com/ai-process/ktms-admin/internal/usage" + "github.com/ai-process/ktms-admin/internal/web" ) const csrfHeader = "X-Requested-With" -const csrfValue = "llm-proxy-admin" +const csrfValue = "ktms-admin" type Server struct { cfg *config.Config diff --git a/internal/api/api_test.go b/internal/api/api_test.go index eba73be..84a54c5 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -13,10 +13,10 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" - "github.com/ai-process/llm-proxy-admin/internal/auth" - "github.com/ai-process/llm-proxy-admin/internal/config" - pb "github.com/ai-process/llm-proxy-admin/internal/gen/ktms/v1" - "github.com/ai-process/llm-proxy-admin/internal/usage" + "github.com/ai-process/ktms-admin/internal/auth" + "github.com/ai-process/ktms-admin/internal/config" + pb "github.com/ai-process/ktms-admin/internal/gen/ktms/v1" + "github.com/ai-process/ktms-admin/internal/usage" ) type fakeAdmin struct { @@ -269,7 +269,7 @@ func call(h http.Handler, method, path, body string, c *http.Cookie, hdr map[str return w } -var csrf = map[string]string{"X-Requested-With": "llm-proxy-admin", "Content-Type": "application/json"} +var csrf = map[string]string{"X-Requested-With": "ktms-admin", "Content-Type": "application/json"} func TestAPIRequiresSession(t *testing.T) { s, _, _ := newServer(t, "") @@ -297,7 +297,7 @@ func TestMutationsNeedRoleAndCSRF(t *testing.T) { if w := call(h, "PUT", "/api/proxy/models/m2", body, boss, nil); w.Code != 403 { t.Errorf("no csrf header = %d", w.Code) } - cross := map[string]string{"X-Requested-With": "llm-proxy-admin", "Origin": "https://evil.example"} + cross := map[string]string{"X-Requested-With": "ktms-admin", "Origin": "https://evil.example"} if w := call(h, "PUT", "/api/proxy/models/m2", body, boss, cross); w.Code != 403 { t.Errorf("cross origin = %d", w.Code) } diff --git a/internal/api/proxy.go b/internal/api/proxy.go index 3776288..f99d7ab 100644 --- a/internal/api/proxy.go +++ b/internal/api/proxy.go @@ -16,9 +16,9 @@ import ( "google.golang.org/grpc/status" "google.golang.org/protobuf/encoding/protojson" - "github.com/ai-process/llm-proxy-admin/internal/auth" - pb "github.com/ai-process/llm-proxy-admin/internal/gen/ktms/v1" - "github.com/ai-process/llm-proxy-admin/internal/usage" + "github.com/ai-process/ktms-admin/internal/auth" + pb "github.com/ai-process/ktms-admin/internal/gen/ktms/v1" + "github.com/ai-process/ktms-admin/internal/usage" ) const rpcTimeout = 20 * time.Second @@ -1015,7 +1015,7 @@ func (s *Server) fetchPrices(ctx context.Context) (map[string]usage.Price, error return out, nil } -// modelPrices returns cached prices, or nil when llm-proxy is not configured +// modelPrices returns cached prices, or nil when the gateway is not configured // or unreachable; the usage view then simply omits cost. func (s *Server) modelPrices(ctx context.Context) map[string]usage.Price { if s.priceFetch == nil { diff --git a/internal/api/tenancy_access_test.go b/internal/api/tenancy_access_test.go index 03edc88..13c1b6e 100644 --- a/internal/api/tenancy_access_test.go +++ b/internal/api/tenancy_access_test.go @@ -12,9 +12,9 @@ import ( "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/timestamppb" - "github.com/ai-process/llm-proxy-admin/internal/auth" - "github.com/ai-process/llm-proxy-admin/internal/config" - pb "github.com/ai-process/llm-proxy-admin/internal/gen/ktms/v1" + "github.com/ai-process/ktms-admin/internal/auth" + "github.com/ai-process/ktms-admin/internal/config" + pb "github.com/ai-process/ktms-admin/internal/gen/ktms/v1" ) func (f *fakeAdmin) GetTeamSpend(_ context.Context, r *pb.GetTeamSpendRequest, _ ...grpc.CallOption) (*pb.GetTeamSpendResponse, error) { diff --git a/internal/api/usage.go b/internal/api/usage.go index 716ab64..5e7bb0f 100644 --- a/internal/api/usage.go +++ b/internal/api/usage.go @@ -4,8 +4,8 @@ import ( "net/http" "time" - "github.com/ai-process/llm-proxy-admin/internal/auth" - "github.com/ai-process/llm-proxy-admin/internal/usage" + "github.com/ai-process/ktms-admin/internal/auth" + "github.com/ai-process/ktms-admin/internal/usage" ) const maxRangeDays = 400 diff --git a/internal/gen/ktms/v1/admin.pb.go b/internal/gen/ktms/v1/admin.pb.go index 33a3d6c..57fef26 100644 --- a/internal/gen/ktms/v1/admin.pb.go +++ b/internal/gen/ktms/v1/admin.pb.go @@ -207,17 +207,18 @@ func (x *ModelSpec) GetBudgetGraceMargin() float64 { } type RuleSpec struct { - state protoimpl.MessageState `protogen:"open.v1"` - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // unique, logged and put in usage meta - Priority int32 `protobuf:"varint,2,opt,name=priority,proto3" json:"priority,omitempty"` // unique; higher wins, first match takes the request - Effort Effort `protobuf:"varint,3,opt,name=effort,proto3,enum=ktms.v1.Effort" json:"effort,omitempty"` // EFFORT_UNSPECIFIED = matches any effort - Attributes map[string]string `protobuf:"bytes,4,rep,name=attributes,proto3" json:"attributes,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` // equality predicates over request attributes - Use []string `protobuf:"bytes,5,rep,name=use,proto3" json:"use,omitempty"` // ordered fallback chain of model ids, cheaper-first - Enabled bool `protobuf:"varint,6,opt,name=enabled,proto3" json:"enabled,omitempty"` - Weights []int32 `protobuf:"varint,7,rep,packed,name=weights,proto3" json:"weights,omitempty"` // traffic weights per model in use; empty means deterministic (100% on use[0]). Canary distribution is deterministic and sticky when request attribute canary_key or user_id is provided, or weighted random if omitted or canary_mode="random". - ShadowModel string `protobuf:"bytes,8,opt,name=shadow_model,json=shadowModel,proto3" json:"shadow_model,omitempty"` // optional model id for async traffic mirroring - ShadowPercentage float64 `protobuf:"fixed64,9,opt,name=shadow_percentage,json=shadowPercentage,proto3" json:"shadow_percentage,omitempty"` // percentage of requests to mirror (0.0 - 100.0) - ShadowEval bool `protobuf:"varint,10,opt,name=shadow_eval,json=shadowEval,proto3" json:"shadow_eval,omitempty"` // optional automated quality evaluation using Judge + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // unique, logged and put in usage meta + Priority int32 `protobuf:"varint,2,opt,name=priority,proto3" json:"priority,omitempty"` // unique; higher wins, first match takes the request + Effort Effort `protobuf:"varint,3,opt,name=effort,proto3,enum=ktms.v1.Effort" json:"effort,omitempty"` // EFFORT_UNSPECIFIED = matches any effort + Attributes map[string]string `protobuf:"bytes,4,rep,name=attributes,proto3" json:"attributes,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` // equality predicates over request attributes + Use []string `protobuf:"bytes,5,rep,name=use,proto3" json:"use,omitempty"` // ordered fallback chain of model ids, cheaper-first + Enabled bool `protobuf:"varint,6,opt,name=enabled,proto3" json:"enabled,omitempty"` + Weights []int32 `protobuf:"varint,7,rep,packed,name=weights,proto3" json:"weights,omitempty"` // traffic weights per model in use; empty means deterministic (100% on use[0]). Canary distribution is deterministic and sticky when request attribute canary_key or user_id is provided, or weighted random if omitted or canary_mode="random". + // Shadow traffic and its evaluation may change in a 1.x minor release. + ShadowModel string `protobuf:"bytes,8,opt,name=shadow_model,json=shadowModel,proto3" json:"shadow_model,omitempty"` // Experimental: optional model id for async traffic mirroring + ShadowPercentage float64 `protobuf:"fixed64,9,opt,name=shadow_percentage,json=shadowPercentage,proto3" json:"shadow_percentage,omitempty"` // Experimental: percentage of requests to mirror (0.0 - 100.0) + ShadowEval bool `protobuf:"varint,10,opt,name=shadow_eval,json=shadowEval,proto3" json:"shadow_eval,omitempty"` // Experimental: optional automated quality evaluation using Judge unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1328,9 +1329,14 @@ func (*RotateEncryptionRequest) Descriptor() ([]byte, []int) { return file_ktms_v1_admin_proto_rawDescGZIP(), []int{22} } +// RotateEncryption re-encrypts every vendor key and registered node auth token +// with the active keyring version; calling it again changes nothing. A vendor +// key encrypted with a version the keyring lacks fails it with +// FAILED_PRECONDITION and nothing changes. UNAVAILABLE after the vendor keys +// committed means only the node tokens are left: call it again. type RotateEncryptionResponse struct { state protoimpl.MessageState `protogen:"open.v1"` - Reencrypted int32 `protobuf:"varint,1,opt,name=reencrypted,proto3" json:"reencrypted,omitempty"` // vendor keys re-encrypted with the active keyring version + Reencrypted int32 `protobuf:"varint,1,opt,name=reencrypted,proto3" json:"reencrypted,omitempty"` // vendor keys and node auth tokens re-encrypted unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -4087,9 +4093,9 @@ const file_ktms_v1_admin_proto_rawDesc = "" + "UpdateTeam\x12\x1a.ktms.v1.UpdateTeamRequest\x1a\x1b.ktms.v1.UpdateTeamResponse\x12E\n" + "\n" + "DeleteTeam\x12\x1a.ktms.v1.DeleteTeamRequest\x1a\x1b.ktms.v1.DeleteTeamResponse\x12K\n" + - "\fGetTeamSpend\x12\x1c.ktms.v1.GetTeamSpendRequest\x1a\x1d.ktms.v1.GetTeamSpendResponseB\x99\x01\n" + + "\fGetTeamSpend\x12\x1c.ktms.v1.GetTeamSpendRequest\x1a\x1d.ktms.v1.GetTeamSpendResponseB\x94\x01\n" + "\vcom.ktms.v1B\n" + - "AdminProtoP\x01ZAgithub.com/ai-process/llm-proxy-admin/internal/gen/ktms/v1;ktmsv1\xa2\x02\x03KXX\xaa\x02\aKtms.V1\xca\x02\aKtms\\V1\xe2\x02\x13Ktms\\V1\\GPBMetadata\xea\x02\bKtms::V1b\x06proto3" + "AdminProtoP\x01Z { document.title = (name ? PAGES[name].title + ' · ' : '') + 'KTMS admin'; }, [name]); const u = session.me.user; const Page = name && PAGES[name].comp; - const signOut = async () => { await fetch('/auth/logout', { method: 'POST', headers: { 'X-Requested-With': 'llm-proxy-admin' } }); location.href = '/'; }; + const signOut = async () => { await fetch('/auth/logout', { method: 'POST', headers: { 'X-Requested-With': 'ktms-admin' } }); location.href = '/'; }; return html`
- ${st.data?.limitsError && html`
Spend caps could not be read: ${st.data.limitsError.message}
`} + ${st.data?.spendError && html`
Key spend could not be read: ${st.data.spendError.message}
`} ${canReadGlobal() && html`<${VendorKeys} vendorKeys=${st.data?.vendorKeys || []} error=${st.data?.cfgError} keys=${keys} loading=${st.loading} reload=${st.reload} />`} `; } @@ -64,7 +62,7 @@ export function KeysPage({ params }) { function Wizard({ teams, team, onDone }) { const lead = !isAdmin(); const labels = lead ? ['Name', 'Done'] : ['Name', 'Access', 'Limits', 'Done']; - const fresh = () => ({ step: 0, name: '', team: lead ? session.me.user.team_id : team, scopes: ['generate'], daily: '', monthly: '', key: '', warn: '', copied: false, err: '', busy: false }); + const fresh = () => ({ step: 0, name: '', team: lead ? session.me.user.team_id : team, scopes: ['generate'], daily: '', monthly: '', rpm: '', tpm: '', key: '', warn: '', copied: false, err: '', busy: false }); const [s, setS] = useState(fresh); const set = (p) => setS((x) => ({ ...x, ...p })); const last = labels.length - 1; @@ -74,6 +72,7 @@ function Wizard({ teams, team, onDone }) { if (s.step === 0 && !s.name.trim()) return 'Give the key a name.'; if (labels[s.step] === 'Access' && !s.scopes.length) return 'Pick at least one kind of access.'; if (labels[s.step] === 'Limits' && [s.daily, s.monthly].some((v) => v !== '' && !(num(v) > 0))) return 'A cap must be above $0, or left empty.'; + if (labels[s.step] === 'Limits' && [s.rpm, s.tpm].some((v) => v !== '' && !(Number.isInteger(num(v)) && num(v) > 0))) return 'A rate must be a whole number above 0, or left empty.'; return ''; }; @@ -81,6 +80,7 @@ function Wizard({ teams, team, onDone }) { const team = teams.find((t) => t.id === s.team); const body = { name: s.name.trim(), scopes: lead ? ['generate'] : s.scopes }; if (team) { body.team_id = team.id; body.org_id = team.org_id; } + if (s.rpm !== '' || s.tpm !== '') body.policy = { rpm_limit: num(s.rpm) || 0, tpm_limit: String(num(s.tpm) || 0) }; const res = await api('POST', '/api/proxy/keys', body); // The key exists now; a cap that fails to save must not hide it. const failed = []; @@ -121,7 +121,9 @@ function Wizard({ teams, team, onDone }) { `} ${at === 'Limits' && html` <${Field} label="Daily spend cap (optional)" hint="Calls are refused once the key has spent this much since 00:00 UTC.">
$ set({ daily: e.currentTarget.value })} />
- <${Field} label="Monthly spend cap (optional)" hint="Counted per UTC calendar month.">
$ set({ monthly: e.currentTarget.value })} />
`} + <${Field} label="Monthly spend cap (optional)" hint="Counted per UTC calendar month.">
$ set({ monthly: e.currentTarget.value })} />
+ <${Field} label="Requests per minute (optional)" hint="Over all models. Allowed models are set with Policy after the key exists."> set({ rpm: e.currentTarget.value })} /> + <${Field} label="Tokens per minute (optional)" hint="Input and output over all models."> set({ tpm: e.currentTarget.value })} />`} ${at === 'Done' && html`
Key created. This is the only time it is shown. Store it in your secret manager now; KTMS keeps only a hash.
${s.key}
@@ -135,6 +137,60 @@ function Wizard({ teams, team, onDone }) { `; } +// SpendChips shows each capped period as KTMS counts it: spent of the cap, or +// only the cap when nothing is counted (no Redis), or unknown when unreadable. +function SpendChips({ spend, failed }) { + if (failed || spend?.error) return html`unknown`; + const capped = (spend?.windows || []).filter((w) => num(w.limit_nanousd) > 0); + if (!capped.length) return html`—`; + return html`
${capped.map((w) => { + const cap = `${usd(nanoUSD(w.limit_nanousd))} / ${w.period}`; + return html`${w.metered ? `${usd(nanoUSD(w.spent_nanousd))} of ${cap}` : cap}`; + })}
`; +} + +function PolicyChips({ policy }) { + const p = policy || {}; + const chips = []; + if ((p.allowed_models || []).length) chips.push(html`${p.allowed_models.length === 1 ? p.allowed_models[0] : p.allowed_models.length + ' models'}`); + if (num(p.rpm_limit) > 0) chips.push(html`${compact(num(p.rpm_limit))} rpm`); + if (num(p.tpm_limit) > 0) chips.push(html`${compact(num(p.tpm_limit))} tpm`); + return chips.length ? html`
${chips}
` : html`—`; +} + +// modelChoices lists the models a key's allowlist can usefully name: its team's +// allowed models when the team has a list (KTMS intersects the two), else the +// gateway's, plus whatever it names now. Empty means type the ids. +function modelChoices(data, k) { + const team = (data?.teams || []).find((t) => t.id === k.team_id); + const base = team?.allowed_models?.length ? team.allowed_models : (data?.models || []); + return [...new Set([...base, ...(k.policy?.allowed_models || [])])].sort(); +} + +function PolicyForm({ k, models, close }) { + const [picked, setPicked] = useState(k.policy?.allowed_models || []); + const toggle = (m) => setPicked((p) => (p.includes(m) ? p.filter((x) => x !== m) : [...p, m])); + const submit = async (fd) => { + const rpm = num(fd.get('rpm')), tpm = num(fd.get('tpm')); + if (![rpm, tpm].every((v) => Number.isInteger(v) && v >= 0)) throw new Error('Rates are whole numbers, 0 for no limit.'); + const allowed = models.length ? picked : String(fd.get('models') || '').split(',').map((m) => m.trim()).filter(Boolean); + await api('PUT', `/api/proxy/keys/${encodeURIComponent(k.name)}/policy`, { allowed_models: allowed, rpm_limit: rpm, tpm_limit: String(tpm) }); + toast('Policy saved'); + }; + return html`<${FormDialog} title=${'Policy of ' + k.name} sub="Applies from the key's next call, on top of its team's models and each model's limits." close=${close} onSubmit=${submit}> +
Allowed models + ${models.length + ? html`
${models.map((m) => html``)}
` + : html`<${Field} label="Model ids" hint="Comma-separated; empty allows any model the team allows.">`} + ${models.length > 0 && html`

${picked.length ? 'Only these, and only where the team allows them too.' : 'None picked: any model the team allows.'}

`} +
+
+ <${Field} label="Requests per minute" hint="Over all models; 0 = no limit."> + <${Field} label="Tokens per minute" hint="Input and output over all models; 0 = no limit."> +
+ `; +} + function VendorKeys({ vendorKeys, error, keys, loading, reload }) { const active = keys.filter((k) => !k.disabled).map((k) => k.name); const setKey = () => openDialog(VendorForm, { names: active }).then((ok) => ok && reload()); diff --git a/internal/web/static/js/pages/models.js b/internal/web/static/js/pages/models.js index b3075c2..b9d034b 100644 --- a/internal/web/static/js/pages/models.js +++ b/internal/web/static/js/pages/models.js @@ -8,13 +8,15 @@ const CAPS = [ ['tts', 'Speech synthesis'], ['image', 'Image generation'], ['judge', 'Judge'], + ['prompt_cache', 'Anthropic: cache tools, system prompt and conversation'], ]; const BLANK = { id: '', vendor: 'openai', endpoint: '', efforts: ['EFFORT_LOW', 'EFFORT_MEDIUM', 'EFFORT_HIGH'], capabilities: [], rpm: 0, rpm_burst: 0, queue_timeout_ms: 0, budget_grace_margin: 0, price_in_per_mtok: 0, price_out_per_mtok: 0, price_in_peak_per_mtok: 0, price_out_peak_per_mtok: 0, - price_in_batch_per_mtok: 0, price_out_batch_per_mtok: 0, daily_tokens_per_key: 0, daily_tokens_per_user: 0, enabled: true, + price_in_batch_per_mtok: 0, price_out_batch_per_mtok: 0, price_cache_read_per_mtok: 0, price_cache_write_per_mtok: 0, + daily_tokens_per_key: 0, daily_tokens_per_user: 0, enabled: true, }; const rpmText = (m) => { @@ -74,7 +76,7 @@ function ModelForm({ model, close }) { if (!/^[A-Za-z0-9._:/-]+$/.test(id)) throw new Error('The model id may contain letters, digits and . _ : / - only.'); const efforts = ['LOW', 'MEDIUM', 'HIGH'].filter((e) => fd.get('effort_' + e)).map((e) => 'EFFORT_' + e); const spec = { ...m, id, vendor: fd.get('vendor'), endpoint: String(fd.get('endpoint')).trim(), efforts, capabilities: caps, enabled: !!fd.get('enabled') }; - for (const k of ['rpm', 'rpm_burst', 'queue_timeout_ms', 'budget_grace_margin', 'price_in_per_mtok', 'price_out_per_mtok', 'price_in_peak_per_mtok', 'price_out_peak_per_mtok', 'price_in_batch_per_mtok', 'price_out_batch_per_mtok', 'daily_tokens_per_key', 'daily_tokens_per_user']) spec[k] = num(fd.get(k)); + for (const k of ['rpm', 'rpm_burst', 'queue_timeout_ms', 'budget_grace_margin', 'price_in_per_mtok', 'price_out_per_mtok', 'price_in_peak_per_mtok', 'price_out_peak_per_mtok', 'price_in_batch_per_mtok', 'price_out_batch_per_mtok', 'price_cache_read_per_mtok', 'price_cache_write_per_mtok', 'daily_tokens_per_key', 'daily_tokens_per_user']) spec[k] = num(fd.get(k)); await api('PUT', '/api/proxy/models/' + encodeURIComponent(id), spec); toast('Model saved'); }; @@ -97,6 +99,7 @@ function ModelForm({ model, close }) { ${n('price_in_per_mtok', 'Input', '', { min: 0 })}${n('price_out_per_mtok', 'Output', '', { min: 0 })} ${n('price_in_peak_per_mtok', 'Peak input', '0 = one price all day', { min: 0 })}${n('price_out_peak_per_mtok', 'Peak output', '', { min: 0 })} ${n('price_in_batch_per_mtok', 'Batch input', '0 = half the input price', { min: 0 })}${n('price_out_batch_per_mtok', 'Batch output', '', { min: 0 })} + ${n('price_cache_read_per_mtok', 'Cached input (read)', '0 = vendor default (Anthropic 0.1x input)', { min: 0 })}${n('price_cache_write_per_mtok', 'Cache write', '0 = vendor default (Anthropic 1.25x input)', { min: 0 })}

Limits

diff --git a/proto/ktms/v1/admin.proto b/proto/ktms/v1/admin.proto index 934259c..c9ee402 100644 --- a/proto/ktms/v1/admin.proto +++ b/proto/ktms/v1/admin.proto @@ -30,6 +30,11 @@ message ModelSpec { int32 rpm_burst = 16; // extra burst allowed above steady-state rpm (0 = default burst of rpm - 1, <0 = burst disabled) int32 queue_timeout_ms = 17; // max wait for pool inference slot in ms (0 = fallback immediately; capped to 250ms for interactive non-svc users) double budget_grace_margin = 18; // allowed daily budget overdraft (0.0 = system default 0.05, <0.0 = disabled) + // Prompt-cache rates at the standard rate, scaled like input for peak and batch. + // 0 = a vendor default that never undercharges: anthropic 0.1x input to read + // and 1.25x to write, bedrock 0.25x and 1.25x, any other vendor 1x. + double price_cache_read_per_mtok = 19; + double price_cache_write_per_mtok = 20; } message RuleSpec { @@ -104,8 +109,28 @@ message MintAPIKeyRequest { repeated string scopes = 2; // "generate" | "admin" string org_id = 3; string team_id = 4; + APIKeyPolicy policy = 5; } +// APIKeyPolicy limits one key on top of its team and each model's rpm. Refusals: +// PERMISSION_DENIED (model not allowed), RESOURCE_EXHAUSTED throttled:key_rpm | +// key_tpm, FAILED_PRECONDITION request_too_large (above a minute of tokens). +message APIKeyPolicy { + repeated string allowed_models = 1; // model ids the key may call; empty = any + int32 rpm_limit = 2; // calls per minute over all models, 0 = unlimited + // Tokens per minute over all models, 0 = unlimited. A call books its input + // estimate when admitted and its real input + output when it ends. + int64 tpm_limit = 3; +} + +// SetAPIKeyPolicy replaces a key's policy; it applies from the key's next call. +message SetAPIKeyPolicyRequest { + string name = 1; + APIKeyPolicy policy = 2; +} + +message SetAPIKeyPolicyResponse {} + message MintAPIKeyResponse { string plain_key = 1; // returned exactly once, never stored or logged } @@ -125,6 +150,7 @@ message APIKeyInfo { bool disabled = 4; string org_id = 5; string team_id = 6; + APIKeyPolicy policy = 7; } message ListAPIKeysResponse { @@ -377,6 +403,26 @@ message GetTeamSpendResponse { bool metered = 4; // false: no budget set, or no Redis (spend is not counted) } +// GetSpend reports a key's, end user's or team's spend as the gateway enforces +// it: one window per spend limit period that applies to the subject. +message GetSpendRequest { + string subject_type = 1; // "key" (subject_id = key name) | "user" | "team" + string subject_id = 2; +} + +message SpendWindow { + string period = 1; // "day" | "month" (UTC) + int64 spent_nanousd = 2; + int64 limit_nanousd = 3; // 0 = no cap + int64 remaining_nanousd = 4; // limit - spent, never below 0; 0 without a cap + google.protobuf.Timestamp resets_at = 5; + bool metered = 6; // false: no cap (spend is counted only under a cap), or no Redis +} + +message GetSpendResponse { + repeated SpendWindow windows = 1; +} + // All admin RPCs require the "admin" scope. Every config mutation validates // the resulting full config in one transaction and bumps the config version. service AdminService { @@ -389,6 +435,7 @@ service AdminService { rpc MintAPIKey(MintAPIKeyRequest) returns (MintAPIKeyResponse); rpc RevokeAPIKey(RevokeAPIKeyRequest) returns (RevokeAPIKeyResponse); rpc ListAPIKeys(ListAPIKeysRequest) returns (ListAPIKeysResponse); + rpc SetAPIKeyPolicy(SetAPIKeyPolicyRequest) returns (SetAPIKeyPolicyResponse); rpc RotateEncryption(RotateEncryptionRequest) returns (RotateEncryptionResponse); rpc MintNodeKey(MintNodeKeyRequest) returns (MintNodeKeyResponse); rpc RevokeNodeKey(RevokeNodeKeyRequest) returns (RevokeNodeKeyResponse); @@ -411,4 +458,5 @@ service AdminService { rpc UpdateTeam(UpdateTeamRequest) returns (UpdateTeamResponse); rpc DeleteTeam(DeleteTeamRequest) returns (DeleteTeamResponse); rpc GetTeamSpend(GetTeamSpendRequest) returns (GetTeamSpendResponse); + rpc GetSpend(GetSpendRequest) returns (GetSpendResponse); } diff --git a/proto/ktms/v1/gateway.proto b/proto/ktms/v1/gateway.proto index 94ca09f..23d8b6a 100644 --- a/proto/ktms/v1/gateway.proto +++ b/proto/ktms/v1/gateway.proto @@ -169,6 +169,9 @@ message GenerateTextRequest { message TokensUsage { int64 input = 1; int64 output = 2; + // Parts of input read from and written to the vendor's prompt cache. + int64 cache_read = 3; + int64 cache_write = 4; } message Choice { @@ -219,6 +222,9 @@ message ModelInfo { double price_out_per_mtok = 7; double price_in_batch_per_mtok = 8; double price_out_batch_per_mtok = 9; + // Effective prompt-cache rates, vendor defaults applied. + double price_cache_read_per_mtok = 10; + double price_cache_write_per_mtok = 11; } message ListModelsResponse { @@ -237,14 +243,15 @@ message GenerateTextStreamRequest { // paused every model in the chain with Retry-After, "vendors_unavailable" // carries RetryInfo with the soonest one frees up: wait that long first. // RESOURCE_EXHAUSTED = throttled ("throttled:rpm" | "throttled:budget_key" | -// "throttled:budget_user") — back off, do not hot-retry; or a spend limit +// "throttled:budget_user"), or the key's own rate ("throttled:key_rpm" | +// "throttled:key_tpm", with RetryInfo) — back off, do not hot-retry; or a spend limit // ("throttled:spend__", "throttled:spend_team_monthly") // with ErrorInfo "insufficient_quota" and RetryInfo until the UTC period resets. -// PERMISSION_DENIED: the key's team may not use the model ("model \"\" not -// allowed for team", "no models in chain allowed for team", "model access -// denied by team policy"); do not retry. +// PERMISSION_DENIED: the key or its team may not use the model ("model \"\" +// not allowed for ", "no models in chain allowed for ", +// "model access denied by key or team policy" for batches); do not retry. // FAILED_PRECONDITION: "output_truncated" (raise max_output_tokens, do not -// retry as-is), "no_capable_model", "no_vendor_key", "not_configured", +// retry as-is), "request_too_large" (above a minute of the key's tpm_limit), "no_capable_model", "no_vendor_key", "not_configured", // "batch not configured". // INVALID_ARGUMENT: "guardrail_violation" (PII or prompt injection detected), // "bad_request: " (validation errors, empty batch, duplicate custom_id). diff --git a/scripts/e2e.refs b/scripts/e2e.refs index 69025d5..5b20113 100644 --- a/scripts/e2e.refs +++ b/scripts/e2e.refs @@ -1,4 +1,4 @@ # Refs scripts/e2e.sh tests against unless KTMS_REF / TOKENMETER_REF are set. -# Switch to the release tags (v1.0.0) once they exist. -E2E_KTMS_REF=main -E2E_TOKENMETER_REF=main +# The 1.0 release branches until the v1.0.0 tags exist, then the tags. +E2E_KTMS_REF=release/v1.0.0 +E2E_TOKENMETER_REF=release/v1.0.0 diff --git a/scripts/e2e.sh b/scripts/e2e.sh index bcb2522..eace462 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -5,8 +5,8 @@ set -eu cd "$(dirname "$0")/.." -# The tested pair: main until 1.0 is tagged, then the release tags, so a pull -# request is not failed by an unrelated upstream change. The daily run uses main. +# The tested pair comes from e2e.refs, so a pull request is not failed by an +# unrelated upstream change. The daily run uses main. . ./scripts/e2e.refs KTMS_REF=${KTMS_REF:-$E2E_KTMS_REF} TOKENMETER_REF=${TOKENMETER_REF:-$E2E_TOKENMETER_REF} @@ -134,6 +134,15 @@ api GET /api/proxy/keys | jq -e '.keys | map(.name) | index("e2e-client")' >/dev api PUT /api/proxy/spend-limits '{"subjectType":"key","subjectId":"e2e-client","period":"day","limitNanousd":"5000000000"}' >/dev/null || fail "set spend limit" api GET /api/proxy/spend-limits | jq -e '.spend_limits | map(.subject_id) | index("e2e-client")' >/dev/null || fail "spend limit not listed: $(api GET /api/proxy/spend-limits)" +api PUT /api/proxy/keys/e2e-client/policy '{"allowedModels":["mock-gpt"],"rpmLimit":60,"tpmLimit":"100000"}' >/dev/null || fail "set key policy" +api GET /api/proxy/keys | jq -e '.keys[] | select(.name == "e2e-client") | .policy.rpm_limit == 60 and .policy.allowed_models == ["mock-gpt"]' >/dev/null || + fail "key policy not listed: $(api GET /api/proxy/keys)" +i=0 +until api GET /api/proxy/key-spend 2>/dev/null | jq -e '.keys["e2e-client"].windows[] | select(.period == "day") | .limit_nanousd == "5000000000"' >/dev/null; do + i=$((i + 1)) + [ "$i" -gt 15 ] && fail "spend of the key: $(api GET /api/proxy/key-spend)" + sleep 1 +done ORG=$(api POST /api/proxy/organizations '{"name":"e2e-org"}' | jq -r .organization.id) || true [ -n "$ORG" ] && [ "$ORG" != null ] || fail "create organization" api POST /api/proxy/teams '{"orgId":"'"$ORG"'","name":"e2e-team","monthlyBudgetUsd":10,"allowedModels":["mock-gpt"]}' >/dev/null || fail "create team" @@ -141,7 +150,7 @@ api GET /api/proxy/teams | jq -e '.teams | map(.name) | index("e2e-team")' >/dev api DELETE /api/proxy/keys/e2e-client >/dev/null || fail "revoke client key" api GET /api/proxy/keys | jq -e '.keys | map(select(.name == "e2e-client" and (.disabled | not))) | length == 0' >/dev/null || fail "the revoked key is still active: $(api GET /api/proxy/keys)" -echo "e2e: keys, spend limits, organizations and teams work" +echo "e2e: keys, key policies, spend limits and spend, organizations and teams work" # Before the call: a run that crosses midnight UTC still finds the event. SINCE=$(date -u +%F) From ded2220cfdd7d9651ccc9d5303ac353857a00f86 Mon Sep 17 00:00:00 2001 From: Vasil coylOne Kulakov Date: Fri, 23 Oct 2026 18:46:21 +0200 Subject: [PATCH 6/8] ci: proto/ktms/v1 must match KTMS, and internal/gen the copy (#25) * ci: proto/ktms/v1 must match KTMS, and internal/gen the copy A proto job compares the copy with KTMS at the ref the e2e test uses and regenerates internal/gen, so the console's contract cannot drift from the gateway's unnoticed. * ci: whole-directory proto check in a script, pinned plugins, daily main run scripts/proto-check.sh clones KTMS at KTMS_REF or the e2e pair, diffs the whole proto/ktms/v1 directory and regenerates internal/gen from scratch, so added or removed files count. The generator plugins are pinned, and the daily main run checks the protocol too. --- .github/workflows/e2e-main.yml | 9 +++++++++ .github/workflows/test.yml | 17 +++++++++++++++-- README.md | 4 +++- buf.gen.yaml | 5 +++-- scripts/e2e.sh | 2 +- scripts/proto-check.sh | 26 ++++++++++++++++++++++++++ 6 files changed, 57 insertions(+), 6 deletions(-) create mode 100755 scripts/proto-check.sh diff --git a/.github/workflows/e2e-main.yml b/.github/workflows/e2e-main.yml index 27a0343..e864766 100644 --- a/.github/workflows/e2e-main.yml +++ b/.github/workflows/e2e-main.yml @@ -16,7 +16,16 @@ jobs: 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 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d6d51ed..a532bd6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,8 +22,21 @@ 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 - # The console against KTMS and light-tokenmeter built from main, with - # light-tokenmeter's authentication on: README "Compatibility". + # 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 diff --git a/README.md b/README.md index b36e438..0e12eb1 100644 --- a/README.md +++ b/README.md @@ -363,7 +363,9 @@ 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/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 diff --git a/buf.gen.yaml b/buf.gen.yaml index 83ad4a2..efb9d97 100644 --- a/buf.gen.yaml +++ b/buf.gen.yaml @@ -1,10 +1,11 @@ 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 diff --git a/scripts/e2e.sh b/scripts/e2e.sh index eace462..63372a6 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -1,6 +1,6 @@ #!/bin/sh # End-to-end check of the console against KTMS and light-tokenmeter built -# from their main branches (or KTMS_REF / TOKENMETER_REF), with +# from the refs in e2e.refs (or KTMS_REF / TOKENMETER_REF), with # light-tokenmeter's authentication on and a mock vendor. set -eu diff --git a/scripts/proto-check.sh b/scripts/proto-check.sh new file mode 100755 index 0000000..26459c4 --- /dev/null +++ b/scripts/proto-check.sh @@ -0,0 +1,26 @@ +#!/bin/sh +# KTMS owns ktms.v1: proto/ktms/v1 must be its copy at KTMS_REF (default: the +# pair in e2e.refs), whole directory, and internal/gen generated from the copy. +set -eu + +cd "$(dirname "$0")/.." +# shellcheck source=/dev/null +. ./scripts/e2e.refs +REF=${KTMS_REF:-$E2E_KTMS_REF} +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +git clone -q --depth 1 --branch "$REF" https://github.com/ai-process/ktms.git "$WORK/ktms" || + { echo "proto-check: cannot fetch ktms@$REF" >&2; exit 1; } +diff -ru "$WORK/ktms/proto/ktms/v1" proto/ktms/v1 || + { echo "proto-check: proto/ktms/v1 differs from ktms@$REF: copy the directory over and run make proto" >&2; exit 1; } + +# From scratch, so the output of a removed proto file shows up as a deletion. +rm -rf internal/gen +buf generate +if [ -n "$(git status --porcelain -- internal/gen)" ]; then + git status --short -- internal/gen >&2 + echo "proto-check: internal/gen is stale: run make proto and commit" >&2 + exit 1 +fi +echo "proto-check: proto/ktms/v1 matches ktms@$REF and internal/gen is current" From 5b2267ec0110c95748e0ba6cb9df24c515e9e202 Mon Sep 17 00:00:00 2001 From: Vasil coylOne Kulakov Date: Fri, 23 Oct 2026 19:25:55 +0200 Subject: [PATCH 7/8] docs: SECURITY.md for 1.0 (versions, verifying images, every sign-in method); e2e refs in README (#26) --- README.md | 8 ++++---- SECURITY.md | 31 ++++++++++++++++++++++++++++--- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 0e12eb1..ec633e7 100644 --- a/README.md +++ b/README.md @@ -216,10 +216,10 @@ The console's own `/api` serves its web UI only and is not a public API. 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. -`scripts/e2e.sh` checks this on every pull request and daily: it builds KTMS and light-tokenmeter -from `main` (or `KTMS_REF` / `TOKENMETER_REF`), turns light-tokenmeter's authentication on, and -drives the console's API through models, rules, vendor keys, client keys, spend limits, tenants, a -playground call through a mock vendor and its usage report. +`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 diff --git a/SECURITY.md b/SECURITY.md index b139c7d..5752e1a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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. From f16a7e6f573c8225b299d4d93492c0ddc897c14d Mon Sep 17 00:00:00 2001 From: Vasil coylOne Kulakov Date: Fri, 23 Oct 2026 19:53:15 +0200 Subject: [PATCH 8/8] test(e2e): test against the KTMS and light-tokenmeter v1.0.0 tags (#27) --- scripts/e2e.refs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/e2e.refs b/scripts/e2e.refs index 5b20113..4b60777 100644 --- a/scripts/e2e.refs +++ b/scripts/e2e.refs @@ -1,4 +1,4 @@ -# Refs scripts/e2e.sh tests against unless KTMS_REF / TOKENMETER_REF are set. -# The 1.0 release branches until the v1.0.0 tags exist, then the tags. -E2E_KTMS_REF=release/v1.0.0 -E2E_TOKENMETER_REF=release/v1.0.0 +# Refs scripts/e2e.sh tests against unless KTMS_REF / TOKENMETER_REF are set: +# the released pair this console is tested with. The daily run uses main. +E2E_KTMS_REF=v1.0.0 +E2E_TOKENMETER_REF=v1.0.0