API Docs

Gate Forge is an OpenAI-compatible gateway: point an existing OpenAI SDK at it and swap the base URL and key. This page documents every route worth calling directly — request/response shapes come straight from the route source, not from memory.

Authentication

Every request carries an API key as a bearer token:

Authorization: Bearer gf-...

An X-Api-Key: gf-... header works too, if a bearer header isn't convenient for your client. Get a key from API Keys once you're signed in.

Keys are scoped. Every endpoint below notes the scope it requires, and a key whose scope list is non-empty is held to it exactly: POST /v1/chat/completions needs completions:write (the legacy name completions:call still counts), /v1/models needs models:read, and so on. A missing scope is a 403 insufficient_scope naming the scope, decided before anything is routed upstream, metered or logged.

If you create a key from the dashboard and leave the scopes field blank, it is issued with no scope restrictions and can call every route on your own account — an empty scope list means "unrestricted", not "nothing" (see keyHasRequiredScopes). Set scopes explicitly only when you want to limit what a key can do, and include completions:write whenever the key should be able to call completions.

Quickstart

Create a key on API Keys, then:

curl https://gate-forge.metalgames.xyz/v1/chat/completions \
  -H "Authorization: Bearer gf-your-key" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "openrouter/free",
    "messages": [{ "role": "user", "content": "Say hi in five words." }]
  }'

POST/v1/chat/completions

OpenAI-compatible chat completions. Routes to whichever gateway is next in your BYOK chain, the free aggregator tier, or gate-forge's own shared key, in that order — see Routing. Auth: Bearer key with completions:write scope (or an unrestricted key with no scopes). Streaming and non-streaming, on every routing path — this is the endpoint that spends tokens.

Request body

{
  "model": "openrouter/free",          // set it explicitly; omitting it is not useful in production
  "messages": [                        // required, min 1
    { "role": "user", "content": "..." }
  ],
  "max_tokens": 512                    // optional, positive integer
}

"stream": true is supported and returns a normal text/event-stream of OpenAI chat.completion.chunk frames ending in data: [DONE]; the final frame carries usage and the gate_forge block. content may be a plain string or an array of content parts (text parts are joined; non-text parts are dropped). Source of truth: the completionSchema zod schema in src/routes/completions.ts. Anything else in the OpenAI request shape (temperature, tools, …) is still not validated or forwarded by this route.

Response body

{
  ...standard OpenAI chat completion fields (id, choices, usage, ...),
  "gate_forge": {
    "cost_cents": 12,                        // what this request actually cost, in cents
    "cost_source": "upstream" | "price_table", // upstream-reported cost vs. gate-forge's static price table
    "upstream_provider": "openrouter",       // which gateway/vendor actually served it
    "routing_source": "account_byok" | "free_aggregator" | "server_key" | "mock",
    "routing_path": "byok" | "free_aggregator" | "shared_paid" | "mock",
    "free_sibling": null,                    // sibling free-tier key id, if this used one
    "credits_charged": false,                // true only when it drew on your gate-forge credit balance
    "limit_remaining": 4988,                 // remaining spend-cap budget on this key, in cents (null if uncapped)
    "limit_reset": "monthly"                 // when limit_remaining rolls over
  }
}

