TypeScript SDK (`sociaro-ai`)
One OpenAI-compatible API for every model behind the gateway — LLM chat
(with streaming), image generation/editing, async video, client-side fallback
chains, typed errors, and a typed /gw management client for resellers.
npm install sociaro-aiRequires Node.js 20+ or any edge runtime with WHATWG fetch (Cloudflare
Workers, Vercel Edge, Deno) — the SDK is fetch-based, never imports
node:http/node:fs, ships ESM + CJS with types, and has zero runtime
dependencies.
Quick start
Section titled “Quick start”import { Sociaro } from "sociaro-ai";
const client = new Sociaro({ apiKey: "gw_live_..." }); // or set SOCIARO_API_KEY
const response = await client.chat.completions.create({ model: "alibaba/qwen3.7-max", // always a catalog slug "author/model" messages: [{ role: "user", content: "What is the speed of light?" }],});console.log(response.choices[0].message.content);model is always a catalog slug "author/model"
(see the model catalog) — the gateway’s doors
resolve the slug and route to wherever inference runs. The author only
selects the request format client-side: anthropic/... models are translated
to the Anthropic Messages format and sent to POST /v1/messages; every other
author (including ones the SDK has never heard of) uses the OpenAI format via
POST /v1/chat/completions. Unknown slugs get the server’s 404/400 — the
SDK does not pre-validate them.
JavaScript is async-native, so a single Sociaro class covers what Python
splits into Sociaro and AsyncSociaro — every method returns a Promise
(or an AsyncIterable when streaming).
Client options
Section titled “Client options”Every option is passed to the new Sociaro({...}) constructor:
| Option | Type | Default | Notes |
|---|---|---|---|
apiKey | string | SOCIARO_API_KEY env | Falls back to the SOCIARO_API_KEY environment variable when a process.env exists. A missing key throws AuthError at construction time (not on the first request). |
baseURL | string | https://api.sociaro.com | Trailing slashes are stripped. Point it at http://localhost:8080 for local development. |
timeout | number (ms) | 60000 | Per-request timeout, enforced with an AbortSignal.timeout. |
maxRetries | number | 2 | Transport-level retries for transient errors (connect/timeout/502/503/504). See Retries vs fallback. |
attribution | Record<string, string> | — | Default X-Attr-* headers sent on every request (see Attribution). |
models | Record<string, string[]> | — | Alias registry mapping a logical name to an ordered fallback chain (see Fallback chains). |
fallbackOnRateLimit | boolean | true | When true, a 429 advances the fallback chain; when false, a 429 is terminal. |
fetch | FetchLike | globalThis.fetch | Custom fetch implementation for proxies, testing, or runtimes that do not expose a global fetch. Signature: (input: string | URL, init?: RequestInit) => Promise<Response>. |
const client = new Sociaro({ apiKey: "gw_live_...", // or set SOCIARO_API_KEY baseURL: "http://localhost:8080", // override for local dev timeout: 30_000, // ms; default 60000 maxRetries: 3, // transport retries; default 2 attribution: { team: "product" }, // default X-Attr-* tags models: { smart: ["openai/gpt-4o", "anthropic/claude-sonnet-4-6"] },});On edge runtimes without process.env (some Cloudflare Workers / Deno deploy
configurations), always pass apiKey explicitly — the env fallback is guarded
so the SDK does not crash where process is absent, but it cannot read a key
that is not there, so construction throws AuthError. client.baseURL
exposes the resolved base URL (never the api key).
Retries vs fallback
Section titled “Retries vs fallback”Two distinct layers handle failures, and they compose:
- Transport retries (
maxRetries) — for one chain entry, the transport retries connection errors, timeouts and502/503/504responses with exponential backoff:500 ms,1000 ms,2000 msbetween attempts (the schedule caps at the last value). Total attempts =maxRetries + 1. A connect/timeout error that survives all retries surfaces as anAPIErrorwithstatusCode === 0. - Fallback chain — only after an entry’s transport retries are exhausted
does the SDK decide whether to advance to the next entry in the chain.
Retryable outcomes (
ProviderUnavailableError, thestatusCode === 0connect/timeoutAPIError, andRateLimitErrorwhenfallbackOnRateLimitis on) advance; terminal outcomes (BadRequestError,AuthError, any otherAPIError) throw immediately.
So a single-entry request still gets maxRetries + 1 transport attempts; a
multi-entry chain gets that many attempts per entry before the chain moves on.
/gw management calls and streaming opens use the same transport (streaming
performs a single attempt — no transport retry once the stream is opening).
Streaming
Section titled “Streaming”const stream = client.chat.completions.create({ model: "anthropic/claude-sonnet-4-6", messages: [{ role: "user", content: "Count to 5." }], stream: true,});for await (const chunk of stream) { process.stdout.write(chunk.choices[0]?.delta.content ?? "");}Images
Section titled “Images”const img = await client.images.generate({ model: "bytedance/seedream-5-0", prompt: "A red fox in snow",});console.log(img.data[0].url);images.generate goes through the POST /v1/images/generations
door — any door-reachable image-kind slug works by its catalog
slug (a few slugs whose upstream has no OpenAI-compatible images path are
door-unreachable; see media doors).
images.edit is the one escape hatch: it stays on the native
/openai/v1/images/edits prefix (the
doors reject multipart uploads by design) and supports openai/... models only.
Note: no openai image model is in the live catalog yet, so images.edit is
currently unusable in production — the example below is illustrative of the API
shape only. It accepts byte inputs only — Uint8Array, ArrayBuffer or Blob
(File extends Blob). Filesystem paths are a Python-only idiom (the TS SDK
is edge-safe); Node users read the file themselves:
import fs from "node:fs/promises";
// Illustrative only — no openai image model is enabled in the catalog today.const edited = await client.images.edit({ model: "openai/<image-model>", image: await fs.readFile("original.png"), prompt: "Add a sunset",});Video (async jobs)
Section titled “Video (async jobs)”Video is called through the standard POST /v1/videos door by catalog slug
author/model (e.g. bytedance/seedance-1-5-pro, alibaba/wan2.7-t2v) — the
same slug convention as chat and images. The door resolves the slug and routes
to wherever inference runs. Blocking — the SDK drives the create→poll→result
loop:
const result = await client.video.generate({ model: "bytedance/seedance-1-5-pro", // catalog slug "author/model" prompt: "A timelapse of cherry blossoms blooming", seconds: 5, size: "1280x720", // OpenAI-style "WxH"; the door buckets by short side intervalMs: 3000, // poll interval (default 2000) timeoutMs: 300_000, // overall deadline (default 300000) onProgress: (status) => console.log("status:", status),});console.log(result.url); // first/primary URL; result.urls is the full listgenerate and submit share these params: model, prompt, seconds,
size, image, attribution, plus any extra keys forwarded verbatim
through the door. image enables image-to-video: pass a URL or a data: URI
string (the SDK passes it through — raw byte upload is out of scope):
const result = await client.video.generate({ model: "bytedance/seedance-1-5-pro", prompt: "Animate this still into a gentle pan", image: "https://example.com/frame.png", // or "data:image/png;base64,..." seconds: 5,});Non-blocking — submit returns a Job handle (a single CREATE; no fallback —
only the first chain entry is used, since a job id is bound to one model):
const job = await client.video.submit({ model: "bytedance/seedance-1-5-pro", prompt: "Ocean waves crashing at sunset", seconds: 5,});console.log(job.taskId, await job.status()); // status() = one pollconst result = await job.wait({ timeoutMs: 300_000 }); // poll to terminal, cachesconsole.log(job.result().url); // cached VideoResult; throws if wait() hasn't run yetThe Job handle exposes taskId, status() (one poll, returns the
door’s raw status string), wait(options) (poll to a terminal state and
cache the VideoResult), and result() (return the cached result, throwing
"job result not available yet — call wait() first" if wait() has not
completed). wait() is idempotent — a second call returns the cache.
Polling knobs are milliseconds: intervalMs (default 2000) and timeoutMs
(default 300000) — Python’s interval/timeout seconds. The poll wait grows
exponentially (×1.5, with jitter) up to a 15 s cap. Watch the units: these
polling knobs and the client timeout are milliseconds, but the
AsyncJobTimeout.timeout field on a timeout error is seconds (see
Errors).
Fallback chains
Section titled “Fallback chains”Define logical model aliases backed by an ordered list of catalog slugs; the SDK advances down the chain on retryable errors (5xx, timeouts, connection errors, and 429 by default):
const client = new Sociaro({ apiKey: "gw_live_...", models: { smart: ["openai/gpt-4o", "anthropic/claude-sonnet-4-6", "alibaba/qwen3.7-max"] },});const response = await client.chat.completions.create({ model: "smart", messages: [{ role: "user", content: "Hello" }],});Terminal errors (400/404/422, 401/403) throw immediately. If every entry
fails, AllProvidersFailed aggregates the per-entry errors. Streaming
fallback happens only before the first chunk is delivered. 429 fallback is
controlled by fallbackOnRateLimit (default true).
Attribution
Section titled “Attribution”const client = new Sociaro({ apiKey: "gw_live_...", attribution: { team: "product" } });
await client.chat.completions.create({ model: "openai/gpt-4o", messages: [{ role: "user", content: "Hello" }], attribution: { feature: "chat" }, // → X-Attr-Feature header});Tags show up as dimensions in GET /gw/stats.
Errors
Section titled “Errors”Every error is a subclass of SociaroError; the api key never appears in any
message or field.
SociaroError├── APIError HTTP error from the gateway│ ├── AuthError 401 / 403│ ├── RateLimitError 429│ ├── BadRequestError 400 / 404 / 422│ └── ProviderUnavailableError 502 / 503 / 504 (after transport retries)├── AsyncJobFailed async media job reached terminal failure├── AsyncJobTimeout async media job did not finish within timeout└── AllProvidersFailed every provider in the fallback chain failed| Class | Extra fields |
|---|---|
APIError (and the four HTTP subclasses) | statusCode (0 for a connect/timeout error that escaped the transport retries), body (response body, truncated to 2000 chars), provider (the routed slug, when known, else null) |
AsyncJobFailed | jobId: string, reason: string |
AsyncJobTimeout | jobId: string, timeout: number — in SECONDS (mirrors the Python SDK), even though the polling knob you set (timeoutMs) is in milliseconds |
AllProvidersFailed | errors: SociaroError[] — one entry per chain element; each entry’s message names the slug, and the original error is chained via the standard cause |
Units trap: the polling option is
timeoutMs(milliseconds), but theAsyncJobTimeout.timeoutfield on the resulting error is in seconds. A job that runs pasttimeoutMs: 300000throwsAsyncJobTimeoutwithtimeout === 300.
import { AllProvidersFailed, AsyncJobFailed, AsyncJobTimeout, AuthError, RateLimitError,} from "sociaro-ai";
try { const result = await client.video.generate({ model: "bytedance/seedance-1-5-pro", prompt });} catch (err) { if (err instanceof AllProvidersFailed) { for (const e of err.errors) console.error(" -", e.message); // per-entry } else if (err instanceof AsyncJobFailed) { console.error(`job ${err.jobId} failed: ${err.reason}`); } else if (err instanceof AsyncJobTimeout) { console.error(`job ${err.jobId} timed out after ${err.timeout}s`); // SECONDS } else if (err instanceof RateLimitError) { console.error("rate limited:", err.statusCode); } else if (err instanceof AuthError) { console.error("auth failed:", err.statusCode); } else { throw err; }}/gw management client
Section titled “/gw management client”client.gw is a typed client for the
/gw self-service API: everything a reseller needs to
automate key issuance, sub-accounts, budgets, white-label branding, analytics
and compliance export from their backend. /gw calls are single-target
management calls — no fallback chains; the transport retry on transient errors
applies.
| Group / method | Endpoint |
|---|---|
gw.me() | GET /gw/me — the calling key’s scopes and entitlements |
gw.ceiling() | GET /gw/ceiling — the maximum a child key may be granted |
gw.keys.list() / create() / revoke(id) | GET/POST /gw/keys, DELETE /gw/keys/{id} |
gw.subaccounts.list() / create() / update(id) | GET/POST /gw/subaccounts, PATCH /gw/subaccounts/{id} |
gw.budgets.list() / set() | GET /gw/budgets, PUT /gw/budgets |
gw.branding.get() / set() | GET/PUT /gw/branding |
gw.async.list() / iterate() | GET /gw/async (cursor pagination; iterate auto-follows) |
gw.stats() / gw.usage() | GET /gw/stats, GET /gw/usage |
gw.evidence() | GET /gw/evidence — AsyncIterable of parsed NDJSON lines |
The flagship flow — issue a scoped, sub-account-bound key and use it:
const reseller = new Sociaro({ apiKey: process.env.SOCIARO_RESELLER_KEY });
const ceiling = await reseller.gw.ceiling(); // what a child key may getconst sub = await reseller.gw.subaccounts.create({ name: "Acme Corp", slug: "acme-corp" });
const created = await reseller.gw.keys.create({ scopes: ["inference:use"], entitlements: [{ provider: "openai", model_pattern: "gpt-4o*", effect: "allow" }], subAccountId: sub.id,});
// created.key is the PLAINTEXT secret — returned ONLY here. Store it now.const customer = new Sociaro({ apiKey: created.key });await customer.chat.completions.create({ model: "openai/gpt-4o", messages: [{ role: "user", content: "Hello from Acme!" }],});Notes that matter in practice:
-
Plaintext key once:
keys.create()returns the secret (created.key) only in that one response; the gateway stores a hash and cannot show it again. The response is aGWKeyCreated(api_key_id,key,scopes, optionalsub_account_id). -
entitlementsomitted (ornull) → the key inherits the client’s full ceiling; an explicit[]is a deliberate deny-all on inference (the two are distinct on the wire). -
Requested scopes/entitlements must fit
gw.ceiling()(403 otherwise). -
Entitlement axis — each entitlement is
{ provider, model_pattern, effect }plus an optionalaxis:axis: "provider"(the default when omitted) matchesprovideragainst the inference provider andmodel_patternagainst the native model id.axis: "vendor"matchesprovideragainst the catalog slug vendor (the author-part, a glob) andmodel_patternagainst the model name.axis: "kind"matchesprovideragainst the catalog modality (llm|image|video|3d|embedding|rerank|audio) andmodel_patternagainst the model name. Likevendor, it only matches catalog-known slugs; unlikevendor,allowrules on this axis are always operator-issued (there is no self-service resolve path).
Pick
vendor/kindwhen you reason in catalog slugs/modalities (e.g. allow everything authored byalibaba, or everything ofkind: "image"), andproviderwhen you reason in inference providers:entitlements: [{ axis: "vendor", provider: "alibaba", model_pattern: "*", effect: "allow" },{ axis: "kind", provider: "image", model_pattern: "*", effect: "allow" },{ axis: "provider", provider: "openai", model_pattern: "gpt-4o*", effect: "allow" },], -
gw.evidence()streams the tamper-evident export (evidence): a header line, usage/audit records, then a checksum line — each line’sprevfield carries the SHA-256 hash chain. -
/gwerror responses carry the{"error":{type,message}}envelope inAPIError.body; extract it withparseGWError(err)(returnsnullfor non-envelope bodies, never throws).
import { APIError, parseGWError } from "sociaro-ai";
try { await reseller.gw.branding.set({ portalSlug: "taken-slug", productName: "Acme AI" });} catch (err) { if (err instanceof APIError) { console.error(parseGWError(err)); // e.g. { type: "conflict", message: "..." } }}Async-media ledger
Section titled “Async-media ledger”gw.async reads the client’s async-media (video/image job) ledger. list()
returns one cursor page ({ data, nextCursor }); iterate() is an
AsyncGenerator that auto-follows nextCursor until the cursor is empty, with
the same filters applied to every page:
// Every failed video task in the last 7 days, across the whole client.for await (const task of reseller.gw.async.iterate({ scope: "client", // "key" (default) or "client" status: "failed", // pending | succeeded | failed | cancelled | expired kind: "video", createdAfter: "2026-06-01T00:00:00Z", // RFC 3339; createdBefore is the upper bound limit: 100, // page size, 1..500 (gateway default 100)})) { console.error(task.task_id, task.provider, task.model, task.provisional_cost_usd);}list() accepts the same scope/status/kind/createdAfter/createdBefore/
limit/cursor filters; cursor is the opaque nextCursor from a previous
page. Each GWAsyncTask (snake_case wire shape) carries:
| Field | Meaning |
|---|---|
task_id, provider, model, kind | Job identity and modality |
status | pending | succeeded | failed | cancelled | expired |
provisional_cost_usd | Estimated cost; null on a tariff miss |
final_cost_usd | Settled cost; null until finalization (and for expired tasks) |
expiration_reason | null for non-expired tasks |
attribution | The job’s X-Attr-* tags, or null |
created_at, finalized_at | RFC 3339; finalized_at is null while pending |
Exports and typed results
Section titled “Exports and typed results”sociaro-ai ships type declarations; the public surface is fully typed. The
symbols a consumer imports:
- Client:
Sociaro(the value), and the option/result typesSociaroOptions,FetchLike,Route(a catalog-slug string),VERSION(the package version string). - Error classes (all
instanceof-checkable):SociaroError,APIError,AuthError,RateLimitError,BadRequestError,ProviderUnavailableError,AsyncJobFailed,AsyncJobTimeout,AllProvidersFailed, plus theparseGWError(err: APIError)helper. - Video:
Job,JobWaitOptions,VideoGenerateParams,VideoSubmitParams,VideoImageInput, and theVideoResultresult type. - Images:
ImageGenerateParams,ImageEditParams,ImageInput, and theImagesResponseresult type. - Chat:
ChatCompletionCreateParams(and the…NonStreaming/…Streamingvariants), and the result typesChatCompletion,ChatCompletionChoice,ChatCompletionMessage,ChatCompletionChunk,ChatCompletionChunkChoice,ChatCompletionChunkDelta,ChatMessageParam,CompletionUsage. /gwclient: the resource classesGW,GWKeys,GWSubAccounts,GWBudgets,GWBranding,GWAsync; the param typesGWKeyCreateParams,GWSubAccountCreateParams,GWSubAccountUpdateParams,GWBudgetSetParams,GWBrandingSetParams,GWStatsParams,GWUsageParams,GWAsyncListParams,GWEvidenceParams; and the result typesGWMeResponse,GWCeilingResponse,GWKeyInfo,GWKeyCreated,GWSubAccount,GWBudget,GWBrandingConfig,GWStatsRow,GWUsageRow,GWAsyncTask,GWAsyncListPage,GWEntitlement, and the evidence-line unionGWEvidenceLine(GWEvidenceHeaderLine|GWEvidenceUsageLine|GWEvidenceAuditLine|GWEvidenceChecksumLine).
import { Sociaro, VERSION, AllProvidersFailed, parseGWError, type Route, type ChatCompletion, type GWAsyncTask,} from "sociaro-ai";
console.log("SDK version:", VERSION);Wire-shaped result fields stay snake_case (finish_reason, task_id,
provisional_cost_usd, …) because they are the verbatim gateway shapes; only
the option names you pass in are camelCase.
Parity with the Python SDK
Section titled “Parity with the Python SDK”Both SDKs are kept behaviourally identical by shared contract fixtures
(sdk/contract-fixtures/):
golden JSON files locking routing, the fallback policy, error mapping,
adapters, attribution, the async runner and the /gw client (query-string and
body construction, error-envelope and NDJSON parsing). Both test suites run
against the same fixtures in CI, so the SDKs cannot drift apart silently.
The differences that remain are deliberate idiom, not behaviour:
| TypeScript | Python | |
|---|---|---|
| Option names | camelCase (subAccountId, limitUsd) | snake_case (sub_account_id, limit_usd) |
| Duration inputs | milliseconds (timeout, intervalMs, timeoutMs) | seconds (timeout, interval) |
| Async-ledger group | gw.async | gw.async_ (async is a Python keyword) |
| Client classes | single Sociaro (async-native) | Sociaro + AsyncSociaro |
| Image inputs | bytes only: Uint8Array / ArrayBuffer / Blob (edge-safe) | bytes, filesystem path, or file object |
Wire-shaped response fields stay snake_case in both SDKs (finish_reason,
sub_account_id, …) — they are the verbatim gateway shapes. The one duration
field that breaks the rule: AsyncJobTimeout.timeout is in seconds in both
SDKs, even though the TypeScript polling input is timeoutMs in milliseconds
(see Errors).
Examples and source
Section titled “Examples and source”This page is the authoritative SDK reference — it documents every client
option, resource and result type the package exports. For copy-pasteable
end-to-end scripts, the repository ships runnable examples:
sdk/typescript/examples
(node-chat.mjs, reseller-issue-key.mjs, and nextjs-route.ts — a Vercel
Edge route showing the SDK running on an edge runtime). The package
README
is a shorter quick-start; this page is a superset of it.
The same behaviour is available in the Python SDK, kept in lockstep by the shared contract fixtures above.