API Reference

Both planes are served by the gateway on port 8080. They are separated by the key that reaches them, not by the port: a data key opens /v1 and nothing else, an admin key opens /admin/v1 and nothing else. A key presented to the wrong plane is a 401 with code wrong_plane, never a partial success.

[!TIP] Both planes are published in full as an OpenAPI specification and a Postman collection — every route, on either plane. Both are generated from the source rather than written, and the gateway's own tests fail if either drifts from what it serves.

Every failure, on either plane, is the same envelope:

json
{
    "error": {
        "message": "The requested model is not available to this tenant.",
        "type": "invalid_request_error",
        "code": "model_not_found",
        "param": "model"
    }
}

1. Data plane (/v1)

Authenticate with a tenant data key: Authorization: Bearer cg-.... Admin keys carry the prefix cga-, so which plane a credential belongs to is legible without trying it.

MethodPathPurpose
POST/v1/chat/completionsRoute a completion, streaming or not
GET/v1/modelsThe models this tenant can reach, aliases included
GET/v1/models/{id}One model's metadata
GET/v1/usageToken and spend totals over a window
GET/v1/usage/breakdownThe same totals grouped by model, provider, key or tag
GET/v1/healthPer-provider reachability for this tenant
GET/v1/metaWhat this deployment supports, and its limits

GET /healthz needs no key at all — it is what a load balancer polls. GET /metrics serves Prometheus when metrics are enabled, guarded by its own token when one is configured.

Route a completion

POST /v1/chat/completions

bash
curl -X POST http://localhost:8080/v1/chat/completions \
  -H "Authorization: Bearer $COGNIGATE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "fast",
    "messages": [{"role": "user", "content": "What is the capital of France?"}]
  }'

model may be a concrete model id or one of the tenant's capability aliases. Set "stream": true for server-sent events; the response is relayed chunk by chunk and the usage record is written when the stream ends. 1 and 0 are accepted for it as well, because some clients send those; anything that is not a boolean in one of those spellings is a 400.

Every response carries X-CogniGate-Request-Id. A routed one also carries X-CogniGate-Served-By — the provider and model that actually answered — and X-CogniGate-Fallback-Depth, which is 0 when the first choice answered. When quotas are configured, X-CogniGate-Quota-State reports the caller's position on every response, refusals included: ok, soft-exceeded or hard-exceeded.

Send X-Client-Request-Id on the request to tag it with a correlation id of your own. It is stored on the usage record and is what group_by=client_request_id groups on.

Read usage back

GET /v1/usage?window=day

window is day or month, and defaults to day. The window it resolves to is half-open — recorded_at >= since and recorded_at < until — so a record on a boundary falls in exactly one of two adjacent windows, and the resolved bounds come back in the response.

bash
curl "http://localhost:8080/v1/usage?window=month" \
  -H "Authorization: Bearer $COGNIGATE_KEY"
json
{
    "object": "usage",
    "window": "month",
    "since": "2026-09-01T00:00:00Z",
    "until": "2026-09-03T11:04:22Z",
    "requests": 1420,
    "cached_requests": 88,
    "prompt_tokens": 384200,
    "completion_tokens": 91043,
    "total_tokens": 475243,
    "charge_usd": 4.81,
    "state": "ok",
    "limits": []
}

There is no cost_usd there because billing.cost_visibility defaults to hidden: what the provider charged the operator is not published to tenants, and charge_usd is what this caller owes. Set it to hint or exact and the key appears; the operator reads the exact figure on the admin plane either way.

GET /v1/usage/breakdown?group_by=model returns the same figures per bucket under data, sorted by spend. group_by accepts model, provider, key and client_request_id. The response is capped at the 200 costliest buckets and sets "truncated": true when it had to cut — grouping by client_request_id is what makes that bound necessary.


2. Admin plane (/admin/v1)

