client.ai
OpenAI-compatible chat, embeddings, image generation and model catalogue via OpenRouter.
client.ai
An OpenAI-compatible client pointed at OpenRouter. Same surface as the official
openaiSDK (chat.completions,embeddings,images,models), so any code or tool that speaks OpenAI works against any of the 300+ models OpenRouter resolves.
Mental model
client.ai wraps the official openai package with baseURL
overridden to OpenRouter. The underlying OpenAI instance is built
lazily on the first method call, so env var rotation between
createClient() and the first request is honoured. A single instance
is reused across all four namespaces.
Required capability: ai.openrouter. Every method awaits the
contract handshake before
issuing the request.
Configuration
| Source | Falls back to |
|---|---|
createClient({ openrouterKey }) |
OPENROUTER_API_KEY |
createClient({ openrouterBaseUrl }) |
OPENROUTER_BASE_URL, then https://openrouter.ai/api/v1 |
Missing the API key surfaces openrouter_key_missing with an
actionable hint. Both HTTP-Referer and X-Title are sent on every
request so OpenRouter's leaderboard credits the agent platform.
Chat completions
import { createClient } from '@stackbone/sdk';
const client = createClient();
const result = await client.ai.chat.completions.create({
model: 'openai/gpt-4o-mini',
messages: [
{ role: 'system', content: 'You are a helpful assistant.' },
{ role: 'user', content: 'Summarise the SDK contract handshake.' },
],
});
if (result.error) throw new Error(result.error.code);
console.log(result.data.choices[0]?.message.content);Streaming
Pass stream: true and consume the resulting iterator. The Result
envelope only covers connection establishment; once the stream is
open, mid-flight errors propagate through the iterator — wrap your
for await loop in try/catch.
const stream = await client.ai.chat.completions.create({
model: 'openai/gpt-4o-mini',
messages: [{ role: 'user', content: 'Stream a haiku.' }],
stream: true,
});
if (stream.error) throw new Error(stream.error.code);
try {
for await (const chunk of stream.data) {
process.stdout.write(chunk.choices[0]?.delta?.content ?? '');
}
} catch (cause) {
// Mid-flight provider error — same `mapApiError` shape as non-streaming.
}Aborting
All four namespaces accept an optional signal: AbortSignal in the
second argument. Aborts surface as ai_aborted in non-streaming
calls; in streaming, the iterator simply terminates.
const controller = new AbortController();
setTimeout(() => controller.abort(), 5_000);
await client.ai.chat.completions.create(
{ model: 'openai/gpt-4o-mini', messages },
{ signal: controller.signal },
);Embeddings
const result = await client.ai.embeddings.create({
model: 'openai/text-embedding-3-small',
input: 'Hello, world.',
});
if (result.error) throw new Error(result.error.code);
const [embedding] = result.data.data;For RAG ingest/retrieve you almost never need to call this directly —
client.rag embeds for you using the same OpenRouter pool.
Image generation
OpenRouter does not implement OpenAI's /v1/images/generations
endpoint; image models are reached via /v1/chat/completions with
modalities: ['image'] and the resulting image comes back as a
non-standard message.images[] array of base64 data URLs. The SDK
encapsulates that quirk and surfaces an OpenAI-shaped response:
const result = await client.ai.images.generate({
model: 'google/gemini-2.5-flash-image-preview',
prompt: 'A retro pixel-art skyline at sunset.',
imageConfig: { aspect_ratio: '16:9' }, // forwarded as `image_config`
});
if (result.error) throw new Error(result.error.code);
const [image] = result.data.data;
console.log(image.mimeType, image.b64Json?.slice(0, 32), '…');If the model returns no images (wrong model, content policy, …) the
result is ai_no_image_generated rather than an empty success — the
failure is never silently swallowed.
Model catalogue
const result = await client.ai.models.list();
if (result.error) throw new Error(result.error.code);
for (const model of result.data.data) {
console.log(model.id, model.context_length, model.pricing);
}models.list() calls GET ${baseURL}/models directly (instead of
going through the upstream openai.models.list() parser) so
OpenRouter-specific fields like pricing, context_length,
supported_parameters and architecture make it through verbatim.
Errors
Upstream OpenAI.APIErrors are mapped to stable ai_* codes:
| Code | When |
|---|---|
ai_unauthorized |
401 — bad or revoked API key. |
ai_credits_exhausted |
402 — workspace ran out of OpenRouter credits. |
ai_forbidden |
403. |
ai_validation_error |
400 / 422 — malformed request. |
ai_rate_limited |
429. |
ai_moderation_blocked |
451. |
ai_timeout |
408 or APIConnectionTimeoutError. |
ai_aborted |
The caller's AbortSignal fired. |
ai_network_error |
Anything that did not reach the provider (connection reset, DNS, …). |
ai_provider_error |
Anything else from OpenRouter (5xx, unmapped 4xx). |
ai_no_image_generated |
images.generate() succeeded but the model returned no images. |
openrouter_key_missing |
OPENROUTER_API_KEY (and openrouterKey override) absent. |
error.meta includes status, model, and OpenRouter's own
code / type when present. error.cause is the original
OpenAI.APIError instance for callers that want to introspect it.
The contract gate adds contract_version_unsupported,
capability_unavailable, contract_unreachable and
contract_malformed — see
overview.
Where to go next
client.rag— usesclient.ai.embeddingsunder the hood for ingest and retrieval.client.approval— pair withclient.aito build LLM-tools that pause for a human before executing.- OpenRouter docs — the upstream provider.