Admin API
The contract between the admin UI and the control plane, and the gateway's data plane.
On this page
Base path: same origin as the admin UI (BFF pattern). JSON uses camelCase, enums are serialised as
strings (e.g. "RevokeImmediately"), timestamps are ISO-8601 with offset, money is in USD (field names end
in Usd). Errors use RFC 9457 application/problem+json:
{ "type", "title", "status", "detail", "errors": { "field": ["message"] } } (English messages).
A malformed request (unreadable body) → 400; an unexpected failure → 500 with the detail "An internal error occurred."
(the exception is only logged, by type). Every response, error responses included, carries X-Content-Type-Options: nosniff,
X-Frame-Options: DENY and Referrer-Policy: no-referrer.
A change is saved before the gateways are told to reload. If they cannot be told (Redis unavailable), the request still
succeeds, the admin API logs a warning, and the response carries Ume-Change-Propagation: delayed: the gateways apply the
change when their cache expires (Gateway:KeyCacheSeconds / Gateway:CatalogCacheSeconds, 30 s by default). The admin UI
shows this as a notice.
Authentication (BFF)
GET /bff/login?returnUrl=/path→ 302 to OIDC provider (Keycloak in dev, Entra ID/AD FS in prod).POST /bff/logout→ signs out (cookie + OIDC end-session).- Logout returns
{ redirectUrl }; navigate to this URL to complete OIDC provider signout. The end-session request includesclient_idand protected state for the local return to/. Register/signout-callback-oidcas an allowed post-logout redirect at the identity provider. Tokens are not saved; providers such as Keycloak may therefore require logout confirmation. POST /bff/session/extend→{ sessionExpiresAt, sessionLimitAt }, renews an authenticated session for eight hours (but not pastsessionLimitAt) without losing unsaved forms. At the limit it answers409: sign in again. Requires the same CSRF headers as other mutations.GET /bff/user→{ isAuthenticated, name, email, roles: string[], departmentCodes: string[], teamCodes: string[], sessionExpiresAt, sessionLimitAt }(always 200;isAuthenticated=falsewhen not signed in).sessionLimitAtis the absolute end of the session.- Session cookie:
__Host-ume-admin, HttpOnly, Secure, SameSite=Lax (compatible with OIDC redirects). No tokens reach the browser. The cookie only holds a session id; the session itself is stored encrypted in Redis (ume:admin-session:*), so signing out ends it for every copy of the cookie. A sign-in lasts eight hours, a session at mostAdmin:MaxSessionHours(12, 1–24) from sign-in; the UI warns before expiry and can start a new login. - CSRF: every non-GET request must send header
X-XSRF-TOKENwith the value of the readable cookieXSRF-TOKEN(set onGET /bff/user). Also sendX-Requested-With: XMLHttpRequest. - Unauthenticated API calls → 401 (no redirect). Missing role → 403.
- Local configuration:
Oidc:Authority,Oidc:ClientId(defaultume-admin), optionalOidc:ClientSecret,ConnectionStrings:gatewaydb,Security:KeyPepper, optional Redis and Data Protection certificate settings shared with the gateway. Production OIDC metadata requires HTTPS. Settings are validated at startup: the API does not start withoutOidc:Authority, withAdmin:RequestsPerMinuteout of range, or with aGateway:BaseUrl/Gateway:OperationsUrlthat is not an absolute http(s) URL.Gateway:BaseUrlis the public gateway address shown in the catalogue (defaulthttps://localhost:5140). - API rate limit: 120 requests/minute per authenticated subject (
Admin:RequestsPerMinute, 1–10000). Exceeded → 429 ProblemDetails withRetry-After. - Suggested Azure AI Foundry prices come from the Azure retail price list of
Admin:AzurePriceRegion(defaultswedencentral). - Model list prices come from models.dev (
Admin:PriceListUrl, defaulthttps://models.dev/api.json, no key; the admin API needs outbound HTTPS tomodels.dev, or point it at a copy of the file). Once a day they are added to the price history of models that follow the list, and empty model details are filled (Admin:AutoUpdatePrices, defaulttrue). An emptyAdmin:PriceListUrlturns the list off. See List prices in providers and models. - The newest release is read from GitHub (
https://api.github.com/repos/<Admin:ReleaseRepository>/releases/latest, defaultthekim1/llmgateway, no key; the admin API needs outbound HTTPS toapi.github.com). SetAdmin:CheckForReleases=false(Admin__CheckForReleases) where it has no internet access; the running version is still shown.
Roles (OIDC roles claim):
| Role | Can |
|---|---|
gateway-admin | everything |
department-admin | manage teams, keys and budgets in own förvaltning(ar) (departmentCodes claim = ansvarskod), read usage for them |
viewer | read-only: catalogue, own department usage |
team-admin | /api/keys for the teams whose team code is in the teamCodes claim (create, update, rotate, revoke; not reveal), GET /api/teams for those teams, the catalogue; everything else 403. See identity-and-roles.md |
Enums
DataResidency:OnPrem,Eu,ExternalProviderType:OpenAI,AzureOpenAI,AzureAIFoundry,Anthropic,Ollama,OllamaCloud,OpenAICompatibleProviderAuthMode:None,Bearer,ApiKeyHeader,XApiKeyHeaderProviderCapabilities(array of):ChatCompletions,Embeddings,Responses,AnthropicMessages,StreamingModelKind:Chat,Embedding;ParameterProfile:Standard,OpenAIReasoningPiiPolicy:Off,Allow,Redact,Block,RerouteToOnPremAttachmentPolicy:Allowed(default),ImagesOnly,None(text only)BudgetScope:Department,Team,VirtualKey;BudgetPeriod:Daily,Weekly,Monthly,Quarterly,YearlyKeyRotationMode:RevokeImmediately(default),Grace24HoursKeyStatus:Active,InGracePeriod,Expired,Revoked,DisabledRequestOutcome:Success,ProviderError,BudgetExceeded,RateLimited,PiiBlocked,Rejected,ClientCancelledCircuitState:Closed,Open
Organisation
GET /api/departments→Department[]whereDepartment = { id, name, costCenterCode, isActive, teamCount, createdAt }POST /api/departments{ name, costCenterCode }→ 201Department(gateway-admin)PUT /api/departments/{id}{ name, costCenterCode, isActive }→DepartmentDELETE /api/departments/{id}→ 204 (409 if it has teams)GET /api/teams?departmentId=→Team[]whereTeam = { id, departmentId, departmentName, name, description, isActive, keyCount, createdAt, requestPolicy: RequestPolicy|null, code: string|null }(team admins get their own teams; every other role the teams of its departments)POST /api/teams{ departmentId, name, description?, requestPolicy?, code? }→ 201TeamPUT /api/teams/{id}{ name, description?, isActive, requestPolicy?, code? }→Team(requestPolicyomitted = unchanged,{}clears it;codeis the team code matched against team admins'teamCodesclaim, trimmed, at most 100 characters, omitted = unchanged,""clears it, 409 when another team has it)RequestPolicy = { maxOutputTokens?, systemPrompt?, defaults?: object, overrides?: object }: applied by the gateway to every request of the team's keys, after the PII policy and routing rules.maxOutputTokens(1–1 000 000) lowersmax_tokens/max_completion_tokens/max_output_tokensto it, or sets it when the request has none;systemPrompt(at most 8 000 characters) goes before the request's own system message,instructionsorsystem;defaultsare request parameters set when the request leaves them out,overridesalways (at most 20 each). They may not setmodel,messages,input,prompt,system,instructions,stream,stream_options,file,image,mask, themax_*token parameters, or the provider tool and storage fields (tools,tool_choice,web_search_options,mcp_servers,container,store,background,conversation,previous_response_id), which need the key's own Allow provider tools / Allow provider storage (400). A key's own policy wins over its team's field by field (parameters by name).DELETE /api/teams/{id}→ 204 (409 if it has keys)
Virtual keys
VirtualKey = { id, teamId, teamName, departmentId, departmentName, name, description, prefix, status: KeyStatus, isEnabled, createdAt, createdBy, expiresAt, revokedAt, graceUntil, lastUsedAt, allowedModels: string[], allowedResidencies: DataResidency[], allowedProviders: string[] (provider names, empty = all; omitted on update = unchanged), canReveal, piiPolicy, attachmentPolicy: AttachmentPolicy, requestsPerMinute, tokensPerMinute, rotatedToKeyId, allowedIpRanges: string[], allowProviderTools: boolean, allowProviderStorage: boolean, maxConcurrentRequests: number|null, requestPolicy: RequestPolicy|null }
(contentGuardPolicy: Off (default), Record or Block, see content guard; omitted on update = unchanged.
maxConcurrentRequests, 1–100 000 or null: requests of the key in progress at once across all gateway instances; more
get 429 rate_limited. Live audio sessions have their own limit. requestPolicy: see Organisation; omitted on update =
unchanged, {} clears it. Both are copied to the replacement key on rotation.)
(allowedIpRanges: client addresses the key may be used from, single IPs or CIDR networks such as 203.0.113.0/24, at most
100; empty = any address; omitted on update = unchanged; returned in canonical form, a network with host bits set such as
10.20.1.5/16 is refused with 400; copied to the replacement key on rotation. Behind a reverse proxy it needs the proxy in
trusted proxies. Any key manager may set it.)
(empty allowedModels / allowedResidencies = all allowed)
(attachmentPolicy omitted = Allowed on create, unchanged on update; it is copied to the replacement key on rotation)
(allowProviderTools / allowProviderStorage omitted = false on create, unchanged on update; copied on rotation; see
provider tools and storage)
(lastUsedAt has minute resolution: each gateway instance writes it at most once per key and minute)
GET /api/keys?teamId=&departmentId=&status=→VirtualKey[](the keys the user manages: own departments, or own teams for a team admin)GET /api/keys/{id}→VirtualKeyPOST /api/keys{ teamId, name, description?, expiresAt?, allowedModels, allowedResidencies, allowedProviders?, allowedIpRanges?, piiPolicy, attachmentPolicy?, allowProviderTools?, allowProviderStorage?, requestsPerMinute?, tokensPerMinute?, maxConcurrentRequests?, requestPolicy? }→ 201{ key: VirtualKey, secret: "ume-sk-…" }— secret is shown once, never retrievable againPUT /api/keys/{id}{ name, description?, expiresAt?, allowedModels, allowedResidencies, allowedProviders?, allowedIpRanges?, piiPolicy, attachmentPolicy?, allowProviderTools?, allowProviderStorage?, requestsPerMinute?, tokensPerMinute?, maxConcurrentRequests?, requestPolicy?, isEnabled }→VirtualKeyPOST /api/keys/{id}/rotate{ mode: KeyRotationMode }→{ key: VirtualKey, secret, previousKey: VirtualKey }POST /api/keys/{id}/reveal{ purpose: "Reveal" | "Copy" }→{ secret }(gateway-admin only; writes an audit entryreveal/copywith the actor; 409 for revoked keys and for keys created before the secret was stored,VirtualKey.canRevealis false for those)POST /api/keys/{id}/revoke→VirtualKey(immediate, also cancels grace)- Rotation retains the predecessor's key budgets and spend across the full rotation lineage, including the grace-period key. It does not reset the remaining budget. Concurrent conflicting mutations → 409.
Data API access (gateway-admin)
Keys for the read-only Data API (data-access.md), for integrations that cannot use OAuth client credentials.
DataApiKey = { id, name, description, prefix, permissions: DataPermission[], status: "Active"|"Expired"|"Revoked", createdAt, createdBy, expiresAt, revokedAt, lastUsedAt }
(DataPermission = usage.aggregate | usage.detail | security.read | catalog.read, stored in that order; lastUsedAt is written by the Data API at most every 5 minutes per key)
GET /api/data-access→{ dataApiUrl: string|null, permissions: DataPermission[] }(dataApiUrlfrom the admin API'sDataApi:PublicUrl)GET /api/data-access/keys→DataApiKey[](sorted by name; never the secret or hash)GET /api/data-access/keys/{id}→DataApiKeyPOST /api/data-access/keys{ name, description?, permissions: DataPermission[] (1+), expiresAt? }→ 201{ key: DataApiKey, secret: "ume-dk-…" }— secret is shown once, only its SHA-256 is storedPUT /api/data-access/keys/{id}(same body) →DataApiKey; 409 for a revoked keyPOST /api/data-access/keys/{id}/revoke→DataApiKey; 409 if already revoked. The Data API refuses the key withinDataApi:KeyCacheSeconds(30 s).DELETE /api/data-access/keys/{id}→ 204; 409 for an active key (revoke it first)- Names are unique (400 with a field error on
name);expiresAtmust be in the future. Every change is audited (entity typeDataApiKey, actionscreate,update,revoke,delete; details never hold the secret or hash).
Providers, models, routes
- Plain HTTP is accepted only for on-prem local/test configuration. Production gateway invocation requires HTTPS even for on-prem targets and never follows upstream redirects. Configure approved TLS endpoints before enabling production providers.
Provider = { id, name, displayName, type, baseUrl, authMode, hasCredential, residency, capabilities: ProviderCapabilities[], isEnabled, isDrained, timeoutSeconds, createdAt, deploymentCount, autoUpdatePrices, priceList|null }(autoUpdatePrices: the provider's models follow the models.dev list price unless a model opts out;priceList: the models.dev provider its models are looked up in, null when it has none) (credential is write-only, never returned)GET /api/providers·POST /api/providers{ name, displayName?, type, baseUrl, authMode, credential?, residency, capabilities?, timeoutSeconds, isEnabled, autoUpdatePrices? }(capabilitiesonly forOpenAICompatible: omitted = unchanged, chat completions, streaming and embeddings for a new one,[]is refused; other types ignore it) (omittedautoUpdatePrices= unchanged, on for a new provider)PUT /api/providers/{id}same body;credential: omitted/null = unchanged,""= remove. A stored credential is only kept while the base URL's scheme, host and port stay the same: moving the provider elsewhere without a newcredential(or"") answers400with a field error oncredential.POST /api/providers/{id}/drain{ drained: boolean }→Provider·DELETE /api/providers/{id}(409 if models exist)POST /api/providers/{id}/discover-models→DiscoveredModel[] = { id, displayName, kind, parameterProfile, contextWindow|null, features: string[], price: { inputPerMillionUsd, cachedInputPerMillionUsd, outputPerMillionUsd, cacheWriteInputPerMillionUsd? }|null, priceSource|null, alreadyAdded, description|null, baseModel|null }. Calls the provider's/modelswith the stored credential, so it doubles as a connection test (502/504 on failure). Context size, features and price are included only when the provider reports them; when the provider is in the models.dev list, a model gets what the provider does not report from it (price, capabilities, context window, description;priceSourcesays where a suggested price came from). A Foundry deployment is looked up by the model behind it (baseModel); an Azure AI Foundry provider lists the project's Claude deployments too (the gateway sends them to the resource's Anthropic API), an Anthropic provider at a Foundry address only those. Azure OpenAI is unsupported (400). In Production the provider address must be https (400 otherwise), like the gateway; one attempt, 20 s timeout.Price = { inputPerMillionUsd, cachedInputPerMillionUsd, cacheWriteInputPerMillionUsd|null, outputPerMillionUsd, audioPerMinuteUsd, audioInputPerMillionUsd, audioOutputPerMillionUsd, perImageUsd, effectiveFrom, source|null }(audioPerMinuteUsdis for models billed by the duration of the input audio, such as Whisper and gpt-realtime-translate, up to ten decimals: a price per hour is sent divided by 60, one per second multiplied by 60; with it set andaudioInputPerMillionUsdat 0, audio input tokens are covered by the duration and not billed as text;audioInputPerMillionUsdandaudioOutputPerMillionUsdprice the audio part of the tokens of realtime and gpt-4o-transcribe models, 0 = billed as text tokens;perImageUsdis for image models billed per picture, such as DALL·E; all four omitted on create = 0;cacheWriteInputPerMillionUsdprices prompt-cache writes (Anthropiccache_creation_input_tokens), omitted or null = billed atinputPerMillionUsd;sourceis read-only: where the price came from, e.g.models.dev anthropic/claude-opus-4-5, null when an admin entered it)ProviderCapabilities:ChatCompletions,Embeddings,Responses,AnthropicMessages,Streaming,AudioTranscriptions(speech to text),Realtime(live audio over WebSocket),Images(image generation and editing). A provider'scapabilitiesare what it serves: fixed by its type, except forOpenAICompatible, where the request'scapabilitiesare the endpoints the server offers (see What a provider serves in providers and models).ModelKind(models and routes):Chat,Embedding,Transcription,Realtime,SpeechTranslation,Image. Discovery adds Whisper and*-transcribemodels asTranscription,*realtime-translate*asSpeechTranslation, other*realtime*models asRealtimeandgpt-image*,dall-e*,flux*,imagen*andstable-diffusion*models asImage; speech synthesis, video and chat-with-audio models are still left out.baseUrlmay carry query parameters (e.g.?api-version=…for Foundry Voice Live); they are kept after the path the gateway appends. Parameters that look like credentials (*key*,*token*,*secret*,*auth*,sig,code) are refused.Model = { id, providerId, providerName, residency, name, upstreamModel, kind, parameterProfile, contextWindow, isEnabled, features: string[], currentPrice: Price|null, publicName, description|null, autoUpdatePrice, priceMode, priceListKey|null, baseModel|null }(autoUpdatePrice: the price follows the models.dev list price when listed;priceMode:Listfollows the list price ofpriceListKey,Unlistedis not in the list and priced by hand until it is,Manualhas automatic prices off for the model, its provider or the gateway, or is an Azure deployment other than Claude (priced by hand; the update only fills a missing price);priceListKey: the model's models.dev entry,provider/model, null when it has none;baseModel: the model behind an Azure deployment, which finds it in the list, and aclaude*base model on a Foundry provider is sent to the resource's Anthropic API)GET /api/models·POST /api/models{ providerId, name, upstreamModel, kind, parameterProfile, contextWindow?, isEnabled, features?: string[], price?: Price, autoUpdatePrice?, description?, baseModel? }(omittedautoUpdatePrice= true)PUT /api/models/{id}{ name, upstreamModel, kind, parameterProfile, contextWindow?, isEnabled, features?: string[], autoUpdatePrice?, description?, baseModel? }(omitted = unchanged;""removesdescriptionorbaseModel) ·DELETE /api/models/{id}(409 if used by a route)GET /api/models/{id}/prices→Price[]·POST /api/models/{id}/pricesPrice(effectiveFrom optional = now); 409 while the model'spriceModeisList: turn offautoUpdatePriceon the model orautoUpdatePriceson its provider first.Route = { id, name, description, kind, isEnabled, targets: [{ modelId, modelName, providerName, residency, priority, weight }] }GET /api/routes·POST /api/routes·PUT /api/routes/{id}body{ name, description?, kind, isEnabled, targets: [{ modelId, priority, weight }] }·DELETE /api/routes/{id}
Routing rules
Concepts, condition language and evaluation order: routing rules. All endpoints require
gateway-admin. Mutations are audited (RoutingRule: create, update, delete, reassign, reorder, deactivate)
and invalidate the gateway catalogue.
RoutingRule = { id, name, description, isEnabled, isTemplate, priority, scope: "Global"|"Department"|"Team"|"VirtualKey", scopeIds: Guid[], owners: [{ id, name|null }], isOrphaned, condition, chain, targets: [{ model, weight }], fallbacks: string[], missingModels: string[], validationErrors: [{ message, position|null, length|null }], createdAt, updatedAt }nameis the rule's title (the UI labels it Title);descriptiona short description.scopeIds/owners: the keys, teams or departments (all of the kindscopenames) the rule is shared by; empty for a global rule. An owner'snameisnullwhen it no longer exists.isOrphaned: none of the rule's owners exists any more. Such a rule never applies and cannot be enabled until reassigned.isTemplate: a shipped example rule (seeded disabled). Editing, enabling or reassigning it makes it an ordinary rule (isTemplate: false).missingModels: targets or fallbacks that are neither a route alias nor a model. A disabled rule may have them; it cannot be enabled until they exist.validationErrors: why the gateway ignores an enabled rule (empty when valid).
GET /api/routing-rules?scope=&scopeId=&orphaned=(ordered by scope, then priority;scopeIdreturns the rules whose owners include it) ·GET /api/routing-rules/{id}POST /api/routing-rules·PUT /api/routing-rules/{id}body{ name, description?, scope, scopeIds?: Guid[] (≤1000), isEnabled, priority, condition?, chain, targets: [{ model, weight (1..1000000) }] (1..20), fallbacks?: string[] }→RoutingRule.modelis a route alias or model name. Validation → 400 with field errors (conditionmessages include the character position,targets,fallbacks,scopeIds). An enabled rule needs at least one owner (all existing), a non-chained enabled rule may only target existing names and its fallbacks must exist; a disabled rule may name models that do not exist yet and may keep an owner that has been removed (new owners must exist). Duplicate owner ids are dropped. Names are unique among global rules, and among rules of one scope that share an owner (409, case-insensitive).PUT /api/routing-rules/{id}/enabled{ isEnabled }→RoutingRule. The on/off switch. Turning a rule on checks it as a save would (owners and models must exist); otherwise 400 withdetail"Regeln kan inte aktiveras. …" and the fielderrors. Audited asupdate.DELETE /api/routing-rules/{id}→ 204POST /api/routing-rules/{id}/reassign{ scope, scopeIds?: Guid[], enable = true }→RoutingRule. Attaches the rule to new teams, departments or keys (at least one, all existing) or makes it global and, by default, enables it (then its models must exist).POST /api/routing-rules/reorder{ scope, ruleIds: Guid[] }→RoutingRule[]. Sets priorities 0, 10, 20, … in the given order; every id must belong to that scope (the order is kept per scope, since rules can be shared by several owners).POST /api/routing-rules/validate{ condition }→{ valid, errors: [{ position, length, message }], variables: string[], available: [{ name, type }] }(live validation while typing).POST /api/routing-rules/test(dry run){ model, endpoint?, headers?, params?, keyId?, teamId?, departmentId?, budgetUsed?, tokensUsed?, piiDetected?, promptTokens?, seed? }→{ matched, primaryModel, models: [{ name, exists, type, kind, enabled }], applied: [{ ruleId, name, fromModel, toModel }], chainLimitReached, evaluation: [{ ruleId, name, scope, priority, chainStep, model, outcome: "Matched"|"NotMatched"|"Skipped", trace: [{ text, leftValue, result|null }] }], ignoredRules, note }. Evaluates the stored, enabled rules.keyIdfills in team, department and key name (and the key's rotation lineage); usage values that are not supplied are unknown, so conditions using them do not match.seedmakes weighted choices repeatable.- Deleting a team or department that has rules.
DELETE /api/teams/{id}andDELETE /api/departments/{id}take an optional query parameterroutingRules=delete|deactivate. The team/department is removed from every rule shared with other owners (audited asupdate; those rules keep applying to the rest). If it is the only owner of some rules and the parameter is missing, the call fails with 409 and{ code: "routing_rules_scoped", choices: ["delete","deactivate"], rules: [{ id, name, isEnabled }], detail }(only those rules) so the client can ask the user.deleteremoves them;deactivatedisables them with no owners and keeps everything else, so they can be attached to new owners withreassign. The choice and the delete are saved in one transaction and audited per rule. Any other value → 400. - Names in use. A route alias or model that a rule uses as target or fallback cannot be deleted or renamed (409 naming the rule). Conditions that compare
modelwith a name are not checked. - Key rotation: a rule scoped to a key keeps applying to the key that replaced it, like budgets.
Budgets & alerts
- Cost budgets are in USD, the currency provider prices are in, so what a budget allows does not change with the exchange
rate; spend is the sum of the usage records'
costUsd. Token quotas (unit: "Tokens") count input plus output tokens instead (limitTokens,spentTokens; their USD fields are 0), optionally only of requests served by providers of oneresidency(OnPrem,Eu,External; null = all): the way to share on-prem capacity, whose models cost nothing. One cost budget per owner and period, and one token quota per owner, period and residency (409 otherwise). Budget = { id, scope, scopeId, scopeName, limitUsd, period, alertThresholds: number[], isActive, periodStart, periodEnd, spentUsd, percentUsed, unit: "Usd"|"Tokens", limitTokens, spentTokens, residency: DataResidency|null }GET /api/budgets?scope=&scopeId=·POST /api/budgets{ scope, scopeId, limitUsd, period, alertThresholds, isActive, unit?, limitTokens?, residency? }·PUT /api/budgets/{id}·DELETE /api/budgets/{id}(unitomitted =Usd; a token quota needslimitTokens> 0;residencyonly on token quotas, 400 otherwise)GET /api/alerts?acknowledged=false→[{ id, budgetId, scope, scopeName, thresholdPercent, spentUsd, limitUsd, periodStart, timestamp, acknowledged, spentTokens|null, limitTokens|null }](token quota alerts havespentTokens/limitTokensand USD amounts of 0; the alert webhook addsunit,spentTokens,limitTokens)POST /api/alerts/{id}/acknowledge→ 204GET /api/settings/retention(gateway-admin) →{ usageRetentionDays, auditRetentionDays (null = kept for ever), usageRetainedFrom, nextUsageCutoff, nextAuditCutoff, enabled, nextRunAt, running, lastRunAt, lastRunDeleted, lastRunError }·PUT /api/settings/retention{ usageRetentionDays, auditRetentionDays }(90–3 650; audited asSettings) ·POST /api/settings/retention/run→ 202 with the state (409 when a run is going; audited asrun). See Data retention in the runbook.usageRetainedFromis the oldest instant usage records cover;GET /api/usage/summaryreturnsmonthlyTotalsBeforewhen its period reaches before it (usage before that instant comes from whole-month totals;groupBy=dayshows those months asyyyy-MM). The CSV export and budget spend use the totals the same way.GET /api/settings/network(gateway-admin) →{ trustedProxies: string[], updatedAt, observed: { remoteAddress, forwardedFor } }·PUT /api/settings/network{ trustedProxies: string[] }(gateway-admin). Trusted reverse proxies: the gateway takes the client address fromX-Forwarded-Foronly on connections from these addresses (single IPs or CIDR networks, at most 100, canonicalised;/0networks are refused).observedis the connection the admin API saw for this request, to help find the proxy's address. Saving is audited (Settings) and publishes a configuration change, so the gateways apply it within seconds. See Client addresses and trusted proxies in the runbook.GET /api/settings/version(gateway-admin) →{ version, developmentBuild, checkEnabled, latest: { version, name, url, publishedAt } | null, updateAvailable, checkedAt, checkFailed }.versionis the running admin API's (0.0.0-localfor a local build:developmentBuild, neverupdateAvailable).latestis the newest published GitHub release (drafts and pre-releases excluded), checked at most every 6 hours (after a failure, again within 30 minutes;checkFailedthen keeps any earlier result); null when the check is off or no release exists.urlis always agithub.comlink. Browsers never call GitHub.GET /api/settings/model-prices(gateway-admin) →{ enabled, priceListUrl, lastRunAt, followed, updated, enriched, error, nextRunAt, running }: the daily models.dev update.enabledis false whenAdmin:AutoUpdatePricesis off orAdmin:PriceListUrlempty.followedis the number of models in price modeListat the last run,updatedthe prices added (including missing Azure prices),enrichedthe models whose empty description, capabilities or context window were filled; these,lastRunAtanderrorare those of the admin API instance that answered.POST /api/settings/model-prices/run→ 202 with the state: downloads the list now and updates in the background (409 when a run is going or updates are off). Each added price is audited asprice, each filled model asupdate, bysystem:price-list.GET /api/settings/request-log(gateway-admin) →{ active, until, updatedAt }·PUT /api/settings/request-log{ logAllRequests, noTimeLimit }(gateway-admin). Whether the gateways log successful requests too (normally only refused, failed and cancelled ones; every request is a usage record either way). Switched on, it lasts 24 hours unlessnoTimeLimitis true;untilis when it switches itself off (null withactive= no time limit;activeis false once it has expired). Saving is audited (Settings) and publishes a configuration change; the gateways check the end time on every request.- Budget alerts are stored with the usage batch that crossed the threshold. With
Gateway:AlertWebhookUrlset, the gateway also POSTs each alert once (metadata only, 5 s timeout, no retries) from its own queue of 1 000; when that queue is full the alert is stored but not sent (a warning is logged). Body:{ type: "budget_threshold", scope, scopeId, thresholdPercent, spentUsd, limitUsd, periodStart }.
Usage (metadata only – never prompt/response content)
GET /api/usage/summary?from=&to=&groupBy=department|team|key|model|provider|day&departmentId=&teamId=→{ from, to, totalCostUsd, totalRequests, totalInputTokens, totalOutputTokens, totalErrors, rows: [{ key, label, requests, inputTokens, outputTokens, costUsd, errors, fallbacks }] }GET /api/usage/metrics?from=&to=&bucket=auto|hour|day&departmentId=&teamId=&keyId=&model=&provider=&outcome=&endpoint=(any role, own departments) →{ from, to, bucket, timeZone, recordsFrom, totals, buckets: [...], models: [...] }, the Usage dashboard. Cost is in USD.bucket:auto(default) ishourfor periods up to two days, elseday. Hours are cut in UTC, days at Europe/Stockholm midnight (timeZone).hourallows at most 7 days; the period is at most one year (default: the last 30 days). Every bucket in the period is returned, empty ones with zeros andnulltimings, so the series have no gaps.- Filters:
modelis the requested model (alias or deployment name),providerthe provider account name,outcomeaRequestOutcome,endpointaGatewayEndpointname. Anything else is 400. totalsand each bucket:requests,successes,providerErrors,refused(BudgetExceeded, RateLimited, PiiBlocked, Rejected),cancelled,fallbacks(requests with at least one fallback),costUsd,inputTokens,cachedInputTokens,outputTokens,latencyandoverhead(each{ avg, p50, p90, p95, p99 }in ms) andtokensPerSecond.totalsalso hascacheHitRate(cached ÷ input tokens) andfirstToken: { avg, p50, p95 }; a bucket hasfirstTokenAvgMsandfirstTokenP95Ms.- Timings:
latencyleaves out live audio sessions and requests no provider handled;overheadislatencyMs − upstreamMsfor records that have it; time to first token covers streamed answers;tokensPerSecondis output tokens ÷ provider time over successful requests with output. Percentiles are nearest-rank (percentile_disc), over the whole period fortotals. models:[{ model, requests, costUsd, requestsPerBucket[], costUsdPerBucket[] }], the 8 requested models with the most requests, then one entry withmodel: nullfor all others together; the arrays line up withbuckets.recordsFromis set when the period starts before the oldest kept usage record (data retention): monthly totals have no timings or days, so the figures cover onlyrecordsFrom..to.
GET /api/usage/requests?from=&to=&keyId=&teamId=&departmentId=&model=&provider=&outcome=&errors=&page=1&pageSize=50→{ items: UsageRequest[], total }whereUsageRequest = { requestId, timestamp, keyPrefix, keyName, teamName, departmentName, endpoint, requestedModel, providerName, upstreamModel, inputTokens, cachedInputTokens, outputTokens, latencyMs, statusCode, outcome, fallbackCount, streamed, piiActionApplied, piiCategories, errorCode, routingRuleId, routingRuleName, costUsd, upstreamMs, timeToFirstTokenMs, guardFlags, providerFirstTokenMs }(routingRuleName: the routing rule that decided where the request went,nullwhen none applied;upstreamMs: time spent waiting on providers, failed fallback attempts and a streamed answer included,nullwhen no provider was called and for live audio sessions;timeToFirstTokenMs: streamed answers only, until the first event reached the client;providerFirstTokenMs: streamed answers only, from sending the request to the provider that answered until its first event, without failed fallback attempts, retries or gateway time)- Filters:
modelis the requested model;providerthe provider account name, or-for requests no provider handled (as the summary groups them);outcomeoneRequestOutcome;errors=truekeeps only requests that did not succeed (every outcome butSuccess).
- Filters:
GET /api/usage/requests/{requestId}→UsageRequest(lookup byx-request-idfrom gateway response)GET /api/usage/pii-incidents?from=&to=&keyId=&teamId=&departmentId=&category=&action=&page=1&pageSize=50(any role, own departments) →{ from, to, items: PiiIncident[], total, totalIncidents, categories: [{ category, requests, occurrences }], actions: [{ action, requests }] }wherePiiIncident = { requestId, timestamp, keyId, keyPrefix, keyName, teamId, teamName, departmentId, departmentName, endpoint, requestedModel, providerName, action, categories: [{ category, count }], outcome, statusCode, routingRuleName }. An incident is a usage record with PII found (piiCategoriesset), newest first; this includes policyAllow(detected and sent on).categoryisPersonnummer|Samordningsnummer|Email|Phone|Iban,actionisAllow|Redact|Block|RerouteToOnPrem(case-insensitive; anything else is 400).items/totalhonour every filter;categories,actionsandtotalIncidentscover the period and the department/team/key filters only. Default period: the last 30 days, at most one year. Keys with PII policyOffare not scanned and have no incidents.GET /api/usage/export.csv?from=&to=→ CSV: ansvarskod, förvaltning, team, nyckel, requests, tokens, kostnad USD. Semicolon-separated; cells are quoted only when they contain a semicolon, a quote or a line break. A text cell a spreadsheet would run as a formula (starting with a tab or line break, or with=,+,-or@after any leading spaces) is prefixed with'; numbers such as-1.5are left as they are. The Data API's CSV (comma-separated) uses the same rule.
Catalogue (developer portal, any authenticated user)
GET /api/catalog→{ gatewayBaseUrl, routes: [{ name, description, kind, residencies: DataResidency[], capabilities: ProviderCapabilities[], inputUsdPerMillion, outputUsdPerMillion }], models: [{ name, kind, residency, capabilities, inputUsdPerMillion, outputUsdPerMillion, description|null, features|null }] }
Operations (gateway-admin)
GET /api/ops/health→{ checkedAt, components: [{ name, status: "Healthy"|"Degraded"|"Unhealthy", description }], versions: { adminApi, gateway: string|null, schema }, usageWriter: { queueDepth, capacity, inFlightRecords, lastWriteAt: string|null, consecutiveFailures, journaledBatches }|null, providers: [{ id, name, type, residency, isEnabled, isDrained, circuitState, requests24h, errorRate24h, providerErrorRate24h, fallbackRate24h, p50FirstTokenMs, p95FirstTokenMs, status, statusReason, recentRequests, recentProviderErrors }] }(providers sorted by name;p50FirstTokenMs/p95FirstTokenMsare the provider's time to first token on streamed answers over 24 h, see/api/ops/providers/stats;nullwhen it streamed none)errorRate24h: share of the last 24 h's requests that did not succeed (client errors and cancellations included).providerErrorRate24h: only provider errors, i.e. outcomeProviderErrorwith status ≥ 500 and an error code other thaninternal_error(upstream 5xx, timeouts, broken streams).status:"Healthy"|"Degraded"|"Unhealthy"|"CircuitOpen"|"NoRecentTraffic"|"Disabled", computed on every call:Disabledif the provider is disabled, elseCircuitOpenif its circuit is open, else from the last hour (recentRequests,recentProviderErrors): no requests →NoRecentTraffic; fewer than 10 →Healthy(too few to judge); otherwise provider error rate ≥ 50 % →Unhealthy, ≥ 5 % →Degraded, elseHealthy.statusReasonis an English sentence for the UI, e.g."4 of 40 requests failed at the provider in the last hour (10 %).".
GET /api/ops/providers/stats?from=&to=&bucket=hour|day→{ from, to, bucket: "Hour"|"Day", timeZone, providers: [{ providerId, name, exists, requests, providerErrors, availability, fallbackRate, p50FirstTokenMs, p95FirstTokenMs }], buckets: [{ providerId, start, requests, providerErrors, errorRate, fallbacks, p50FirstTokenMs, p95FirstTokenMs }] }bucket(case-insensitive) defaults tohour;todefaults to now andfromto 24 hours (hour) or 30 days (day) beforeto.fromis moved back to the start of its bucket and returned. Hour buckets are UTC hours (Stockholm's offset is whole hours, so they are also local hours) and allow at most 7 days; day buckets are Europe/Stockholm days and allow at most 90. Otherwise 400 ("Timvis indelning tillåter högst 7 dygn. Välj day för längre perioder.","Välj ett tidsintervall på högst 90 dygn.","Tidsintervallets slut måste ligga efter dess början.","Indelningen måste vara hour eller day.").bucketsholds only buckets with traffic (no row means no requests).providersis the whole period, most stable first:availability(1 − provider error rate) descending, thenp95FirstTokenMsascending, then requests. A provider deleted since hasexists: falseand the name recorded with its requests. Provider errors are counted as for/api/ops/health; the percentiles are ofproviderFirstTokenMs(streamed answers only: from sending the request to the provider that answered until its first event, so neither the answer's length nor failed fallback attempts count;nullwhen the provider streamed nothing);fallbacks/fallbackRatecount requests this provider answered after an earlier candidate failed.
- Gateway status/version/queue are fetched live over
Gateway:OperationsUrl(HTTPS; defaults toGateway:BaseUrl) within five seconds. An unavailable, malformed or unauthenticated probe yields an explicitUnhealthygateway component and null version/usageWriter, never fabricated values. Probe failures log only a safe reason.lastWriteAt=nullmeans no successful writes yet. - The gateway's metadata-only
GET /health/operationsrequires a 30-second timestamp-bound, purpose-separated HMAC signature using the shared pepper; it has no admin mutation capability. Unsigned/stale/invalid requests are 401. The pepper/signature are never returned to the browser. Usage write failures retain the in-flight batch for retry and report Degraded until recovery; queue persistence across process/host loss is not guaranteed. POST /api/ops/providers/{id}/circuit{ state: "Open" | "Closed" }→ 204 (force open = take out of rotation for 15 min)POST /api/ops/cache/invalidate-keys→ 204 (audited asinvalidateonKeys; the cache invalidations that ordinary changes publish are not audited separately)GET /api/ops/config/export→ JSON document (providers without credentials, models, prices, routes, global routing rules)POST /api/ops/config/import(same JSON) →{ created, updated, skipped }- Config schema:
{ schemaVersion: 1, providers: [{ configuration: ProviderRequest (without credential), credentialEnvironment?: "UME_PROVIDER_..." }], models: [{ providerName, configuration: ModelRequest, prices: Price[] }], routes: [{ name, description, kind, isEnabled, targets: [{ modelName, priority, weight }] }], routingRules?: [{ name, description?, isEnabled, priority, condition?, chain, targets: [{ model, weight }], fallbacks?: string[] }] }. Only global routing rules are transferred (key/team/department ids differ between environments), and not untouched templates (every installation has them); rules are matched by name, created or updated, never deleted by import, and each is validated like one saved through the API (an invalid rule fails the whole import). Documents withoutroutingRules(older exports) still import. Changing the kind of a model that a route uses → 409 "Modelltypen kan inte ändras när modellen används i en rutt." (same asPUT /api/models/{id}). References use stable names; database IDs in model configuration are ignored on import. Import is transactional, rejects plaintext credentials, preserves existing encrypted credentials when a reference is omitted, and never overwrites price history. Environment references must already exist in the Admin API process. Export never contains secret values or encrypted credentials. GET /api/audit?page=&pageSize=&entityType=→{ items: [{ id, timestamp, actor, action, entityType, entityId, details, actorName, actorRoles, actorDepartments, sourceAddress, sessionId, requestId, userAgent }], total }.actoris the OIDC subject; the other actor fields are recorded with each entry: name or e-mail, roles and department codes at the time, the client address behind the trusted proxies (Admin:AuditSourceAddress:Fullby default,TruncatedorNone), a random session id given at sign-in (never the cookie), the request's trace id and the user agent (200 characters). Entries written before have them empty.GET /api/ops/config/exportandGET /api/usage/export.csvwrite an entry with actionexport(entityConfigorUsage, with counts or the period and row count).detailsis JSON{ before, after }(camelCase, enums as names) with snapshots of the entity's own stored settings, never a secret: no derived or volatile values such asdeploymentCount,createdAt,currentPrice,providerName, keystatusorlastUsedAt. For a rotationafteris{ previousKey, key }, for a reveal/copy the key's name and prefix, for a config import the counts{ created, updated, skipped }.
Gateway (data plane) – for the developer portal snippets
- Base URL:
{gatewayBaseUrl}/v1. Auth:Authorization: Bearer ume-sk-…(alsox-api-key/api-key). POST /v1/chat/completions,POST /v1/embeddings,GET /v1/models,POST /v1/responses,POST /v1/messages(Anthropic format).POST /v1/audio/transcriptionsandPOST /v1/audio/translations(speech to text, OpenAI format):multipart/form-datawith the recording infileand the text fieldsmodel(aTranscriptionalias or model),language,prompt,response_format(json,text,srt,vtt,verbose_json),temperature,stream,timestamp_granularities[]…; every field exceptfileandmodelis passed on unchanged. The answer comes back in the provider's format and content type. Only providers with theAudioTranscriptionscapability are used. Uploads are limited toGateway:MaxAudioRequestBodyBytes(26 MB) and held in memory, never on disk. The PII policy scansprompt; the recording itself counts as an audio attachment, so keys set toImagesOnlyorNonegetattachment_not_allowed. Billing: provider-reported duration (usage.secondsor thedurationofverbose_json) ×audioPerMinuteUsd, plus reported tokens × token prices (gpt-4o-transcribe); whatever is not reported is estimated from the file (exact for WAV and FLAC, from the bitrate for MP3, 32 kbps otherwise). Usage records carryaudioSeconds.POST /v1/images/generations(JSON:modelanImagealias or model,prompt,n,size,quality,response_format…) andPOST /v1/images/edits(multipart/form-data: the pictures inimageorimage[], at most 16, an optionalmask, and the text fieldsmodel,prompt,n,size…): OpenAI format; every field except the files is passed on unchanged and the answer comes back as the provider sent it (data[]withb64_jsonorurl,usagefor token-billed models). Only providers with theImagescapability are used. Edits are limited toGateway:MaxImageRequestBodyBytes(50 MB for all pictures together) and held in memory, never on disk.streamis refused with400 invalid_request. The PII policy scans the text fields; the pictures count as image attachments, so keys set toNonegetattachment_not_allowedon edits. Billing: the number of pictures indata×perImageUsd, plus reported tokens × token prices (gpt-image-1).- Live audio: see below.
- Response headers:
x-request-id,x-ume-provider,x-ume-model,x-ume-fallbacks,x-ume-cost-usd,x-ratelimit-limit-requests,x-ratelimit-remaining-requests,x-ume-budget-remaining-usd,x-ume-rule(id(s) of the routing rule(s) that applied; see routing rules). - Error body (OpenAI style):
{ "error": { "message", "type", "code", "request_id", "doc_url" } }(Englishmessage;doc_urlis null whenGateway:PublishApiReferenceis off andGateway:DocsUrlpoints at the gateway itself) with codesinvalid_api_key,key_expired,key_revoked,key_disabled,address_not_allowed(403: the key is restricted to other client addresses),model_not_allowed,model_not_found,model_ambiguous,budget_exceeded,rate_limited,pii_blocked,attachment_not_allowed,tool_not_allowed,storage_not_allowed,no_eligible_provider,all_providers_failed,invalid_request(also for a body that is not valid JSON or repeats a property, such as twomodelfields),internal_error(500: an unexpected failure inside the gateway after the request was accepted; it is recorded as usage with outcomeProviderError, nothing is charged and the budget reservation is released; quote therequest_id). - Model names: a model is addressed by its public name (the plain name while one provider has it) or
<provider>/<model>(always). A plain name several providers share, with no route of that name, is refused with 400model_ambiguouslisting the<provider>/<model>choices; the gateway never picks a provider by itself.GET /api/modelsreturns each model'spublicName; model names are unique per provider (409 otherwise). - Fallback and circuit breaker: only to what an admin set up (a route's targets, a routing rule's targets and fallbacks;
never to another provider's model of the same name). The next one is tried after a transient failure (408, 409, 429, 5xx, timeout,
connection error, invalid JSON) or a provider configuration failure (upstream 401/403/404,
https_required,credential_unavailable,endpoint_not_supported). Only transient failures count toward the provider's circuit breaker. Other client errors (e.g. 400) are returned to the client, see below. GET /v1/modelslists only aliases and models the key may use and that some endpoint of their kind can reach, by the same rule as routing: model and provider enabled and not drained, a provider capability for that endpoint, and the key's provider and residency allow-lists.- Outgoing requests to providers carry
User-Agent: Ume-LlmGateway/<version>. A provider-side refusal is returned with the provider's status, but only itsmessage,type,codeandparam, in the client's error shape (OpenAI, or Anthropic on/v1/messages) with the gateway'srequest_id; the rest of the provider's body (inner errors, deployment or organisation names) is dropped, and a body that is not JSON becomes The provider rejected the request. (provider_rejected). One the gateway raises itself isunsupported_content(400): a Chat Completions request to a Claude model contains a part the Messages API cannot take (audio, video, an OpenAIfile_id, or a file other than PDF or plain text). An audio upload with nofile, or one that is notmultipart/form-data, getsinvalid_request(400) orunsupported_media_type(415); one over the limit getsrequest_too_large(413).
Live audio (realtime)
WebSocket endpoints speaking the OpenAI Realtime protocol (JSON events, audio as base64), relayed to the provider:
GET /v1/realtime?model=<alias>: aTranscriptionalias gives a live transcription session, aRealtimealias a realtime session. Other query parameters are passed on (e.g.intent=transcriptionfor OpenAI's transcription sessions with gpt-4o-transcribe, in which case the model is not put in the URL), except credentials andmodel.GET /v1/realtime/translations?model=<alias>: live interpreting with aSpeechTranslationalias.- Upstream URL: the provider's base URL +
/realtimeor/realtime/translations,ws(s), withmodel=<upstream model>. Only providers with theRealtimecapability are used. TheOpenAI-Betaheader is passed on; the subprotocolrealtimeis accepted. - Authentication as for HTTP (
Authorization: Bearer …), so server-side clients only. Everything the HTTP pipeline checks is checked before the upgrade, and refusals are ordinary HTTP errors:invalid_request(400: not a WebSocket request, nomodel, or a model of the wrong kind),attachment_not_allowed(the key'sattachmentPolicyis notAllowed),rate_limited(429: per-minute limit, or the key already hasGateway:Realtime:MaxSessionsPerKeyopen sessions),budget_exceeded(402),all_providers_failed(502/504, after trying each candidate). - During the session the gateway sends OpenAI-style
errorevents{ type: "error", error: { type, code, message, event_id } }(event_idis the client event refused):model_not_allowed(an input transcription model that is not aTranscriptionalias/model of the session's provider, or whose provider is unavailable or outside the key's provider and residency allow-lists),pii_blocked,tool_not_allowed(anmcptool insession.updateorresponse.createon a key without provider tools),invalid_request(binary frame, or a session or conversation event that is not valid JSON or repeats a property; it is not passed on),request_too_large(event overMaxMessageBytes; the session is closed with 1009). It ends the session with an error event and a close frame onbudget_exceeded(close 1008; the message names the exhausted budget like the HTTP 402),session_time_limit(1000),gateway_shutting_down(1001) andprovider_disconnected(1001). - Usage: one record per session;
streamedis true,latencyMsis the session length,audioSecondsthe audio streamed (provider-reported duration when given, otherwise counted from the appended audio at the session's input format), tokens the sum of the provider's usage events. Outcomes:Success(closed by either side, time limit or shutdown, witherrorCodesession_time_limit/gateway_shutting_down),ClientCancelled(connection dropped),ProviderError(stream_interrupted, orinternal_errorwith the usage so far billed),BudgetExceeded. - Settings (
Gateway:Realtime:):MaxSessionsPerKey(20, 0 = no limit; needs the Redis ACL commandszadd zrem zcard zremrangebyscore, otherwise the limit is not enforced and a warning is logged),MaxSessionMinutes(120),BudgetCheckSeconds(30),ReserveMinutes(5),MaxMessageBytes(4 MB).
Attached files. Files travel inline in the JSON body as content parts; the gateway has no upload endpoint.
The key's attachmentPolicy is checked right after the body is parsed, before rate limiting and the PII policy:
| Kind | Chat Completions | Responses | Anthropic Messages |
|---|---|---|---|
| Image | image_url | input_image | image |
| Document | file (file_data or file_id) | input_file (file_data, file_id or file_url) | document, container_upload |
| Audio | input_audio | — | — |
| Video | video_url | input_video | — |
Parts nested inside other content (for example an Anthropic tool_result) are found too. ImagesOnly refuses
documents, audio and video; None refuses all four. A refused request gets 400 attachment_not_allowed and is recorded
with outcome Rejected. The PII policy scans text only, never file contents, so a key that must not send personal
data to a provider should use None. It cannot stop a client that extracts a file's text itself and sends it as an
ordinary message (common in chat front-ends); the PII policy is what covers that text.
Provider tools and storage. Checked after the attachment policy, on Chat Completions, Responses and Anthropic Messages. Both key settings are off by default:
allowProviderTools: tools the provider runs itself send the conversation to third parties or the internet. Without it only client tools are accepted:functionandcustom(OpenAI), and Anthropic tools without a type,custom, and the client-executedbash_*,text_editor_*,computer_*andmemory_*. Anything else intools(web_search,file_search,code_interpreter,mcp,image_generation, Anthropicweb_search_*,web_fetch_*,code_execution_*, …), Chat Completionsweb_search_options, and Anthropicmcp_serversorcontainerget 400tool_not_allowed.allowProviderStorage:store: true, Responsesbackground: trueand Responsesconversationkeep the conversation at the provider; without it they get 400storage_not_allowed, and a Responses request withoutstoreis sent withstore: false(the Responses API stores by default).previous_response_idthen only finds responses the provider kept.
Both refusals are recorded with outcome Rejected and logged as gateway.request.refused.
Key budgets. A key can have one budget per period (Hourly, Daily, Weekly, Monthly, Quarterly, Yearly) via POST /api/budgets with scope: "VirtualKey"; a second budget for the same owner and period returns 409. The gateway enforces every applicable budget (key, team, department) and rejects with 402 when any is exhausted. Hourly windows are aligned to the clock hour. requestsPerMinute and tokensPerMinute on the key are enforced over a sliding minute: this minute's count plus the previous minute's, weighted by how much of it the last 60 seconds still cover, so a client cannot send twice its limit across a minute boundary (429 rate_limited with Retry-After when one more fits; token usage is counted after each response; refused requests are not counted).