Serving inference
GuideOperationsSince version 0.1.0
On this page
Operations guide: configure, deploy and observe the gateway. What the capability does, product side: product guide; calling it: developer guide.
Goal: serve the OpenAI-compatible surface (/v1/chat/completions, /v1/models,
/v1/embeddings since 1.5.0) through the
same data plane as the API - same identity, same rate limits, same policies, same trace -, routed
by policy to backends, under a per-tenant token budget.
Command - an ai: section. It needs no contract: the gateway declares its operations
itself (ai.chatCompletions, ai.listModels, ai.embeddings), served on every host.
ai:
security: # the identities accepted, one suffices - as `security` in a contract
- oidc: { scopes: [inference] }
- api_key: {} # a key bound to ai.chatCompletions (see API keys)
sensitive_header: X-Sensitive # "true" pins the request to sensitive_backend
default_backend: eu-provider
sensitive_backend: on-prem # internal; unreachable, or no internal backend at all: refused, never an external one
backends:
eu-provider:
url: https://api.provider.example/v1
api_key: "file:/etc/flexway/secrets/provider-key" # a reference, read at each call
default_model: mistral-small-3.2-24b # the gateway chooses the model
external: true
on-prem:
url: http://vllm.inference.svc:8000/v1
default_model: mistral-7b-instruct
budget:
store: "${COUNTERS_URL}" # redis://..., shared by every replica
monthly_tokens: 40000000 # per tenant, 0 for none
daily_tokens: 2000000
overrides: # the first match wins
- { match: { tenant: acme, agent: batch-summaries }, backend: on-prem }Four possible identities, each an alternative of ai.security:
| Alternative | The caller presents | Tenant |
|---|---|---|
trusted_headers |
The headers a mesh ingress wrote (auth.trusted_headers), believed from a peer of server.trusted_proxies only |
The tenant header; subject names the agent |
oidc |
A token of one of the issuers, with the declared scopes or roles | The tenant_claim claim |
api_key |
Authorization: ApiKey <key>, the key bound to ai.chatCompletions |
The key's |
mtls |
A declared client certificate, bound to the operation | The certificate's |
Behind a mesh ingress that already authenticated the caller (the header names are the deployment's, never the product's):
server:
trusted_proxies: [10.42.0.0/16] # the ingress, and only it: from anyone else, the header is a forgery
auth:
trusted_headers: { tenant: X-Mesh-Tenant, subject: X-Mesh-Agent }
ai:
security: [ { trusted_headers: {} } ]Cost, soft cap and fallback
Each backend carries its price per million tokens; the budget in euros adds to the token budget - the first reached decides (full example: Cost, soft cap and fallback):
ai:
backends:
eu-large:
# ...
price: { input_per_mtok: 2.00, output_per_mtok: 6.00 } # euros per million tokens
fallback: [eu-small, on-prem] # in order, before any byte reaches the client
budget:
monthly_eur: 50 # per tenant, over the calendar month
soft: { at: 0.8, backend: eu-small }
overrides:
- { match: { tenant: acme }, monthly_eur: 500 }| Mechanism | Behaviour |
|---|---|
| Cost | Pre-booked (input estimated at body size / 4, output at max_tokens), reconciled on the real usage at the price of the backend that served; a call that fails or is refused gives its booking back, tokens and euros; carried by the audit and the flexway.inference.cost metric (euros, by tenant and backend) |
| Soft cap | Past at x budget: a Flexway-Budget: soft header, a metric, a log line; the call goes to soft.backend - except a sensitive request, which stays pinned |
| Hard cap | A pre-booking that would pass the euro (or token) budget: 429 budget_exceeded, before any call |
| Fallback | On connection failure, 5xx, timeout or open circuit: the next of fallback, each backend once; never on a 4xx (the provider's 429 included), nor after a stream's first byte, nor to an external backend for a sensitive request. The answer is marked Flexway-Degraded: backend-fallback |
A price is required for every backend once a budget in euros exists: a cost nobody can compute cannot be budgeted.
0is a price - an internal backend's.
Check:
| Call | Response |
|---|---|
| An accepted identity, budget left | relayed as the backend wrote it; the backend gets the client's body with the policy's model, and its own key - never the caller's identity |
stream: true |
events arrive as they are produced; the budget is reconciled on the final usage |
| No identity, or an identity without a tenant | 401 tenant_required |
| The tenant's budget spent (completion or stream) | 429 budget_exceeded, before any backend call |
X-Sensitive: true with no internal backend |
403 sensitive_no_internal_backend - never an external backend |
| Backend down, no fallback left | 502 backend_unavailable; during a stream, a last event {"error":{"type":"backend_unavailable"}} |
A gateway refusal on /v1/* (scope, rate, unknown route) |
OpenAI envelope, OpenAI's type (permission_error, rate_limit_error...) and the gateway's code |
A tenant past soft.at of its euro budget |
Flexway-Budget: soft; served by soft.backend |
A backend down, with fallback |
served by the next one, Flexway-Degraded: backend-fallback |
| A policy naming an undeclared backend | refused at load, the key named |
Rate limits:
/v1/*follows the policy chain like any route - arate-limitpolicy of its own, attached toroutes: { "/v1/**": [...] }, bounds it apart (example 12.17). Behind an ingress, the bucket is the tenant and agent it vouches for, never the ingress's address.Migrating from
config/policy.yaml- no longer read since0.1.0: withoutFLEXWAY_CONFIG, the gateway refuses to start. The mapping, to apply once by hand:
policy.yamlflexway.yamldefault_backend,backends.<n>.default_model,externalai.default_backend,ai.backends.<n>.*backends.<n>.base_url_envai.backends.<n>.url- the URL itselfbackends.<n>.api_key_env: VARai.backends.<n>.api_key: "${VAR}"orfile:/...pii_guard.sensitive_backendai.sensitive_backendbudget.*_tokens_defaultai.budget.*_tokens;REDIS_URL->ai.budget.storeoverridesai.overrides, same shapeHeaders injected by the ingress auth.trusted_headers+server.trusted_proxies+ai.security: [ { trusted_headers: {} } ]
Several backends: images, embeddings, relays and specialised models
Since 1.5.0, a backend declares where it is (kind), what it
serves (capabilities), the models a caller may name (models), how the gateway
authenticates (auth) and who meters the cost (metering). A configuration written before
stays valid as it is: kind: openai, capabilities: [chat], api_key is
auth: { scheme: bearer }, metering: local.
The three kinds:
ai:
default_backend: platform
sensitive_backend: local-vision
backends:
platform: # another Flexway: it meters, it caps
kind: relay
url: https://llm.platform.example/v1
auth: { scheme: token_file, path: /var/run/flexway/peer-token } # read again at each call
identity_headers: { tenant: X-Tenant, agent: X-Agent, sensitive: X-Sensitive }
metering: upstream
external: false # the operator declares it: sensitive data may go there
capabilities: [chat, vision, embeddings]
provider-direct: # a provider called directly: the key lives here
kind: openai
url: https://api.provider.example/v1
auth: { scheme: header, header: X-Api-Key, key: "${PROVIDER_KEY}" }
external: true
capabilities: [chat, vision, embeddings]
models: [model-a, model-vision-b, embed-c] # served when asked for, else default_model
default_model: model-a
price: { input_per_mtok: 0.15, output_per_mtok: 0.35 }
local-vision: # a model of the cluster
url: http://vision.models.svc:8080/v1
default_model: vision-small
auth: { scheme: none }
capabilities: [vision] # an image; text too
faces: # a specialised model: the upstream of a gRPC contract
kind: grpc
upstream: models # declared under upstreams:, protocol: grpc
capabilities: [vision]
overrides:
- { match: { agent: screen-reader }, backend: provider-direct }
- { match: { capability: embeddings }, backend: provider-direct } # routes only, never a budget
vision:
max_image_bytes: 8MiB # one image, decoded (the default)
allowed_origins: ["https://cdn.example"] # otherwise inline images (data:) only| Key | Values | Effect |
|---|---|---|
kind |
openai (default), relay, grpc |
An OpenAI-compatible API; another Flexway; the upstream of a gRPC specialised model, never a target of /v1/* |
capabilities |
chat (default), vision, embeddings; model (grpc) |
An image only goes to a vision backend, embeddings only to an embeddings one, else 400 capability_unavailable |
models |
list | The model asked for is served when listed, else default_model; the audit keeps the model served. A relay without default_model forwards the model asked for |
auth.scheme |
none, bearer (key), header (header, key), token_file (path) |
The key is a reference (${VAR}, file:), read again at each call; token_file reads a short-lived token again (workload identity) |
metering |
local (default), upstream |
See below |
external |
boolean | A sensitive request never goes there; a relay is external unless external: false |
identity_headers |
tenant (required), agent, sensitive |
The names a relay receives the call's identity under - those the other Flexway declares in auth.trusted_headers and ai.sensitive_header |
tls |
as an upstream's | Mutual TLS toward a relay |
match.capability (override) |
chat, vision, embeddings |
Chooses the backend of the calls of that capability, after the tenant and agent override; never a budget |
Metering - who counts the cost:
metering |
Tokens | Euros | Upstream refusal | Series |
|---|---|---|---|---|
local |
Booked, reconciled on the usage | At the declared price | A provider's 4xx: 502 backend_unavailable |
metered_by="local" |
upstream |
Local cap always applied, on the usage the upstream returns | At the declared price if any; without one, the upstream is authoritative | Relayed as it came, 429 budget_exceeded included |
metered_by="upstream" |
A consumption view sums
metered_by!="upstream"only - never="local": a series from before1.5.0has no such label -, a relayed call being countedlocalby the Flexway that pays for it. The console shows the upstream's share beside, under "Metered upstream" (Reading with the console).flexway validatewarns of arelaymetered locally.
Platform side - the Flexway receiving the relay believes the identity headers from this peer only:
server:
trusted_proxies: [10.42.0.0/16] # the products' Flexway
auth:
trusted_headers: { tenant: X-Tenant, subject: X-Agent }
ai:
security: [ { trusted_headers: {} } ]
sensitive_header: X-SensitiveImage and embeddings:
# an inline image, to a vision backend
curl -s http://localhost:8080/v1/chat/completions -H "Authorization: Bearer $TOKEN" \
-d '{"model":"model-vision-b","messages":[{"role":"user","content":[{"type":"text","text":"What does this screen show?"},{"type":"image_url","image_url":{"url":"data:image/png;base64,iVBORw0..."}}]}]}'
# embeddings
curl -s http://localhost:8080/v1/embeddings -H "Authorization: Bearer $TOKEN" -d '{"model":"embed-c","input":["one","two"]}'gRPC specialised model - a method of the product's contract, metered by the ai option:
rpc Classify(Image) returns (Labels) {
option (flexway.v1.operation) = { ai: { unit: "call", price_per_1k: 0.02, capability: "vision" } };
}Each call books its price in the tenant's euro budget (ai.budget.monthly_eur and the
overrides), before the upstream; unit: byte counts the bytes of the request and the answer as they
cross. An upstream that is down costs nothing.
Check:
| Call | Answer |
|---|---|
Image to a vision backend |
served; the body as written, the model admitted |
Image with no vision backend chosen, or embeddings with no embeddings backend |
400 capability_unavailable, before any backend |
Image past max_image_bytes |
413 payload_too_large |
Image by URL from an undeclared origin, an input_audio part |
400 invalid_request |
| Relay: tenant past the platform's budget | the platform's 429 budget_exceeded, relayed as it came |
| Relay: tenant past the local cap | 429 budget_exceeded, the platform never called |
| Metered gRPC method past the budget | RESOURCE_EXHAUSTED, reason budget_exceeded; the upstream never called |
Metered gRPC method with no grpc backend naming its upstream |
refused at load |
Available since 0.1.0; several backends since 1.5.0.