# API Gateway

> **Use this when:** you want one key and one billing account in front of Anthropic, OpenAI, and the other providers AgentSky supports — no sessions, no agents, no state.
>
> **Don't use this when:** you need a persistent conversation with history and streaming (use the [Agent API](https://agentsky.dev/docs/agent-api.md) instead). Or if you want to use your agents from an MCP client (see [MCP server](https://agentsky.dev/docs/mcp.md)).

https://gateway.agentsky.dev serves the Anthropic Messages API, the OpenAI Chat Completions, Responses and Embeddings APIs, and the decisions API behind one AgentSky key, plus APIs for search, maps, email, speech, transcription, SEO data and image generation (the run API). Every completed model call is billed; the charge appears inline on most responses and is always retrievable via `GET /v1/generation`.

## Base URLs

| Client | Base URL |
|---|---|
| Anthropic SDK, Claude Code (`ANTHROPIC_BASE_URL`) | `https://gateway.agentsky.dev` |
| OpenAI SDK, Codex (`OPENAI_BASE_URL`) | `https://gateway.agentsky.dev/v1` |
| Decisions API | `https://gateway.agentsky.dev` |

Authenticate with an AgentSky API key (`ast_…`, created under Developer → API keys) as `x-api-key` or `Authorization: Bearer`. Model calls and runs need the `write` scope; catalog and account reads need `read`.

## Routes

| Method | Path | Notes |
|---|---|---|
| POST | /v1/messages | Anthropic Messages API, streamed or not |
| POST | /v1/messages/count_tokens | unmetered; requires a model route that supports token counting |
| POST | /v1/chat/completions, /v1/responses, /v1/embeddings | OpenAI APIs |
| POST | /api/alpha/decisions | decision models: `{model, state, questions}` → an answer per question |
| GET | /v1/models, /v1/models/{vendor}/{model} | public catalog with per-token prices, context, and modalities |
| GET | /v1/endpoints, /v1/endpoints/{provider}/{endpoint} | public run catalog, and one API with its input fields and limits |
| GET | /v1/providers | API provider list |
| POST | /v1/run | `{provider, endpoint, input}` → run object |
| GET | /v1/runs/by-idempotency | read a saved run using its original `Idempotency-Key` and API token |
| GET | /v1/runs/{runId} | retrieve a saved run owned by your account |
| GET | /v1/credits | balance and total used |
| GET | /v1/generation?id= | one call's record: tokens and cost |

## Model ids

Use the catalog id (`anthropic/claude-sonnet-5`, `qwen/qwen-max`), an accepted alias (`glm/glm-5.2`, `kimi/k3`), or the bare id (`claude-sonnet-5`). A vendor prefix that matches nothing is a 404.

## Anthropic compatibility

Messages supports JSON and streaming responses. Token counting is available for active Anthropic resources whose IDs end in `-anthropic-api`; it requires `read` scope and does not charge the wallet. Other resource routes may have different endpoint support.

Send `anthropic-version` on `GET /v1/models` to receive the Anthropic SDK list format (`data`, `has_more`, `first_id`, `last_id`), with `limit`, `before_id`, and `after_id` pagination. Retrieve a model with `GET /v1/models/{id}` and the same `anthropic-version` header; URL-encode the full model ID when it contains a slash. Without this header, the endpoint returns the AgentSky catalog with prices. Unknown model capability metadata is returned as null.

On Anthropic responses, `request-id` preserves the upstream request identifier when one is supplied and valid. Gateway-generated errors and Models responses use a gateway identifier instead. `x-agentsky-request-id` remains the separate AgentSky support reference; include both identifiers when reporting a problem. A request ID or thinking signature alone does not establish a direct connection to Anthropic.

For Messages requests to `anthropic/claude-fable-5-1-anthropic-api`, use `thinking: {"type": "adaptive"}` and `output_config.effort` to control reasoning. Explicit `thinking.type` values of `disabled` or `enabled` are not supported on this resource.

Message Batches (`/v1/messages/batches`) is not currently supported. Batch pricing and any batch discount are not offered by this gateway at this time.

