-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathllmleaf.example.toml
More file actions
520 lines (479 loc) · 30.5 KB
/
Copy pathllmleaf.example.toml
File metadata and controls
520 lines (479 loc) · 30.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
# Example llmleaf config — the immutable base of the core (SOUL.md principle 6).
# Everything here is config-only operable. The control plane is OUTBOUND: llmleaf PULLS identity +
# verdicts and PUSHES usage to the endpoints named in the optional [control] section at the bottom.
# There is no inbound mutation surface.
#
# Run with: llmleaf llmleaf.example.toml
[server]
listen = "0.0.0.0:8080"
# Guards the read-only admin GETs (/admin/routes, /admin/health, /admin/keys). `env:VAR` reads the
# environment. Omit entirely to disable those read-only surfaces (the proxy still serves consumers).
# Held in memory only as a SHA-256 digest — the plaintext token is never kept around.
admin_token = "env:LLMLEAF_ADMIN_TOKEN"
# Signs the opaque batch ids handed out by POST /v1/batches. When set, a batch id embeds its creating
# key and an HMAC tag, and the poll/cancel/results surfaces (which authenticate identity only — the
# id names no model to gate on) refuse every key but the owner: a learned or guessed batch id is
# then useless to another tenant. Omit ⇒ legacy unsigned ids: batches are protected only by the
# unguessability of the upstream id (a startup warning is logged).
#
# Generate a fresh secret (any high-entropy string works — it is an HMAC key, never a password):
# openssl rand -base64 32 # or: openssl rand -hex 32
# head -c 32 /dev/urandom | base64
# Then distribute the SAME value to every node behind the load balancer (via env:, never committed
# to a shared file) so an id minted on one node verifies on all of them. Rotate by deploying the new
# secret everywhere — note ids minted under the old secret stop verifying (in-flight batches 404),
# so rotate only when no unfinished batches matter.
batch_id_secret = "env:LLMLEAF_BATCH_ID_SECRET"
# Set true to include full canonical payloads in lifecycle events (for archival/replay bolt-ons via
# the [control.usage] push sink below).
include_payloads = false
# Seconds a node skips a provider after it fails a request (node-local HA — principle 9).
fallback_cooldown_secs = 15
# Upper bound (ms) on how long the hot path may WAIT for rate-limit capacity when every target on a
# chain is over its limit (see [providers.limits] below). The engine first falls through the fallback
# chain; only if all targets are saturated does it sleep until the soonest frees up, capped here, then
# serve — returning 429 (with Retry-After) only if this elapses. `0` ⇒ never wait (fall through, then
# 429 immediately). This is latency you opt into knowingly (principle 1: the hot path is sacred).
rate_limit_max_wait_ms = 5000
# Maximum inbound request body size (bytes). Multimodal chat requests inline images as base64 `data:`
# URIs, which easily exceed the framework's 2 MiB default and would otherwise 413 ("Payload Too Large").
# Default is a generous 32 MiB; raise for larger image/audio payloads, lower to cap per-request memory.
max_body_bytes = 33554432
# --- Providers (back-end extensions). `kind` is resolved by the binary's factory, never the core. ---
#
# Known kinds:
# distinct dialects : anthropic (claude), gemini (google), vertex (vertex-ai, google-vertex), cohere,
# ollama (native `/api/*`), lmstudio (lm-studio; native `/api/v0/*`)
# OpenAI-wire family: openai, meta (meta-ai, meta-model-api, muse), openrouter, requesty, groq,
# deepseek, xai (grok), mistral, together,
# fireworks, perplexity, cerebras, zai (z.ai, glm),
# bedrock (amazon-bedrock, aws-bedrock), huggingface (hugging-face, hf),
# deepinfra (deep-infra), cloudflare (workers-ai, cloudflare-workers-ai),
# oci (oracle, oci-generative-ai), databricks (databricks-model-serving),
# nvidia-nim (nim),
# moonshot (kimi, kimi-k2; tool JSON schemas are rewritten into the upstream's
# restricted "moonshot flavored" subset — see the moonshot provider docs),
# minimax (minimax-coding, minimax-token-plan), azure-openai (azure)
# subscription plans: zai-coding (glm-coding; the GLM Coding Plan — same wire as zai on its
# dedicated /api/coding/paas/v4 base, plan-bound keys),
# kimi-coding (kimi-for-coding; the "Kimi for Coding" subscription at
# api.kimi.com/coding/v1 — keys from kimi.com/code/console; available model IDs
# are membership-tier dependent). MiniMax's Token Plan needs no separate
# endpoint — the minimax-* aliases exist so configs read clearly; only the
# key differs (plan-bound `sk-cp…`).
# local/testing : echo
# The `endpoint` is optional for most kinds — each has a sensible default (the OpenAI-wire vendors'
# public hosts; localhost for the ollama/lmstudio local runtimes).
#
# Upstream chat streaming policy (optional, per provider instance):
# settings.upstream_streaming = "always" # default: always use SSE/incremental upstream
# settings.upstream_streaming = "when_requested" # only when the consumer requests streaming
# settings.upstream_streaming = "never" # always use the provider's collected response
# Keep the default where possible: some providers disable thinking or impose much smaller output limits
# on collected calls. `when_requested` avoids the extra SSE parsing/collection work for non-streaming
# consumers; `never` is useful for providers or gateways whose streaming path is broken or undesirable.
# Combine this key with any other keys already present in the provider's inline `settings` table.
[[providers]]
name = "openai-main"
kind = "openai"
credential = "env:OPENAI_API_KEY"
settings = { organization = "org-xxxx" }
# Example benchmark-oriented opt-out:
# settings = { organization = "org-xxxx", upstream_streaming = "when_requested" }
# Chat API: `kind = "openai"` speaks the Responses API (`POST /responses`) by default, transparently
# falling back to `/chat/completions` per-request when the request carries chat-only fields (`stop`,
# `response_format`, `logit_bias`, …). A brand opted into Responses whose upstream turns out not to
# serve `POST /responses` (answers 404/405) also transparently retries that request over
# `/chat/completions`. Pin it back with `settings = { chat_api = "chat_completions" }`;
# opt any OTHER OpenAI-wire brand (xai, groq, fireworks, …) into Responses with
# `settings = { chat_api = "responses" }` — for `kind = "openrouter"` that targets OpenRouter's own
# beta Responses endpoint (see the openrouter provider below), and for `kind = "groq"` Groq's beta
# `POST /openai/v1/responses` (open unsigned reasoning replay; Groq documents `include`,
# `previous_response_id`, and `store: true` as unsupported, so llmleaf's stateless mapping omits
# `include` there). Not supported for `azure-openai` (its Responses URL differs).
# settings = { organization = "org-xxxx", chat_api = "chat_completions" }
# Select a different API only for chat requests containing inline audio, while keeping ordinary chat
# on Responses. Omit this override for the backward-compatible automatic selection:
# settings = { organization = "org-xxxx", chat_api = "responses", chat_with_audio_input_api = "chat_completions" }
# OpenAI and OpenRouter accept inline `input_audio` parts by default. For another OpenAI-wire
# endpoint that supports the same Chat Completions shape, opt in explicitly:
# settings = { audio_input = true }
# Node-local rate limits (optional) — flow control TOWARD this provider so a node never pushes it past
# its published limits and eats 429s. This is the node-local HA family (principles 8 and 9), NOT per-key
# usage accounting (that is the pulled [control.limits] verdicts, principle 5). Each dimension is
# independently optional (omit ⇒ unlimited). MULTI-NODE: every node enforces its OWN slice with no
# cross-node coordination, so divide a cluster-wide cap by the node count (or treat it as approximate) —
# exactly like fallback_cooldown_secs. Over-limit behavior is governed by rate_limit_max_wait_ms above.
[providers.limits] # provider-global: applies to EVERY request to this instance
requests_per_min = 10000 # token bucket on request count (burst = this value)
tokens_per_min = 2000000 # token bucket on provider-reported tokens (debited as usage arrives)
max_concurrent = 200 # max simultaneous in-flight requests (a semaphore)
# Per-model overrides, keyed by the UPSTREAM model id (what this provider sees — a route target's
# `model`, not the consumer's logical model). A request must pass BOTH the global limits above AND the
# matching per-model entry; the stricter binds. A model absent here is governed by the global limit alone.
[providers.model_limits."gpt-4o"]
requests_per_min = 5000
tokens_per_min = 800000
max_concurrent = 100
[providers.model_limits."gpt-4o-mini"]
requests_per_min = 8000 # only an RPM cap; tokens/min and concurrency inherit no per-model limit
[[providers]]
name = "anthropic-main"
kind = "anthropic"
credential = "env:ANTHROPIC_API_KEY"
# Prompt caching (opt-in). Stamps Anthropic `cache_control` breakpoints onto the stable prefix
# (system prompt + tools) and the running conversation, so repeat requests read the prefix from
# Anthropic's cache instead of re-billing it. Omit the setting (the default) to leave requests
# untouched; un-comment one of these to enable it:
# settings = { prompt_cache = true } # enable, 5-minute window (Anthropic's default)
# settings = { prompt_cache = "1h" } # enable, extended 1-hour window
[[providers]]
name = "gemini-main"
kind = "gemini"
credential = "env:GEMINI_API_KEY"
# Meta Model API (`api.meta.ai`), which superseded the retired hosted Llama API. The Responses API is
# the default chat surface; set `settings = { chat_api = "chat_completions" }` to use Meta's compatible
# Chat Completions endpoint instead. Chat, vision input, reasoning controls, tool calling, SSE streaming,
# and authenticated `GET /v1/models` discovery are supported. Meta may attach client-specific model
# `metadata`; llmleaf preserves it verbatim when the catalog returns it.
[[providers]]
name = "meta-model-api"
kind = "meta"
credential = "env:MODEL_API_KEY"
# Google Vertex AI — the enterprise Gemini surface (same generateContent dialect as `gemini`, but on
# *-aiplatform.googleapis.com under your GCP project). Differences from `gemini`:
# - auth is an OAuth2 *bearer* (cloud-platform scope), NOT an API key. Supply a short-lived access
# token as the credential and keep it fresh out-of-band (e.g. `gcloud auth print-access-token`,
# an ADC sidecar, or Workload Identity). The proxy sends it verbatim and mints nothing.
# - `project` and `location` are REQUIRED in settings; the host is derived from the location
# (regional `https://{location}-aiplatform.googleapis.com`, or the bare global host when
# location = "global"). Override `endpoint` only to point at a proxy/private host.
# Modalities: chat (generateContent), embeddings (`:predict`), and model listing (publisher catalog).
# Batch is not offered (Vertex batch is a GCS/BigQuery job API — left unsupported).
[[providers]]
name = "vertex-main"
kind = "vertex"
credential = "env:GOOGLE_VERTEX_TOKEN"
settings = { project = "my-gcp-project", location = "us-central1" }
# settings = { project = "my-gcp-project", location = "global" } # global endpoint
# For embeddings, `task_type` (e.g. RETRIEVAL_DOCUMENT) and `auto_truncate` are operator-configurable:
# settings = { project = "my-gcp-project", location = "us-central1", task_type = "RETRIEVAL_DOCUMENT" }
[[providers]]
name = "groq"
kind = "groq"
credential = "env:GROQ_API_KEY"
[[providers]]
name = "openrouter"
kind = "openrouter"
credential = "env:OPENROUTER_API_KEY"
# `prefix` exposes this provider's whole catalog without a route per model: any request for
# `<prefix>/<model>` with no explicit route goes here, upstream model = the part after `or/`.
# e.g. `or/openai/gpt-4o` -> openrouter with model `openai/gpt-4o`. Explicit routes always win;
# among providers the longest matching prefix wins.
prefix = "or"
# OpenRouter attribution headers (optional):
settings = { http_referer = "https://your.app", x_title = "your-app" }
# OpenRouter also serves its own Responses API (`POST /api/v1/responses`, currently beta) with signed
# open-reasoning replay across its providers; the default stays `/chat/completions` while it is beta.
# Opt in per instance:
# settings = { http_referer = "https://your.app", x_title = "your-app", chat_api = "responses" }
# Voice catalog for `GET /v1/audio/voices` (text-to-speech). llmleaf serves voices automatically where a
# real catalog exists: a *documented* set for OpenAI speech models (incl. via this prefix, e.g.
# `or/openai/gpt-4o-mini-tts`, and Azure OpenAI), and a *live* fetch for a `mistral` provider (its
# `GET /v1/audio/voices` endpoint — so a direct `[[providers]] kind = "mistral"` needs no declaration).
# For an upstream with neither (e.g. a Mistral TTS model proxied through OpenRouter, which does not
# expose that endpoint), DECLARE the voices keyed by the upstream model id; the endpoint returns them
# verbatim (never a guess).
# Switch `settings` above from the inline form to a table to add this, e.g.:
# [providers.settings]
# http_referer = "https://your.app"
# x_title = "your-app"
# [providers.settings.voices]
# "mistralai/voxtral-mini-tts-2603" = ["aurora", "basalt"]
# "some/other-tts" = [{ id = "nia", name = "Nia", languages = ["en", "sw"] }]
# Requesty — another OpenAI-wire gateway (router.requesty.ai), `provider/model` ids like
# OpenRouter and the same optional HTTP-Referer/X-Title attribution headers.
[[providers]]
name = "requesty"
kind = "requesty"
credential = "env:REQUESTY_API_KEY"
prefix = "rq" # e.g. `rq/openai/gpt-4o` -> requesty with model `openai/gpt-4o`
settings = { http_referer = "https://your.app", x_title = "your-app" }
# Amazon Bedrock's OpenAI-compatible Mantle endpoint. Region is part of the URL, so `endpoint` is
# required. Bedrock API keys are bearer tokens; IAM/SigV4 and native Converse are not used by this kind.
# `GET <endpoint>/models` is supported and returns the models available on Mantle in that region.
[[providers]]
name = "bedrock"
kind = "bedrock"
endpoint = "https://bedrock-mantle.eu-central-1.api.aws/v1"
credential = "env:AWS_BEDROCK_API_KEY"
# Hugging Face Inference Providers. Model suffixes can select routing policy/backends, for example
# `:fastest`, `:cheapest`, `:preferred`, or a specific inference provider. Its `/models` response
# carries provider availability, optional pricing/performance, architecture, and capability metadata;
# llmleaf preserves that vendor data in each catalog entry's `extra` object.
[[providers]]
name = "huggingface"
kind = "huggingface"
credential = "env:HF_TOKEN"
prefix = "hf"
# DeepInfra's OpenAI-compatible inference base. Model listing uses its separate public rich catalog
# (`https://api.deepinfra.com/models/list`); model type, pricing, tags, quantization, availability,
# deprecation, and other vendor metadata are preserved where reported.
[[providers]]
name = "deepinfra"
kind = "deepinfra"
credential = "env:DEEPINFRA_API_KEY"
prefix = "di"
# Baidu AI Cloud Qianfan. The mainland `/v2` endpoint is the default and supports chat (including
# SSE/tool calls), embeddings, rerank, model listing, and batch jobs with a bearer API key. For the
# international service, set `endpoint = "https://api.baiduqianfan.ai/v1"`.
[[providers]]
name = "qianfan"
kind = "baidu-qianfan" # aliases: baidu, qianfan, baidu-ai-cloud
credential = "env:QIANFAN_API_KEY"
prefix = "qianfan"
# Cloudflare Workers AI. The account id is part of the compatible inference URL, so `endpoint` is
# required. Cloudflare's model search is a separate account control-plane API, not `/ai/v1/models`;
# this kind therefore does not claim runtime model listing.
[[providers]]
name = "cloudflare"
kind = "cloudflare"
endpoint = "https://api.cloudflare.com/client/v4/accounts/ACCOUNT_ID/ai/v1"
credential = "env:CLOUDFLARE_API_TOKEN"
# OCI Generative AI's regional OpenAI-compatible endpoint using a service API key. OCI's model catalog
# requires IAM signing plus a compartment id, so it intentionally remains separate/unsupported here.
[[providers]]
name = "oci"
kind = "oci"
endpoint = "https://inference.generativeai.eu-frankfurt-1.oci.oraclecloud.com/openai/v1"
credential = "env:OCI_GENERATIVE_AI_API_KEY"
# Databricks Unity AI Gateway / Model Serving OpenAI client base. Listing serving endpoints is a
# workspace control-plane operation rather than an OpenAI `/models` call, so catalog listing is not
# advertised by this provider kind.
[[providers]]
name = "databricks"
kind = "databricks"
endpoint = "https://WORKSPACE.cloud.databricks.com/ai-gateway/mlflow/v1"
credential = "env:DATABRICKS_TOKEN"
# NVIDIA NIM's OpenAI-compatible vLLM surface. The default is `http://localhost:8000/v1`; override it
# for a remote deployment. `/models` is enabled and preserves optional NIM fields such as owned_by,
# root/parent, permissions, and max_model_len.
[[providers]]
name = "nim"
kind = "nim"
endpoint = "http://localhost:8000/v1"
# credential = "env:NVIDIA_NIM_API_KEY" # only when the deployment/proxy requires one
# A local, always-up fallback so a route never hard-fails in dev.
[[providers]]
name = "echo"
kind = "echo"
# --- Routes: logical model -> ordered fallback chain (order IS the chain — principle 8). ---
[[routes]]
model = "gpt-4o"
targets = [
{ provider = "openai-main", model = "gpt-4o" },
{ provider = "openrouter", model = "openai/gpt-4o" },
{ provider = "echo" },
]
# Cross-vendor fallback: try Anthropic, fall back to an OpenAI-family model, then echo.
[[routes]]
model = "smart"
targets = [
{ provider = "anthropic-main", model = "claude-sonnet-4" },
{ provider = "gemini-main", model = "gemini-2.0-flash" },
{ provider = "groq", model = "llama-3.3-70b-versatile" },
{ provider = "echo" },
]
[[routes]]
model = "demo"
targets = [{ provider = "echo" }]
# Routes are modality-agnostic: the same logical model resolves to the same chain for chat,
# embeddings, speech (TTS), and transcription (STT) alike — only the provider method the engine
# calls at the end differs. So these reuse the very same provider blocks declared above; any
# modality a provider supports is reachable through its routes. A provider that lacks the requested
# modality returns Unsupported and is skipped with NO health penalty, so a chat-only target can sit
# ahead of a modality-capable one in a chain without breaking it.
# Embeddings (POST /v1/embeddings), with a cross-vendor fallback.
[[routes]]
model = "text-embedding-3-small"
targets = [
{ provider = "openai-main", model = "text-embedding-3-small" },
{ provider = "openrouter", model = "openai/text-embedding-3-small" },
]
# Rerank (POST /v1/rerank), Cohere/Jina/OpenRouter dialect. Cohere serves it natively (/v2/rerank);
# Together and OpenRouter serve /v1/rerank; a self-hosted OpenAI-wire reranker (vLLM/Infinity/TEI)
# opts in with `settings.rerank_api = true` on its provider block. No `cohere` provider instance is
# declared above, so the native target is left commented — add a `[[providers]] kind = "cohere"` block
# to enable it; meanwhile the request routes through the `openrouter` instance that IS declared.
[[routes]]
model = "rerank-v3.5"
targets = [
# { provider = "cohere", model = "rerank-v3.5" }, # native path — needs a `cohere` provider block
{ provider = "openrouter", model = "cohere/rerank-v3.5" },
]
# Text-to-speech (POST /v1/audio/speech) — returns raw audio bytes.
[[routes]]
model = "tts-1"
targets = [{ provider = "openai-main", model = "tts-1" }]
# Speech-to-text (POST /v1/audio/transcriptions) — multipart upload, like OpenAI.
[[routes]]
model = "gpt-4o-transcribe"
targets = [{ provider = "openai-main", model = "gpt-4o-transcribe" }]
# Batch (asynchronous jobs) needs NO extra config — it reuses these same routes. A consumer submits an
# inline array of OpenAI-shaped chat requests and gets back an opaque batch id:
# POST /v1/batches { "requests": [ { "custom_id": "a", "body": { <chat request> } }, … ] }
# GET /v1/batches/{id} poll status
# POST /v1/batches/{id}/cancel request cancellation
# GET /v1/batches/{id}/results stream results as JSONL (one line per request)
# Each request's `body` routes by its `model` exactly like a live chat call; all requests in one batch
# must resolve to the same provider (a batch is one upstream job — a mixed-provider batch is rejected).
# The returned id encodes which provider owns the job, so poll/cancel/results need no node-local state
# (any node serves them; SOUL principle 9) — llmleaf stores nothing, the provider's upstream is the store.
# Supported upstreams: anthropic (native inline) and gemini (native inline `:batchGenerateContent`);
# the OpenAI-wire brands openai, groq, moonshot (OpenAI `/v1/batches`) and together (its `batch-api`
# files variant); azure-openai (resource-scoped `/openai/batches?api-version=`); and mistral (its
# `/v1/batch/jobs`). For the file-based ones the JSONL upload happens inside the create call — nothing
# is held afterward. Brands without a batch API (deepseek, openrouter, perplexity, cerebras, zai) report
# it unsupported and are skipped. xAI, Fireworks, and Cohere have batch APIs but
# their own dialects (paginated/dataset-based, partly undocumented) and are not wired yet.
# --- Base consumer keys. The limiter named in [control.limits] can attach verdicts at runtime, which
# llmleaf PULLS and caches; these file keys are always the identity base. ---
#
# A consumer authenticates with `Authorization: Bearer <base64(key-id:password)>` — the HTTP-Basic
# shape (split on the first `:`, so the id holds no colon, the password may). Config stores only the
# password's *hash*, never the plaintext: a standard Unix/crypt(3) MCF string. Generate one with, e.g.
# htpasswd -bnBC 12 demo-team 's3cret' # bcrypt ($2y$…); take the part after the first ':'
# `$1$` / `$5$` / `$6$` shadow hashes (e.g. `openssl passwd -6`) work too. `env:VAR` indirection is
# supported, so the hash can live outside the file. For the example below the consumer would send
# Authorization: Bearer ZGVtby10ZWFtOnMzY3JldA== # = base64("demo-team:s3cret")
#
# PITFALL: base64 the credential with NO trailing newline. `echo "id:pw" | base64` appends a newline
# that gets encoded INTO the value, so the decoded password becomes "pw\n" and fails the hash check —
# surfacing as `401 unknown api key` even when pw_hash is correct. Encode with printf (or `base64 -w0`):
# printf 'demo-team:s3cret' | base64
[[keys]]
id = "demo-team"
pw_hash = "env:LLMLEAF_DEMO_KEY_HASH"
# Static allow-list; a verdict may narrow further at runtime but never widen past this. Entries may
# use `*` as a wildcard alongside exact ids — e.g. "gpt-*", "openrouter/openai/*" — and a list
# containing a bare "*" means every routed model (same as omitting the line).
allowed_models = ["gpt-4o", "demo"]
# The key the bundled examples (`cargo run -p llmleaf --example chat`) use out of the box. Its
# password is "llmleaf-dev" — published here ON PURPOSE as a local-only demo credential (the hash is
# bcrypt cost 12, `htpasswd -bnBC 12 example-cli llmleaf-dev`). NEVER ship a known password in a real
# deployment. The example sends: Authorization: Bearer ZXhhbXBsZS1jbGk6bGxtbGVhZi1kZXY=
[[keys]]
id = "example-cli"
pw_hash = "$2y$12$e8yd7Vi6xtWz5gUpEvKBkOtgIoq4nIBkFZ1iMtWj5T8oeAQWL/WSi"
# --- OAuth2 resource server (optional). A SECOND, additive consumer-auth scheme beside [[keys]]. ---
#
# When configured, a consumer may present an IdP-issued JWT access token instead of a static key, on the
# same `Authorization: Bearer <token>` header — a bearer that is a JWT (three dot-separated segments) is
# validated here; anything else falls through to the [[keys]] store above. The gateway acts as an OAuth2
# resource server: it verifies the JWT signature against the issuer's JWKS, checks `iss`/`aud`/`exp`, and
# authorizes models by mapping a roles/groups claim to model sets. An authenticated token flows on as the
# same log-safe identity (`identity_claim`, e.g. `sub`) stamped on every event — exactly like a key.
#
# Like keys, this is base config (principle 6): an inline `jwks` makes the JWT path operable from the file
# alone; naming `jwks_uri` (or just `issuer`, for OIDC discovery) lets the control plane refresh the keys
# on an interval. The JWKS pull + optional introspection are OUTBOUND HTTP and live in llmleaf-control —
# the core only verifies crypto. Omit the whole [oauth] section to disable JWT auth (keys-only).
#
# [oauth]
# issuer = "https://idp.example.com/" # expected `iss`; OIDC discovery base for jwks_uri/introspection
# audience = ["llmleaf"] # accepted `aud` values
# jwks_uri = "https://idp.example.com/jwks" # optional; omit to discover from `issuer`
# algorithms = ["RS256", "ES256"] # allow-list; `none`/HS* are refused (public-key only)
# identity_claim = "sub" # claim → the log-safe identity (default "sub")
# roles_claim = "roles" # claim holding roles/groups: array or space-delimited string
# jwks_refresh_secs = 3600
# on_error = "deny" # cold-start JWKS unreachable ⇒ fail closed (authentication)
#
# Role/group → allowed models. A token's allow-list is the union over its roles; a role mapped to ["*"]
# grants every routed model; entries may use `*` as a wildcard (e.g. "gpt-*", "openrouter/openai/*");
# a token bearing no mapped role can use none.
# [oauth.role_models]
# "llmleaf-power" = ["gpt-4o", "smart"]
# "llmleaf-basic" = ["demo"]
#
# Optional RFC 7662 introspection (revocation). Omit ⇒ liveness rests on the JWT `exp`. The `active`
# answer is cached per token for cache_ttl_secs, so it is not a network round-trip on every request.
# [oauth.introspection]
# url = "https://idp.example.com/introspect"
# credential = "env:LLMLEAF_INTROSPECT_SECRET"
# cache_ttl_secs = 30
# timeout_ms = 2000
# --- Control plane (outbound). Omit the whole [control] section for pure data-plane operation. ---
#
# Inverted model (SOUL.md principle 5): llmleaf PULLS key identity + verdicts from your limiter on an
# interval and caches them node-locally, PUSHES usage events out to a sink, and OPTIONALLY calls a sync
# intercept hook in-flight. Every sub-table is independently optional — omit one and the core falls back
# to the file [[keys]] above (config-only operation, principle 6).
# Named auth schemes (optional). Define a credential ONCE here and reference it from any sub-table below
# with `auth = "<id>"`, so the same token/header is centralized instead of repeated inline. Two kinds:
# - kind = "bearer" ⇒ Authorization: Bearer <token> (the conventional HTTP bearer)
# - kind = "header" ⇒ <header>: <value> (a custom header, e.g. X-API-Key)
# `env:VAR` indirection works for `token`/`value`. A sub-table sets `auth` OR an inline `credential`,
# never both. Omit these entirely and each sub-table just uses its own inline `credential` (a bearer).
# [[control.auth]]
# id = "limiter" # the id sub-tables reference; unique within [[control.auth]]
# kind = "bearer"
# token = "env:LLMLEAF_CONTROL_TOKEN"
#
# [[control.auth]]
# id = "screener"
# kind = "header"
# header = "X-API-Key" # sent verbatim as the header name
# value = "env:LLMLEAF_SCREENER_KEY"
# PULL: who exists. Replaces/augments the file [[keys]] roster. AUTHENTICATION — fails CLOSED on cold
# start (an empty identity cache must reject), but a warm node always serves its last-good cache.
# [control.identity]
# url = "https://limiter.internal/llmleaf/keys"
# auth = "limiter" # reference a [[control.auth]] id...
# # credential = "env:LLMLEAF_CONTROL_TOKEN" # ...or set an inline bearer instead (not both)
# refresh_secs = 30
# timeout_ms = 2000
# on_error = "deny" # default for identity; shown for clarity
# PULL: verdicts (block / suspend / narrow models). The mutable overlay, refreshed fast. Fails OPEN:
# a limiter blip keeps last-good verdicts so paying keys keep serving (principle 8).
# [control.limits]
# url = "https://limiter.internal/llmleaf/verdicts" # may be the same host/service as identity
# auth = "limiter" # the same central scheme, shared with identity
# refresh_secs = 5
# timeout_ms = 2000
# on_error = "allow" # default for limits; shown for clarity
# PULL: dynamic topology — provider instances + routes the control plane layers ON TOP of the file
# base above. The endpoint answers `{ "providers": [ … ], "routes": [ … ] }` in exactly the shapes of
# [[providers]] / [[routes]] (as JSON; `env:VAR` credential indirection resolves node-locally, so no
# secret has to ride the wire). Every refresh is DIFFED against the previously pulled topology and
# reconciled: new entries are added, entries that vanished are removed (instance dropped, node-local
# rate/health state cleaned up), changed entries are updated in place — while untouched providers
# keep their live rate-limit buckets and cooldowns. The file base always wins: a pulled provider (or
# route) whose name (or model) collides with one declared above is skipped with a warning — the
# pulled layer extends the base, it never overrides it (principle 6). Fails OPEN: a failed pull keeps
# the last-good dynamic layer; a cold node runs from this file alone. Note the converse: a SUCCESSFUL
# pull answering empty lists removes every dynamic resource (the controller declared none).
# [control.topology]
# url = "https://limiter.internal/llmleaf/topology"
# auth = "limiter"
# refresh_secs = 30
# timeout_ms = 2000
# PUSH: usage/lifecycle events, batched and async. Never back-pressures the hot path; a full ring
# (server.event_buffer) drops oldest, exactly as the old broadcast tap did for slow consumers.
# [control.usage]
# url = "https://limiter.internal/llmleaf/usage"
# auth = "limiter"
# batch_ms = 1000 # flush at most once a second...
# batch_max = 256 # ...or early once this many events accrue
# timeout_ms = 5000
# SYNC: in-flight screening (the one sanctioned hot-path insertion — principle 1). Opt-in by data:
# with no `phases` it never fires. Pass | block | rewrite. Fails OPEN by default (availability over
# screening); set on_error = "deny" for a hard gate.
# [control.intercept]
# url = "https://screener.internal/llmleaf/intercept"
# auth = "screener" # a different scheme — here a custom X-API-Key header
# phases = ["request", "response"] # empty/omitted ⇒ disabled
# keys = ["demo-team"] # omit ⇒ every key
# models = ["gpt-4o", "smart"] # omit ⇒ every model
# timeout_ms = 1000
# on_error = "allow"