@dxv-systems/ai
App client for the DXV AI gateway (/api/ai/v1): a standard OpenAI client, a Vercel AI SDK provider, Kennisbank Ask and agent runs, all with the app's own credentials and a per-feature label.
Server only. The helpers use gateway credentials. Importing the package in a browser bundle is a bug: every factory throws AiConfigError when window exists. Call it from route handlers, server actions and jobs.
Install
npm install @dxv-systems/ai # ask() and runs
npm install openai # only for @dxv-systems/ai/openai
npm install @ai-sdk/openai-compatible ai # only if you use the AI SDK subpath
openai and @ai-sdk/openai-compatible are optional peer dependencies: install the one you use. openai() lives in @dxv-systems/ai/openai and aiSdk() in @dxv-systems/ai/ai-sdk, separate entry points so a bundler never resolves a package you did not install. The main entry (ask, createRuns, the errors, isAiUnavailable) imports neither.
Environment
| Variable | Meaning |
|---|---|
DXV_AI_GATEWAY_URL | Full gateway base. Default https://platform.dxvagentic.com/api/ai/v1. |
DXV_AI_GATEWAY_TOKEN | Gateway token. When set, it is sent as the bearer. |
VERCEL_OIDC_TOKEN | Used when DXV_AI_GATEWAY_TOKEN is not set. Read through @vercel/oidc on every request (it refreshes; nothing is cached at import time). |
AI_PROVIDER | dxv (default), openai, anthropic or openrouter. Swaps inference only, see below. |
AI_MODEL_LOW AI_MODEL_MEDIUM AI_MODEL_HIGH AI_MODEL_MAX (AI_MODEL_AUTO) | Model id each dxv/* alias maps to under a non-dxv provider. |
OPENAI_API_KEY OPENROUTER_API_KEY ANTHROPIC_API_KEY | Key of the swapped-to provider. |
Credentials
The package looks for a credential in this order on every request:
DXV_AI_GATEWAY_TOKEN, when set.- A Vercel OIDC token. On a Vercel deployment it is injected for you. Locally, run
vercel env pullin the app: it writesVERCEL_OIDC_TOKENto.env.local. The token is short lived; pull again when it expires.
With neither source the call throws AiConfigError naming these variables and vercel env pull.
Never put a provider key in the app unless you set AI_PROVIDER to swap away from DXV. With the default provider the gateway holds the keys, budgets and usage records; an app-held key bypasses all of it.
Features
Every factory takes { feature }, the label that appears in the gateway's usage and AI feature registry (X-DXV-Feature). It must match ^[A-Za-z0-9._:-]{1,100}$, otherwise the factory throws AiConfigError. Use a stable key per product feature (search.summary), never a per-user value.
OpenAI client
import { openai } from "@dxv-systems/ai/openai";
const client = openai({ feature: "support.reply" });
const res = await client.chat.completions.create({
model: "dxv/medium", // dxv/low | dxv/medium | dxv/high | dxv/max | dxv/auto
messages: [{ role: "user", content: "Summarise this ticket" }],
});
openai() returns a plain OpenAI instance from the openai package. The base URL, the per-request bearer and X-DXV-Feature are set for you; the API key is a placeholder the gateway ignores. Retries are the SDK's own (the gateway's x-should-retry stops retries on budget errors). Errors stay the SDK's errors (APIError, RateLimitError, ...).
openai({ feature, context }) also sends X-DXV-Context (at most 2000 characters, Latin-1, no control characters) for features with retrieval enabled.
Images
The gateway generates images. Use the SDK's own images.generate():
import { openai } from "@dxv-systems/ai/openai";
const res = await openai({ feature: "content.image" }).images.generate({
model: "google/gemini-3.1-flash-image",
prompt: "A red bicycle against a white wall",
aspect_ratio: "4:5", // a gateway field, not in the SDK's types
} as never);
const image = Buffer.from(res.data![0].b64_json!, "base64");
modelis an exact image model id. Adxv/*alias or a text model is a 400.- The gateway forwards
prompt,n(1 to 10),resolution,aspect_ratio,size,quality,output_format,background,output_compressionandinput_references(1 to 8 source images for an edit, each{ type: "image_url", image_url: { url } }with adata:orhttps://URL). Other fields, such as the SDK'sresponse_format, are dropped, not refused. stream: trueis a 400, and a request body over 4 MB is a 413.- With another
AI_PROVIDER, the model id and the extra fields are that provider's.
Vercel AI SDK
import { generateText } from "ai";
import { aiSdk } from "@dxv-systems/ai/ai-sdk";
const dxv = aiSdk({ feature: "support.reply" });
const { text } = await generateText({ model: dxv.chatModel("dxv/high"), prompt: "..." });
Ask
import { ask } from "@dxv-systems/ai";
const r = await ask({ feature: "faq", question: "What are the opening hours?", endUser: session?.userId ?? null });
r.answer; r.sources; r.needsHuman; r.insufficientContext; r.reviewRequired;
endUser is required (string | null): it narrows the gateway's per-person limits. path optionally narrows the search: one folder or document, or a list of 1-10 searched as one set (for example the folders a user's role may read). Every entry must lie inside the feature's registered folders, else the gateway answers 400 (AiRequestError).
Access groups
When who may see a document is data in your app (roles set per document), map your roles onto access groups. The kennisbank never sees your users or roles, only these keys.
import { ask, setDocumentGroups, syncAccess } from "@dxv-systems/ai";
// After each deploy and nightly: the whole state. Groups and labels left out are removed.
await syncAccess({
feature: "assistent",
groups: roles.map((r) => ({ key: `role_${r.id}`, name: r.name })),
documents: docs.map((d) => ({ path: d.kennisbankPath, groups: d.roleIds.map((id) => `role_${id}`) })),
});
// When an admin changes one document's roles.
await setDocumentGroups({ feature: "assistent", path: doc.kennisbankPath, groups: ["role_17"] });
// Ask for a user: only documents labelled with one of their groups are searched.
await ask({ feature: "assistent", question, groups: [`role_${user.roleId}`], endUser: user.id });
Group keys are your own stable ids (A-Za-z0-9._:-, 1-100 characters); renaming a role only changes name. A document without any group, or a group you never defined, gives nothing: send a group such as everyone to every user for documents all may see. Without groups, ask() searches every document in the feature's folders. Labelled paths must lie inside those folders. A preview has its own groups, apart from production, so sync there too.
Passages and read
For an app with its own model loop (tools), retrieve without an answer, scoped exactly like ask():
import { passages, read } from "@dxv-systems/ai";
const hits = await passages({ feature: "assistent", question, groups, limit: 8 }); // [{ id, path, version, heading, page, text }]
const page = await read({ feature: "assistent", path: hits[0].path, offset: 0, groups }); // { excerpt, offset, totalChars, truncated, ... }
read() with groups answers a document none of them may see as 404 (AiRequestError), the same as a missing one.
Agent runs
import { createRuns } from "@dxv-systems/ai";
const runs = createRuns({ feature: "intake" });
const { id, duplicate } = await runs.fireRun(agentUuid, { requestId: 12 }, { eventKey: "request:12" });
const state = await runs.pollRun(id, { deadlineMs: 120_000 }); // { id, status, createdAt, updatedAt, result, error? }
const many = await runs.pollMany([id1, id2], { deadlineMs: 120_000 }); // one shared deadline
fireRun(agentUuid, input, { eventKey?, replyTo?, timeoutMs = 15000 }): one POST, no retry, redirects never followed (a 3xx throwsRunRefusedError, so the token cannot be sent elsewhere). 202 gives{ id, duplicate: false }; 200skipped: "duplicate"(sameeventKeywithin 24 h) gives{ id, duplicate: true }; any other 2xx shape throwsRunRefusedError.eventKeyuses the feature charset. A timeout throwsRunNoAnswerError: the run may have started, so do not blindly re-send without aneventKey.- Reply-to fallback (explicit behaviour): if a request with
replyTois answered 400 or 404 (the gateway cannot place the reply, for example the parent run is unknown), it is re-sent once withoutreplyToand starts a fresh run. Nothing else is retried; a request withoutreplyTois never re-sent. getRun(id): a 404 reads as{ status: "failed", error: "not found" }.pollRun(id, { deadlineMs, intervalMs = 3000 }): polls untilcompleted,failedorcancelled. At the deadline it returns the last state (never throws for a deadline). BriefAiUnavailable/AiRateLimitedblips during polling are tolerated; if no state was ever read, the last error is thrown.pollMany(ids, { deadlineMs, intervalMs? }): all runs share one deadline.RunStatus = "pending" | "implementing" | "completed" | "failed" | "cancelled".
Swapping the provider
AI_PROVIDER changes inference only (openai() and aiSdk()):
AI_PROVIDER | Base URL | Key |
|---|---|---|
openai | https://api.openai.com/v1 | OPENAI_API_KEY |
openrouter | https://openrouter.ai/api/v1 | OPENROUTER_API_KEY |
anthropic | https://api.anthropic.com/v1/ (OpenAI-compatible endpoint) | ANTHROPIC_API_KEY |
dxv/* model ids are rewritten to the id in AI_MODEL_LOW|MEDIUM|HIGH|MAX (dxv/auto uses AI_MODEL_AUTO, else AI_MODEL_MEDIUM). An alias without a mapping is an AiConfigError (through openai() the SDK surfaces it as a 400 BadRequestError carrying the same message, so it is not retried); a model id that is not a dxv/* alias is passed through. No X-DXV-* headers are sent. ask(), passages(), read(), syncAccess(), setDocumentGroups() and createRuns() exist only on the DXV gateway and throw AiConfigError under another provider.
Errors
Exported classes, each with optional status and code. Messages never contain a token.
| Error | When | What to do |
|---|---|---|
AiUnavailable | 503, any 5xx, network failure | The degraded mode: show a fallback, retry later. |
AiBudgetExceeded | 429 budget_exceeded (has retryAfterSeconds) | The app's monthly cap is reached. Do not retry in a loop; degrade and tell DXV. |
AiRateLimited | other 429 (has retryAfterSeconds) | Back off for retryAfterSeconds. |
AiRequestError | other 4xx (gateway message and code) | A bug or bad input; fix the call. |
AiConfigError | bad feature, missing credential, bad env, browser use, unmapped alias | Fix the configuration. |
RunNoAnswerError | fireRun timed out | The run may exist. Re-send only with an eventKey. |
RunRefusedError | fireRun got a 3xx or an unexpected 2xx | Report it; do not retry. |
openai() throws the OpenAI SDK's own errors. Use isAiUnavailable(err) to treat this package's AiUnavailable and the SDK's 5xx and connection errors as one degraded mode:
try {
await client.chat.completions.create(...);
} catch (err) {
if (isAiUnavailable(err)) return fallback();
throw err;
}