The gate_forge object is gate-forge's own extension — it's how you can tell, per request, which upstream actually served it and what it actually cost, instead of trusting a blended average. This is the honest-billing differentiator: credits_charged and cost_cents only reflect gate-forge's own shared-key spend, not your BYOK provider's bill (that's billed by the provider directly).

Error responses

401  { "error": { "message": "Missing API key" | "Invalid API key", "type": "auth_error" } }
403  { "error": { "message": "Spend cap exceeded for this API key", "type": "spend_cap_exceeded" } }
400  { "error": { "message": "Invalid request body", "type": "invalid_request_error" } }
400  { "error": { "message": "Model  is not routed through gate-forge", "type": "invalid_request_error" } }
402  { "error": { "message": "Insufficient account credits", "type": "insufficient_credits", "path": "shared_paid" } }
4xx/502  { "error": { "message": "...", "type": "..." } }  — routing/upstream failure; "hint" suggests attach_byok_or_topup, wait_free_quota_or_attach_key, or attach_byok_key

curl

curl https://gate-forge.metalgames.xyz/v1/chat/completions \
  -H "Authorization: Bearer gf-your-key" \
  -H "Content-Type: application/json" \
  -d '{"model":"openai/gpt-4o-mini","messages":[{"role":"user","content":"hello"}]}'

POST/v1/messages

Anthropic Messages API, so Claude Code can set ANTHROPIC_BASE_URL to this host and ANTHROPIC_API_KEY to a Gate gf- key (x-api-key or Authorization: Bearer). Same routing chain as chat completions, including streaming and tool use. Auth: completions:write, or an unrestricted key. This does not turn on paid Anthropic: Claude models stay off the Vercel AI Gateway, and shared_paid stays off unless SHARED_PAID_ENABLED is set.

POST /v1/messages/count_tokens returns { "input_tokens": N } from a local estimate and does not call a provider. Non-streaming usage.input_tokens is the provider's prompt token count. Streaming message_start input_tokens is that same local estimate, because the provider's count is not known when the stream starts. The two numbers can differ. Usage logs store the provider's count.

A claude* model id is refused with invalid_request_error when the account has an enabled Anthropic key, unless MESSAGES_ALLOW_ANTHROPIC=true (off by default).

Manual Claude Code smoke (operator, not CI; needs a real gf- key, which must not be committed or pasted into logs):

  1. Create a Gate key with completions:write. Set ANTHROPIC_BASE_URL to this host and ANTHROPIC_API_KEY to that key.
  2. Use a non-Anthropic model id the account already routes. Expect HTTP 200, an id starting with msg_, and stop_reason end_turn.
  3. Send malformed JSON. Expect HTTP 400, type error, and error.type invalid_request_error.
  4. Send role robot. Expect HTTP 400 and a message that the role must be user or assistant, not invalid model ID.
  5. Ask for a tool. Expect a tool_use id starting with toolu_.
  6. Send a claude* model id while an Anthropic key is attached. Expect HTTP 400 unless MESSAGES_ALLOW_ANTHROPIC is on. Leak-scan every saved response for the key.
curl https://gate-forge.metalgames.xyz/v1/messages \
  -H "x-api-key: gf-your-key" \
  -H "content-type: application/json" \
  -d '{"model":"mock-model","max_tokens":64,"messages":[{"role":"user","content":"hello"}]}'

POST/v1/images/generations · /v1/images/edits

OpenAI-compatible image generation, billed to your account like completions. Runs on your own vendor key, picked by model prefix: openai/gpt-image-* / openai/dall-e-* → your OpenAI key, xai/grok-*image* (e.g. grok-imagine-image-2.0) → your xAI key, fal-ai/* → your fal key (attach under BYOK). Auth: Bearer key with images:write (or an unrestricted key). Priced per image; if the price is more than your key has left under its spend cap, the request is refused before anything is generated. Usage shows on Activity, Logs and Apps as "N images".

Generations request

{
  "model": "fal-ai/flux/schnell",      // required — see GET /v1/models, type "image"
  "prompt": "a brass gear, studio light",
  "n": 1,                              // 1–10
  "size": "1024x1024",                 // optional, WIDTHxHEIGHT or "auto"
  "response_format": "url",            // "url" (default) | "b64_json"
  "quality": "medium",                 // optional, OpenAI models
  "style": "vivid"                     // optional, DALL·E 3
}

Edits request (JSON)

{
  "op": "edit",                        // "edit" (default) | "remove_background" | "upscale" | "expand"
  "model": "fal-ai/nano-banana/edit",  // e.g. fal-ai/bria/background/remove, fal-ai/esrgan, fal-ai/bria/expand
  "image": "https://… | data:image/png;base64,… | ",
  "mask": "…",                         // optional (edit)
  "prompt": "make it night",           // required for "edit"
  "scale": 4,                          // optional (upscale)
  "size": "2048x1024"                  // new canvas (expand)
}

Response body

{
  "created": 1759000000,
  "data": [{ "url": "https://…" }],    // or { "b64_json": "…" }; + revised_prompt when the vendor sends one
  "gate_forge": {
    "model": "fal-ai/flux/schnell", "op": "generate", "images": 1,
    "cost_cents": 1, "charged_cents": 1,
    "cost_source": "image_price_table" | "image_price_estimate",
    "upstream_provider": "fal", "routing_source": "account_byok", "routing_path": "byok",
    "credits_charged": false, "limit_remaining": 499, "limit_reset": "monthly"
  }
}

Error responses

403  { "error": { "type": "insufficient_scope" } }        — key lacks images:write
403  { "error": { "type": "spend_cap_exceeded" } }        — this request costs more than the key has left
400  { "error": { "type": "invalid_request_error" | "unsupported_op" | "invalid_image" | "image_url_not_supported" } }
402  { "error": { "type": "no_byok_keys", "hint": "attach_byok_key" } }  — no key for that vendor attached

curl

curl https://gate-forge.metalgames.xyz/v1/images/generations \
  -H "Authorization: Bearer gf-your-key" \
  -H "Content-Type: application/json" \
  -d '{"model":"openai/gpt-image-1","prompt":"a red fox, watercolor","size":"1024x1024"}'

GET/v1/models

OpenAI-compatible model catalog for your account — the models your current BYOK/free/shared-key chain can actually route to. Auth: Bearer key with models:read scope, or a signed-in session.

Response body

{
  "object": "list",
  "data": [
    { "id": "openai/gpt-4o-mini", "object": "model", "created": 1700000000, "owned_by": "openai" }
  ]
}

For richer per-model metadata (best-deal pricing, capabilities, which provider serves it) see GET /v1/models/catalog — same auth, powers the Models page.

curl

curl https://gate-forge.metalgames.xyz/v1/models \
  -H "Authorization: Bearer gf-your-key"

GET/v1/keys · POST/v1/keys · PATCH/v1/keys/:id · POST/v1/keys/:id/rotate · DELETE/v1/keys/:id

Manage your own API keys. Auth: Bearer key with keys:admin scope, or a signed-in session — this is what the API Keys page calls.

POST /v1/keys — request body

{
  "name": "My integration",         // optional, 1-128 chars, defaults to "Default"
  "scopes": ["models:read"],        // optional array of scope strings, defaults to []
  "spendCapCents": 5000,            // optional, integer >= 0, or null for uncapped
  "limitReset": "monthly"           // optional: "daily" | "weekly" | "monthly", defaults to "monthly"
}

POST /v1/keys — response (201)

{
  "key": {
    "id": "key_...",
    "name": "My integration",
    "secret": "gf-...",     // shown once, at creation — store it now
    "scopes": ["models:read"],
    "createdAt": "2026-09-04T00:00:00.000Z",
    "spendCapCents": 5000,
    "limitReset": "monthly"
  }
}

GET /v1/keys — response

{
  "keys": [
    {
      "id": "key_...", "name": "My integration", "masked": "gf-...abcd",
      "app": null,            // suite slug when minted via POST /v1/auth/app-key
      "scopes": ["models:read"], "createdAt": "...", "lastUsedAt": "...",
      "revokedAt": null, "status": "active",
      "spendCapCents": 5000, "spendUsedCents": 120, "requestCount": 4,
      "limitReset": "monthly", "limitRemaining": 4880, "usage": { "...": "period usage rollups" }
    }
  ]
}

Note: the full secret is only ever returned once — from POST /v1/keys or POST /v1/keys/:id/rotate. GET /v1/keys only returns the masked form.

PATCH /v1/keys/:id — request body

{
  "spendCapCents": 10000,   // optional, integer >= 0, or null
  "limitReset": "weekly"    // optional: "daily" | "weekly" | "monthly"
}

POST /v1/keys/:id/rotate — response

{ "revoked": { "...": "the old key record" }, "key": { "...": "a freshly issued key, new secret included" } }

DELETE /v1/keys/:id — response

{ "key": { "...": "the now-revoked key record" } }

curl

curl -X POST https://gate-forge.metalgames.xyz/v1/keys \
  -H "Authorization: Bearer gf-an-admin-scoped-key" \
  -H "Content-Type: application/json" \
  -d '{"name":"My integration","scopes":["models:read"]}'

GET/v1/byok · GET/POST/v1/byok/keys

Bring-your-own-key provider management — attach your own OpenRouter/OpenAI/Anthropic/etc. credentials so completions route through them instead of gate-forge's shared key. Auth: Bearer key with byok:write scope, or a signed-in session — this is what the Accounts page calls.

GET /v1/byok — response

{
  "attached": true,
  "provider": "openrouter",
  "keyPrefix": "sk-or-...",
  "lastRoutingProvider": "openrouter",
  "modelCount": 320,
  "catalogSource": "upstream"
}

This is the singular, one-key-per-account shape kept for OpenAI-compatible clients. New integrations should prefer the portfolio endpoints below, which support multiple keys with priority tiers.

GET /v1/byok/keys — response

{
  "keys": [
    {
      "id": "byok_...", "provider": "openrouter", "kind": "gateway",
      "label": "Primary OpenRouter", "keyPrefix": "sk-or-...", "baseUrl": null,
      "enabled": true, "tier": "prioritized", "sortOrder": 0,
      "createdAt": "...", "lastUsedAt": "...",
      "lastFailureAt": null, "lastFailureStatus": null, "lastFailureMessage": null,
      "consecutiveFailures": 0
    }
  ],
  "providers": [ "...BYOK_PROVIDERS: openrouter, vercel, custom, openai, deepseek, xai, anthropic, google" ],
  "gateways": [ "...openrouter, vercel, custom" ],
  "vendors": [ "...openai, deepseek, xai, anthropic, google" ],
  "server": { "...": "describeServerSharedKey() — status of gate-forge's own shared key for this account" }
}

POST /v1/byok/keys — request body

{
  "provider": "openrouter",     // required — one of the BYOK_PROVIDERS ids above
  "apiKey": "sk-or-...",        // required, min 1 char
  "label": "Primary",           // optional, max 60 chars
  "tier": "prioritized",        // optional: "prioritized" | "fallback"
  "kind": "gateway",            // optional: "vendor" | "gateway"
  "baseUrl": null                // optional, max 300 chars — required for kind:"vendor" custom bases
}

The key is validated against the provider before it's stored — an invalid key returns 400 with the provider's own rejection message, not a generic error. Also see PATCH /v1/byok/keys/:id, DELETE /v1/byok/keys/:id, POST /v1/byok/keys/:id/test, and GET /v1/byok/routing-decision?model=... (explains which candidate a given model would route to right now) in src/routes/byok.ts.

curl

curl https://gate-forge.metalgames.xyz/v1/byok/keys \
  -H "Authorization: Bearer gf-a-byok-write-scoped-key"

GET/v1/credits · GET/v1/credits/transactions

Your gate-forge credit balance and ledger — only meaningful for requests that routed through gate-forge's own shared key (gate_forge.credits_charged: true on a completion). BYOK usage is billed by your provider directly, not through this balance. Auth: Bearer key with credits:read scope for reads (credits:write for PUT /v1/credits, top-ups, auto-top-up changes and checkout), or a signed-in session.

GET /v1/credits — response

{ "data": { "total_credits": 12.5, "total_usage": 3.42 } }

GET /v1/credits/transactions — query params

limit   optional, integer 1-100, default 20
offset  optional, integer >= 0, default 0

GET /v1/credits/transactions — response

{
  "data": [
    {
      "id": "txn_...", "type": "topup" | "usage" | "...",
      "amountCents": -12, "balanceAfter": 1088,
      "keyId": "key_...", "model": "openai/gpt-4o-mini",
      "createdAt": "2026-09-04T00:00:00.000Z"
    }
  ],
  "pagination": { "total": 42, "limit": 20, "offset": 0 }
}

curl

curl "https://gate-forge.metalgames.xyz/v1/credits/transactions?limit=10" \
  -H "Authorization: Bearer gf-your-key"

POST/v1/analytics/query · GET/v1/analytics/gateway-compare

Usage analytics grouped by model, key, app, or provider, and a per-gateway cost comparison. Auth: Bearer key with analytics:read scope, or a signed-in session — powers Activity and Logs.

POST /v1/analytics/query — request body

{
  "start": "2026-08-01T00:00:00.000Z",    // required, ISO datetime
  "end": "2026-09-01T00:00:00.000Z",      // required, ISO datetime
  "group_by": "model",                     // required: "model" | "key" | "app" | "provider"
  "metrics": ["spend", "requests", "tokens"],  // optional array, default shown; also: prompt_tokens, cache_hit, blended_cost
  "key_id": "key_...",                     // optional filter
  "model": "openai/gpt-4o-mini",           // optional filter
  "routing_source": "account_byok"         // optional: account_byok | free_aggregator | server_key | mock
}

POST /v1/analytics/query — response

{
  "data": {
    "overview": { "spend_cents": 0, "requests": 0, "tokens": 0, "...": "period totals + Δ vs. prior period" },
    "rows": [ { "group": "openai/gpt-4o-mini", "group_label": "...", "spend_cents": 0, "requests": 0, "tokens": 0 } ],
    "group_by": "model", "start": "...", "end": "...", "key_id": null, "model": null, "routing_source": null
  }
}

GET /v1/analytics/gateway-compare — query params

start, end   required, ISO datetime
key_id, model, routing_source   optional filters, same as above

GET /v1/analytics/gateway-compare — response

{
  "data": {
    "gateways": [
      {
        "group": "openrouter", "group_label": "OpenRouter",
        "spend_cents": 0, "requests": 0, "tokens": 0,
        "blended_cost_per_million_usd": 0.42, "is_cheapest": true, "vs_cheapest_pct": 0
      }
    ],
    "models": [ "...per-model gateway comparisons, only for models seen through 2+ gateways" ],
    "start": "...", "end": "...", "key_id": null, "model": null, "routing_source": null
  }
}

Related, same auth: POST /v1/analytics/timeseries, GET /v1/analytics/events (raw per-request rows, backs Logs), GET /v1/analytics/export.csv / export.json — see src/routes/analytics.ts.

curl

curl -X POST https://gate-forge.metalgames.xyz/v1/analytics/query \
  -H "Authorization: Bearer gf-an-analytics-read-scoped-key" \
  -H "Content-Type: application/json" \
  -d '{"start":"2026-08-01T00:00:00.000Z","end":"2026-09-01T00:00:00.000Z","group_by":"model"}'

GET/v1/seats ? PUT/v1/seats/:seat

Latest usage reading per seat (Claude Max, SuperGrok, Cursor, GitHub Pro, Copilot, or any other lowercase seat id a feeder reports). Gate stores usage numbers only. It does not sign in to those products and it does not scrape them. Auth: GET needs seats:read, which analytics:read also grants, so existing usage keys work. PUT and DELETE need seats:write. A signed-in session can do both. Renders on Seats. get_seat_meters on POST /mcp returns this same payload and still requires analytics:read.

PUT /v1/seats/:seat ? request body

{
  "used_pct": 42,                         // required, number from 0 to 100
  "read_at": "2026-10-04T12:00:00.000Z",  // required, ISO datetime
  "source": "scraped",                    // required: "api" | "scraped"
  "resets_at": "2026-10-05T12:00:00.000Z", // optional ISO datetime or null
  "extras_spend_usd": 1.25,               // optional number >= 0, or null
  "limit": 20,                            // optional number >= 0, or null
  "note": "week window"                   // optional, max 200 chars
}

Unknown fields are rejected. A reading that looks like a token, cookie, bearer token, session, JWT, or other long opaque string is rejected and not stored. The latest reading per seat is what GET returns; the last 30 pushes per seat are kept. DELETE /v1/seats/:seat removes every stored reading for that seat on the calling account and returns { "seat", "deleted" }. A seat with no readings is 404.

GET /v1/seats ? response

{
  "seats": [
    {
      "seat": "cursor",
      "used_pct": 42,
      "resets_at": "2026-10-05T12:00:00.000Z",
      "extras_spend_usd": 1.25,
      "limit": 20,
      "read_at": "2026-10-04T12:00:00.000Z",
      "source": "scraped",
      "note": "week window",
      "age_seconds": 10800,
      "stale": false,
      "status": "ok"
    }
  ]
}

stale is true when read_at is older than SEAT_STALE_HOURS (default 12). status is ok, amber, or red from SEAT_AMBER_PCT (default 80) and SEAT_RED_PCT (default 95); red wins when both match. An empty seats array means nothing has been reported yet, not that usage is 0.

POST/mcp

Model Context Protocol (Streamable HTTP) for agent upkeep. Auth is a bearer gf- key. A session cookie and the admin token are not accepted. Each call is logged with the account id, key id, tool, arguments and outcome. The credential is never logged, and no tool returns a secret or a masked prefix.

Read tools: whoami (any key), list_keys (keys:read, and a keys:admin key can call it too), get_balances (credits:read), get_usage and get_seat_meters (analytics:read).

Write tools dry-run first. A call without confirm changes nothing and returns the exact before/after plus a confirm_ token bound to the calling key. The token expires after a few minutes. A second call from that same key with the same arguments and confirm set to that token applies it. A stale, expired, mismatched, or other-key token is refused, and confirm: true is refused. Tools: set_spend_cap, set_key_scopes, rotate_key and revoke_key require keys:admin. reorder_routing requires byok:write and mirrors the Routing page (tier plus order). set_key_scopes refuses an empty list, because an empty list means unrestricted, and refuses any scope the calling key does not hold. rotate_key and revoke_key refuse to target the calling key. rotate_key does not return the new secret; the Keys page shows it once, and the stored secret is deleted after that reveal or within a few minutes.

Errors

Error responses are consistently shaped as:

{ "error": { "message": "human-readable reason", "type": "auth_error" | "invalid_request_error" | "insufficient_scope" | "..." } }

A 403 with insufficient_scope means the key has an explicit scope list that lacks the one this endpoint needs — the message names the missing scope. Check the "Auth" line on the endpoint above, or the key's scopes on the API Keys page.