OpenRouter Chat & Router

aimock simulates OpenRouter's routing layer — provider selection, cost accounting, and models[] failover — offline and deterministically, not just "another chat provider". Point any OpenAI SDK at aimock with a baseURL ending in /api/v1 and every chat response is shaped like real OpenRouter: a gen- id, a top-level provider, native_finish_reason, and a rich usage with a scriptable cost. Callers on the plain OpenAI base (/v1/…) are left byte-for-byte unchanged.

Detection is by request path. A request whose original path starts with /api/v1/ (OpenRouter's canonical base) is shaped as OpenRouter; a request on /v1/ stays plain OpenAI. This mirrors how a real client is wired (baseURL: "https://openrouter.ai/api/v1") — no model-slug sniffing.

Endpoints

Method Path Response
POST /api/v1/chat/completions OpenRouter-shaped chat completion (SSE when stream: true, else JSON)
GET /api/v1/models { data: […] } model catalog (ids from loaded chat fixtures)
GET /api/v1/key { data: { label, limit, usage, … } } key metadata
GET /api/v1/credits { data: { total_credits, total_usage } }

Point the OpenAI SDK at aimock

The dominant real-world integration path is the OpenAI SDK with its baseURL repointed — no OpenRouter-specific client needed.

openrouter.test.ts ts
import { LLMock } from "@copilotkit/aimock";
import OpenAI from "openai";

const mock = new LLMock();
mock.on({ userMessage: "hi" }, { content: "Hello there, friend!" });
await mock.start();

const client = new OpenAI({
  apiKey: "sk-test",
  // the /api/v1 suffix is the OpenRouter signal
  baseURL: `${mock.url}/api/v1`,
});

const res = await client.chat.completions.create({
  model: "openai/gpt-4o-mini",
  messages: [{ role: "user", content: "hi" }],
});

expect(res.id).toMatch(/^gen-/); // not chatcmpl-
expect((res as { provider?: string }).provider).toBeDefined();

Response Shape

A non-streaming OpenRouter completion as aimock emits it — the shape reproduces a real OpenRouter response field-for-field. To show every OpenRouter field at once this is a fully-scripted fixture: it overrides provider and system_fingerprint, pins the token counts, and scripts usage.cost (with its cost_details breakdown) and usage.is_byok. A minimal fixture ({ content: "…" }) omits all of those — it defaults provider to the slug author ("openai" for openai/gpt-4o-mini, taken verbatim before the first /), leaves system_fingerprint/service_tier null, estimates the token counts, and emits no cost/cost_details/is_byok at all (see the field notes below):

response-shape.test.ts ts
mock.on(
  { userMessage: "hi" },
  {
    content: "Hello there, friend!",
    provider: "Azure",                  // top-level provider override
    systemFingerprint: "fp_27599ce29d",
    usage: {
      prompt_tokens: 13,
      completion_tokens: 6,
      cost: 0.00000555,
      cost_details: {
        upstream_inference_cost: 0.00000555,
        upstream_inference_prompt_cost: 0.00000195,
        upstream_inference_completions_cost: 0.0000036,
      },
      is_byok: false,
    },
  }
);
POST /api/v1/chat/completions json
{
  "id": "gen-1784755927-jQCaqCF0bQXeUfk77eAH",
  "object": "chat.completion",
  "created": 1784755927,
  "model": "openai/gpt-4o-mini",
  "provider": "Azure",
  "system_fingerprint": "fp_27599ce29d",
  "service_tier": null,
  "choices": [{
    "index": 0,
    "logprobs": null,
    "finish_reason": "stop",
    "native_finish_reason": "stop",
    "message": {
      "role": "assistant",
      "content": "Hello there, friend!",
      "refusal": null,
      "reasoning": null
    }
  }],
  "usage": {
    "prompt_tokens": 13,
    "completion_tokens": 6,
    "total_tokens": 19,
    "cost": 0.00000555,
    "cost_details": {
      "upstream_inference_cost": 0.00000555,
      "upstream_inference_prompt_cost": 0.00000195,
      "upstream_inference_completions_cost": 0.0000036
    },
    "is_byok": false
  }
}

The OpenRouter-only fields aimock layers onto the OpenAI shape:

Fixture-Scriptable Fields

Cost, provider, and finish reasons are set on the fixture response so a test can assert routing/billing behavior deterministically — e.g. prove a budget guard trips at $5 without spending a cent.

cost-guard.test.ts ts
mock.on(
  { userMessage: "summarize" },
  {
    content: "…",
    provider: "Azure",            // who served it (top-level provider)
    nativeFinishReason: "stop",     // raw upstream finish reason
    usage: {
      cost: 5.5,                 // scripted usage.cost
      is_byok: false,
      cost_details: { upstream_inference_cost: 5.5 },
    },
  }
);

models[] Fallback Simulation

The headline capability. Send OpenRouter's fallback array and aimock walks the candidate list [model, ...models] in order, serving the first fixture that returns a NON-error response. An error fixture (a 429/503 runtime provider failure) falls through to the next candidate — exactly as real OpenRouter fails over. The winning slug is echoed back as the top-level model, which is the only fallback signal a real client sees, so a test asserts failover by reading response.model.

fallback.test.ts ts
// primary is "down" (a runtime 429), fallback answers
mock.on(
  { model: "openai/gpt-4o", userMessage: "route" },
  { error: { message: "rate limited" }, status: 429 }
);
mock.on(
  { model: "anthropic/claude-3.5-sonnet", userMessage: "route" },
  { content: "served by the fallback" }
);

const res = await fetch(`${mock.url}/api/v1/chat/completions`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    model: "openai/gpt-4o",
    models: ["openai/gpt-4o", "anthropic/claude-3.5-sonnet"],
    messages: [{ role: "user", content: "route" }],
  }),
});

