First Tenant & Key

A complete walkthrough of creating your first tenant, minting an API key, and verifying the setup through all three datastores.

This walkthrough creates a tenant, mints a key, makes a test call, and explains exactly what happened in each datastore.

Prerequisites: The Docker Compose stack is running (docker compose ... up -d). See Quick Start if you haven't started it yet.


1. Create a tenant

A tenant is the fairshare identity — it holds the weight, quota, and group assignment.

TOKEN=dev-admin-token

curl -s -X POST http://localhost:9090/api/v1/tenants \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "my-team",
    "weight": 200,
    "tokens_per_minute": 500000
  }' | jq .

Expected response:

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "name": "my-team",
  "fairshare_group": "default",
  "weight": 200,
  "tokens_per_minute": 500000,
  "max_in_flight": null,
  "created_at": "2026-01-15T10:00:00Z",
  "updated_at": "2026-01-15T10:00:00Z"
}

Save the id:

TID=$(curl -s -X POST http://localhost:9090/api/v1/tenants \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"my-team","weight":200,"tokens_per_minute":500000}' \
  | jq -r .id)
echo $TID

What happened: The Management API wrote a row to tenants in Postgres, then synced a ResolvedKey skeleton to Redis and published an invalidation to all pods.


2. Mint an API key

SECRET=$(curl -s -X POST "http://localhost:9090/api/v1/tenants/$TID/keys" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"prod"}' \
  | jq -r .secret)
echo $SECRET

The secret looks like sk_a1b2c3d4e5f6... (48 hex characters after the prefix). Store it now — only the SHA-256 hash is kept in Postgres. If you lose it, delete the key and create a new one.

What happened: The Management API:

  1. Generated a cryptographically random secret.
  2. Stored sha256(secret) as key_hash in api_keys (Postgres).
  3. Stored the ResolvedKey JSON (tenant details + weight + quota) at obleth:key:{hash} in Redis.
  4. Published the hash to obleth:invalidate so all pods' moka caches are primed.

3. Verify the key in Redis

KEY_HASH=$(printf '%s' "$SECRET" | sha256sum | awk '{print $1}')
docker exec -it obleth-redis-1 redis-cli GET "obleth:key:$KEY_HASH"

You should see the JSON ResolvedKey stored there. The data plane will cache this in moka for 5 minutes after first lookup.


4. Make a test call

curl -s http://localhost/v1/chat/completions \
  -H "Authorization: Bearer $SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "mock-model",
    "messages": [{"role": "user", "content": "Hello from my-team"}],
    "max_tokens": 32
  }' | jq .

Expected: a valid OpenAI-style response from the mock backend.

What happened on the data plane:

  1. Auth: bearer() extracted the token, hash_api_key() hashed it, moka lookup → Redis lookup returned the ResolvedKey.
  2. Token estimation: ~5 input tokens for the short message.
  3. Fairshare: capacity available, admitted immediately (Fast).
  4. Budget: reserved ~37 tokens (5 input + 32 max_output) from the Redis token bucket.
  5. Upstream: proxied to the mock backend, streamed the response.
  6. Reconcile: actual output tokens read from upstream usage field, bucket adjusted.
  7. Telemetry: one UsageRecord sent to the async channel → ClickHouse.

5. Check usage in ClickHouse

docker exec -it obleth-clickhouse-1 clickhouse-client \
  --user obleth --password obleth \
  --query "SELECT tenant_id, model, admission, input_tokens, output_tokens, total_ms \
           FROM obleth.usage ORDER BY ts_ms DESC LIMIT 5"

You should see your test request with admission='fast'.


6. Check the audit log

curl -s http://localhost:9090/api/v1/audit \
  -H "Authorization: Bearer $TOKEN" | jq .

You'll see the create_tenant and create_api_key entries with timestamps.


Recap

StepWhere
Tenant + key creationPostgres (tenants, api_keys tables)
Key hot-cacheRedis (obleth:key:{hash})
Token budgetRedis (obleth:budget:{tenant_id})
Usage recordClickHouse (usage)
Audit trailPostgres (audit_log)

Next steps