Configuration reference¶
Copy examples into config.yaml or a file under configs/, then set:
export MERIDIAN_CONFIG=/path/to/config.yaml # always UPPERCASE
See also: QUICKSTART.md, enterprise_example.yaml.
Configuration¶
Create a config.yaml (or use one from the configs/ directory):
gateway:
host: "0.0.0.0"
port: 8080
strategy: "least_inflight" # weighted_round_robin | least_inflight | ewma_latency | token_aware
# Token-aware routing knobs (only used when strategy: "token_aware").
# Score = (backend.inflight_cost + request_cost) * (ewma_latency or 1.0)
# request_cost = prompt_tokens * prefill_weight + max_tokens * decode_weight
prefill_weight: 1.0 # weight applied to estimated prompt tokens
decode_weight: 4.0 # weight applied to max_tokens (decode is more expensive per token)
default_max_tokens: 256 # used when the request doesn't set max_tokens
token_estimator: "heuristic" # only "heuristic" implemented today
# Capacity-aware penalties (added to the token_aware base score when backend
# telemetry exposes the corresponding signal). Default 0.0 = telemetry has
# no routing effect unless an operator tunes these.
queue_weight: 0.0 # multiplied by reported queue_depth
mem_weight: 0.0 # multiplied by reported gpu_mem_util (0.0-1.0)
# Workload tiering (optional, disabled by default). Routes requests to backend
# pools by request shape. A request maps to "long_prompt" when its estimated
# prompt size >= long_prompt_tokens, else "long_decode" when max_tokens >=
# long_decode_tokens, else "default". Precedence is fixed (long_prompt first).
# Each tier maps to backend tags; if the matched tier has no healthy backend,
# Meridian falls back to all healthy backends (reliability over isolation). The
# chosen tier is surfaced on the `x-meridian-tier` response header and in logs.
tiering:
enabled: false
long_prompt_tokens: 4000
long_decode_tokens: 1000
tiers:
long_prompt: ["prefill-pool"]
long_decode: ["decode-pool"]
default: ["general"]
# Tenant isolation (optional, "shared" by default = pre-0.12 behavior: every
# backend visible to every org). "dedicated" pins orgs listed in `pools` to
# backend tag pools (pool tags ⊆ backend tags — same subset semantics as
# tiering). A pinned org NEVER falls back outside its pool: an empty pool is
# a 503, not a leak onto a neighbor's capacity. Orgs NOT listed (and anonymous
# traffic) are excluded from every reserved pool — any backend matching a
# claimed pool's full tag set. Session affinity remaps a pin whose backend has
# left the org's pool. Empty pool lists, blank org keys/tags, and unknown keys
# in this section fail config validation at load/reload.
isolation:
mode: "shared" # shared | dedicated
pools: {} # org_id -> required backend tags
# pools:
# acme-enterprise: ["acme-dedicated", "gpu"]
# Canary rollout (optional, disabled by default). Per-request weighted split
# between the `stable_tags` and `canary_tags` backend pools. The controller
# walks `steps` on a schedule (promotion) and rolls back to weight 0 when the
# canary pool's rolling-window error rate breaches rollback_error_rate with at
# least rollback_min_samples observations (demotion). A pool with no eligible
# backend spills to the other side (availability beats split fidelity); if
# NEITHER pool matches a backend for a model (e.g. untagged deployment),
# routing falls back to all visible backends with a warning rather than
# 503-ing. Dedicated-mode pinned orgs bypass the rollout (pool containment
# wins). Session pins onto a rolled-back canary pool remap immediately; pins
# survive ordinary weight tuning.
# Interplay with health checking: passive failure tracking counts upstream 5xx
# cumulatively between health sweeps — a canary that uniformly dies is EJECTED
# by passive health marking before the error window can fill. The rollback
# path is meant for *flaky* rollouts (a fraction of requests failing); dead
# ones are handled by failover. If you demo/test rollback with injected
# failures, raise health.fail_threshold above your burst size.
# State: GET /meridian/status ("canary" block),
# meridian_canary_weight gauge, meridian_canary_rollbacks_total counter.
canary:
enabled: false
canary_tags: ["canary"] # tag set of the candidate pool
stable_tags: ["stable"] # tag set of the incumbent pool
start_weight: 0.0 # % of new-session traffic on the canary pool
steps: [] # promotion schedule: weight + duration_s (null = hold)
# steps:
# - {weight: 10.0, duration_s: 600}
# - {weight: 50.0, duration_s: 1800}
# - {weight: 100.0, duration_s: null}
rollback_error_rate: 0.5 # fraction of 5xx outcomes in the window
rollback_min_samples: 10 # don't roll back on fewer samples (startup flaps)
window_s: 60.0 # rolling outcome window
tick_s: 5.0 # controller cycle period
# Session affinity (optional, disabled by default). Pins a session to one backend
# for KV-cache reuse. Requests carrying the `header` route to the same backend
# while it stays healthy. Sliding TTL: each use refreshes the idle timeout.
# `max_sessions` bounds memory; `sweep_interval_s` controls background eviction.
# If the pinned backend becomes unhealthy, requests remap to another healthy
# backend. Affinity state is surfaced via `x-meridian-session-route` header.
session_affinity:
enabled: false
header: "x-meridian-session"
ttl_s: 600.0
sweep_interval_s: 60.0
max_sessions: 100000
# API-key authentication (optional, disabled by default). When enabled, every
# request to /v1/* must carry Authorization: Bearer <key>. Keys are matched
# against this list; unrecognised or missing keys get HTTP 401. Each key maps
# to an identity (org_id required, team_id and user_id optional). Duplicate
# keys are rejected at config load. The /metrics, /meridian/*, and /ui
# endpoints are always open (no auth gate). Key format: mrdn_ followed by
# 20-40 alphanumeric characters.
#
# Optional per-key `key_id` overrides the non-secret prefix identifier used in
# logs, budgets.keys, and usage queries (defaults to the key's first 13 chars).
# `keys_file` (see DEPLOY.md) holds keys outside the main config; when set,
# GET/POST/DELETE /meridian/keys manage those file keys live (admin-only: role
# admin or ops_admin; the raw key is returned exactly once on POST) — see
# OPS_RUNBOOK.md "Auth & keys". Inline `keys:` entries are NOT API-deletable.
auth:
enabled: false
# keys_file: /secrets/keys.yaml # durable key store (managed via API)
keys:
- key: "mrdn_3kTyXq9Zm4PwR7sN8vBcDfGhJ"
org_id: "acme"
team_id: "eng"
user_id: "alice"
- key: "mrdn_9Bv4QwX8Ty2Rs5Np7MfLkHgDc"
org_id: "acme"
# key_id: "svc-embedder" # optional explicit non-secret id
health:
interval_s: 5
timeout_s: 2
fail_threshold: 2
success_threshold: 1
# Upstream HTTP timeouts (seconds). Global defaults below preserve historical
# behavior; every key is optional and can also be overridden per backend under
# `backends[].timeout` (a backend key wins; unset keys inherit the global).
# `stream_read` only applies to streaming (SSE) requests and defaults to
# `read` — set it to close stalled streams gracefully (client gets an SSE
# error event + [DONE], and the request is recorded as 504 stream_read_timeout).
timeouts:
connect: 5.0
read: 300.0
write: 5.0
pool: 5.0
stream_read: null # null = use `read` for streams
# Resilience (optional, all off by default = historical behavior).
# - max_retries: retry transport errors (connection/timeout — no response was
# received). Safe window only: non-stream requests, and the OPEN phase of
# streams (before the first byte). A stream that has started NEVER retries —
# delivered tokens can't be un-sent; stalled/broken streams end gracefully
# (SSE error event + [DONE]) instead. Backoff = retry_backoff_base * 2^n.
# Retried attempts are counted in meridian_upstream_retries_total{backend}.
# - circuit_breaker: per backend. Opens after failure_threshold consecutive
# upstream failures; while open, requests are rejected pre-flight with 503
# ({"error":{"type":"meridian_circuit_open"}}) without touching the backend.
# After open_seconds a single half-open probe is admitted; success closes
# the circuit, failure re-opens it. State is visible in /meridian/status
# and via meridian_backend_circuit_open{backend}.
resilience:
max_retries: 0
retry_backoff_base: 0.1
circuit_breaker:
enabled: false
failure_threshold: 5
open_seconds: 30.0
logging:
level: "INFO"
jsonl_path: "./meridian_requests.jsonl"
backends:
- name: "fast"
url: "http://backend-fast:9001"
engine: "vllm"
model: "demo-model"
weight: 80
tags: ["fast"]
health_endpoint: "/v1/models"
# Optional per-backend timeout override (fields merge over `timeouts:`).
timeout:
read: 60.0
stream_read: 10.0
# Scale-to-zero (optional, off by default). After N minutes without request
# traffic the backend is marked idle: excluded from routing, health checks
# paused (a scaled-to-zero pod SHOULD fail them), and meridian_backend_idle
# = 1 on /metrics so external scalers (KEDA) can drive replicas to zero.
# The first request matching this backend's model/tags wakes it (and may
# see 502s while the engine cold-starts — see resilience.max_retries).
idle_timeout_min: 30
# Optional: tell Meridian to scrape capacity signals from the backend.
# Failures here NEVER mark the backend unhealthy — telemetry is purely a
# routing-preference signal and falls back safely when missing.
telemetry:
type: "json"
url: "http://backend-fast:9001/stats"
interval_s: 5.0
timeout_s: 2.0
- name: "slow"
url: "http://backend-slow:9002"
engine: "vllm"
model: "demo-model"
weight: 20
tags: ["cheap"]
health_endpoint: "/v1/models"
Pre-built configs are available in the configs/ directory:
- configs/mock_demo.yaml — mock backends for Docker Compose demo
- configs/local_gpu.yaml — single GPU backend (Ollama)
- configs/dual_backend.yaml — dual-backend failover testing with delay proxy
- configs/tiering_demo.yaml — workload tiering across prefill/decode/general pools
API-key Authentication¶
Authentication is disabled by default and fully backward compatible — existing deployments without an auth: block continue to work unchanged.
When auth.enabled: true, every request to /v1/* must include a valid Authorization: Bearer <key> header. The /metrics, /meridian/*, and /ui endpoints are always open with no auth gate.
Error responses follow the OpenAI error shape:
- Missing or malformed header → HTTP 401,
"type": "invalid_request_error" - Header present but key not found → HTTP 401,
"type": "authentication_error"
Key format: mrdn_ followed by 20–40 alphanumeric characters. Each key is mapped to an identity at config load (org_id required; team_id and user_id optional). Duplicate keys are rejected at startup.
Identity-aware logging¶
When auth is enabled, the resolved identity is attached to every request's observability output as metadata only — the API key itself is never logged. Each JSONL line and audit event carries org_id and team_id (both null when auth is disabled), so operators can attribute traffic per org/team:
{"request_id": "mrdn-...", "model": "demo", "chosen_backend": "vllm-a", "status_code": 200, "org_id": "acme", "team_id": "eng", ...}
When auth is enabled, rate limiting keys on the caller's org (org:{org_id}) instead of source IP, so a tenant shares one bucket no matter which IP its requests arrive from. With auth disabled the limiter falls back to per-IP. The same rate_limit.token_capacity/token_refill_rate apply per bucket.
Model access control¶
Each key may declare an allowed_models allow-list. When set, requests for any model outside the list return HTTP 403 ("type": "permission_error"); an empty or absent list leaves the key unrestricted. The gate only applies when auth is enabled.
auth:
enabled: true
keys:
- key: "mrdn_3kTyXq9Zm4PwR7sN8vBcDfGhJ"
org_id: "acme"
team_id: "eng"
allowed_models: ["qwen2.5:0.5b", "demo-model"] # this key is limited to these
- key: "mrdn_9Bv4QwX8Ty2Rs5Np7MfLkHgDc"
org_id: "acme" # no list => all models
Tenant budgets & quotas¶
Budgets are disabled by default. When budgets.enabled: true, Meridian meters each authenticated request against configured caps before routing to a backend. Metering uses the same estimated request cost as token-aware routing (prompt_tokens * prefill_weight + max_tokens * decode_weight) plus a request count — no response-body parsing, so streaming stays zero-copy.
Caps cascade org → team → user → key. A request debits every applicable level; the first exhausted level returns HTTP 429 ("type": "rate_limit_exceeded") with Retry-After until the UTC period rolls (daily YYYY-MM-DD / monthly YYYY-MM). Scope keys:
| Level | Config map | Key format |
|---|---|---|
| org | budgets.orgs |
org_id |
| team | budgets.teams |
{org_id}/{team_id} |
| user | budgets.users |
{org_id}/{user_id} |
| key | budgets.keys |
{key_id} (the non-secret prefix id) |
budgets:
enabled: true
store: sqlite # or memory (ephemeral / tests)
sqlite_path: ./meridian_usage.db
orgs:
acme:
daily:
tokens: 1000000
requests: 5000
monthly:
tokens: 20000000
teams:
acme/eng:
daily:
tokens: 200000
users:
acme/alice:
daily:
requests: 200
keys:
mrdn_OrgKey00: # per-key caps (Phase 3): one chatty key can
daily: # blow its own budget without tripping the org
tokens: 50000
# Token-bucket rate limiting (not budget caps). Scopes, checked in order:
# global (rate_limit.global_limit) — one fleet-wide bucket (alias `global:`)
# model (rate_limit.models[model]) — one bucket per listed model id
# org (rate_limit.org_overrides) — per org (fallback: client IP)
# A request is checked against EVERY applicable scope first and only consumes
# when all admit — a rejection never burns another scope's token. Rejections
# land on meridian_ratelimit_rejections_total{scope=global|model|org|ip}.
rate_limit:
enabled: true
token_capacity: 100
token_refill_rate: 10
global_limit: # optional fleet-wide bucket
token_capacity: 1000
token_refill_rate: 100
models: # optional per-model buckets
big-expensive-model:
token_capacity: 5
token_refill_rate: 0.5
org_overrides:
acme:
token_capacity: 20
token_refill_rate: 5
Rejections increment meridian_budget_rejections_total{level,period} (never labeled by tenant id). Auth must be enabled for budgets to apply — without an identity there is no tenant to meter.
Scope: the identity keystone (auth, identity logging, per-org rate limiting, model access, tenant budgets) is complete. See
ship.md.
PII detection & redaction (India pack)¶
Disabled by default. When pii.enabled: true, Meridian scans request message text only (response bodies are not scanned in v0.7) for:
| Entity | Notes |
|---|---|
| Aadhaar | 12 digits + Verhoeff checksum (rejects random 12-digit strings) |
| PAN | AAAAA9999A |
| GSTIN | 15-char GSTIN pattern |
| IFSC | Bank IFSC |
| UPI | user@handle |
| Indian mobile | 10-digit starting 6–9, optional +91 |
Policies (global pii.policy, optional per-key pii_policy override):
| Policy | Behaviour |
|---|---|
block |
HTTP 400; request never reaches a backend |
redact_and_replace |
Mask PII in messages, then forward |
redact_for_logs / audit_only |
Forward raw; record counts by type only in JSONL/audit |
Security rules: matched values are never written to JSONL, audit events, metrics labels, or error messages. Prometheus: meridian_pii_detections_total{entity,policy}.
pii:
enabled: true
policy: redact_and_replace # or block | redact_for_logs | audit_only
entities: [] # empty = all types
auth:
enabled: true
keys:
- key: "mrdn_3kTyXq9Zm4PwR7sN8vBcDfGhJ"
org_id: "acme"
pii_policy: block # optional override for this key
Quick curl examples¶
# Without a key — returns 401
curl -i http://localhost:8080/v1/models
# HTTP/1.1 401 Unauthorized
# {"error": {"message": "Missing or malformed Authorization header", "type": "invalid_request_error"}}
# With a valid key — returns 200
curl -i http://localhost:8080/v1/models \
-H "Authorization: Bearer mrdn_3kTyXq9Zm4PwR7sN8vBcDfGhJ"
# HTTP/1.1 200 OK
# {"object":"list","data":[...]}
# Chat completions also require the header when auth is enabled
curl -i http://localhost:8080/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer mrdn_3kTyXq9Zm4PwR7sN8vBcDfGhJ" \
-d '{"model":"demo-model","messages":[{"role":"user","content":"Hello!"}]}'
Pre-built configs¶
| File | Use |
|---|---|
configs/mock_demo.yaml |
Docker Compose quickstart |
configs/local_gpu.yaml |
Ollama / single GPU |
configs/enterprise_example.yaml |
Auth + budgets + cost template |
configs/poc_design_partner.yaml |
Multi-tenant PoC |