openapi-ai-sdk
v0.7.0
Published
TypeScript SDK for openapi.ai — a drop-in extension of the OpenAI client, plus capability contracts: schema-frozen interfaces called like functions.
Maintainers
Readme
openapi-ai-sdk
The TypeScript SDK for OpenAPI — a drop-in replacement for the OpenAI client, plus the one thing no other gateway offers: capability contracts, versioned schema-frozen interfaces you call like functions while the platform guarantees what goes in and what comes out.
npm install openapi-ai-sdkDrop-in for the OpenAI SDK
Change one import and one class name; everything else is your existing code.
import { OpenAPI } from "openapi-ai-sdk"; // was: import OpenAI from "openai"
const client = new OpenAPI({ apiKey: "gw_…" });
const response = await client.chat.completions.create({
model: "openai/gpt-4.1-mini",
messages: [{ role: "user", content: "hello" }],
});OpenAPI extends OpenAI and overrides nothing. Streaming, tool calls,
retries, and typed responses are the OpenAI SDK's own, and anything built on
that client takes this one unchanged. What the platform adds appears as
namespaces on the same client: capabilities, invocations, callbacks.
Capability contracts
The reason this SDK exists. Instead of integrating a provider's API, your organization defines a contract — a named, versioned pair of JSON Schemas — and providers register endpoints that fulfill it. The platform validates input before dispatch and output before you see it, retries across fulfillments, and never returns nonconforming data. Published versions are frozen forever: the schema you integrate against today cannot change under you.
const summarize = client.capabilities.get("doc-summary", { version: 3 });
// Synchronous — resolves with a conforming result, or throws a typed refusal:
const result = await summarize.invoke({ input: { text: "..." } });
// Asynchronous — a job handle now, the document when the work finishes:
const job = await summarize.submit({ input: { text: "..." } });
const output = (await job.wait()).output;version is required on purpose: there is no floating "latest", so
publishing v4 breaks nobody on v3.
From Zod schemas, typed end to end
Define the contract and the call site from one pair of schemas.
register() is the mirror of get(): that one binds a version somebody
already published, this one publishes the version and binds it. The schema
pair you declare is the schema pair the platform enforces — one definition,
not two hand-maintained copies:
import { z } from "zod";
const DocSummaryIn = z.object({ text: z.string(), maxWords: z.number().int().optional() });
const DocSummaryOut = z.object({ summary: z.string() });
const client = new OpenAPI({ apiKey: "gw_…", provisioningKey: "pk_…" });
// Publish the contract from the schemas — defines, drafts and freezes,
// then hands back a handle bound to what it just published.
const summarize = await client.capabilities.register("doc-summary", {
input: DocSummaryIn,
output: DocSummaryOut,
description: "Summarize a document", // capability policy, first definition only
});
// Or bind a version somebody already published.
const bound = client.capabilities.get("doc-summary", {
version: 3,
input: DocSummaryIn,
output: DocSummaryOut,
});
const result = await bound.invoke({ input: { text: "long document" } });
result.output.summary; // string — inferred, not `unknown` to fumbleInput is validated client-side before anything leaves the process — a
refusal carries the same typed error shape as the gateway's own
(code: "invalid_input", JSON-pointer paths) — and output is validated
back through the bound schema. What you publish and what you type-check
are one declaration, not two copies held together by discipline.
Anything implementing Standard Schema
(Valibot, ArkType, …) works for typing and validation. JSON Schema
derivation at register() time is built in for Zod 4 and for schemas
exposing toJsonSchema() (ArkType); for anything else pass
inputJsonSchema/outputJsonSchema alongside, or a plain JSON Schema
pair as before — zod is an optional peer dependency, never required.
Registering is management, not inference, so it authenticates with a
provisioning key (pk_…, contracts scope) — pass provisioningKey
or set OPENAPI_PROVISIONING_KEY. The call is safe to re-run: an existing
capability is not redefined, the next run publishes the next version.
Publishing freezes both schemas forever; publish: false (or
capabilities.draft(...)) stages a version without freezing it, and
capabilities.publishVersion(name, n) freezes it later — after the review
that permanence deserves.
Failures are typed, and kept deliberately distinct:
| What happened | How you see it |
|---|---|
| Refusal — nonconforming input, in-flight cap, exhausted fulfillments | CapabilityInvocationError at the call site, with the typed code, JSON-pointer paths, and attempt trail |
| Failed invocation — terminal, already retried by the platform | Data: job.failed, job.error; reading job.output throws InvocationFailed so the happy path can't quietly read nothing |
| wait() timeout | InvocationTimeout — changes nothing; the job keeps running server-side |
For long-running work, skip polling: pass callbackUrl to submit, then
verify the signed webhook that arrives —
client.callbacks.verify(rawBody, signatureHeader, { secret: "whsec_…" })
does the constant-time, tolerance-checked HMAC and returns the full document.
Files in, files out
A file slot is part of the contract like any other field — a string property
carrying contentEncoding: "base64". fileSlot() writes the fragment for
you, and File values handle both directions:
import { File, fileSlot } from "openapi-ai-sdk";
// In the published contract:
// input_schema.properties.document = fileSlot("application/pdf")
// output_schema.properties.redacted = fileSlot("application/pdf")
const redact = client.capabilities.get("doc-redact", { version: 1 });
const result = await redact.invoke({
input: { document: File.fromPath("contract.pdf"), reason: "pii" },
});
(result.output.redacted as File).save("contract-redacted.pdf");A plain Buffer or Uint8Array in the input encodes itself the same way,
and output file slots on a bound handle come back as File objects with
.bytes, .mediaType, and .save() — never base64 soup. The contract
decides how the bytes travel, and the code does not change: inline slots
ride the payload (10 MB cap by default); by-reference slots
(format: "file-id") upload to the platform file store first and the
payload carries only the id — outputs download back into the same File
objects. The store itself is client.fileStore when you want an id without
an invocation.
Contracts as tools, for any model
A published contract already is a tool definition:
const tools = [await client.capabilities.get("doc-summary", { version: 3 }).asTool()];
// … the model answers with tool_calls …
messages.push(await client.capabilities.executeToolCall(call));executeToolCall runs the model's call through the platform and answers with
the role:"tool" message. Model-caused failures — hallucinated arguments,
nonconforming input — become tool-message content, never exceptions: an
agent loop that crashes on a bad model turn is a loop nobody can run. The
platform can also run the loop server-side, or serve your contracts over
MCP — see invoking capabilities.
Static types, generated from the contract
npx openapi pull doc-summary@3writes doc_summary_v3.ts: interfaces for both sides plus typed
invoke/submit helpers, so the compiler enforces the same schema pair the
provider is held to at runtime. Commit the file — it cannot rot, because
published versions are frozen. Constructs an interface cannot express degrade
to unknown, never to a crash. The key comes from --api-key or
OPENAPI_API_KEY.
Fulfilling contracts (the provider side)
Your endpoint is your own server; the SDK ships the pieces every fulfiller needs:
import { verifyDispatch, parseDispatch, pushResult, pushFailure } from "openapi-ai-sdk";
verifyDispatch(req.headers.authorization, { secret: DISPATCH_SECRET });
const job = parseDispatch(req.body); // input already conforms
await pushResult(job.completionUrl!, { summary: "…" }); // or pushFailure(...)A nonconforming result throws CompletionRejected with JSON-pointer paths —
and the one-time completion token is burned (.gone marks a consumed or
expired one). Pull-mode endpoints answer the dispatch with
pullAck(jobId) and their registered status URL template
(…/jobs/{job_id}/status, polled with the same bearer secret on an
exponential backoff) with pullPending(), pullResult(output), or
pullFailure(error) — the full wire contract is vendored in
docs/fulfillment-protocol.md.
Rehearse before the platform judges you. Your first nonconforming
output should fail a test, not burn a production attempt
(npm install -D ajv — an optional peer, testing-time only):
import { ContractCheck, MockDispatcher } from "openapi-ai-sdk/testing";
const check = await ContractCheck.fromFile("contract.json");
const mock = new MockDispatcher(check, { secret: "fs_test" });
const dispatch = mock.dispatch({ input: { text: "hello" } }); // authorized, pre-validated
await myEndpoint(dispatch.body, dispatch.headers); // your real handler
await pushResult(job.completionUrl!, output, { fetch: mock.fetch });
expect(mock.completed[0].output).toEqual(output);The mock answers completions exactly as the platform does — 200 on
conforming output, the 422 nonconforming_output envelope with paths, 410
once the token is spent — so your CompletionRejected handling is
exercised too. check.assertOutput(result) is the one-line version, and
npx openapi conform doc-summary@3 --output @sample.json (or
--contract contract.json offline) is the same verdict from CI.
Validation is pinned to JSON Schema Draft 2020-12, the draft the gateway
itself validates with.
Run your inference through the credential the dispatch carries. When the contract sponsors platform inference, the job arrives with a gateway credential scoped to that attempt — the enterprise's requirements (allowed model providers, jurisdictions, retention) are enforced on it, its cost is theirs, and the work is attributable:
const client = gatewayClient(job); // bound to this attempt's credential
await client.chat.completions.create({ model: "…", messages: [...] });That is the whole integration. A contract without sponsorship dispatches no
credential and gatewayClient throws NoGatewayCredential rather than
handing you something that would be refused.
Routing, attribution, and usage
Everything below spreads into standard OpenAI calls:
| Helper | What it does |
|---|---|
| provider(...) | which provider serves the request: order, only, ignore, sort, jurisdictions, dataCollection |
| models(...) | fallback models, tried in order |
| transforms(...) | middle-out prompt compression |
| metadata(...) | labels recorded with the request, filterable afterwards |
| allowedModels(...) | narrow what this request may reach, within what the key permits |
| routing(...) | merges the above into one spreadable body |
| sessionId(...) | attributes this request to a conversation, right on the call |
| withSession(...) | the same, for every request inside the callback |
import { provider, metadata, routing, sessionId } from "openapi-ai-sdk";
await client.chat.completions.create({
model: "openai/gpt-4.1-mini",
messages: [...],
...routing(
provider({ order: ["groq"], sort: "throughput" }),
metadata({ tenant: "acme" }),
sessionId("conversation-42"),
),
});jurisdictions: ["EU"] restricts to providers whose serving regions are
all within those jurisdictions, and fails rather than falling back — being
served elsewhere is the outcome you were ruling out. allowedModels is a
convenience, not a control: the enforceable version is a key with its own
allow-list. The default deployment is https://openapi.ai/v1; point
baseUrl at your own stack to self-host.
The openapi CLI
Installed with the package — the same surface, from a shell:
npx openapi chat "say hello" --model openai/gpt-4.1-mini --session c42
npx openapi invoke doc-summary@3 --input '{"text": "..."}'
npx openapi invoke doc-redact@1 --file document=contract.pdf --save redacted=clean.pdf
npx openapi submit doc-summary@3 --input @input.json --wait
npx openapi invocation inv-… --wait
npx openapi contract doc-summary@3
npx openapi credits
npx openapi key
npx openapi publish doc-summary --input in.schema.json --output out.schema.json
npx openapi capabilities
npx openapi versions doc-summary
npx openapi pull doc-summary@3JSON on stdout for machines, the assistant's words for chat, refusals on
stderr with the typed code and paths. The key comes from OPENAPI_API_KEY
(or --api-key), the base URL from OPENAPI_BASE_URL — except publish,
capabilities, and versions, which authenticate with the provisioning
key (OPENAPI_PROVISIONING_KEY / --provisioning-key) and need no
inference credential at all; --draft stages a version without freezing
it. In code, the same listings are client.capabilities.list() and
client.capabilities.versions(name).
Documentation
- Capability contracts — concepts and the invocation lifecycle
- Invoking capabilities — sync, async, tools, MCP, generated types
- Tutorials — your first capability, end to end
Development
yarn install
yarn test
yarn typecheck && yarn lint
yarn build # emits dist/ with declarationstest/vectors.json is generated from the Python SDK and checked by both
suites: cacheKey must produce byte-identical output to openapi_ai._hash,
so a store written by one SDK and read by the other cannot silently miss
every entry.
License
Apache-2.0 — use it against the hosted platform or any self-hosted deployment.
Naming a contract across organizations
A capability name belongs to the organization that published it, not to the
platform: two organizations may both have a doc-summary, against entirely
different schemas. Every command accepts the qualified form when you want to
be explicit about which one you mean:
npx openapi invoke acme/doc-summary@3 --input '{"text": "..."}'
npx openapi pull acme/doc-summary@3 # writes acme/doc_summary_v3.tsThe organization is checked, never used to look anything up — your API key
decides which organization a call reaches. Naming a different one is refused
before the request is sent, rather than quietly invoking your own contract of
the same name. Unqualified references behave exactly as before, including
where pull writes.