## Decision models

A decision model answers typed questions about supplied state instead of generating text, so it is served on `/api/alpha/decisions` rather than the chat paths — the catalog marks it `"type": "decision"` with `"wires": ["decisions"]`. Send `{model, state, questions}`, where each named question is `{type: "noul" | "choice" | "score", instructions, criteria?}`; the reply carries an answer with a probability per question. A chat model on this path is a 404, as is a decision model on the chat paths. The wire does not stream: `stream: true` is a 400. Errors use `{"error":{"code","message"}}`.

## Failover

A model may be served from more than one place. The gateway tries them in its own order and fails over to the next when one is unreachable, times out before the first byte, or answers 408, 429, 401, 403 or 5xx; any other reply is returned as-is, and nothing is retried once response bytes have been sent. Which upstream serves a call is platform configuration, not a request option: OpenRouter's `provider` body field and Vercel's `providerOptions.gateway` are accepted and dropped before forwarding.

## Cost in the response

Model calls: `usage.cost` (USD) on the final usage object — the usage-only last chunk of an OpenAI Chat Completions stream, `response.completed` on the Responses API, `message_delta` on the Anthropic wire, or the JSON body. Responses API streams ending in `response.incomplete` or `response.failed` are billed when they carry usage, but those terminal events are relayed without a `usage.cost` field; use `GET /v1/generation?id=<response id>` to retrieve the charge. Runs: `price.amount.value` is the customer charge in `price.amount.currency` (USD). The public `billing` cost fields have null values; they do not expose supplier costs. A `RUNNING` run may report zero before settlement; this is not a final price or a free generation. Prices are per token or per unit as listed in the catalog; `GET /v1/generation?id=<upstream id or call id>` returns the record once it has landed.

## Runs

```
POST /v1/run
{ "provider": "exa", "endpoint": "search", "input": { "query": "agent gateways", "numResults": 5 } }

200 { "runId": "run_…", "status": "COMPLETED", "provider": "exa", "endpoint": "search",
      "output": { … vendor response … },
      "price": { "type": "PER_CALL", "amount": { "value": 0.014, "currency": "USD" } },
      "providerResponse": { "httpStatus": 200 }, … }
```

A request can return a completed run, or a saved run with `status: RUNNING`. Image and video generation can outlast the HTTP connection. A network timeout, HTML error page, or other non-JSON response does not tell you whether generation succeeded. Read `GET /v1/runs/{runId}` until the status is `COMPLETED`, `FAILED`, or `TIMED_OUT`; use a key with both `read` and `write` scopes.

Send an `Idempotency-Key` on the first POST. Save it, the exact request body, and any returned `runId` before retrying. If you lost the run ID, send `GET /v1/runs/by-idempotency` with the **same API token** and original `Idempotency-Key`; it requires `read` scope and never starts another supplier request. A 404 means no saved run is visible yet, so keep the original key and retry the read; a missing or malformed key is 400. The response is private and not cached. You can also repeat the POST with the **same API key, idempotency key, and body** to retrieve the existing run; that path may create a run if the first POST never arrived. Reusing the key with a different body returns `409 idempotency_conflict`. Use a new key only for an intentionally new generation. Do not rotate keys or automatically submit a fresh paid request after a timeout.

### Node.js example: submit, recover, and poll

Save this as `run.mjs` and run it with Node.js 22 or later. Set `AGENTSKY_API_KEY` and `AGENTSKY_IDEMPOTENCY_KEY` in your environment. Choose a unique idempotency key for this generation and keep it unchanged when restarting this script. Set `RUN_ID` to resume a known run directly. This example stops waiting after 30 minutes without cancelling the server-side job.