const body = await res.json();
expect(body.model).toBe("anthropic/claude-3.5-sonnet"); // the winner
expect(body.provider).toBe("anthropic");

When every candidate fails, the last error is served (in the OpenRouter envelope). A request without a models array behaves as an ordinary single-model match. Sending provider: { allow_fallbacks: false } disables fall-through: only the primary candidate is tried, and its error (or a miss) is terminal instead of walking the rest of the list.

Real OpenRouter fails over inconsistently by error class: a 403 budget-exceeded or a generic "provider returned error" is served as terminal (it does not advance to the next candidate), while 429/503 usually do fail over. Reproduce that per error class with fallthrough: false on the error fixture — the loop stops and serves that error as terminal, no failover, even when a good candidate follows. Absent (or true) keeps the default fall-through, so existing fixtures are unchanged. This composes with the request-level gate: fall-through happens only when both allow it (if either allow_fallbacks: false or fallthrough: false says stop, the error is terminal). Model an error class a dev must handle themselves — the exact failure that burned them — by setting fallthrough: false on that class's fixture (e.g. the 403) and leaving 429/503 fixtures to default.

terminal-error.test.ts ts
// a 403 that does NOT fail over — the app must handle it, no fallback
mock.on(
  { model: "openai/gpt-4o", userMessage: "route" },
  { error: { message: "budget exceeded" }, status: 403, fallthrough: false }
);
mock.on(
  { model: "anthropic/claude-3.5-sonnet", userMessage: "route" },
  { content: "never reached" }
);

// models[] lists a good fallback, but the 403 is terminal — no failover
expect(res.status).toBe(403);

Deliberate non-goal. Real OpenRouter rejects an unknown/invalid model in models[] up front with a 400 "not a valid model ID" and only fails over on runtime errors (429/503). aimock is fixture-driven, so an unknown model is simply a fixture miss (a strict miss / 404) — we do not replicate that up-front invalid-model 400. Frame a primary "failure" fixture as a 429/503 runtime error.

