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.
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):
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,
},
}
);
{
"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:
-
id—gen-…prefix instead ofchatcmpl-(a fixtureidoverride still wins verbatim). -
provider(top level) — identifies the upstream that served the request. In real OpenRouter this is a capitalized display name (e.g."Azure","OpenAI") that can differ from the model-slug author (real capture: slugopenai/gpt-4o-miniserved by"Azure"). aimock's fixture-derived default is the slug's author segment taken verbatim — the text before the first/, not lowercased:"anthropic"foranthropic/claude-3.5-sonnet,"openai"foropenai/gpt-4o-mini. A slug with no/yields the whole slug, and a mixed-case author likeFoo/baryields"Foo"unchanged. Override it per-fixture to assert a specific capitalized display name. -
choices[].native_finish_reason— the raw upstream finish reason alongside the normalizedfinish_reason(mirrors it unless overridden). -
choices[].message.reasoning— always present on the assistant message: the shaping layer mirrors whatever reasoning survived upstream resolution (reasoning_content), elsenull. A fixture'sreasoningis gated by the requested model's capability before it reaches this field — a reasoning-capable model keeps it; a non-reasoning model still emits it by default (with a warning), but strict mode (X-AIMock-Strict) suppresses it, leaving thisnull. -
system_fingerprintandservice_tier— always present on the non-streaming response, eachnullwhen the fixture leaves it unset. -
usage.costwith itsusage.cost_detailsbreakdown — the single biggest OpenRouter-vs-OpenAI tell. Fixture-scriptable;cost_detailsis the breakdown ofcostand rides with it, so both are emitted only when a fixture supplies acost.
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.
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 },
},
}
);
provider— overrides the top-level provider display name.-
nativeFinishReason— overridesnative_finish_reason(otherwise it mirrorsfinish_reason). -
usage.cost— the per-request cost; when set,cost_detailsis emitted too (its threeupstream_inference_*fields default to 0). Omitcostentirely and neither is emitted (aimock never fabricates a cost). -
usage.is_byok,usage.prompt_tokens_details,usage.completion_tokens_details— each emitted only when overridden, independently ofcost.
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.
// 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.
// 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).
: 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:.
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": { "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.