Authenticate with an admin key. The bootstrap key is set at startup; every key after that is minted through the plane itself. An admin key carries a scope — root reaches every tenant and can create them, a tenant-scoped key reaches exactly one.

Reaching a tenant your key is not scoped to is a 404, not a 403. A 403 would confirm that the tenant exists, which is enough to enumerate a deployment's customer list by guessing ids.

Deployment

MethodPathPurpose
GET/admin/v1/metaVersion, features, effective limits
POST/admin/v1/catalog/refreshRe-read model catalogues from providers
GET/admin/v1/auditEvery mutation, with actor and result

Admin keys

MethodPathPurpose
POST/admin/v1/admin-keysMint an admin key
GET/admin/v1/admin-keysList them (metadata only)
DELETE/admin/v1/admin-keys/{id}Revoke one

Tenants

MethodPathPurpose
POST/admin/v1/tenantsCreate — root only
GET/admin/v1/tenantsList
GET/admin/v1/tenants/{id}Read one
PATCH/admin/v1/tenants/{id}Rename, suspend, set limits, cache, capture and instruction policy
DELETE/admin/v1/tenants/{id}Delete
bash
curl -X POST http://localhost:8080/admin/v1/tenants \
  -H "Authorization: Bearer $COGNIGATE_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "acme"}'
json
{
    "id": "ten_7f3a…",
    "name": "acme",
    "status": "active",
    "created_at": "2026-09-03T11:04:22Z"
}

PATCH also carries system_instruction: text this tenant's completion requests put in front of the caller's own messages, after whatever the deployment configures. An empty string is a value rather than an absent field, and sets the tenant back to adding nothing. It is charged like any other prompt text, because the provider counts its tokens.

Under a tenant

Everything below is scoped to one tenant at /admin/v1/tenants/{tenant}/….

MethodPathPurpose
POST GETkeysMint and list this tenant's data keys
DELETEkeys/{id}Revoke one
POST GETprovidersRegister an upstream and its credentials
PATCH DELETEproviders/{id}Update or remove one
PUT GET DELETEaliases/{name}Define a capability alias (GET lists all)
PUT GETrouting-rulesDefine fallback chains
DELETErouting-rules/{id}Remove one
PUT GET DELETEquotaToken, spend and request caps for the tenant
PUT GET DELETEkeys/{id}/quotaThe same, narrowed to one key
POSTcache/flushDrop this tenant's cached responses
GETcapturesRead debug captures, when the tenant enabled them
POST GETwebhooksSubscribe to events
DELETEwebhooks/{id}Unsubscribe
GETeventsPull the same events a webhook would push
GETusageThis tenant's usage, as an administrator
GETusage/breakdownGrouped, as an administrator

Mint a data key

POST /admin/v1/tenants/{tenant}/keys

bash
curl -X POST http://localhost:8080/admin/v1/tenants/ten_7f3a/keys \
  -H "Authorization: Bearer $COGNIGATE_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "production", "expires_at": "2027-01-01T00:00:00Z"}'
json
{
    "key": {
        "id": "key_2b91…",
        "tenant_id": "ten_7f3a…",
        "plane": "data",
        "name": "production",
        "prefix": "cg-2b91",
        "created_at": "2026-09-03T11:06:10Z",
        "expires_at": "2027-01-01T00:00:00Z"
    },
    "secret": "cg-2b91…",
    "warning": "This is the only time the secret is shown. Store it now; it cannot be retrieved again."
}

The store keeps only a hash. secret is returned here and nowhere else — a listing returns the metadata and the prefix, never the credential.

Register a provider

POST /admin/v1/tenants/{tenant}/providers

bash
curl -X POST http://localhost:8080/admin/v1/tenants/ten_7f3a/providers \
  -H "Authorization: Bearer $COGNIGATE_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "openai",
    "kind": "openai",
    "base_url": "https://api.openai.com/v1",
    "keys": ["<your provider key>"]
  }'