```js
const apiKey = process.env.AGENTSKY_API_KEY;
const idempotencyKey = process.env.AGENTSKY_IDEMPOTENCY_KEY;
if (!apiKey || !idempotencyKey) {
  throw new Error("Set AGENTSKY_API_KEY and a saved AGENTSKY_IDEMPOTENCY_KEY");
}
const baseUrl = "https://gateway.agentsky.dev";
const body = JSON.stringify({
  provider: "openai",
  endpoint: "gpt-image-2",
  input: { prompt: "A red bicycle on a white background", quality: "low" },
});
let runId = process.env.RUN_ID;
const deadline = Date.now() + 30 * 60 * 1000;
let finished = false;

while (Date.now() < deadline) {
  let response;
  let run;
  try {
    response = await fetch(runId ? baseUrl + "/v1/runs/" + encodeURIComponent(runId) : baseUrl + "/v1/run", {
      method: runId ? "GET" : "POST",
      headers: {
        Authorization: "Bearer " + apiKey,
        "Content-Type": "application/json",
        ...(runId ? {} : { "Idempotency-Key": idempotencyKey }),
      },
      ...(runId ? {} : { body }),
      signal: AbortSignal.timeout(Math.min(180_000, deadline - Date.now())),
    });
    run = await response.json();
  } catch {
    if (response && response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429) {
      throw new Error("HTTP " + response.status + "; keep the saved request and key for investigation");
    }
    await new Promise(resolve => setTimeout(resolve, 5000));
    continue;
  }
  if (run.runId) {
    runId = run.runId;
    console.log("Save runId:", runId);
    if (run.status === "COMPLETED") {
      console.log("Output:", run.output);
      console.log("Customer charge:", run.price.amount);
      finished = true;
      break;
    }
    if (run.status === "FAILED" || run.status === "TIMED_OUT") {
      throw new Error(JSON.stringify({ runId, status: run.status, error: run.error, price: run.price }));
    }
  } else if (!response.ok && response.status !== 408 && response.status !== 429 && response.status < 500) {
    throw new Error(JSON.stringify(run.error ?? run));
  } else if (response.ok) {
    throw new Error("Unexpected response; retain the same request and idempotency key");
  }
  await new Promise(resolve => setTimeout(resolve, 5000));
}
if (!finished) {
  throw new Error("Still unresolved. Resume with the same saved key/body or RUN_ID=" + (runId ?? "unknown"));
}
```

A terminal failure includes `error: { code, message }`. An HTTP failure or connection timeout does not establish the run outcome or charge: a pending generation can still complete and settle. Retrieve the saved run and check its terminal status and `price.amount`; keep the run ID for reconciliation. Do not treat a pending zero amount as final or start a new generation merely because the connection failed.

### Reading image and video outputs

The `output` object follows the endpoint's response shape; there is no universal binary wrapper. Check the endpoint reference and select the appropriate field after `COMPLETED`:

| Endpoint family | Media location in the run |
|---|---|
| GPT Image | `run.output.data[].b64_json` (base64 image) |
| Nano / Gemini image | `run.output.candidates[].content.parts[].inlineData.data`, with `inlineData.mimeType`; other parts can contain text |
| Wan image | `run.output.output.choices[].message.content[].image` (URL) |
| Video | H3, Seedance, Wan and HappyHorse: `run.output.video_url` (URL). |
| Binary audio endpoints | `run.output.data` (base64), with `mediaType` and `bytes` |

For a GPT image, save `Buffer.from(run.output.data[0].b64_json, "base64")` using Node's `writeFile`; use the returned format or MIME type for the file extension. For URL outputs, download the returned URL promptly and save the response bytes. Gateway media URLs contain a signed token valid for one hour: keep them private. Reading the run again issues a fresh link while the saved run and underlying media remain available; this does not guarantee permanent storage of the provider's media.

## Errors

Errors take the shape of the wire: Anthropic `{ "type": "error", "error": { "type", "message" } }`, OpenAI `{ "error": { "message", "type", "param", "code" } }`, and `{ "error": { "code", "message" } }` on run and account routes. Codes you will see: `model_not_found`, `endpoint_not_found`, `pricing_unavailable` (503, the SKU has no rate yet), `upstream_unavailable`, `insufficient_credits` / `insufficient_quota` (spend gate), `rate_limited`.

## Limits

Request bodies up to 10 MB on the model wires and 1 MB on most APIs (35 MB for audio uploads). Media outputs up to 20 MB inline. Per-key rate limits apply.
