Every key needed an operator with a shell. This adds the login system that made that necessary, which is the thing I argued against building until it was asked for — so it is built as if it will be attacked, because a public login form is. Signing up produces an account that can do nothing. Approval is what makes it work and stays on the CLI: having a shell on the control plane is what makes someone an administrator, and that is a smaller thing to get right than roles and an admin UI that mints credentials. The details that matter: Passwords are argon2id, where a slow hash is correct — the input is chosen by a human and has a dictionary, which is the opposite of an API key. Both kinds of authentication now live in this codebase and they are in separate packages so neither set of assumptions leaks into the other. Length is the only rule: composition rules push people towards Password1! and cost more entropy than they buy. Signup never says whether an address already has an account, and sign-in answers the same way for a wrong password and a missing account — verifying against a dummy hash when there is no user, so a stopwatch cannot enumerate accounts. Account state is checked after the password, not before, so guessing an address reveals nothing about it. Guessing is bounded per address rather than per IP: an attacker has many addresses to come from and one to guess against, and limiting by IP locks out an office behind one NAT. Sessions are server-side, so signing out and suspending take effect immediately rather than whenever a token expires; suspension is checked in the same query that resolves the session, because a second round trip is a check somebody eventually forgets. Every state-changing form carries a CSRF token verified against a hash in the session — a cookie authenticates the browser, not the page that asked. Key ownership is enforced in the UPDATE rather than by reading first, so a guessed key id from another account changes nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
196 lines
7.8 KiB
YAML
196 lines
7.8 KiB
YAML
# llmgw router configuration (phase 1).
|
|
#
|
|
# The endpoint list is static here. From phase 3 it comes from the control
|
|
# plane instead and this section disappears; everything else stays.
|
|
#
|
|
# Any field can be overridden from the environment with an LLMGW_ prefix and
|
|
# __ for nesting: LLMGW_ROUTER__RETRIES=3
|
|
|
|
listen: ":8080" # client-facing OpenAI API
|
|
admin_listen: ":9090" # metrics, health, pprof — do not expose this publicly
|
|
# Where node agents connect. Separate from the client port so the fleet-facing
|
|
# surface can be firewalled independently.
|
|
agent_listen: ":7443"
|
|
|
|
# Mutual TLS on the agent port (plan §5.6). With it, a node's identity is taken
|
|
# from its certificate's common name and an agent claiming a different node id
|
|
# is refused. Without it the port is plaintext HTTP/2 and anyone who can reach
|
|
# it can claim to be any node, so it must stay on a trusted network.
|
|
#
|
|
# These certificates come from llmgw's own authority, not a public one: public
|
|
# authorities stopped issuing the clientAuth usage, so no Let's Encrypt
|
|
# certificate can authenticate an agent.
|
|
#
|
|
# llmgw ctl cert init-ca --dir /etc/llmgw/pki
|
|
# llmgw ctl cert issue-server llmgw-ctl --host 10.0.0.140 --dir /etc/llmgw/pki
|
|
#
|
|
# Every address an agent might dial has to appear in --host, bare IP addresses
|
|
# included: hostname verification reads subject alternative names and ignores
|
|
# the common name entirely.
|
|
#
|
|
# This covers the fleet-facing port only. The client-facing API above is public
|
|
# and belongs behind a publicly trusted certificate.
|
|
#
|
|
# agent_tls:
|
|
# ca: /etc/llmgw/ca.pem
|
|
# cert: /etc/llmgw/server.pem
|
|
# key: /etc/llmgw/server-key.pem
|
|
|
|
# The public page at "/". Someone who reaches this address without a key needs a
|
|
# route to getting one, and the gateway cannot guess who to send them to.
|
|
#
|
|
# landing:
|
|
# contact: "platform@example.com" # or an https:// URL, or "#platform on Slack"
|
|
# notice: "Internal use only."
|
|
|
|
# API keys on the client-facing port (plan §7.9). Off by default, because a
|
|
# gateway on a trusted network with no keys issued still has to serve — but
|
|
# nothing else protects that port, so turning it on is what makes it safe to
|
|
# expose.
|
|
#
|
|
# llmgw keys init --store /var/lib/llmgw/llmgw.db
|
|
# llmgw keys create --store /var/lib/llmgw/llmgw.db --project default "laptop"
|
|
#
|
|
# The key is printed once. Only its hash is stored, so there is nothing to
|
|
# recover it from.
|
|
#
|
|
# auth:
|
|
# enabled: true
|
|
# store: /var/lib/llmgw/llmgw.db
|
|
# # How long a resolved key is trusted before being read again. This is the
|
|
# # price of keeping the database off the request path: it bounds how long a
|
|
# # revoked key keeps working.
|
|
# cache_ttl: 30s
|
|
# # How long a rejected key is remembered as rejected, so a client looping
|
|
# # with a bad key cannot become a database read per request.
|
|
# negative_ttl: 5s
|
|
#
|
|
# # Serve the signup form. What it creates is a pending account that can do
|
|
# # nothing until `llmgw users approve` places it in a project, so this is not
|
|
# # a way in — it only moves key creation from you to the person who needs one.
|
|
# # Off, there is no form and you issue keys with `llmgw keys create --claim`.
|
|
# signup_open: true
|
|
#
|
|
# # What each model costs, in micro-units per thousand tokens. A unit is
|
|
# # whatever you decide: self-hosted models have no per-token price, so it can
|
|
# # be GPU-seconds, a notional rate, or 1000 everywhere — which turns budgets
|
|
# # into plain token quotas.
|
|
# #
|
|
# # A model priced at zero is exempt from budget checks, which is what lets a
|
|
# # key with nothing left still be served a cheap fallback.
|
|
# default_price: { prompt_per_1k: 0, completion_per_1k: 0 }
|
|
# pricing:
|
|
# llama-3.3-70b: { prompt_per_1k: 1000, completion_per_1k: 3000 }
|
|
# qwen2.5-32b: { prompt_per_1k: 400, completion_per_1k: 1200 }
|
|
#
|
|
# # Usage rows are written in batches off the request path. Beyond buffer_size
|
|
# # they are dropped rather than blocking a request: losing some accounting is
|
|
# # bad, turning a stalled database into an outage is worse.
|
|
# flush_interval: 200ms
|
|
# batch_size: 500
|
|
# buffer_size: 8192
|
|
|
|
router:
|
|
scrape_interval: 2s
|
|
scrape_timeout: 1s
|
|
# How long an endpoint may go unscraped before it is scored down. It is never
|
|
# dropped for staleness alone: if the whole pool went stale there would be
|
|
# nowhere left to send anything.
|
|
stale_after: 10s
|
|
|
|
# Additional endpoints to try after a failure, and only while nothing has
|
|
# reached the client yet.
|
|
retries: 2
|
|
# How long a failing endpoint leaves the candidate pool. A long fallback list
|
|
# with no cooldown turns one endpoint's rate limit into a cluster-wide retry
|
|
# storm.
|
|
cooldown: 15s
|
|
# Consecutive failures that open the breaker. Set to 1 for pure cooldown
|
|
# behaviour.
|
|
failure_threshold: 3
|
|
|
|
default_profile: chat # chat | batch | long-context | debug
|
|
session_header: x-session-id # affinity key; falls back to the `user` field
|
|
# Ask engines for usage even when the client did not, so token accounting
|
|
# works regardless of client behaviour. The extra chunk is dropped on the way
|
|
# back to a client that did not ask for it.
|
|
inject_usage: true
|
|
max_body_bytes: 33554432
|
|
|
|
timeouts:
|
|
dial: 5s
|
|
response_header: 60s
|
|
first_token: 120s
|
|
idle: 60s # between streamed tokens; catches a wedged engine
|
|
total: 30m
|
|
|
|
# Flow control: decides *when* a request may proceed, before scheduling
|
|
# decides where it goes. Without it a batch workload starves interactive
|
|
# traffic, because an engine queue is FIFO and knows nothing about tenants.
|
|
admission:
|
|
enabled: true
|
|
# With auth on, the key decides the band and this header is ignored. With
|
|
# auth off the header is trusted, so any client can promote itself into the
|
|
# top band — do not expose the port in that configuration.
|
|
band_header: x-llmgw-band
|
|
default_band: gold
|
|
# A band waits once pool saturation passes its ceiling, so the cluster
|
|
# degrades from the bottom up. A ceiling of 1.0 means "flow up to the
|
|
# healthy limit"; use a value above 1 for a band that must never wait.
|
|
bands:
|
|
- { name: platinum, priority: 400, saturation_ceiling: 1.00 }
|
|
- { name: gold, priority: 300, saturation_ceiling: 0.85 }
|
|
- { name: silver, priority: 200, saturation_ceiling: 0.60 }
|
|
- { name: bronze, priority: 100, saturation_ceiling: 0.30 }
|
|
# Drop a request that has waited this long: the client has usually given up,
|
|
# and sending the GPU work nobody is waiting for helps no one.
|
|
queue_ttl: 30s
|
|
# Reject immediately once a band's queue is this deep, rather than letting
|
|
# it accumulate silently.
|
|
queue_capacity_per_band: 2000
|
|
interval: 10ms
|
|
|
|
# What the scorers normalize against. Measure these with `ctl bench` rather
|
|
# than guessing: target_waiting in particular governs how the whole system
|
|
# behaves under load. Too low starves the GPU, too high brings back queueing
|
|
# delay.
|
|
scoring:
|
|
target_waiting: 4
|
|
target_inflight: 8
|
|
kv_ceiling: 0.9
|
|
session_load_factor: 1.25
|
|
|
|
# How aggressively the control plane reconciles.
|
|
control:
|
|
interval: 5s
|
|
base_backoff: 10s
|
|
max_backoff: 10m
|
|
# Node expiry is staged deliberately: a network blip must not tear down
|
|
# replicas that are still serving, and rebuilding a multi-node replica costs
|
|
# tens of minutes of model loading.
|
|
unreachable_after: 15s
|
|
lost_after: 60s
|
|
port_range: [20000, 20999]
|
|
mgmt_interface: eth0
|
|
|
|
models:
|
|
- name: llama-3.3-70b
|
|
aliases: [llama]
|
|
fallbacks: [qwen2.5-32b]
|
|
routing_profile: chat
|
|
|
|
- name: qwen2.5-32b
|
|
aliases: [qwen]
|
|
routing_profile: batch
|
|
|
|
# A static replica list, used only when no model declares a profile. That is the
|
|
# router-only mode for testing against sim-engines; with the control plane
|
|
# running, agents provide the replicas instead.
|
|
#
|
|
# endpoints:
|
|
# - id: sim-1
|
|
# url: http://127.0.0.1:8001
|
|
# models: [llama-3.3-70b]
|
|
# pool: local
|
|
# weight: 1
|