Streaming

Streaming responses are standard OpenAI data: frames terminated by data: [DONE], with OpenRouter's additions on every chunk: the top-level provider and system_fingerprint, and native_finish_reason on every delta choice (null until the finish chunk). The finish_reason chunk carries an empty delta; a separate final chunk (empty choices) then carries the full usage and service_tier: null. Neither usage nor service_tier appears on any content delta, matching real OpenRouter. As with the Response Shape example above, the display-name provider and the cost fields shown below come from a fully-scripted fixture; a minimal fixture would default provider to the slug author and emit no cost. Real OpenRouter opens the stream with a single : OPENROUTER PROCESSING keepalive comment; aimock can emit it too (opt-in — the example below shows it enabled).

stream (verbatim, keepalive opted in) text
: OPENROUTER PROCESSING

data: {"id":"gen-…","object":"chat.completion.chunk","provider":"OpenAI","system_fingerprint":"fp_…","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"native_finish_reason":null}]}

data: {"id":"gen-…","object":"chat.completion.chunk","provider":"OpenAI","system_fingerprint":"fp_…","choices":[{"index":0,"delta":{"content":"Sure","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]}

data: {"id":"gen-…","object":"chat.completion.chunk","provider":"OpenAI","system_fingerprint":"fp_…","choices":[{"index":0,"delta":{},"finish_reason":"stop","native_finish_reason":"stop"}]}

data: {"id":"gen-…","object":"chat.completion.chunk","provider":"OpenAI","system_fingerprint":"fp_…","service_tier":null,"choices":[],"usage":{"prompt_tokens":12,"completion_tokens":28,"total_tokens":40,"cost":0.0000186,"cost_details":{"upstream_inference_cost":0.0000186,"upstream_inference_prompt_cost":0.0000018,"upstream_inference_completions_cost":0.0000168},"is_byok":false}}

data: [DONE]

The : OPENROUTER PROCESSING keepalive is opt-in and off by default (so strict OpenAI SSE parsers aren't surprised). Enable it per-fixture; when on, exactly one comment line is emitted first, before the first data frame. A faithful client discards any :-prefixed line before JSON-parsing the next data:.

keepalive.test.ts ts
mock.on(
  { userMessage: "hi" },
  { content: "…" },
  { openRouterProcessing: true } // emit the keepalive comment
);

Discovery Endpoints

GET /api/v1/models synthesizes the catalog from loaded chat fixtures' match.model values (falling back to a default slug set), returning OpenRouter model objects (string pricing values, architecture, top_provider, supported_parameters, …). GET /api/v1/key returns key metadata (a null limit/limit_remaining means unlimited) and GET /api/v1/credits returns { data: { total_credits, total_usage } }. All three are read-only and require no fixtures.

Error Envelope

Errors on an OpenRouter request use OpenRouter's envelope — { error: { code, message } } where code is the numeric HTTP status (contrast OpenAI's { error: { message, type, param, code } }). A fixture may attach free-form metadata; it is omitted when absent. Errors on the plain /v1/… base keep the OpenAI shape.

error (fixture-driven 503 runtime error) json
{ "error": { "code": 503, "message": "upstream provider is temporarily unavailable" } }

Attribution Headers

A real OpenRouter client sends the attribution headers HTTP-Referer and X-OpenRouter-Title (the legacy X-Title is also seen), along with every OpenRouter request extension on the body (provider, models, route, reasoning, plugins, prediction, usage, and any unknown key). aimock special-cases none of them: no header or body field is required or rejected. It journals them generically like any request header, so they are available for assertions (auth headers redacted).

OpenRouter's dedicated video job API is documented separately under OpenRouter Video. This page covers the chat / LLM router surface. Record mode works the same as other providers — see Record & Replay; configure the openrouter provider base URL to capture real chat turns.