A self-hosted LLM gateway and model switchboard. Your services talk to one endpoint; Keystone holds the vendor API keys, decides which model answers each request, falls back when a vendor is down, enforces rate and spend limits, and records usage.
- One place for vendor keys. OpenAI, Anthropic (Claude), Google Gemini, DeepSeek and TypeSafe credentials live encrypted in Postgres (AES-256-GCM). Client services never see them.
- Routing by rules, not code. Clients send an effort (
LOW/MEDIUM/HIGH) and free-form attributes; an ordered rule list maps them to a model chain with fallbacks. Rules are changed at runtime over an admin RPC, with no redeploy. - Throttling. Per-model requests-per-minute and daily token budgets, per API key and per end user, backed by Redis.
- More than text. Text generation with structured (JSON-schema) output, asynchronous batch generation at 50% discount, speech synthesis, image generation and typed judgments.
- OpenAI-compatible HTTP API. Point any OpenAI SDK or tool at
http://host:8080/v1and get routing, fallbacks and limits for free. - Usage reporting. Optionally forwards one event per call to a gRPC usage sink.
- Small footprint. Single Go binary: ~67 MB image, ~7 MiB idle RAM, under 1 ms of added latency.
- Web console. ktms-admin manages models, rules and keys in the browser.
- How it works
- Quick start (Docker Compose)
- Resource footprint
- Installation
- Configuration reference
- Setting it up: keys, models, rules
- Run a node
- Web console
- Command-line tool
- Calling Keystone
- OpenAI-compatible HTTP API
- Concepts
- Error contract
- API stability
- Operations
- Development
- License
your services ──gRPC (Bearer ktms_…)──▶ KTMS ──▶ OpenAI / Anthropic / Gemini / DeepSeek / TypeSafe
│ │
Postgres ┘ └ Redis (limits) └─▶ usage sink (optional)
config, keys, encrypted vendor keys
Two gRPC services share port 9090:
| Service | Purpose | Needed scope |
|---|---|---|
ktms.v1.GatewayService |
GenerateText, GenerateTextStream, SubmitBatch, GetBatch, ListBatchResults, CancelBatch, SynthesizeSpeech, GenerateImage, Judge, JudgeStream, EvaluateRollouts, GetEmbeddings, ListModels |
generate |
ktms.v1.AdminService |
models, rules, vendor keys, API keys, key rotation, ExplainRoute |
admin; admin:read for the Get* and List* reads and ExplainRoute |
Port 8080 serves the health probes (/healthz, /readyz) and the
OpenAI-compatible HTTP API under /v1. gRPC server
reflection is on, so grpcurl works without the .proto files.
The fastest way to run KTMS locally is with Docker Compose. With AUTO_SEED enabled (default in compose), the proxy automatically initializes default models, routing rules, and a ready-to-use client key (ktms_dev_default_key).
git clone https://github.com/ai-process/ktms.git
cd ktms
# Pass your vendor API key (OpenAI, Gemini, or Anthropic)
OPENAI_API_KEY=sk-... docker compose up -d --build
# Verify readiness
curl -s http://localhost:8080/readyz # {"status":"ready"}Alternatively, run ./scripts/quickstart.sh for an interactive setup that generates master encryption and bootstrap keys, configures .env, and launches the stack.
Note
With AUTO_SEED=true (enabled by default in Compose), the proxy automatically syncs vendor API keys from environment variables (OPENAI_API_KEY, GEMINI_API_KEY, ANTHROPIC_API_KEY) to the client key named dev-default (or $SEED_CLIENT_NAME) on every startup.
You can immediately query the proxy using the OpenAI-compatible HTTP endpoint:
curl -s http://localhost:8080/v1/chat/completions \
-H "Authorization: Bearer ktms_dev_default_key" \
-H "Content-Type: application/json" \
-d '{"model":"auto","messages":[{"role":"user","content":"Say hi in three words."}]}'Or using the official OpenAI Python SDK:
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8080/v1", api_key="ktms_dev_default_key")
response = client.chat.completions.create(
model="auto",
messages=[{"role": "user", "content": "Say hi in three words."}],
)
print(response.choices[0].message.content)The response includes canonical observability headers (X-KTMS-Vendor, X-KTMS-Rule, X-KTMS-Placement) along with backwards-compatible X-LLM-Proxy-* headers.
If you prefer to configure everything manually from scratch (without auto-seeding), disable AUTO_SEED=false and use grpcurl with BOOTSTRAP_ADMIN_KEY:
export ADMIN="authorization: Bearer dev-bootstrap"
G="grpcurl -plaintext -H"
# 1. Mint a client API key (plaintext is returned once)
$G "$ADMIN" -d '{"name":"my-app","scopes":["generate"]}' \
localhost:9090 ktms.v1.AdminService/MintAPIKey
# 2. Assign vendor credential to the client key
$G "$ADMIN" -d '{"api_key_name":"my-app","vendor":"openai","plain_key":"sk-..."}' \
localhost:9090 ktms.v1.AdminService/SetVendorKey
# 3. Register a model
$G "$ADMIN" -d '{"model":{"id":"gpt-4o-mini","vendor":"openai",
"efforts":["EFFORT_LOW","EFFORT_MEDIUM","EFFORT_HIGH"],
"price_in_per_mtok":0.15,"price_out_per_mtok":0.60,"enabled":true}}' \
localhost:9090 ktms.v1.AdminService/UpsertModel
# 4. Set routing rules (catch-all for each effort tier is mandatory)
$G "$ADMIN" -d '{"rules":[
{"name":"default","priority":1,"use":["gpt-4o-mini"],"enabled":true}]}' \
localhost:9090 ktms.v1.AdminService/ReplaceRules
# 5. Call gRPC GenerateText with your client key
$G "authorization: Bearer ktms_<your key>" -d '{
"effort":"EFFORT_LOW",
"messages":[{"role":"MESSAGE_ROLE_USER","text":"Say hi in three words."}]}' \
localhost:9090 ktms.v1.GatewayService/GenerateTextOnce real keys exist in production, unset BOOTSTRAP_ADMIN_KEY.
KTMS is a single Go process with no interpreter. It adds well under a millisecond per request and needs tens of megabytes where Python gateways such as LiteLLM Proxy need gigabytes.
| Metric | KTMS (Go) | LiteLLM Proxy (Python, 4 workers) | Difference |
|---|---|---|---|
| Docker image, linux/amd64 (uncompressed / pull) | ~67 MB / ~21 MB (slim) | ~1.3 GB / ~420 MB | ~20x smaller |
| Idle memory (RSS) | ~7 MiB | ~2.5 GiB | ~370x less |
| Memory at a steady 200 RPS | ~20 MiB, no drift | ~2.9 GiB | ~145x less |
| Peak memory over the whole run (up to 4,000 RPS) | < 50 MiB | ~3.0 GiB | ~60x less |
| Added latency, p50 / p99 | ~0.6 ms / ~1.1 ms | ~8.5 ms / 27–229 ms | ~13x lower p50 |
| Added time to first token (streaming), p50 | ~0.7 ms | ~160 ms | no chunk buffering in either |
| Max sustained throughput (< 1% errors) | ≥ 4,000 RPS (highest step tested) | ~250 RPS | ≥ 16x higher |
| CPU time per 1,000 requests | ~0.63 s | ~9.7 s | ~15x less |
| Time to readiness (container start → first proxied answer) | ~170 ms | ~32 s | ~190x faster |
The same stand also measured Bifrost, Portkey, Kong AI Gateway, Apache APISIX, Higress, Envoy AI Gateway, agentgateway, New API and Plano. KTMS is in the same class as the compiled and Nginx/Envoy-based gateways: all of them add under 1.3 ms at p50 and sustain 4,000 RPS. Among them, only agentgateway matches KTMS's memory footprint, while agentgateway, APISIX and Kong add less latency (0.25–0.35 ms) and use less CPU per request. The Node.js and Python gateways (Portkey, LiteLLM) saturate at 500 and 250 RPS.
Measured in October 2026 on Google Cloud: each gateway alone on a dedicated c3-standard-4 (4 vCPU,
16 GB, x86_64), k6 on a second VM, and a mock OpenAI upstream (0 ms for latency, 10 ms for
throughput). LiteLLM v1.106.0 ran with master key only and no database. Memory is the gateway
container's cgroup RSS; Postgres and Redis are not counted. Two independent runs agreed within ~10%,
except LiteLLM p99. The stand, every gateway config and the run scripts are in bench/.
Release images are published to Docker Hub for linux/amd64 and linux/arm64 in two variants:
coyl/ktms:latest(also tagged:slimor pinned:<version>/:<version>-slim): default lightweight image (~67 MB uncompressed, ~21 MB download). A clean Alpine container withoutffmpeg, optimized for text generation, chat, reasoning, embeddings, and OpenAI TTS.coyl/ktms:audio(or pinned:<version>-audio): extended image (~195 MB uncompressed, ~69 MB download) withffmpeginstalled for Gemini speech synthesis (transcoding raw PCM into OGG/Opus voice messages).
docker pull coyl/ktms:latest # default lightweight image (~67 MB)
docker pull coyl/ktms:audio # with ffmpeg for Gemini speech synthesis (~195 MB)If Gemini TTS is requested without ffmpeg available (e.g. on the default slim image), the proxy returns gRPC Codes.Unimplemented / HTTP 501 Not Implemented: ffmpeg_missing: ffmpeg is required for Gemini speech synthesis: install ffmpeg or use the 'coyl/ktms:audio' container image. OpenAI TTS produces native Opus directly and works on both variants without ffmpeg.
The image runs as a non-root user (appuser, UID 10001), exposes 8080 (HTTP) and 9090 (gRPC), and is configured entirely through environment variables. From 1.0.0, images are signed with cosign and carry an SBOM and provenance, and the release's checksums.txt is signed: see Verifying a Release.
You need a Postgres database (required; 16 is what the compose file uses) and a Redis (optional, see below):
# generate the at-rest encryption keyring once and keep it safe
export KTMS_ENCRYPTION_KEYS="1:$(head -c 32 /dev/urandom | base64)"
export BOOTSTRAP_ADMIN_KEY="$(head -c 24 /dev/urandom | base64 | tr -d '/+=')"
docker run -d --name ktms \
-p 9090:9090 -p 8080:8080 \
-e DATABASE_URL='postgres://user:pass@db-host:5432/ktms?sslmode=require' \
-e KTMS_ENCRYPTION_KEYS \
-e BOOTSTRAP_ADMIN_KEY \
-e REDIS_ADDR=redis-host:6379 \
coyl/ktms:latestA minimal Compose service for your own stack:
services:
ktms:
image: coyl/ktms:latest
restart: unless-stopped
environment:
DATABASE_URL: postgres://ktms:${DB_PASSWORD}@db:5432/ktms?sslmode=disable
KTMS_ENCRYPTION_KEYS: ${KTMS_ENCRYPTION_KEYS}
REDIS_ADDR: redis:6379
ports:
- "9090:9090"
depends_on: [db, redis]On Kubernetes, point the liveness probe at GET :8080/healthz and the readiness probe
at GET :8080/readyz (checks the database). Run as many replicas as you like; they share
Postgres and Redis and pick up configuration changes within ROUTING_RELOAD_SECONDS.
An official Helm v3 chart is published with every release at oci://ghcr.io/ai-process/charts/ktms
(source in deploy/helm/ktms). The chart version is the release version, and
it runs the image of the same version:
# 1. Create a secret with your database URL and encryption keys
kubectl create secret generic ktms-secrets \
--from-literal=DATABASE_URL='postgres://ktms:pass@db:5432/ktms?sslmode=require' \
--from-literal=KTMS_ENCRYPTION_KEYS="1:$(head -c 32 /dev/urandom | base64)" \
--from-literal=BOOTSTRAP_ADMIN_KEY="$(head -c 24 /dev/urandom | base64 | tr -d '/+=')"
# 2. Install the release
helm install ktms oci://ghcr.io/ai-process/charts/ktms --version 1.1.0 \
--set secrets.existingSecret=ktms-secrets \
--set ingress.enabled=true \
--set "ingress.hosts[0].host=proxy.example.com" \
--set "ingress.hosts[0].paths[0].path=/" \
--set "ingress.hosts[0].paths[0].pathType=Prefix"The chart supports non-root security context (appuser:10001), HorizontalPodAutoscaler, Prometheus Operator ServiceMonitor, and separate gRPC Ingress configuration. See deploy/helm/ktms/README.md for full configuration parameters.
Requires Go 1.26+ (and ffmpeg on PATH if you use speech).
make build # → bin/ktms-server and bin/ktms-node
export DATABASE_URL=postgres://ktms:ktms@localhost:5432/ktms?sslmode=disable
export KTMS_ENCRYPTION_KEYS="1:$(head -c 32 /dev/urandom | base64)"
./bin/ktms-serverOr build the image yourself: docker build -t ktms .
bin/ktms-node runs next to a local inference engine (ollama, vllm, tgi, llamacpp),
registers it with Keystone and sends heartbeats with the loaded models, GPU metrics and the
engine's load: running and waiting requests from the /metrics of vLLM, TGI and llama.cpp
(start llama-server with --metrics). Ollama has no such API, so its nodes report no queue. It
authenticates with a node key minted with the MintNodeKey admin RPC. KTMS dials the node,
so the node must be reachable; the agent can do that itself:
export NODE_KEY=... # or --node-key-file
./bin/ktms-node --proxy-addr proxy.example.com:443 --tls \
--engine ollama --expose=cloudflared # needs the cloudflared binaryWith --expose=cloudflared the agent starts a localhost gateway in front of the engine and a
Cloudflare quick tunnel in front of the gateway, registers the tunnel address, and registers
again whenever the tunnel restarts and its address changes. The gateway checks a per-process
access key (sent to the proxy as the node auth token, never printed) and forwards only
POST /v1/chat/completions and GET /v1/models; pulling or deleting models is not reachable.
Slow responses are kept alive with padding so the tunnel's 100 s idle cutoff does not drop them.
Quick tunnels have no SLA; for a stable address use --expose=none and your own --endpoint.
Flags: --proxy-addr (default 127.0.0.1:50051), --tls (dial over TLS with the system CA
roots, for a proxy behind a TLS load balancer; off by default), --node-id (defaults to the
hostname), --node-name, --engine (ollama, vllm, tgi, llamacpp, decisions, generic),
--base-weight, --max-concurrent.
--judge-model (repeat or comma-separate) tags specific models on the node with the judge capability
for local JEPA / decision evaluation, routing /v1/decisions and Judge calls to them.
--only-model (repeat it or comma-separate) reports only the named models to the proxy, for an
engine that shares its model directory with other tools; by default every model the engine has is
reported. If the node key restricts allowed_models, the proxy ignores (and logs) other reported
models and registers the node with the allowed ones; it refuses only when none is allowed.
--engine-url is where the agent itself polls the engine (default http://127.0.0.1:11434);
--endpoint is the address reported to the proxy, which dials it (default: --engine-url).
--expose is none (default; you supply --endpoint) or cloudflared, with
--cloudflared-path to pick the binary. NODE_AUTH_TOKEN is the bearer the engine itself
requires, if any.
If a node does not show up or serves badly, ktms-node doctor takes the same flags (plus
--model) and says in plain words what is wrong: engine not answering, model missing or not
loaded, Ollama on the CPU (an Intel build under Rosetta on Apple Silicon), proxy unreachable or
needing/not needing --tls, a node key that is wrong or bound to another node id, or a tunnel
that cannot come up. It also asks the proxy to vet the exact models the agent would report
against the key's allowed_models (nothing is stored). It exits 1 if it finds a problem.
A node on someone's own machine can follow a load schedule in local time with
--schedule-file FILE, one rule per line:
# [days] HH:MM-HH:MM level
23:00-07:00 100
07:00-10:00 50
mon-fri 10:00-23:00 0
Days are mon..sun, ranges (mon-fri) and lists (sat,sun); without them a rule applies daily.
A window past midnight belongs to the day it starts on, 24:00 ends a day, uncovered time is 100%,
and overlapping rules are an error. The level is the share of time the node spends on proxy
requests: the proxy gives it ceil(max_concurrent × level) slots and, where that is more than the
level, holds the slot idle after each request (one slot at 50%: a 20 s request, then 20 s idle).
At 0% the proxy sends nothing, the gateway answers 503 after a 5 s grace, and once the proxy has no
requests left on the node the agent unloads the served models from Ollama; it loads them again
--warmup (default 5m) before the next window. The tunnel and heartbeats keep running, so a pause
ends at once. The agent reads the file every 30 s and on a change, keeps the last valid schedule if
an edit breaks it, and runs at 100% without the file. A proxy that predates capacity levels
routes at 100% (the agent logs a warning). ktms-node schedule --file FILE checks a
file and prints the level now and the next switch; doctor reports it too. Engines other than
Ollama only stop being routed to.
While the proxy refuses the node (wrong key, other node id, no allowed model) the agent does not exit and does not open a tunnel: it checks with the proxy first, then retries with a pause that grows to five minutes and says why in the log, so a supervisor never restarts it into a fresh Cloudflare tunnel per attempt.
- TLS. The server speaks plaintext gRPC. Terminate TLS in front of it (Envoy, nginx
with
grpc_pass, a service mesh, a cloud load balancer) or keep it on a private network. Node agents that reach the proxy through that TLS endpoint run with--tls. - Back up the encryption keyring, apart from the database: without
KTMS_ENCRYPTION_KEYSthe stored vendor keys are unreadable. See Backups. - Mint real keys, then unset
BOOTSTRAP_ADMIN_KEY. It is accepted as an implicit admin key. - Run Redis if you want limits. With
REDIS_ADDRempty, RPM, token budgets and spend limits are not enforced (the proxy fails open by design, and does the same if Redis errors; setSPEND_LIMITS_FAIL_MODE=closedto refuse spend-limited calls instead). - Give each client its own API key (and its own vendor credentials), so spend, models and revocation are per client.
- Mind your Postgres connection count. Each replica holds up to
DB_MAX_CONNS(default 25) connections. - Back up Postgres. All configuration lives there; see Backups.
- Keep internal ports internal. Expose gRPC (
:9090, clients and node agents) and HTTP (:8080). Metrics (:9091) belong on the cluster network./healthzand/readyzshare:8080with the API, and/readyzreports database state, so block both paths at a public ingress. - Network policies. Ingress: clients, node agents, the console, Prometheus (
:9091) and the kubelet probes. Egress: the vendors, Postgres, Redis, the collector, the nodes, and what you configured (OTEL_EXPORTER_OTLP_ENDPOINT,SENTRY_DSN,LATENT_ENCODER_URL). - TLS to dependencies. Use
sslmode=requirefor Postgres andUSAGE_COLLECTOR_TLSfor the collector. The Redis client has no TLS: keep Redis on a private network withREDIS_PASSWORD. - Least-privilege keys.
generatefor clients;admin:readfor dashboards, assistants and anything that only looks;adminonly for the console and operators. - Spend limits on every client key (Spend limits).
- Secrets. Keep
KTMS_ENCRYPTION_KEYS,DATABASE_URLandUSAGE_COLLECTOR_TOKENin a secret manager; the collector token needs only light-tokenmeter'swritescope. Rotate the keyring with Rotating the encryption key. - Nodes. Leave
NODE_ALLOW_LOCALHOST_ENDPOINTS=falseoutside development. - Alerts. Wire up the suggested alerts; Redis and the collector fail open, so without them a dead limiter or lost accounting goes unnoticed.
Everything is an environment variable; see env.example.
| Variable | Default | Description |
|---|---|---|
DATABASE_URL |
– | Required. Postgres connection string. |
KTMS_ENCRYPTION_KEYS / LLMPROXY_ENCRYPTION_KEYS |
– | Required. Versioned keyring 1:<base64 32 bytes>[,2:…]. The highest version encrypts new writes; all listed versions can decrypt. |
GRPC_ADDR |
:9090 |
gRPC listen address (data and admin planes). |
GRPC_MAX_RECV_MSG_SIZE |
16777216 |
Maximum gRPC message receive size in bytes (16MB). |
HTTP_ADDR |
:8080 |
HTTP listen address (health probes and the OpenAI-compatible API). |
HTTP_API_ENABLED |
true |
Serve the /v1 API on HTTP_ADDR; false leaves only the health probes. |
HTTP_API_TIMEOUT |
5m |
Read/write timeout for one HTTP API call (it waits on an upstream model). |
METRICS_ADDR |
:9091 |
Internal HTTP listen address for Prometheus /metrics and /healthz. Empty disables the metrics server. |
LOG_LEVEL |
info |
zerolog level. |
SHUTDOWN_TIMEOUT |
30s |
Graceful drain time on SIGTERM. |
DB_MAX_CONNS / DB_MIN_CONNS |
25 / 5 |
Postgres pool bounds. |
DB_TIMEOUT |
3s |
Bounds connecting to Postgres (unless the DSN sets connect_timeout) and each per-request query such as key checks, so a hung database fails calls fast. |
AUTH_STALE_IF_ERROR |
5m |
While Postgres is unreachable, a key that verified within this window keeps working (a revocation made just before the outage can lag by at most this much). 0 refuses every call during an outage. |
ROUTING_RELOAD_SECONDS |
60 |
How often replicas poll for config changed elsewhere. |
AUTO_MIGRATE |
true |
Apply embedded migrations at startup (guarded by an advisory lock, safe for rolling deploys). Off means applying migrations/*.sql by hand. |
MIGRATE_TIMEOUT |
3m |
Bound for the whole migration run. |
AUTO_SEED |
false |
Automatically populate initial models, rules, and client key when starting against an unconfigured database (enabled in docker-compose). |
SEED_CLIENT_KEY |
empty | Pre-configured client API key (e.g. ktms_dev_default_key) for auto-seed; generated if unset. |
SEED_CLIENT_NAME |
dev-default |
Name assigned to the auto-seeded client API key. |
SEED_OPENAI_KEY / OPENAI_API_KEY |
empty | OpenAI API key to encrypt and register during auto-seeding. |
SEED_GEMINI_KEY / GEMINI_API_KEY |
empty | Google Gemini API key to encrypt and register during auto-seeding. |
SEED_ANTHROPIC_KEY / ANTHROPIC_API_KEY |
empty | Anthropic API key to encrypt and register during auto-seeding. |
REDIS_ADDR |
empty | Redis for throttling. Empty disables limits. |
REDIS_PASSWORD / REDIS_DB |
empty / 0 |
Redis auth and database. |
REDIS_KEY_PREFIX |
empty | Namespace for counters when Redis is shared (compose uses dev). |
REDIS_TIMEOUT |
500ms |
Per-command Redis read/write timeout (dials get twice as long, one retry). With the fail-fast guard, about what a hung Redis adds to a request before limits fail open. |
BUDGET_GRACE_MARGIN |
0.05 |
Fraction of daily token budget allowed as grace overdraft (0.05 = 5%, 0.0 or negative disables grace overdraft). |
SPEND_LIMITS_FAIL_MODE |
open |
What spend limits do when Redis errors: open lets calls through unmetered (logged), closed refuses calls under a spend limit with UNAVAILABLE throttled:spend_unavailable. |
SPEND_DEFAULT_OUTPUT_RESERVE_TOKENS |
4096 |
Output tokens priced into a spend reservation when the call sets no max tokens. |
CIRCUIT_BREAKER_ENABLED |
true |
Enable upstream Circuit Breaker to fail-fast during provider outages without network IO. |
CIRCUIT_BREAKER_FAIL_THRESHOLD |
5 |
Consecutive upstream failures (5xx, network timeouts) before tripping circuit to Open. |
CIRCUIT_BREAKER_COOLDOWN |
30s |
Cooldown period before allowing a single probe request in Half-Open state. |
UPSTREAM_REQUEST_TIMEOUT |
0 (disabled) |
Upstream vendor timeout (total per-attempt deadline for unary/media; per-chunk idle watchdog for streaming). Stalls trip breaker and advance fallback chains. |
VENDOR_COOLDOWN_MAX |
5m |
Longest pause honoured when a vendor answers 429/503 with Retry-After. 0 disables pauses. |
BATCH_POLL_SECONDS |
60 |
Background polling interval in seconds for checking unfinished batch jobs. |
BATCH_MAX_ITEMS |
10000 |
Maximum number of items allowed in a single batch submission. |
RESULTS_RETENTION_DAYS |
7 |
Retention period for completed batch results before cleanup (accepts integer days, e.g. 7, or Go duration string, e.g. 168h). |
NODE_HEARTBEAT_INTERVAL_SECONDS |
5 |
Node agent heartbeat reporting interval in seconds. |
NODE_HEARTBEAT_TTL_SECONDS |
15 |
Heartbeat TTL in seconds before an idle node transitions to OFFLINE. |
NODE_RETENTION_TTL_DAYS |
1 |
Retention period in days for inactive/offline nodes before garbage collection. |
NODE_ALLOW_LOCALHOST_ENDPOINTS |
false |
Allow loopback/link-local endpoints for local inference nodes (useful in tests/dev). |
BOOTSTRAP_ADMIN_KEY |
empty | Implicit admin key for creating the first real key. Unset afterwards. |
USAGE_COLLECTOR_HOST |
empty | host:port of a gRPC usage sink. Empty disables reporting. |
USAGE_COLLECTOR_TLS |
false |
Reach the sink over TLS, e.g. light-tokenmeter on Cloud Run (host:443). |
USAGE_COLLECTOR_TOKEN |
empty | Bearer token sent to the sink on every call; for light-tokenmeter, a token with the write scope. Empty sends none. |
USAGE_PROJECT_ID |
ktms |
Default project name on usage events. |
SENTRY_DSN |
empty | Error reporting; empty disables it. |
SENTRY_ENVIRONMENT / SENTRY_RELEASE |
development / empty |
Sentry metadata. |
SENTRY_ENABLE_TRACING |
false |
Sentry performance tracing. |
SENTRY_DEBUG |
false |
Sentry SDK debug logging. |
SENTRY_WITH_BREADCRUMBS |
false |
Attach recent log lines to Sentry events as breadcrumbs. |
OTEL_SERVICE_NAME |
ktms |
Service name reported in OpenTelemetry traces. |
OTEL_EXPORTER_OTLP_ENDPOINT |
empty | OTLP gRPC endpoint (host:port) for distributed tracing; empty disables remote export. |
CACHE_ENABLED |
false |
Enable prompt and completion exact-match cache in Redis. |
CACHE_TTL |
1h |
Expiration time for cached responses. |
CACHE_MAX_BYTES |
1048576 |
Maximum size in bytes of a single cached response (1MB). Responses exceeding this are not cached. |
GUARDRAILS_ENABLED |
false |
Enable input/output security guardrails (PII masking and prompt injection detection). |
GUARDRAILS_PII_ACTION |
block |
Action on detected PII: block (reject with guardrail_violation) or redact (mask with placeholders and desanitize in output). |
GUARDRAILS_PROMPT_INJECTION_ACTION |
block |
Action on detected prompt injection: block or log. |
GUARDRAILS_LATENT_ENABLED |
false |
Experimental. With GUARDRAILS_ENABLED, also screen prompts in latent space against injection, jailbreak, prompt-leak and exfiltration centroids (see Latent guardrails). |
GUARDRAILS_LATENT_THRESHOLD |
0.70 |
Cosine similarity to a malicious centroid at which a prompt is flagged. Tuned for the local encoder; recalibrate for a JEPA encoder. |
GUARDRAILS_LATENT_ACTION |
block |
block (reject with guardrail_violation) or log. |
GUARDRAILS_LATENT_CATEGORIES_FILE |
empty | JSON array of extra categories (name, phrases, and/or precomputed centroids of {model, vector}), added to the built-in set. |
SEMANTIC_CACHE_ENABLED |
false |
Experimental. Serve enable_cache requests whose final user message is semantically close to a cached one. Needs Redis; uses CACHE_TTL and CACHE_MAX_BYTES. |
SEMANTIC_CACHE_SIMILARITY_THRESHOLD |
0.95 |
Minimum cosine similarity for a semantic hit. |
SEMANTIC_CACHE_MAX_ENTRIES |
256 |
Vectors kept (and scanned per lookup) for one exact context; older entries are trimmed. |
LATENT_ENCODER_URL |
empty | Experimental. OpenAI-compatible /v1/embeddings endpoint, e.g. a node serving a JEPA encoder. Empty uses the local hash encoder; failures fall back to it. |
LATENT_ENCODER_MODEL |
jepa-text |
Model name sent to the encoder; also namespaces cached vectors. |
LATENT_ENCODER_API_KEY |
empty | Bearer token for the encoder, if it needs one. |
LATENT_ENCODER_TIMEOUT |
50ms |
Bound for one encoder call before falling back to the local encoder. |
LATENT_LOCAL_ENCODER_DIMS |
512 |
Width of the local hash encoder's vectors. |
LATENT_ENCODER_CACHE_SIZE |
4096 |
In-memory LRU of recent embeddings, so guardrails and cache encode a prompt once. 0 disables it. |
All of this is done with the admin service (AdminService) using a key that has
the admin scope. Mutations are validated in a transaction together with the existing
config, so an invalid state can never become active. Prefer a browser or a terminal to
grpcurl? See the web console and the command-line tool.
Everything below can also live in a YAML file in your repository and be applied with the
ktms command, which plans the changes first, like terraform plan:
export KTMS_ADDR=localhost:9090 KTMS_ADMIN_KEY=ktms_... # admin; admin:read is enough for plan and export
ktms config export -o ktms.yaml # start from what is running
ktms config plan -f ktms.yaml # what would change; --detailed-exitcode exits 3 on drift
ktms config apply -f ktms.yaml # asks before applying; --yes for scripts# yaml-language-server: $schema=https://raw.githubusercontent.com/ai-process/ktms/main/schema/ktms-config.schema.json
api_version: ktms.ai-process.dev/v1alpha1
models:
- {id: gpt-4o-mini, vendor: openai, efforts: [EFFORT_LOW, EFFORT_MEDIUM, EFFORT_HIGH],
price_in_per_mtok: 0.15, price_out_per_mtok: 0.6, price_cache_read_per_mtok: 0.075}
rules:
- {name: default, priority: 1, use: [gpt-4o-mini]}
organizations:
- name: acme
teams: [{name: search, monthly_budget_usd: 100, allowed_models: [gpt-4o-mini]}]
api_keys:
- {name: billing, policy: {rpm_limit: 120, tpm_limit: 200000}}
- {name: search-bot, organization: acme, team: search}
vendor_keys:
- {api_key_name: billing, vendor: openai, plain_key_env: OPENAI_KEY_BILLING}
spend_limits:
- {subject_type: key, subject_id: billing, period: day, limit_usd: 5}
user_rate_limits:
- {user_id: "*", model_id: gpt-4o-mini, rpm: 30}- Models, rules, user rate limits and key policies are the Admin API's own messages
(
ModelSpec,RuleSpec, …) with the same field names; a model or rule withoutenabledis enabled. The JSON Schema isschema/ktms-config.schema.json(alsoktms config schema), a full exampleschema/example.yaml. - Only the sections in the file are managed; a missing section is left alone.
--prunedeletes what a managed section does not list, exceptadminandadmin:readkeys, which it only reports. - Secrets never go in the file. A vendor key is read from the variable named in
plain_key_envand compared with the stored one by fingerprint (VendorKeyInfo.fingerprint); a new client key is printed once, after apply. Exported vendor keys point atKTMS_VENDOR_KEY_<KEY>_<VENDOR>. - What the Admin API cannot change is refused before anything is applied: a key's scopes or team, or a revoked key named again. Mint a key under another name instead.
- A team's cap is its
monthly_budget_usd;spend_limitsholds key and user caps. - Steps run in dependency order (organizations, models, keys, vendor keys, rules, limits, then deletes). If one fails, the earlier ones stay applied; plan again to see what is left.
grpcurl -plaintext -H "authorization: Bearer $ADMIN_KEY" \
-d '{"name":"billing-service","scopes":["generate"]}' \
localhost:9090 ktms.v1.AdminService/MintAPIKeyKeys look like ktms_<hex> (keys minted before the rename start with llm_ and keep working). Only a hash is stored, and the plaintext is returned once.
Scopes: generate (data plane), admin (implies everything) and admin:read (the Admin API
reads: GetConfig, GetSpend, GetTeam, GetTeamSpend, ExplainRoute and every List*; nothing that changes
state). A read key sees every tenant, so it cannot be bound to an organization or team. GetConfig
never returns a vendor key itself, only its fingerprint.
ListAPIKeys and RevokeAPIKey do what they say.
A key can carry its own policy, on top of its team's and each model's rpm, at minting
("policy": {...}) or later with SetAPIKeyPolicy (it replaces the policy; the key's next call
uses it):
grpcurl -plaintext -H "authorization: Bearer $ADMIN_KEY" \
-d '{"name":"billing-service","policy":{"allowed_models":["gpt-4o-mini"],"rpm_limit":120,"tpm_limit":200000}}' \
localhost:9090 ktms.v1.AdminService/SetAPIKeyPolicyallowed_models: the models the key may call; empty allows any. A chain keeps only the models both the key's and its team's lists allow; an override outside them, or a chain with none left, isPERMISSION_DENIED(the message nameskeyorteam).rpm_limit: calls per minute over all models, a minute of them as burst.tpm_limit: tokens per minute over all models (GCRA weighted by tokens). A call books its input estimate when admitted and its real input + output when it ends, so an underestimate is paid for by the next calls; a call larger than a minute of tokens never fits and is refused withFAILED_PRECONDITION request_too_large(HTTP 400). A cache hit counts as a call, with no tokens.- Both rates count text generation, embeddings, judgments, speech and images (media calls by
their real tokens only), not batches. Refusals are
RESOURCE_EXHAUSTEDthrottled:key_rpm/throttled:key_tpmwithRetryInfo(HTTP 429 withRetry-After). Like every limit they need Redis and fail open without it.
A vendor credential is assigned to one API key and encrypted at rest. A client can
only use models of vendors it holds a credential for, and ListModels reflects that. The
exception is the local vendor: models served by registered nodes need no vendor key, so every
client can call them.
grpcurl -plaintext -H "authorization: Bearer $ADMIN_KEY" \
-d '{"api_key_name":"billing-service","vendor":"google","plain_key":"AIza..."}' \
localhost:9090 ktms.v1.AdminService/SetVendorKeyVendors: openai, anthropic, google, deepseek (OpenAI-compatible API, default endpoint
https://api.deepseek.com), groq (OpenAI-compatible API, default endpoint https://api.groq.com/openai/v1),
mistral (OpenAI-compatible API, default endpoint https://api.mistral.ai/v1), azure (Azure OpenAI Service;
endpoint is required, e.g. https://<resource>.openai.azure.com with optional ?api-version=...; model id maps
to deployment name; content filter violations automatically advance to the next fallback candidate), bedrock
(Amazon Bedrock Converse/ConverseStream API; key format is <access_key_id>:<secret_access_key>[:<region>[:<session_token>]],
defaulting region to us-east-1; model id maps to Bedrock foundation model or inference profile ARN/ID; optional endpoint
overrides the base URL; content filter and guardrail interventions automatically advance to fallback candidate), ollama (any
self-hosted OpenAI-compatible server such as Ollama or llama.cpp; the model's endpoint is required, e.g.
https://host/v1, and the vendor key is the server's own) and typesafe
(judgments only). Use DeleteVendorKey to remove one.
{ "model": {
"id": "gemini-2.5-flash", // referenced by rules and model_override; also sent to the vendor
"vendor": "google",
"endpoint": "", // optional base-URL override (Azure, self-hosted gateways…)
"efforts": ["EFFORT_LOW", "EFFORT_MEDIUM"],
"capabilities": ["google_search"],
"rpm": 600, // per client key per minute, 0 = unlimited
"price_in_per_mtok": 0.30, // for usage reporting
"price_out_per_mtok": 2.50,
"price_in_peak_per_mtok": 0, // for vendors with peak-hour pricing, else 0
"price_out_peak_per_mtok": 0,
"price_in_batch_per_mtok": 0, // batch pricing; 0 defaults to half normal price
"price_out_batch_per_mtok": 0,
"price_cache_read_per_mtok": 0.075, // cached input; 0 = vendor default (see below)
"price_cache_write_per_mtok": 0,
"daily_tokens_per_key": 50000000, // per API key per UTC day, 0 = unlimited
"daily_tokens_per_user": 500000, // per attributes["user_id"] (or svc:<key_name> fallback) per UTC day
"enabled": true } }(The // comments are for illustration; strip them from real JSON.) Send it to UpsertModel.
When price_in_batch_per_mtok and price_out_batch_per_mtok are 0, the proxy automatically defaults them to half the regular price (price_in_per_mtok / 2, price_out_per_mtok / 2).
Prompt caching. Input served from or written to the vendor's prompt cache is priced at
price_cache_read_per_mtok and price_cache_write_per_mtok (standard rates; peak and batch calls
scale them like input). At 0 the vendor default applies, chosen never to undercharge: for
anthropic, 0.1x input to read and 1.25x to write (the 5-minute cache); for bedrock, 0.25x
and 1.25x (Claude reads at 0.1x and Nova at 0.25x there); for every other vendor, the input
price. Set the read rate for OpenAI, Gemini and DeepSeek models to get their discount; the seeded
OpenAI and Gemini models carry theirs. KTMS reads the cached part from each vendor (cache_read_input_tokens and
cache_creation_input_tokens, prompt_tokens_details.cached_tokens, DeepSeek's
prompt_cache_hit_tokens, Gemini's cachedContentTokenCount, Bedrock's cacheRead/WriteInputTokens),
bills spend limits by it, sends it to light-tokenmeter as cache_read_tokens and
cache_write_tokens, and returns it in TokensUsage.cache_read / cache_write and, over HTTP,
usage.prompt_tokens_details.cached_tokens.
Capabilities a model may declare:
| Capability | Meaning |
|---|---|
batch |
Can serve asynchronous batch text generation requests (SubmitBatch). |
google_search |
Can serve requests with enable_google_search. |
no_thinking |
The model reasons by default and bills the reasoning as output (DeepSeek V4, Gemma 4 on Ollama). The proxy turns it off: thinking: {"type": "disabled"} for DeepSeek, reasoning_effort: "none" for Ollama, and for local models when the serving node runs Ollama (other engines get no switch). OpenAI-compatible vendors only. |
prompt_cache |
Anthropic only: puts cache_control breakpoints after the tools, the system prompt and, in a multi-turn conversation, the latest turn, so repeated prefixes are read at 0.1x input. A new prefix is written at 1.25x, so set it on models whose instructions or conversations repeat. |
vision |
Can process image parts in multimodal messages (ImagePart). |
audio_input |
Can process audio parts in multimodal messages (AudioPart). |
pdf_input |
Can process PDF document parts in multimodal messages (DocumentPart). |
tts |
Can serve SynthesizeSpeech. |
image |
Can serve GenerateImage. |
judge |
Can serve Judge (TypeSafe jev-latest, OpenAI gpt-6-luna). |
embedding_input |
A judge model that scores raw latent vectors in JudgeRequest.state_media (EmbeddingPart). |
grpcurl -plaintext -H "authorization: Bearer $ADMIN_KEY" -d '{"rules":[
{"name":"spanish-high", "priority":30, "effort":"EFFORT_HIGH",
"attributes":{"language":"es"}, "use":["gemini-2.5-pro","gpt-4o"], "enabled":true},
{"name":"low-default", "priority":20, "effort":"EFFORT_LOW",
"use":["deepseek-chat","gpt-4o-mini"], "enabled":true},
{"name":"anything-else", "priority":1,
"use":["gpt-4o-mini"], "enabled":true}
]}' localhost:9090 ktms.v1.AdminService/ReplaceRules- The highest priority matching rule wins; priorities and names are unique.
- A rule matches when its
effortequals the request's (unspecified = any effort) and every attribute in the rule equals the request's attribute. useis the ordered fallback chain, cheaper-first by convention. If the first model is throttled or its vendor is unavailable, the next is tried.weights(optional) enables weighted traffic routing (Canary or A/B testing). If specified,weightsmust contain exactly one positive integer per model inuse(e.g.[90, 10]). When an explicitcanary_keyoruser_idattribute is provided, distribution is deterministic and sticky per key (salted per rule name). If omitted or whencanary_mode: "random"is passed, routing uses weighted random distribution. Whenweightsis omitted, routing is strictly deterministic (100% onuse[0]).shadow_modelandshadow_percentage(optional) enable Shadow Routing (Request Mirroring): asynchronously duplicates a sample (0.0–100.0%) of incoming traffic to a candidate shadow model in the background without affecting caller latency or responses. Shadow calls are non-billable to end-users (recorded at 0 nano-USD cost in usage events), carryis_shadow: "true"andshadow: "true"metadata, do not count against caller spend limits or daily token budgets, respect model RPM limits and global concurrency semaphores (512 for shadow, 256 for evaluation) to protect proxy resources and upstream providers, and never trip circuit breakers for primary traffic.shadow_eval(optional boolean, requiresshadow_model): enables automated quality evaluation comparing primary vs shadow model outputs using an internalJudgemodel asynchronously in the background. The judge evaluates preference (primary,shadow, ortie) and quality scores without adding latency to the primary caller, respects judge model RPM and circuit breakers without consuming caller spend limits, and exports results via Prometheus metrics (ktms_shadow_judge_evaluations_totalandktms_shadow_judge_scores).- Validation requires a catch-all (no attribute predicates) for every effort, so no
request can ever fall through.
anything-elseabove covers all three at once. ReplaceRulesswaps the whole set atomically; there is no per-rule CRUD.
Inspect the live configuration with GetConfig.
Hard caps in USD across all models, kept in nano-USD (1 USD = 1 000 000 000) to avoid float
drift. Calls are priced from the model's price_*_per_mtok (peak and batch rates included):
# $50 a day for the "support-bot" key, and $20 a month for every end user by default.
grpcurl -plaintext -H "authorization: Bearer $ADMIN_KEY" -d '{"spend_limit":
{"subject_type":"key","subject_id":"support-bot","period":"day","limit_nanousd":50000000000}}' \
localhost:9090 ktms.v1.AdminService/SetSpendLimit
grpcurl -plaintext -H "authorization: Bearer $ADMIN_KEY" -d '{"spend_limit":
{"subject_type":"user","subject_id":"*","period":"month","limit_nanousd":20000000000}}' \
localhost:9090 ktms.v1.AdminService/SetSpendLimitsubject_typeiskey(API key name) oruser(attributes["user_id"], orsvc:<key_name>);subject_id*is the default for every subject of that type.periodis a UTCdayormonth.- Before each model in the chain is called, its worst case
(
estimated input × the dearer of the input and cache-write price + max tokens × output price, orSPEND_DEFAULT_OUTPUT_RESERVE_TOKENSwithout max tokens) is reserved atomically on every applicable counter by one Redis Lua script; when the call ends the reservation is settled to the actual cost. A call that would cross any cap is refused, so a cheaper model further down the chain may still be tried. - Batches are checked at submission (refused once a cap is reached) and charged at the batch rate when results arrive.
- Refusals are
RESOURCE_EXHAUSTED throttled:spend_key_daily,throttled:spend_key_monthly,throttled:spend_user_dailyorthrottled:spend_user_monthly, with aRetryInfountil the period resets (HTTP: 429insufficient_quotaplusRetry-After). ListSpendLimits,DeleteSpendLimitandGetConfigshow and lift caps. Every usage event carries itscost_nanousd, which light-tokenmeter sums per day, model, user or job.GetSpendshows where a subject stands now, from the counters that refuse calls: for a key ({"subject_type":"key","subject_id":"billing-service"}), user or team, one window per period withspent_nanousd,limit_nanousd,remaining_nanousdandresets_at. Spend is counted only under a cap (a*limit counts for every subject): an uncapped window saysmetered: false, and a cap set mid-period counts from then on. A new cap shows once the config reloads: right away on the replica that set it, withinROUTING_RELOAD_SECONDSon the others.
Group keys into organizations and teams to give each team its own models and monthly budget:
grpcurl -plaintext -H "authorization: Bearer $ADMIN_KEY" -d '{"name":"acme"}' \
localhost:9090 ktms.v1.AdminService/CreateOrganization
grpcurl -plaintext -H "authorization: Bearer $ADMIN_KEY" -d '{"org_id":"<org id>","name":"search",
"monthly_budget_usd":500,"allowed_models":["gpt-5.1","local-qwen3-32b"]}' \
localhost:9090 ktms.v1.AdminService/CreateTeam
grpcurl -plaintext -H "authorization: Bearer $ADMIN_KEY" -d '{"name":"search-service",
"scopes":["generate"],"org_id":"<org id>","team_id":"<team id>"}' \
localhost:9090 ktms.v1.AdminService/MintAPIKey- A team key may call only the team's
allowed_models(empty means any). A model override outside them isPERMISSION_DENIED; a chain is filtered to them, and a chain with none left isPERMISSION_DENIED. The proxy's own judge calls (auto effort, shadow evaluation) are not restricted.ListModels(and/v1/models) lists only the allowed models. UpdateTeamreplaces the team: send every field, since an omitted budget or model list lifts the cap or the restriction.monthly_budget_usdis a spend limit on all the team's keys together (UTC month), refused withRESOURCE_EXHAUSTED throttled:spend_team_monthly. It is set only throughCreateTeam/UpdateTeam(0lifts it);SetSpendLimitrefusessubject_type: "team".GetTeamSpendreads the enforced counter, which runs only while a budget is set.- Rules can match
org_idandteam_id. The proxy sets them from the key; callers cannot. - Deleting a team or organization detaches revoked keys and is
FAILED_PRECONDITIONwhile active keys remain: revoke or move them first.
These modalities carry no effort. The proxy routes them at EFFORT_LOW with a reserved
attributes["modality"] set to tts, image or judge, and only rules that name that
modality can match, over models that declare the matching capability:
{"name":"speech", "priority":100, "attributes":{"modality":"tts"},
"use":["gemini-2.5-flash-preview-tts"], "enabled":true}Without such a rule the call fails with FAILED_PRECONDITION no_capable_model.
New writes use the highest version in KTMS_ENCRYPTION_KEYS; every listed version can decrypt.
- Add a new version and deploy it to every replica:
1:<old>,2:<new>. Until all replicas run the new keyring, do not add or change vendor keys (or theSEED_*_KEYvalues withAUTO_SEED): a replica still on1:<old>cannot read a key written with version 2. It logs an error, countsktms_dependency_errors_total{dependency="keyring"}, and keeps the credential it already had for that client and vendor; a replica that starts in that state has none, so that client's requests skip that vendor's models (the next model in the chain serves, orno_vendor_keyif none can). The rest of the config keeps loading either way. A keyring that can read no vendor key at all is refused at startup: that is a wrong keyring, not a rotation. - Call
RotateEncryptiononce, on any replica. It re-encrypts every vendor key with version 2 (in one transaction that locks the rows it rewrites, so a key set at the same moment is not lost) and the auth tokens of registered nodes, and returns how many it changed; calling it again changes nothing. If a vendor key needs a version the keyring lacks, it fails withFAILED_PRECONDITIONnaming the key and changes nothing. - Remove version 1 and redeploy, but only when no database backup you may restore still holds keys encrypted with it (see Backups). Until then, leaving it listed is harmless. Cached responses encrypted with a removed version are treated as cache misses.
TestRotateEncryptionLetsTheOldKeyGo runs this sequence against Postgres in CI.
Lend a machine's GPU to the proxy: an Apple Silicon Mac, or Linux with an NVIDIA card (or just a CPU, slowly). Three steps; no tunnels or service files to write by hand, and no admin rights on macOS.
1. Get a key. Whoever runs the proxy mints one key per machine, bound to a node id, and gives
you the key (ktms_node_...), the node id and the proxy address. They also need a local model
whose id is exactly the Ollama tag the node serves (G and ADMIN as in the quick start;
drop -plaintext for a proxy behind TLS):
# admin, once per machine
$G "$ADMIN" -d '{"name":"anna-mac","node_id":"anna-mac"}' \
proxy.example.com:443 ktms.v1.AdminService/MintNodeKey # plain_key shown once
# admin, once per model; no_thinking turns Gemma's reasoning off
$G "$ADMIN" -d '{"model":{"id":"gemma4:26b-a4b-it-qat","vendor":"local",
"efforts":["EFFORT_LOW","EFFORT_MEDIUM"],"capabilities":["no_thinking"],
"queue_timeout_ms":2000,"enabled":true}}' \
proxy.example.com:443 ktms.v1.AdminService/UpsertModel2. Run one command on the machine (replace the three values), and paste the key when asked. The input is hidden and the key is stored in a file only you can read:
curl -fsSL https://raw.githubusercontent.com/ai-process/ktms/vX.Y.Z/scripts/install-node.sh | sh -s -- \
--proxy proxy.example.com --node-id anna-mac --model gemma4:26b-a4b-it-qatIt downloads Ollama, the model and the node agent, starts them as background services and keeps
the machine awake, and waits until the node has registered. Linux services start at boot. On a
Mac they are login items: after a reboot the node comes back when its user logs in, so turn on
automatic login for an unattended Mac. The model is large, so the first run takes a while. Add
--max-concurrency N for a machine that can serve more than one request at a time (default 1).
3. Check. The last lines say Node anna-mac is running and registered. The proxy admin
confirms with ListNodes that the node is HEALTHY and has the model.
To keep the machine free while its owner works, add --schedule FILE with load levels by local
time (format above, under the node agent). The installer checks the file and copies it to
~/.ktms-node/schedule; edit that file later and the agent picks it up, no re-run needed. The
last lines then show the level now and the next switch (Schedule: now 0%, next 23:00 100%), and
during a pause the installer does not load the model into memory.
Run the same command again to update; a re-run only restarts what changed, and a new agent that
fails to start is rolled back. Remove everything with the same command plus --uninstall
(downloaded models stay in ~/.ollama).
A machine that ran the node before the rename (llm-proxy-node, in ~/.llm-proxy-node) is
taken over by the same command. Its node key is reused when it was minted for the same
--node-id (for another id you are asked first), and its Ollama build instead of a download.
Once everything else is in place, right before the new Ollama starts, its services
(com.llm-proxy.* launch agents, llm-proxy-* user units) are stopped for good and their files
and the old directory moved to ~/.ktms-node.backup/<time>/; an install that fails before that
leaves the old node running. --dry-run shows this plan and changes nothing; --keep-legacy
leaves the old directory in place, though its services still go, or two nodes would share one id
and port. --uninstall retires old services too; backups survive it unless you add
--purge-backups.
The installer also refuses to run Ollama on an address another process already listens on
(another Ollama, say), since the model would be pulled into that one; it names the process. Stop
it, or set KTMS_OLLAMA_ADDR=127.0.0.1:PORT.
Run the same command with --doctor. It says in plain words what is wrong: Ollama not answering,
the model missing or not loaded, Ollama on the CPU (an Intel build running through Rosetta), the
proxy unreachable or --tls wrong, a key that is wrong or belongs to another node id, or a tunnel
that cannot come up (outbound port 7844 blocked). Logs are in ~/.ktms-node/logs/.
| Symptom | Cause and fix |
|---|---|
Killed: 9, or "Apple could not verify..." |
The agent came through a browser download, which macOS quarantines. Use the curl command above; curl and brew files are not quarantined. |
| Very slow, GPU idle | Intel Ollama through Rosetta, or no NVIDIA driver. The installer uses its own native Ollama and says when it ignores an Intel one. |
key belongs to node "x" |
The key is bound to one machine. Use the node id it was minted for, or ask for a new key. |
model "x" not permitted for this node key |
The key's allowed_models excludes a model on the machine. The installer reports only --model; for a hand-run agent pass --only-model. |
| Needs a new key | Re-run with --new-key. |
127.0.0.1:11435 is taken by … |
Another process holds the node's Ollama port. Stop it, or pick another address with KTMS_OLLAMA_ADDR. |
| Address changed after a restart | Expected: quick tunnel addresses change, and the node re-registers by itself. |
| Node is offline after a Mac reboot | macOS starts the services at login, not at boot. Log in, or enable automatic login (System Settings > Users & Groups). |
| The machine sleeps | The services keep it awake while they run, on a Mac only while plugged in. Closing a laptop lid still sleeps it. |
Installs on Linux need zstd (Ubuntu: sudo apt install zstd) and a normal login session
(systemd user services). The installer script's other options are in install-node.sh --help.
ktms-admin is a small web UI for everything in
the previous section: models, routing rules, client API keys, vendor credentials and a playground for
trying a prompt through your rules. It can also show usage and spend per project, user and model when
paired with light-tokenmeter. Sign-in is a password,
Google or any OpenID Connect provider, GitHub, or an access proxy (oauth2-proxy, Authelia, Cloudflare
Access, …), limited to an e-mail allowlist with wildcards (*@example.com).
# 1. a key for the console
grpcurl -plaintext -H "authorization: Bearer $BOOTSTRAP_ADMIN_KEY" \
-d '{"name":"console","scopes":["admin"]}' localhost:9090 ktms.v1.AdminService/MintAPIKey
# 2. a password hash for you (asks twice)
docker run --rm -it coyl/ktms-admin:latest hash-password you@example.com
# 3. run it (its README covers the other sign-in methods)
docker run -d -p 8081:8080 \
-e PUBLIC_URL=https://admin.example.com \
-e ADMIN_USERS='you@example.com:$argon2id$…' -e ALLOWED_EMAILS='you@example.com' \
-e SESSION_SECRET="$(openssl rand -base64 36)" \
-e KTMS_ADDR=ktms:9090 -e KTMS_ADMIN_KEY=ktms_… \
coyl/ktms-admin:latestktms covers the everyday admin jobs without grpcurl and the proto: the configuration as a
file, keys, spend and limits, nodes, a test call and health checks. Keys never go on the command
line, and none is printed except a new one, once.
# a release archive (darwin/linux, amd64/arm64, signed checksums), or:
go install github.com/ai-process/ktms/cmd/ktms@latest
docker run --rm -e KTMS_ADDR -e KTMS_ADMIN_KEY coyl/ktms-cli:latest models listA context names a Keystone and where its keys live, so a command needs no flags:
ktms context set prod --addr ktms.example.com:443 --tls --http https://ktms.example.com
ktms context store-key prod # reads the admin key without echo, into the keychain
ktms context store-key prod --client # a generate key for ktms chat
ktms context use prodContexts live in ~/.config/ktms/config.yaml (KTMS_CONFIG). A key source is env:VAR,
file:PATH or keychain (macOS Keychain, or the secret service through secret-tool on Linux);
store-key --file keeps the key in a 0600 file instead. Flags win over KTMS_ADDR, KTMS_TLS,
KTMS_HTTP, KTMS_ADMIN_KEY and KTMS_API_KEY, which win over the current context; a context named
with --context or KTMS_CONTEXT wins over them, so its keys never go elsewhere.
ktms health # /healthz, /readyz and the server's version
ktms models list; ktms rules list # -o json or -o yaml for scripts
ktms keys mint billing --team acme/search --rpm 120
echo "$OPENAI_KEY" | ktms vendor-keys set billing openai
ktms keys policy set billing --allowed-models gpt-4o-mini --tpm 200000
ktms chat "Say hi" # the answer, then rule, model and vendor
ktms limits set key billing day 5 # USD per UTC day; * sets the default
ktms spend get key billing # or user ID, team org/team
ktms route explain billing --effort high # where it would go, and why
ktms nodes list; ktms nodes drain gpu-1
ktms node-keys mint gpu-1 --node-id gpu-1 --allowed-cidrs 10.0.0.0/8
ktms keys revoke billing # asks first; --yes for scripts
ktms rotate-encryption
ktms completion zsh > "${fpath[1]}/_ktms" # also bash and fish
ktms mcp # tools for AI assistants, see belowktms route explain KEY --effort low [--attr k=v] [--placement P] [--model M] asks Keystone where
such a request would go: the rule, every model of its chain kept or dropped with the reason (no
vendor key, key or team policy, capability, placement, circuit open, vendor paused, no node), and
what a call would try. It runs Keystone's own routing (AdminService/ExplainRoute), calls no vendor
or judge and spends nothing; rate limits and budgets are not checked. With --auto and no effort it
shows the judge's fallback, medium.
admin:read is enough for everything that only reads (list, get, route explain,
config plan|export).
Exit codes follow the error contract: 0 done, 1 failed, 2 bad usage, 3 plan
has changes (--detailed-exitcode), 4 not authenticated or not allowed, 5 not found, 6 rejected as
invalid, 7 unavailable or throttled (retry later).
ktms mcp gives Claude Code, Claude Desktop, Cursor and other MCP clients Keystone's tools, so an
assistant reads and changes the configuration itself instead of writing grpcurl scripts. It uses
the same contexts and keys as the CLI; the key never reaches the assistant.
- Read tools (the only ones by default):
get_config(the live configuration as a ktms config file),list_models,list_rules,list_keys,list_vendor_keys,list_teams,list_spend_limits,get_spend,list_nodes,list_node_keys,explain_route,health. Anadmin:readkey is enough. - Write tools, offered only with
--allow-write:config_plan(a ktms config file in, the plan and aplan_idout; nothing changes) andconfig_apply(applies that plan once, in the session that planned it, and only if the live configuration has not changed since),mint_key(the key comes back once, marked for the user),revoke_key,set_key_policy,set_spend_limit,delete_spend_limit,drain_node. Vendor keys in a plan come from the MCP server's environment, never from the chat, and only from variables namedKTMS_VENDOR_KEY_*(the namesktms config exportwrites), so an assistant cannot pointplain_key_envat the admin key or another secret. Each tool carriesreadOnlyHint/destructiveHint. - Resources
ktms://config(the live configuration) andktms://schema(the file's JSON Schema); promptsconfigure_routing,integrate_service,local_node,production_review. - Names and values from Keystone are other people's text: the tools say so, and an assistant should treat them as data.
Claude Code (.mcp.json in a repository, or claude mcp add ktms -- ktms mcp), Claude Desktop
(claude_desktop_config.json) and Cursor (.cursor/mcp.json) take the same entry:
{
"mcpServers": {
"ktms": { "command": "ktms", "args": ["mcp"], "env": { "KTMS_CONTEXT": "prod" } }
}
}Add "--allow-write" to args to let the assistant plan and apply changes. Over the network,
ktms mcp --listen 127.0.0.1:8765 serves streamable HTTP at /mcp; every request needs
Authorization: Bearer <token> with the token from KTMS_MCP_TOKEN (16+ characters, e.g.
openssl rand -hex 32). It speaks plain HTTP: keep it on localhost or behind a TLS proxy.
{
"mcpServers": {
"ktms": { "type": "http", "url": "http://127.0.0.1:8765/mcp",
"headers": { "Authorization": "Bearer ${KTMS_MCP_TOKEN}" } }
}
}plugin/ is a Claude Code plugin with both MCP servers (ktms mcp, tokenmeter mcp,
read-only) and skills for the usual tasks: connecting a service, configuring routing, deploying,
adding a local node, migrating from LiteLLM, usage reports and a production review.
claude plugin marketplace add ai-process/ktms && claude plugin install ktms@ai-processThe skills are plain SKILL.md folders, so other agents that read Agent Skills can use them too;
see plugin/README.md. CI fails when a skill names a command, flag, MCP tool or
README section that does not exist.
Authenticate every call with authorization: Bearer ktms_… (legacy llm_… keys supported) gRPC metadata.
You can import the committed Go stubs directly from this repository:
go get github.com/ai-process/ktms/gen/ktms/v1Generate a client for your language from proto/ktms/v1 (below). Pre-built SDKs on the Buf
Schema Registry are planned and will be announced in the CHANGELOG when they are published.
Generate client stubs with buf, without installing local compiler plugins:
make proto # Go stubs in gen/
make proto-py # Python stubs in gen/python/
make proto-ts # TypeScript stubs in gen/ts/{
"effort": "EFFORT_MEDIUM",
"attributes": {"language": "es", "user_id": "u-42", "task": "summary"},
"base_system_instruction": "You are a concise assistant.",
"request_instruction": "Answer in one paragraph.",
"messages": [{"role": "MESSAGE_ROLE_USER", "text": "Summarize …"}],
"response_schema": {"type": "object",
"properties": {"summary": {"type": "string"}}, "required": ["summary"]},
"max_output_tokens": 800,
"action_id": "app.summarize"
}| Field | Notes |
|---|---|
effort |
Routing tier (EFFORT_LOW, EFFORT_MEDIUM, EFFORT_HIGH). |
attributes |
Opaque key/values matched by rules and copied to usage events. user_id charges the per-user daily budget (falls back to svc:<key_name> for service callers without user_id). usage_project overrides the usage project (see below). |
base_system_instruction, request_instruction |
System content never travels as a message. |
response_schema |
Unset = free text. Set = JSON output in that shape: types string, integer, number, boolean, object, array, plus required, nullable, enum, format and additional_properties. Vendors with structured output enforce it; for the others the proxy checks the reply (types, required keys, enum values, extra keys when additional_properties is false), retries once and then falls back. A format a vendor rejects is passed to the model in the description instead. |
tools, tool_choice |
Function/tool declarations with JSON Schema parameters. tool_choice specifies auto, required, none, or a specific named function. In non-streaming calls, arguments of strict tools are checked against their parameters, since not every vendor enforces strict; a call outside them is retried once and then falls back. Streamed tool calls reach the caller as they arrive and are not checked. |
messages |
Chat history. Supports MESSAGE_ROLE_USER, MESSAGE_ROLE_ASSISTANT, and MESSAGE_ROLE_TOOL (with tool_call_id). User messages support multimodal parts (ContentPart: text, image, audio, document). Assistant messages can carry tool_calls. |
max_output_tokens |
Ceiling, 0 = none. Output tokens are the expensive ones; set it. |
enable_google_search |
Only Google models declaring google_search can serve it. |
action_id |
Free-form label for usage attribution. |
model_override |
Exact model id; bypasses rules but not throttles. |
placement |
Compute locality preference: nodes_first, nodes_only, cloud_first, cloud_only. |
auto_route |
Enables smart model auto-selection; when effort is unpinned, evaluates task complexity via Judge. |
effort_specified |
Signals caller explicitly pinned effort (skips dynamic Judge evaluation). |
The response carries choices, token usage, resolved_model, resolved_vendor,
matched_rule (empty on override), structured details (Choice objects with text, tool calls, and finish reasons),
cached status, and routing metadata: route_mode, placement, judge_score, and judge_model.
Server-streaming RPC rpc GenerateTextStream(GenerateTextStreamRequest) returns (stream GenerateTextChunk).
- Takes a
GenerateTextStreamRequestcontaining the standardGenerateTextRequest. - Streams incremental
GenerateTextChunkitems containingdelta(token text),tool_call_chunks,resolved_model,resolved_vendor,matched_rule,cached,route_mode,placement,judge_score,judge_model, and finalfinish_reasonwith tokenusage. - Fallback guarantee: If upstream models fail before the first chunk is sent, the router falls back to the next model in the rule's chain. Once the first chunk is transmitted to the caller, the stream is locked to that model to guarantee integrity.
- Usage events and daily token budgets are settled on stream completion.
For offline or bulk workflows (such as page generation, backfills, or data pipelines) that prioritize cost over latency, batch generation processes thousands of items asynchronously through vendor Batch APIs (e.g., Google Gemini Batch API) at a 50% discount compared to interactive calls. Jobs typically finish within 24 hours (vendor timeout at 48 hours).
- Route resolution: Resolved once upon submission. The proxy selects the first model in the rule's chain that has the
batchcapability, a vendor key for the client (not needed forlocalmodels), and available daily budget. Once submitted, there is no vendor fallback; per-item outcomes are returned to the client. - Privacy: Prompts are streamed/submitted to the vendor and never persisted in the proxy database.
- Throttling & Accounting: RPM limits do not apply to batches. Daily token budgets are checked at submission (rejecting if already exhausted) and tokens are deducted when final item results arrive. Results are retained for
RESULTS_RETENTION_DAYS(default 7 days; accepts integer days or Go duration strings).
Takes a list of items (custom_id unique within the batch, and standard GenerateTextRequest), batch-level effort and attributes, and an optional client_batch_id for idempotency (resubmitting with the same client batch ID returns the existing batch):
grpcurl -plaintext -H "authorization: Bearer ktms_<key>" -d '{
"effort": "EFFORT_LOW",
"attributes": {"task": "catalog_enrichment"},
"client_batch_id": "job-2026-10-07-001",
"items": [
{
"custom_id": "item-1",
"request": {
"messages": [{"role": "MESSAGE_ROLE_USER", "text": "Extract attributes from product 1"}],
"response_schema": {"type": "object", "properties": {"color": {"type": "string"}}, "required": ["color"]}
}
},
{
"custom_id": "item-2",
"request": {
"messages": [{"role": "MESSAGE_ROLE_USER", "text": "Extract attributes from product 2"}],
"response_schema": {"type": "object", "properties": {"color": {"type": "string"}}, "required": ["color"]}
}
}
]
}' localhost:9090 ktms.v1.GatewayService/SubmitBatchReturns a Batch with status BATCH_STATE_PENDING (or BATCH_STATE_RUNNING), total item count, model, and creation timestamp.
Polls batch state (PENDING, RUNNING, SUCCEEDED, FAILED, CANCELLED, EXPIRED) and progress counts (total, done, failed):
grpcurl -plaintext -H "authorization: Bearer ktms_<key>" -d '{
"batch_id": "0199c0a0-6228-7e10-91b5-685d996cbfa1"
}' localhost:9090 ktms.v1.GatewayService/GetBatchPages through completed items with page_token (and next_page_token). Each item contains its custom_id and either a successful response (GenerateTextResponse with choices, usage, resolved_model, resolved_vendor, matched_rule) or an error (BatchItemError with gRPC code, message, and error reason):
grpcurl -plaintext -H "authorization: Bearer ktms_<key>" -d '{
"batch_id": "0199c0a0-6228-7e10-91b5-685d996cbfa1",
"page_size": 100
}' localhost:9090 ktms.v1.GatewayService/ListBatchResultsRequests cancellation of an unfinished batch job at the vendor. The batch remains RUNNING until vendor confirmation is polled: already completed items preserve their results, while remaining unfinished items fail with reason batch_cancelled.
grpcurl -plaintext -H "authorization: Bearer ktms_<key>" -d '{
"batch_id": "0199c0a0-6228-7e10-91b5-685d996cbfa1"
}' localhost:9090 ktms.v1.GatewayService/CancelBatchSynthesizeSpeech takes text (read verbatim) and an optional language and returns OGG/Opus
audio. GenerateImage takes a prompt and returns image bytes with a mime_type.
Both accept attributes, action_id and model_override, and need the rules described
above.
Judge answers typed questions about one state instead of writing text, so you get
calibrated probabilities to branch on rather than prose to parse. All questions in a call
are answered against the same state in parallel.
| Type | Question | Answer |
|---|---|---|
JUDGE_TYPE_NOUL |
Does this hold? | probability of yes (noul, 0–1) |
JUDGE_TYPE_CHOICE |
Which one? | choice plus full probabilities and confidence |
JUDGE_TYPE_SCORE |
How much? | probability-weighted position on ordered levels |
{
"state_json": "{\"message\":\"My card was charged twice!\"}",
"questions": {
"angry": {"type": "JUDGE_TYPE_NOUL", "instructions": "Is the author upset?"},
"topic": {"type": "JUDGE_TYPE_CHOICE", "instructions": "What is this about?",
"options": {"billing": "payments and charges", "bug": "software defects"}}
}
}Multimodal state goes in state_media (text, image, audio, embedding parts), alongside
or instead of state_json. Each media kind narrows the chain to judge models that also declare
vision, audio_input or embedding_input; TypeSafe takes JSON state only. Image URLs must be
https and are forwarded, not fetched. Guardrails inspect text parts only. Limits: 32 parts,
16 MiB of media, 16384 embedding dimensions.
Supported decision vendors are TypeSafe (jev-latest, billing input only) and OpenAI (gpt-6-luna, billing input and output tokens); both write no text and declare the judge capability.
Vector representations of text. Vector space isolation is strictly enforced: auto model routing and cross-model fallbacks are disallowed to prevent corrupting downstream vector indices. Models must declare the embedding capability.
grpcurl -plaintext -H "authorization: Bearer ktms_<key>" -d '{
"model": "text-embedding-3-small",
"inputs": ["Hello world", "Search document"]
}' localhost:9090 ktms.v1.GatewayService/GetEmbeddingsReturns the models the calling key can use (those whose vendor it holds a credential for), with efforts, capabilities, rpm and prices.
The data plane is also available as HTTP/JSON in the OpenAI wire format, so existing
OpenAI SDKs, LangChain, curl and similar tools work by changing the base URL and key.
It runs on HTTP_ADDR (default :8080) and shares everything with gRPC: the same
ktms_… API keys (scope generate), vendor credentials, rules, fallbacks, throttling and
usage reporting. Set HTTP_API_ENABLED=false to turn it off.
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8080/v1", api_key="ktms_<your key>")
reply = client.chat.completions.create(
model="auto", # let the rules choose
messages=[{"role": "user", "content": "Say hi in three words."}],
)
print(reply.choices[0].message.content, reply.model) # `model` is the one that answeredcurl -s localhost:8080/v1/chat/completions \
-H "Authorization: Bearer ktms_<your key>" -H "Content-Type: application/json" \
-d '{"model":"auto","messages":[{"role":"user","content":"Say hi in three words."}]}'| Endpoint | Maps to | Notes |
|---|---|---|
POST /v1/chat/completions |
GenerateText / GenerateTextStream |
Text, multimodal parts (image_url, input_audio, file/document), structured output, tools, and SSE streaming. |
POST /v1/embeddings |
GetEmbeddings |
Vector embeddings with float or base64 encoding format and optional dimensionality. |
POST /v1/audio/speech |
SynthesizeSpeech |
Returns OGG/Opus audio. |
POST /v1/images/generations |
GenerateImage |
Returns b64_json. |
POST /v1/decisions |
Judge |
OpenAI Decisions API format (predicate, choice, score). input is JSON state, or an array of input_text/text, input_image/image_url, input_audio and input_embedding parts for multimodal state. |
POST /v1/decisions/rollouts |
EvaluateRollouts |
Batch trajectory and rollout candidate evaluation for agentic workflows with candidate ranking. |
POST /v1/judge |
Judge |
Protobuf JSON of JudgeRequest / JudgeResponse. |
GET /v1/models, GET /v1/models/{id} |
ListModels |
Only models the key holds a vendor credential for. |
Authentication is Authorization: Bearer ktms_… (legacy llm_… keys are also supported). Response headers provide full observability:
X-KTMS-VendorandX-KTMS-Rule: vendor and matching rule that served the request.X-KTMS-Placement: infrastructure placement (nodes_first,nodes_only,cloud_first,cloud_only).X-KTMS-Route-Mode: routing mechanism (direct,override,auto-judge,auto-default,auto-effort).X-KTMS-Judge-ScoreandX-KTMS-Judge-Model: evaluation confidence and judge model ID when judge evaluates task complexity.
Note
Backwards Compatibility: All legacy X-LLM-Proxy-* response headers are dual-emitted alongside X-KTMS-* with standard RFC 8594 Deprecation: true, Sunset, and Warning headers to provide seamless zero-downtime migration for existing clients.
The model parameter supports smart auto-routing and infrastructure placement:
model |
Behaviour |
|---|---|
auto (or empty) |
Smart auto-selection with default nodes_first placement. Evaluates prompt complexity via Judge (or uses reasoning_effort if provided). |
auto:low, auto:medium, auto:high |
Pinned effort level with nodes_first placement. |
auto:nodes-first, auto:nodes-only, auto:cloud-first, auto:cloud-only |
Auto-routing with explicit compute locality policy. |
auto:<placement>:<effort> (e.g. auto:nodes-only:high) |
Combined placement policy and pinned effort level. |
| anything else | Exact registry model ID (model_override): bypasses rules but not throttles. |
nodes_first(default): Prefers registered self-hosted compute nodes (VendorLocal,VendorOllama), falling back to cloud providers if unavailable or busy.nodes_only(aliases:nodes,private,self_hosted): Restricts routing strictly to self-hosted nodes. Fails if no capable node is available.cloud_first: Prefers managed cloud providers, falling back to registered nodes.cloud_only: Excludes self-hosted nodes completely.
Placement precedence:
X-KTMS-Placement(or legacyX-LLM-Proxy-Placement) HTTP header- Request body
"placement": "..." - Model suffix (e.g.
model: "auto:nodes-only") - Default:
nodes_first
When model: "auto" is requested without an explicit effort level, the proxy evaluates prompt complexity using the configured Judge model (such as jev-latest or OpenAI Decisions) with a 250ms deadline. If the judge is not configured or times out, it safely falls back to standard medium effort without dropping the request.
Effort precedence:
- Pinned effort in model string (e.g.
auto:low) reasoning_effortrequest field (low,medium,high) — skips judge evaluation- Dynamic Judge evaluation (fails open to
medium)
Routing attributes (what rules match on, and what usage events carry) come from OpenAI's own
fields: metadata is passed through as attributes, user becomes attributes["user_id"]
(unless metadata.user_id is set), and metadata.action_id is used as the usage action label.
{"model": "auto:nodes-first", "user": "u-42", "metadata": {"subject": "french", "action_id": "app.quiz"},
"messages": [{"role": "user", "content": "Translate: good morning"}]}| Request field | Support |
|---|---|
messages |
system/developer messages become the system instruction, user/assistant the conversation. Content is a string or text parts. |
max_tokens, max_completion_tokens |
Output ceiling (max_output_tokens). Set one: an unbounded answer is the costliest failure. |
response_format |
json_schema is enforced: type (incl. number and ["string","null"]), properties, items, required, enum, format and boolean additionalProperties are carried. Enums on non-string types, and formats a vendor rejects, reach the model as a hint in the description. Other keywords (minimum, pattern, oneOf, ...) are dropped. json_object asks for JSON in the prompt. |
stream |
Real incremental SSE streaming backed by gRPC server streaming. Once the first chunk is emitted, stream is locked to the selected model; stream_options.include_usage is honoured on completion. |
reasoning_effort, user, metadata |
Routing, as above. |
enable_google_search |
Extension: let a Google model ground the answer in search. |
n |
Must be 1. |
tools, tool_choice |
Supported. Function declarations with JSON Schema parameters. tool_choice supports "auto", "none", "required", or specific function names. |
role: "tool" |
Supported. Tool responses with tool_call_id. |
| Image/audio content parts | Rejected with 400. |
temperature, top_p, stop, seed, logprobs, penalties… |
Accepted and ignored. |
The response is a standard chat.completion with finish_reason ("stop", "tool_calls", "length", or "content_filter") and token usage.
POST /v1/audio/speech takes model, input and an optional language extension (pronunciation
hint, e.g. "Serbian"); voice, response_format and speed are ignored. The body of the response
is OGG/Opus whatever format was requested; check the Content-Type.
POST /v1/images/generations takes model and prompt; n must be 1 and response_format: "url"
is rejected (the image is returned as data[0].b64_json).
Both need rules for the tts / image modality.
Errors use OpenAI's shape, {"error": {"message", "type", "param", "code"}}, and the proxy's
error contract shows up in code:
| HTTP | type |
code examples |
|---|---|---|
| 400 | invalid_request_error |
output_truncated, no_capable_model, no_vendor_key, not_configured; validation messages |
| 401 / 403 | authentication_error / permission_error |
missing or invalid key, missing scope |
| 429 | rate_limit_error |
throttled:rpm, throttled:key_rpm, throttled:key_tpm, throttled:budget_key, throttled:budget_user (with Retry-After) |
| 429 | insufficient_quota |
insufficient_quota; the message names the cap, e.g. throttled:spend_key_daily. Retry-After is the seconds until the period resets (UTC midnight or month start). |
| 501 | server_error |
ffmpeg_missing (Gemini speech synthesis invoked without ffmpeg on PATH or in container) |
| 502 | server_error |
vendor_error |
| 503 | server_error |
vendors_unavailable, auth_unavailable, storage_unavailable, throttled:spend_unavailable (with SPEND_LIMITS_FAIL_MODE=closed): retry with backoff |
OpenAI SDKs retry 429 and 5xx automatically; for throttled:* and vendor_error you may want to
disable that (max_retries=0) and handle them yourself.
Effort is the only routing field the proxy understands; everything else about a request's purpose travels in free-form attributes, so the proxy knows nothing about your product.
Throttling & Wait Buffer:
- GCRA Rate Limiting: Enforces requests-per-minute (
rpm) via the Generic Cell Rate Algorithm in Redis, with configurable burst capacity (rpm_burst, defaulting torpm - 1to preserve classic fixed-window behavior,< 0to disable burst). Key limits and user limits are evaluated atomically. - User & Model Overrides: Configure fine-grained limits per user and model with
SetUserRateLimit(supports*wildcards for user-wide or model-wide defaults). - Spend Limits: Hard USD caps per API key and per end user (UTC day or month), reserved atomically before each call and settled to the actual cost; see Spend limits.
- Daily Token Budgets: Limits tokens per API key (
daily_tokens_per_key) and per end-user (daily_tokens_per_user, UTC day). A configurable grace margin (budget_grace_margin, default 5%,< 0to strictly disable) absorbs requests that exceed the budget on their last call without refusing them mid-stream. - Inference Pool Wait Buffer: For local node pools, requests waiting for free slots use jittered backoff polling up to
queue_timeout_ms. Interactive requests (non-service callers) are automatically capped to 250ms to safeguard Time-To-First-Token (TTFT). Little's Law predictive admission evaluates the queue depth (KTMS's own leases plus other clients' requests waiting in the engine: what the heartbeat saw waiting beyond KTMS's leases on that node; node selection weighs the same load) and estimated duration, immediately falling back to the next model in the chain if expected wait time exceeds the timeout. A slot lease is renewed for as long as its call runs, unary or streaming, so a long generation never frees a slot the node is still using. - A throttled model is skipped in the fallback chain; if none of the chain is available the caller gets
RESOURCE_EXHAUSTED.
Upstream Circuit Breaker:
- Monitors consecutive upstream failures (5xx server errors, network connection drops, connect/TLS handshake timeouts, and upstream timeouts via
UPSTREAM_REQUEST_TIMEOUT) per(vendor, model). Caller client aborts and overall client deadline cancellations do not trip the circuit breaker, but vendor stalls beyondUPSTREAM_REQUEST_TIMEOUT(total duration for unary/media, per-chunk idle watchdog for streaming) reliably trip it and trigger immediate fallback to the next candidate model. - When consecutive failures reach
CIRCUIT_BREAKER_FAIL_THRESHOLD(default 5), the circuit trips toOPEN. - Requests skip degraded upstream models immediately, without a network round trip.
- After
CIRCUIT_BREAKER_COOLDOWN(default 30s), transitions toHALF_OPENand admits a single probe request. Success restoresCLOSED; failure re-opens the circuit. - If all models in the chain have open circuits, the caller receives
UNAVAILABLE vendors_unavailable.
Vendor back-off (Retry-After):
- Vendor SDKs run with their built-in retries off, so one router attempt is exactly one vendor call. The router retries a
429,5xxor transport failure (connection reset, EOF on a reused connection) once on the same model before falling back, for text, media and streaming calls (a stream only until its first chunk). - The vendor's wait is read from
Retry-Afterorretry-after-ms(OpenAI-compatible APIs, Azure, Anthropic) and fromgoogle.rpc.RetryInfo(Gemini). Bedrock throttling carries no wait, so it is retried and falls back as before. - A wait of up to 1s is honoured in place before the same-model retry. A longer one on a
429or503pauses the model for that client's vendor credential, up toVENDOR_COOLDOWN_MAX, and the chain moves on. Clients sharing one vendor key share the pause; other credentials are unaffected. With pauses off, the ordinary same-model retry applies. Shadow traffic respects pauses but never starts one. - Paused models are skipped without a network round trip (
ktms_fallbacks_total{reason="cooldown"}; pauses started are counted inktms_vendor_cooldowns_total). Pauses live in each replica's memory, like circuit breakers, so each replica learns of a pause from its own first rejected call. - If every model in the chain is paused, the caller receives
UNAVAILABLE vendors_unavailablewith a gRPCRetryInfofor the soonest pause to end; the OpenAI-compatible HTTP API returns it as aRetry-Afterheader. When any model failed for another reason, no wait is promised: it may serve the next request. - Local node models are never paused: the node pool has its own admission control.
Usage reporting. When USAGE_COLLECTOR_HOST is set, every call emits an event (vendor,
model, tokens, duration, status, action_id, rule name and your attributes as metadata) to a
gRPC service implementing usage-proto/usage/v1/usage.proto.
Events are attributed to the project named by the usage_project attribute, falling back to the
API key's name. Any service implementing RecordUsageEvent(s) works as a sink. When
light-tokenmeter has authentication on, give KTMS a token with the write scope in
USAGE_COLLECTOR_TOKEN; a refused token is logged as an error and events are held and retried.
Both project text into a representation space with the encoder at LATENT_ENCODER_URL (a JEPA
or any embedding model behind an OpenAI-compatible /v1/embeddings), falling back to a local,
lexical hash encoder that folds case, homoglyphs, zero-width and spaced-out letters.
- Semantic cache. After an exact-cache miss, a request with
enable_cachecompares its final user message (orrequest_instructionwhen there are no messages) with earlier ones whose everything else — key, config version, system prompt, history, tools, effort, attributes and every number in the message — matches exactly. A hit atSEMANTIC_CACHE_SIMILARITY_THRESHOLDreturnscached: trueand usage metadatacache_match=semanticwithcache_similarity. Prompts containing PII and responses with tool calls are never cached; with guardrails on, the message is screened before a near match is served. Vectors and responses are encrypted at rest. - Latent guardrails. Each prompt is scored against category centroids; the nearest one at
GUARDRAILS_LATENT_THRESHOLDblocks (or logs) withguardrail_violation: latent <category> intent detected. An unreachable encoder fails open after the regex checks have run. - Overhead. The local encoder runs in-process; measure screening and semantic lookup cost on
your hardware with
go test -bench . ./internal/latent ./internal/guardrails ./internal/cache. A remote encoder adds its own latency, bounded byLATENT_ENCODER_TIMEOUT.
| gRPC status | Reason | What to do |
|---|---|---|
NOT_FOUND |
– | Unknown batch ID. |
INVALID_ARGUMENT |
guardrail_violation |
Blocked due to detected prompt injection, PII, or a latent-space threat match. Clean input before retrying. |
INVALID_ARGUMENT |
bad_request: <message> |
Empty batch, duplicate custom_id, too many items (> BATCH_MAX_ITEMS), or invalid parameters. |
UNAVAILABLE |
vendors_unavailable |
The whole chain failed: retry with backoff. When vendors paused every model in the chain with Retry-After, details carry RetryInfo (HTTP: Retry-After header): wait that long first. |
UNAVAILABLE |
auth_unavailable, storage_unavailable |
Postgres is unreachable for a key check (past AUTH_STALE_IF_ERROR) or a batch or admin write. Retry with backoff. |
RESOURCE_EXHAUSTED |
throttled:rpm, throttled:budget_key, throttled:budget_user |
Back off; don't hot-retry. Daily budget exhausted before batch submission. |
RESOURCE_EXHAUSTED |
throttled:key_rpm, throttled:key_tpm |
The key's own rpm_limit or tpm_limit. Details carry RetryInfo: wait that long. |
FAILED_PRECONDITION |
request_too_large |
The call needs more tokens than a minute of the key's tpm_limit; it never fits. Shrink it or raise the limit. |
RESOURCE_EXHAUSTED |
throttled:spend_key_daily, throttled:spend_key_monthly, throttled:spend_user_daily, throttled:spend_user_monthly, throttled:spend_team_monthly |
Spend limit reached. Details carry ErrorInfo{reason: "insufficient_quota"} and RetryInfo until the period resets. |
UNAVAILABLE |
throttled:spend_unavailable |
Spend counters unreachable with SPEND_LIMITS_FAIL_MODE=closed. Retry with backoff. |
PERMISSION_DENIED |
model "<id>" not allowed for <key|team>, no models in chain allowed for <key|team>, model access denied by key or team policy (batch) |
The key's or its team's allowed_models excludes that model. |
FAILED_PRECONDITION |
output_truncated |
Raise max_output_tokens; don't retry as-is. |
FAILED_PRECONDITION |
no_capable_model |
No model in the matched chain can serve this request, modality, or batch capability. |
FAILED_PRECONDITION |
no_vendor_key |
The client key holds no credential for the chain's vendors. local models never cause this. |
FAILED_PRECONDITION |
not_configured |
No models/rules configured yet. |
FAILED_PRECONDITION |
batch not configured |
Batch processing is not enabled/configured on the proxy (no batch store). |
UNIMPLEMENTED |
ffmpeg_missing |
Gemini speech synthesis invoked without ffmpeg on PATH or running slim container. Install ffmpeg or switch to :audio image. |
INTERNAL |
vendor_error |
Terminal vendor failure. |
UNAUTHENTICATED / PERMISSION_DENIED |
– | Missing/invalid key, or key lacks the scope. |
When to retry: UNAVAILABLE with backoff, and RESOURCE_EXHAUSTED after the wait it carries
(RetryInfo, HTTP Retry-After) or with backoff when it carries none. Every other code fails the
same way until the request or the configuration changes.
Items returned in ListBatchResults that failed have an error containing a gRPC code, a message, and one of these reasons:
| Reason | When | What to do |
|---|---|---|
output_truncated |
Item reached max_output_tokens or candidate finish limit. |
Re-submit item with higher max_output_tokens. |
vendor_error |
Vendor error occurred during processing or structured schema validation failed. | Retry synchronously or resubmit in another batch. |
batch_expired |
Vendor batch expired (after 48 h) before the item finished. | Re-submit the item. |
batch_cancelled |
Batch was cancelled before the item completed. | Re-submit if still needed. |
From 1.0, KTMS follows semantic versioning: within 1.x nothing in the public API below changes incompatibly. Breaking changes wait for 2.0, except the one removal announced before 1.0 and listed under Deprecated. Minor releases add features; patch releases fix bugs.
| Surface | What stays compatible in 1.x |
|---|---|
gRPC ktms.v1 (GatewayService, AdminService, NodeRegistryService) |
No RPC, field, enum value or message is removed or renamed, and no field number or type changes. New RPCs and fields may appear. buf breaking checks every pull request. |
HTTP /v1 (OpenAI-compatible) and X-KTMS-* headers |
Paths, request and response fields, and header names, except the experimental ones listed below. New fields and endpoints may appear. |
| Probes and metrics endpoints | /healthz and /readyz on HTTP_ADDR, /metrics and /healthz on METRICS_ADDR. |
| Error contract | The status codes and reason strings in Error contract and the HTTP Errors table keep their meaning. New reasons may appear, so treat unknown ones like their status code. |
| Environment variables | Names, defaults and meaning of every variable in Configuration reference. |
| Database | Any 1.x release upgrades the database of an earlier one by itself (AUTO_MIGRATE, forward-only). No manual SQL steps. The tables are not an API: read and write through the RPCs. |
| Prometheus metrics | Names and labels of ktms_* metrics, except the experimental ones listed below. |
Usage events (usage.v1) |
Field numbers and meaning, checked by buf breaking like ktms.v1. |
| Node agent | A 1.x agent works with any 1.x gateway, in both directions. |
Helm chart (oci://ghcr.io/ai-process/charts/ktms) |
Value names and meaning; new values may appear. The chart version is the release version. |
Not public, and free to change in any release: Redis key layout, log message text, the
internal/ Go packages, CLI output of the tools in cmd/ other than their flags, and benchmark
numbers.
These work and are supported, but may change in a minor release. The change is then listed in the
CHANGELOG. In the proto they carry an Experimental: comment.
-
The configuration file format (
api_version: ktms.ai-process.dev/v1alpha1) and thektmscommand, until the format reachesv1. -
The judge's effort choice: when a request is auto-routed without a pinned effort (
model: "auto"orauto:<placement>over HTTP withoutreasoning_effort;auto_routewitheffort_specifiedfalse in gRPC), a judge picks the effort. Its choice, theauto-judgeandauto-defaultvalues ofroute_mode,judge_score,judge_modeland theX-KTMS-Judge-ScoreandX-KTMS-Judge-Modelheaders are experimental. Auto-routing with a pinned effort (auto:<effort>,auto:<placement>:<effort>, route modeauto-effort), placement policies,model_overrideand theX-KTMS-Route-Modeheader are stable. -
Shadow traffic and its evaluation: the
shadow_model,shadow_percentageandshadow_evalrule fields, and thektms_shadow_*metrics. -
Latent features: the semantic cache (
SEMANTIC_CACHE_*), latent guardrails (GUARDRAILS_LATENT_*), the encoder settings (LATENT_*), and embeddings in judge state (EmbeddingPart,input_embeddingover HTTP). -
JudgeStreamandEvaluateRollouts, andPOST /v1/decisions/rolloutsover HTTP.
| KTMS | ktms-admin | light-tokenmeter | Notes |
|---|---|---|---|
| 1.x | 1.x | 1.x | With light-tokenmeter's authentication on, set USAGE_COLLECTOR_TOKEN to a write token. |
| 1.x | 0.4.x | 0.1.x | Works without authentication; no compatibility promise before 1.0. |
ktms.v1 and usage.v1 stay compatible within 1.x, so any 1.x release of each works with any
1.x release of the others.
- Within 1.x, upgrade in one step from any earlier 1.x release: migrations run forward at
startup and cover every release in between.
scripts/upgrade-test.shupgrades from the previous release and from the oldest release of the same major on every pull request and before each release. - Rolling upgrades work within 1.x: migrations only add, so replicas of the previous release keep serving on the migrated database while new ones start; the upgrade test runs the old release again after the new one migrated. A change that cannot be additive waits for 2.0. Until the roll finishes, old replicas do not apply what the new release added (for example a new kind of limit), so finish rolls promptly.
- Downgrades are not supported. Migrations have no down step; to go back, restore the database backup taken before the upgrade.
- From 0.x, go through 0.14.x to 1.0, then to later 1.x releases: 0.x made no compatibility promise, and only the 0.14 → 1.0 step is tested (by the 1.0 release).
- Read the CHANGELOG before upgrading: it lists every change in Added, Changed, Deprecated, Removed, Fixed and Security.
Deprecated names keep working through 1.x, log a warning (or count in
ktms_legacy_headers_requests_total), and are removed in 2.0. One exception is announced in
advance:
| Deprecated | Use instead | Removed |
|---|---|---|
LLMPROXY_ENCRYPTION_KEYS |
KTMS_ENCRYPTION_KEYS |
2.0 |
X-LLM-Proxy-* request and response headers |
X-KTMS-* |
On their Sunset date, 2027-03-01, as already announced in the responses |
- Health:
GET :8080/healthz(process up, with itsversion) andGET :8080/readyz(database reachable). - Logs: structured JSON via zerolog. Vendor keys, client keys and the keyring are never logged.
- Config changes are picked up by all replicas within
ROUTING_RELOAD_SECONDS; the replica that handled the admin call applies them immediately. - Upgrades: migrations run at startup under a database advisory lock, so rolling deploys are safe. Roll forward by deploying a newer image tag; see Upgrading.
- Graceful shutdown: on SIGTERM in-flight calls get
SHUTDOWN_TIMEOUTto finish.
- Back up Postgres. It holds everything durable: client keys (as hashes), vendor keys
(encrypted), models, rules, limits, tenants, node keys and batches. Use
pg_dumpor your provider's snapshots, and test a restore. - Back up the keyring separately.
KTMS_ENCRYPTION_KEYSis not in the database. Keep it in a secret manager, not next to the dumps: a dump plus the keyring is every vendor key in plaintext. If the keyring is lost, every stored vendor key is lost, and each has to be set again withSetVendorKey; client keys, config and limits survive. - Keep old key versions as long as the backups that need them. A dump taken before a rotation can only be read with the versions it was written with.
- Restore: stop every replica, restore the dump, flush the gateway's Redis keys (the
REDIS_KEY_PREFIXnamespace), then start a release at least as new as the one that took the dump. The flush matters: cached responses are keyed by the config version, which the restore rolls back, so later changes would reuse version numbers whose cache entries belong to another config. An older release could run on the restored schema but would ignore what was added after it, so do not restore with one. - Redis is not backed up. It holds rate, token and spend counters, the response and semantic caches, and the node pool: registrations (with encrypted node auth tokens), heartbeats, slot leases, the node circuit breaker and drain flags. Losing it resets the current day's and month's counters (spend limits then allow up to one more full cap in the current period), empties the caches, drops every node from routing until its next heartbeat re-registers it, and clears drain flags, so drained nodes take traffic again.
Failed calls to Redis, Postgres and the usage collector are counted in ktms_dependency_errors_total{dependency,operation}; alert on it. Nothing below needs a restart: the Redis pool, the Postgres pool and the collector connection reconnect by themselves. After 3 consecutive Redis transport failures, Redis calls fail fast without network IO for 2s, then are tried again, so a hung Redis costs a request about one REDIS_TIMEOUT, not one per command.
| Dependency | What breaks | Behaviour | Why |
|---|---|---|---|
| Redis | RPM limits, daily token budgets | Fail open: calls proceed unmetered. | A metering outage should not become a service outage. |
| Redis | Spend limits | SPEND_LIMITS_FAIL_MODE: open (default) proceeds unmetered; closed refuses calls under a limit with UNAVAILABLE throttled:spend_unavailable. |
Money: the operator chooses. |
| Redis | Settling reservations | The counter keeps the reserved estimate instead of the actual usage until its window resets: a key can hit its limit early, or late when a call used more than was reserved. | Reported, not repaired: the window reset clears it. |
| Redis | Response and semantic cache | Fail open: treated as a miss; storing is skipped. | A cache is an optimisation. |
| Redis | Local node pool | The pool's state is unknown, so pool models are skipped and the chain falls back (e.g. to cloud models); the vendor circuit breaker is not tripped. A node whose state cannot be read is left out, never assumed idle. Node registration returns UNAVAILABLE (the agent retries); the sweep never evicts a node on a read error. |
Never overbook a node; keep serving from the rest of the chain. |
| Usage collector (light-tokenmeter) | Usage events | Queued in memory (10,000 events) and sent in batches; while the collector is down they are held and retried, then delivered exactly once (event IDs deduplicate). Events the collector refuses are dropped one by one, not with their batch. Drops are counted in ktms_usage_events_dropped_total{reason} (queue_full, rejected, shutdown, closed). Shutdown flushes for up to 5s. Requests never wait on it. |
Accounting must not slow down or lose traffic. |
| Postgres | API key and node key checks | Keys that verified within AUTH_STALE_IF_ERROR (5m) keep working; any other key is refused with UNAVAILABLE auth_unavailable. Each check is bounded by DB_TIMEOUT (3s). Routing, limits and vendor keys serve from the last snapshot, so traffic of known keys continues. /readyz stays 200 with "status":"degraded" until AUTH_STALE_IF_ERROR has passed since Postgres last answered, then reports 503 (every cached key has expired); with AUTH_STALE_IF_ERROR=0 it reports 503 at once and every call is refused. |
Availability for known keys. Revocation still applies on the next request whenever Postgres answers; only one made just before an outage can lag, by at most the window. |
| Postgres | Routing config reload | The last good snapshot keeps serving; each poll or rebuild is bounded to 30s and retried every ROUTING_RELOAD_SECONDS. |
|
| Postgres | Admin RPCs, batches | UNAVAILABLE storage_unavailable (cause logged). A batch whose idempotency check fails is refused rather than submitted, so it is never paid for twice. |
|
| Postgres | Startup | Migrations and the first config load must succeed (the first load within 30s); otherwise the process exits and the orchestrator restarts it. | Nothing to serve without a first snapshot. |
Prometheus metrics are served on METRICS_ADDR (:9091) at /metrics; the Helm chart can add a
ServiceMonitor. KTMS's own metrics start with ktms_; the standard Go runtime and process
metrics are served next to them.
| Metric | Labels | What it tells you |
|---|---|---|
ktms_requests_total |
vendor, model, effort, status (success, error, canceled) |
Upstream calls and their outcome; canceled is a caller that went away. |
ktms_request_duration_seconds |
vendor, model, effort |
Upstream call duration, histogram; for a stream, the whole stream. |
ktms_time_to_first_token_seconds |
vendor, model |
Time to the first streamed chunk, histogram. |
ktms_tokens_total |
vendor, model, type (input, output) |
Tokens consumed. |
ktms_cache_tokens_total |
vendor, model, type (read, write) |
The part of input read from or written to the prompt cache. |
ktms_active_requests |
vendor, model |
Upstream calls in flight. |
ktms_throttled_total |
reason (rpm, key_rpm, key_tpm, budget_key, budget_user, spend_key_daily, spend_key_monthly, spend_user_daily, spend_user_monthly, spend_team_monthly, spend_unavailable) |
Calls refused by a limit; spend_unavailable is Redis down with SPEND_LIMITS_FAIL_MODE=closed. |
ktms_fallbacks_total |
from_model, to_model, reason |
Moves down a fallback chain. |
ktms_circuit_breaker_state |
vendor, model |
0 closed, 1 open, 2 half-open. |
ktms_circuit_breaker_transitions_total, ktms_circuit_breaker_rejections_total |
vendor, model (+ from, to) |
Breaker flaps and the calls it refused. |
ktms_vendor_cooldowns_total |
vendor, model |
Pauses started from a vendor Retry-After. |
ktms_dependency_errors_total |
dependency (redis, postgres, usage_collector, keyring), operation |
Failed calls to a dependency; keyring is a stored vendor key that no keyring version decrypts. |
ktms_usage_events_dropped_total |
reason (queue_full, rejected, shutdown, closed) |
Usage events that never reached light-tokenmeter. |
ktms_batch_jobs_total, ktms_batch_items_total |
vendor, state |
Batch progress. |
ktms_batch_poll_duration_seconds |
vendor |
Vendor batch polling, histogram. |
ktms_shadow_requests_total, ktms_shadow_judge_evaluations_total, ktms_shadow_judge_scores |
Shadow traffic and its judged quality. | |
ktms_legacy_headers_requests_total |
header |
Clients still sending X-LLM-Proxy-* headers. |
The throttled and usage_events_dropped reasons are exported at 0 from startup, so increase()
catches the first event after a restart. Suggested alerts (tune thresholds to your traffic; job
is your scrape job, with the chart's ServiceMonitor the Service name, e.g. prod-ktms):
groups:
- name: ktms
rules:
- alert: KTMSUpstreamErrorRate
expr: sum by (vendor, model) (rate(ktms_requests_total{status="error"}[5m]))
/ sum by (vendor, model) (rate(ktms_requests_total{status!="canceled"}[5m])) > 0.05
for: 10m
- alert: KTMSTimeToFirstTokenP99
expr: histogram_quantile(0.99, sum by (le, vendor, model) (rate(ktms_time_to_first_token_seconds_bucket[5m]))) > 10
for: 15m
- alert: KTMSCircuitOpen
expr: max by (vendor, model) (ktms_circuit_breaker_state) == 1
for: 5m
- alert: KTMSDependencyErrors
expr: sum by (dependency) (rate(ktms_dependency_errors_total[5m])) > 0
for: 5m
- alert: KTMSUsageEventsDropped
expr: sum by (reason) (increase(ktms_usage_events_dropped_total[15m])) > 0
- alert: KTMSSpendLimitReached
expr: sum by (reason) (increase(ktms_throttled_total{reason=~"spend_(key|user|team)_.+"}[15m])) > 0
- alert: KTMSDown
expr: up{job="ktms"} == 0
for: 2mKTMSDependencyErrors and KTMSUsageEventsDropped matter most: Redis and the collector fail
open, so the calls keep succeeding and only these metrics show that limits or accounting stopped.
A long stream is not slow, so latency alerts use the time to the first token. Also probe
/readyz: it turns degraded, then 503, during a Postgres outage.
From the benchmark: about 0.65 s of CPU per 1,000 proxied requests, under 50 MiB of memory at 4,000 RPS, no drift at a steady load. Upstream time does not cost CPU, so size by request rate, not by how long vendors take to answer.
- CPU: plan 1 vCPU per 1,000 RPS, which leaves headroom for TLS, logging and streaming.
Guardrails, the semantic cache and the latent encoder add CPU per call; measure them with
go test -bench(see Semantic cache and latent guardrails). - Memory: memory follows the bodies in flight, not RPS. The chart's default (256 MiB request,
512 MiB limit) covers thousands of concurrent short calls; raise it for large prompts, images
or audio (
GRPC_MAX_RECV_MSG_SIZEis 16 MiB per message). - Replicas: run at least two behind a load balancer; they share nothing but Postgres and Redis. Each replica keeps its own circuit breakers and vendor pauses.
- Postgres: replicas ×
DB_MAX_CONNS(25 by default) must fit undermax_connectionswith room for migrations and the other services. Routing serves from a snapshot reloaded everyROUTING_RELOAD_SECONDS, so the hot path costs one indexed key lookup per call; size Postgres for that query rate. - Redis: a few commands per call when limits, caches or the node pool are on, each bounded by
REDIS_TIMEOUT. Keep it in the same zone as the gateway; one small instance serves every replica.
make test # go test -race ./...
make build # binary in bin/
make proto # regenerate gen/ after editing proto/ (needs buf)
make proto-py # generate Python client stubs in gen/python/
make proto-ts # generate TypeScript client stubs in gen/ts/
make clients # regenerate the usage-sink stubs from usage-proto/
docker compose up --build # local stack with Postgres + Redisusage-proto/usage/v1/usage.proto is a byte-for-byte copy of light-tokenmeter's, which owns it.
CI fails when the copy or the stubs generated from it drift; copy the file over and run
make clients.
Layout: cmd/server (entrypoint), internal/router (rule matching, fallback, admission),
internal/registry (validated config snapshots), internal/llm (vendor-neutral chat model and
adapters), internal/throttle (Redis limits), internal/grpcapi (RPC handlers),
internal/crypto (keyring), migrations/ (embedded SQL).
Pull requests are welcome; read CONTRIBUTING.md first.
- Move the
[Unreleased]entries in CHANGELOG.md under the new version and date, and pick the version by API stability: a fix is a patch, a feature is a minor, a break of the public API is a major. - Push a
vX.Y.Ztag (thevmatters: it makes the tag a valid Go module version, sogo install github.com/ai-process/ktms/cmd/node-agent@vX.Y.Zworks):
git tag v1.0.0 && git push origin v1.0.0The release workflow first runs .github/workflows/compat.yml: buf breaking against the
previous published release in the tag's history, and the upgrade test from it (and from the
oldest release of the same major). If it fails, nothing is published; nor if
deploy/helm/ktms/Chart.yaml does not carry the tag's version as version and appVersion
(bump both in the release commit). Then GoReleaser publishes the binaries, after it the Docker
images, and last the Helm chart. An image on Docker Hub marks a complete release, which is how
the next release finds its predecessor (re-run a failed image job before tagging again); a
failed chart job can be re-run at any time and keeps a version already pushed. A release that breaks the proto on purpose (an Experimental change, or a new major) sets
the repository variable ALLOW_BREAKING_TAG to its tag, which skips the proto checks; the
upgrade test still runs. Only the newest stable tag moves latest, slim and audio, so a
backport does not.
- GitHub Release (GoReleaser,
.github/workflows/release.yml):ktms-nodeandktms-serverarchives for darwin and linux, amd64 and arm64, pluschecksums.txt. - Docker Hub (
.github/workflows/docker-publish.yml): multi-archcoyl/ktmstagged as:<tag>,:<tag>-slim,:latest,:slim(default lightweight Alpine without ffmpeg), and:<tag>-audio,:audio(with ffmpeg), pluscoyl/ktms-node:<tag>(node agent). The repository needs the secretsDOCKER_USERNAMEandDOCKER_PASSWORD(an access token). - Helm chart (
.github/workflows/release.yml):oci://ghcr.io/ai-process/charts/ktms, version andappVersionfrom the tag, signed with cosign. It needs no secret; after the first release thecharts/ktmspackage has to be made public once in the organization's package settings. CI installs the chart from an OCI registry on kind (scripts/helm-kind.sh). - Buf Schema Registry (
.github/workflows/buf.yml): each push tomainthat passes lint and the breaking check publishesktms.v1tobuf.build/ai-process/ktms, which generates the SDKs (or run the workflow by hand onmain). It needs the secretBUF_TOKEN(a buf.build token of theai-processorganization); without it thebsr-pushjob fails, so an unpublished SDK does not go unnoticed. - Optional, off until their secrets exist: macOS signing and notarization
(
MACOS_SIGN_P12,MACOS_SIGN_PASSWORD,MACOS_NOTARY_ISSUER_ID,MACOS_NOTARY_KEY_ID,MACOS_NOTARY_KEY) and a Homebrew formula pushed toai-process/homebrew-tap(HOMEBREW_TAP_TOKEN; the tap repository must exist). Without them the release is unsigned. Binaries fetched withcurlor installed bybrewcarry no quarantine flag and run; only browser downloads trigger Gatekeeper.
Dry run: go run github.com/goreleaser/goreleaser/v2@latest release --snapshot --clean --skip=publish.