REF · STACKBONE / WIKI · v0.9.4 stackbone

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 openai SDK (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 — uses client.ai.embeddings under the hood for ingest and retrieval.
  • client.approval — pair with client.ai to build LLM-tools that pause for a human before executing.
  • OpenRouter docs — the upstream provider.