kind selects the adapter. openai covers every provider that reimplements the OpenAI wire format (Together, Groq, Fireworks, OpenRouter, Azure OpenAI, vLLM, Ollama, LM Studio) because only the base URL differs between them. An unrecognised kind falls back to it rather than refusing to route, since that is what almost everything speaks.

gemini and anthropic are that same adapter aimed at each vendor's OpenAI-compatible endpoint. What they add is the endpoint itself: neither vendor leads with that URL in its own documentation, so these kinds carry it as a default and base_url may be omitted for them. They also name the vendor rather than openai in logs and in X-CogniGate-Served-By.

bash
curl -X POST http://localhost:8080/admin/v1/tenants/ten_7f3a/providers \
  -H "Authorization: Bearer $COGNIGATE_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "gemini",
    "kind": "gemini",
    "keys": ["<your provider key>"]
  }'

Anthropic documents its compatibility layer as a way to evaluate Claude with OpenAI client code rather than as a production API, and it ignores rather than rejects the request fields it does not implement: response_format, seed, logprobs, presence_penalty, frequency_penalty. A caller relying on one of those gets a successful answer computed without it. Nothing here translates to Anthropic's Messages API or Google's generateContent, so features exposed only natively are out of reach; a provider with no compatibility endpoint at all still needs a translating proxy in front of it.

Spreading load across several keys

keys is a pool, and key_strategy decides which key a request starts from.

StrategyBehaviour
round_robin (default)Advances one key per request. Four keys take a quarter of the load each.
failoverAlways starts at the first key, reaching the rest only when it is rate limited.

Under both, a 429 walks the remaining keys in the pool before the request cascades to another provider, so no working credential is ever left untried. Pick failover when the keys are not interchangeable (a paid key with a free one behind it) and round_robin when they are, which is the usual reason for registering a second key at all.

bash
curl -X PATCH http://localhost:8080/admin/v1/tenants/ten_7f3a/providers/prv_9c21 \
  -H "Authorization: Bearer $COGNIGATE_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "keys": ["<key one>", "<key two>", "<key three>"],
    "key_strategy": "round_robin"
  }'

Provider keys are held in memory and are never serialised back out — a listing shows key_prefixes, not keys.

Define an alias

PUT /admin/v1/tenants/{tenant}/aliases/fast

bash
curl -X PUT http://localhost:8080/admin/v1/tenants/ten_7f3a/aliases/fast \
  -H "Authorization: Bearer $COGNIGATE_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"capabilities": ["chat"], "cost_tier": "cheapest", "min_context_window": 16000}'

An alias either pins a model outright ("pin": "gpt-4o-mini") or describes what it needs and lets the catalogue choose. See Routing.

Set a quota

PUT /admin/v1/tenants/{tenant}/quota

bash
curl -X PUT http://localhost:8080/admin/v1/tenants/ten_7f3a/quota \
  -H "Authorization: Bearer $COGNIGATE_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "day": {
      "requests": {"cap": 50000, "soft_threshold_pct": 80}
    },
    "month": {
      "tokens": {"cap": 5000000, "soft_threshold_pct": 80},
      "cost":   {"cap": 250.0,   "soft_threshold_pct": 80}
    }
  }'

Caps are set per window, day or month, in any of three units. tokens and requests are counts, cost is what the traffic cost the operator in USD. Omit a unit to leave it unlimited, so a tenant can be capped on spend without also being capped on calls. At least one cap must be set.

day.requests is the per-day request allowance most vendors publish their own limits as, and setting one under the provider's is how a deployment stops a single tenant spending the whole account. Only served requests count: a request refused by a rate limit, a quota or the concurrency cap writes no usage row, so retrying against a cap that is already full does not dig the hole deeper.

Crossing the soft threshold emits an event; crossing the cap refuses the request with quota_exceeded, or budget_exceeded for a cost cap. A key-level quota narrows the tenant's: it is evaluated alongside it and can never widen it.