openvibers/openvibe.sdk Commits

BA3: the latency classes and the batches of OpenVibe.AI (#134)

OP OpenVibers committed 5 days ago · 3f9b6c8e6f · 12 files, +260 −28

* BA3: the latency classes and the batches of OpenVibe.AI

ai.runs.create may ask for the class it may wait in (interactive, background, batch) and, for a batch run, how long the answer may take. waitFor gives a batch run back pending instead of throwing when the wait is over. ai.batches opens, reads, lists and cancels a batch of many prompts.

* BA3: the release reads 0.98.0 everywhere

The version the package, the core, the lockfile, the bundle, the README and the changelog name is the same one.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 74ee81b..75bf848 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,24 @@
# Changelog
++## 0.98.0 (2026-11-07)
++
++- `ai.runs.create` (and the direct operations) may ask for the class the caller may wait in:
++ `{ latency: 'interactive' | 'background' | 'batch' }`, and for a batch run `{ deadlineHours: 1..72 }`
++ (how long the answer may take). The request carries `options.latency` and `options.deadline_hours`.
++ The class belongs to the workflow: a caller may ask for a slower class and never for a faster one, and a
++ faster ask is clamped by the service, not refused — the run answers with the class it really ran in
++ (`run.latency_class`), the tier it was priced on (`run.service_tier`) and, when it was clamped,
++ `run.latency_clamped`.
++- `ai.runs.waitFor` learns that a batch run may take hours: when the wait is over and the run is a batch
++ run, the pending run is returned (its status says where it stands) instead of an error. The answer comes
++ later, with the run's completion event (`ai.run.succeeded`) or the webhook the run was made with.
++- Add `ai.batches`: `create(items, { model, deadlineHours, idempotencyKey })`, `get(id)`, `list()` and
++ `cancel(id)` for OpenVibe.AI's `/api/v1/batches` — many prompts answered later, at half the retail
++ price. A cancelled batch cancels the items that had not run; nothing is charged for them.
++- Add the types `LatencyClass`, `ServiceTier`, `AiBatch`, `AiBatchItem` and `AiBatches`, and
++ `CLASSES` (the three classes) next to `TERMINAL`.
++
++
## 0.97.1 (2026-11-07)
- `types/menu.d.ts`: `MenuRequest` gives `url` and `method` the `| undefined` that `node:http`'s
ddiff --git a/README.md b/README.md
iindex ed86e2c..aeafd2e 100644
---- a/README.md
++++ b/README.md
@@@ -2,7 +2,7 @@
> Supported browser and server clients for the OpenVibe platform.
--**Status:** alpha, v0.97.1 (the five 1.0 core entries — `core`, `auth`, `events`, `db` and `media` — are frozen: their exported names change only with a major release; roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
++**Status:** alpha, v0.98.0 (the five 1.0 core entries — `core`, `auth`, `events`, `db` and `media` — are frozen: their exported names change only with a major release; roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
@@@ -11,7 +11,7 @@
After v0.97.1 is tagged:
```bash
--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.97.1
++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.98.0
```
It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from the temporary Contracts sharing branch, a devDependency the tests check them against).
@@@ -183,7 +183,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| `openvibe-sdk/limits` | node | `createActorLimiter({limits: {minute, hour, day}, actor?, maxActors?, onLimited?})` → `limits(name, {minute?, hour?, day?})` Express middleware: per-actor fixed windows at a capability boundary (service principal, else the signed-in person, else `ip:`), 429 problem+json `rate_limited` with Retry-After before the handler; `defaultActor(req)`, `.stats()` |
| `openvibe-sdk/account-data` | node | `createAccountData({db, service, tables: [{table, subject, value?, file?, columns?, erase?}], extraExport?, extraErase?})` → `.apply(event, {send})`, `.consumer({secrets, send, onEvent?})` (a POST /internal/events handler), `.exportPart(subject)`, `.erase(subjects)`, `.ensureSchema()`; `createNetworkSender({networkInternalUrl, clientId, clientSecret})`; `startSubscriptions({eventsUrl, endpoint, secret, networkInternalUrl, clientId, clientSecret})` (the two Events subscriptions, created at boot when missing): a service's share of account export and deletion (ADR-033), idempotent per export/deletion id; telemetry is covered once here (the person's `telemetry_samples` exported as `telemetry.json`, its `subject` set to NULL and the sample kept) unless the table is listed; `uncovered(db, {tables, taskKeyed?, exempt?})` lists the tables with a person column (`subject`, `owner`, `created_by`, `user_id`, `person`) that nothing covers, for a service test to assert is empty |
| `openvibe-sdk/chat` | both | `createChatClient(client)`: `global.send/history/search`, `rooms.*` (list, create, get, update, messages, send, deleteMessage, join, leave, read, members, setMember), `dms.*` (list, create, get, messages, send, deleteMessage, read, unread, blocks, block, unblock), `exportMine()`, `vibe.*` (list, start, rename, remove: chats with Vibe), `inboxes.*` (list, create, get, update, remove, addMember, removeMember, conversations, messages, reply, setStatus, read, identity, replies, saveReply, deleteReply, webhooks, addWebhook, testWebhook, removeWebhook: a support inbox's team), `support.*` (list, messages, send: their own conversations with sites' chats), `feeds.*` (mine, create, update, rotateKey, remove, view, onLive). With no account: `createInboxVisitor(client, { inbox, token, user, account })` (visit, send, messages, handoff, rate: your own panel for an inbox, from any site) and `createFeedPublisher(client, { feed, key })` (hello, publish, claudeCode: vibe coding events with the feed's key) |
--| `openvibe-sdk/ai` | both | `createAiClient(client)`: `runs.create(workflow, input, {wait, idempotencyKey, …})`, `runs.get/list/cancel/retry/citations/addCitations/waitFor`, `chat/generate/summarize/classify/extract/enrich/embed` |
++| `openvibe-sdk/ai` | both | `createAiClient(client)`: `runs.create(workflow, input, {wait, idempotencyKey, latency, deadlineHours, …})`, `runs.get/list/cancel/retry/citations/addCitations/waitFor`, `batches.create/get/list/cancel`, `chat/generate/summarize/classify/extract/enrich/embed`. A run may ask for the class it may wait in (`latency`: `interactive`, `background`, `batch`; `deadlineHours`: 1–72 for a batch run); `waitFor` gives a batch run back pending rather than throwing when the wait is over — the answer comes with the run's completion event or webhook. |
| `openvibe-sdk/jobs` | both | `createJobsClient(client, {baseUrl?})`: `submit` (Idempotency-Key), `get`, `cancel`, `retry`, `reference`, `unreference`, `events` (SSE, reattaches), `wait`, `file` (raw Response); `isTerminal()`. Default origin: the Tools gateway's jobs facade |
| `openvibe-sdk/tools` | both | `createToolsClient(client, {baseUrl?, anonymousReads?})`: `list({family, q, execution, api, status})`, `get(id)`, `schema(id)`, `run(id, input, {files, waitMs, idempotencyKey, signal, timeoutMs})` (+ `.wait()`), `jobs`; `ToolRunError`, `isToolRunError()` |
| `openvibe-sdk/projects` | both (user token) | `createProjectsClient(client)`: `catalog`, `list`, `create`, `get`, `update`, `archive`, `setAllowance`, `setEnvironmentPolicy`, `members.*`, `apps.*`, `credentials.list/rotate/revoke`, `grants.list/request/approve/deny/revoke`, `quotas.*`, `audit`, `iterateAudit` |
ddiff --git a/STATUS.json b/STATUS.json
iindex 624dca3..92296d3 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,7 +3,7 @@
"stage": "alpha",
"code": true,
"wave": "W2",
-- "version": "0.97.1",
++ "version": "0.98.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex 7680e88..7330566 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -1,4 +1,4 @@
--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.97.1, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.98.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// (auth, locker, registry, modules, realtime, media, community, bot, search, bench, meme, vision, themes, redact, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
@@@ -48,7 +48,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.97.1';
++const SDK_VERSION = '0.98.0';
module.exports = {
createClient,
@@@ -4592,19 +4592,31 @@ function (module, exports) {
*
* const ai = createAiClient(client);
* const { run } = await ai.runs.create('live.stream.describe_frame', { image_url }, { wait: 20000 });
++ * const { run } = await ai.runs.create('blog.write_digest', { items }, { latency: 'background' });
* const done = run.status === 'succeeded' ? run : await ai.runs.waitFor(run.id);
* const summary = await ai.summarize({ text }, { wait: 30000 }); // ai.summarize, waited for
*
++ * A run may ask for the class it may wait in: { latency: 'interactive' | 'background' | 'batch' } and, for
++ * a batch run, { deadlineHours: 1..72 } (how long the answer may take). The class belongs to the workflow:
++ * a caller may ask for a slower class and never for a faster one, and a faster ask is clamped, not refused
++ * — the run answers with the class it really ran in (run.latency_class), the tier it was priced on
++ * (run.service_tier) and, when it was clamped, the class it was asked for (run.latency_clamped).
++ *
* create() answers { run } with 201 (finished within the wait, or served from cache), 202 (still queued
* or running: poll get() or use waitFor()), or 200 (an idempotent replay: `replayed: true`). Pass an
* idempotencyKey so a retried create never runs twice; creates are otherwise not retried. A quota
* refusal or a full queue is a 429 error carrying Retry-After.
++ *
++ * ai.batches is the other door into AI: a batch is many prompts answered later, at half the retail
++ * price (POST /api/v1/batches, GET /api/v1/batches, GET /api/v1/batches/:id, POST
++ * /api/v1/batches/:id/cancel). The answers are not waited for: the batch says where it stands.
*/
const { isOpenVibeError } = __load(3);
const enc = encodeURIComponent;
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);
const OPS = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'embed'];
++const CLASSES = ['interactive', 'background', 'batch'];
function createAiClient(client, defaults = {}) {
const { baseUrl } = defaults;
@@@ -4612,16 +4624,28 @@ function createAiClient(client, defaults = {}) {
const writeOpts = (o) => (o.idempotencyKey ? { idempotencyKey: o.idempotencyKey } : { idempotencyKey: false });
const waitQuery = (o) => (o.wait != null ? { wait: Math.max(0, Math.floor(o.wait)) } : undefined);
const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && err.status === 404) return null; throw err; });
++ // The options a run carries: what the caller asks for (options.latency: interactive, background or
++ // batch) and, for a batch run, how long the answer may take (options.deadline_hours: 1 to 72). A caller
++ // may ask for a slower class than the workflow allows and never for a faster one; a faster ask is
++ // clamped by the service and the run says so (run.latency_clamped).
++ const runOptions = (o) => {
++ if (o.latency == null && o.deadlineHours == null) return o.options;
++ const options = { ...(o.options || {}) };
++ if (o.latency != null) options.latency = o.latency;
++ if (o.deadlineHours != null) options.deadline_hours = o.deadlineHours;
++ return options;
++ };
const runs = {
-- /** workflow key (e.g. 'wiki.generate_page'), its input; o: { wait (ms), version, idempotencyKey, target, attribution, onBehalfOf, options } */
++ /** workflow key (e.g. 'wiki.generate_page'), its input; o: { wait (ms), version, idempotencyKey, target, attribution, onBehalfOf, latency, deadlineHours, options } */
create(workflow, input = {}, o = {}) {
const body = { workflow, input };
if (o.version != null) body.version = o.version;
if (o.target !== undefined) body.target = o.target;
if (o.attribution !== undefined) body.attribution = o.attribution;
if (o.onBehalfOf !== undefined) body.on_behalf_of = o.onBehalfOf;
-- if (o.options !== undefined) body.options = o.options;
++ const options = runOptions(o);
++ if (options !== undefined) body.options = options;
return call({ method: 'POST', path: '/api/v1/runs', query: waitQuery(o), json: body, ...writeOpts(o) }, o);
},
list: (query = {}, o = {}) => call({ path: '/api/v1/runs', query }, o),
@@@ -4630,7 +4654,14 @@ function createAiClient(client, defaults = {}) {
retry: (id, o = {}) => call({ method: 'POST', path: `/api/v1/runs/${enc(id)}/retry`, query: waitQuery(o), ...writeOpts(o) }, o),
citations: (id, o = {}) => call({ path: `/api/v1/runs/${enc(id)}/citations` }, o),
addCitations: (id, citations, o = {}) => call({ method: 'POST', path: `/api/v1/runs/${enc(id)}/citations`, json: { citations }, idempotencyKey: false }, o),
-- /** Poll get() until the run finishes (succeeded, failed or cancelled) or `timeoutMs` passes. → the run */
++ /**
++ * Poll get() until the run finishes (succeeded, failed or cancelled) or `timeoutMs` passes. → the run
++ *
++ * A run of the batch class may take hours. When the wait is over and the run is a batch run, the
++ * pending run is returned instead of an error: its status says where it stands (queued, running).
++ * The answer comes later, with the run's completion event (ai.run.succeeded) or the webhook the run
++ * was made with — a caller that wants the answer in hand reads it there, not from a wait.
++ */
async waitFor(id, { intervalMs = 1000, timeoutMs = 120000, signal } = {}) {
const until = Date.now() + timeoutMs;
for (;;) {
@@@ -4638,24 +4669,44 @@ function createAiClient(client, defaults = {}) {
const run = got && (got.run || got);
if (!run) throw new Error(`run ${id} not found`);
if (TERMINAL.has(run.status)) return run;
-- if (Date.now() + intervalMs > until) throw new Error(`run ${id} still ${run.status} after ${timeoutMs} ms`);
++ if (Date.now() + intervalMs > until) {
++ // A batch run is not late: it may take hours. The pending run is the answer for now.
++ if (run.latency_class === 'batch') return run;
++ throw new Error(`run ${id} still ${run.status} after ${timeoutMs} ms`);
++ }
await new Promise((r) => setTimeout(r, intervalMs));
if (signal && signal.aborted) throw new Error('aborted');
}
},
};
-- const api = { runs, TERMINAL };
++ const batches = {
++ /** The prompts of a batch (a string, or { prompt, model }); o: { model, deadlineHours, idempotencyKey } → { batch } */
++ create(items, o = {}) {
++ const body = { items };
++ if (o.model != null) body.model = o.model;
++ if (o.deadlineHours != null) body.deadline_hours = o.deadlineHours;
++ return call({ method: 'POST', path: '/api/v1/batches', json: body, ...writeOpts(o) }, o);
++ },
++ /** The caller's own batches, newest first → { batches } */
++ list: (query = {}, o = {}) => call({ path: '/api/v1/batches', query }, o),
++ /** One batch with its items → { batch } (null when there is no such batch) */
++ get: (id, o = {}) => orNull(call({ path: `/api/v1/batches/${enc(id)}` }, o)),
++ /** Cancel a batch: the items that had not run are cancelled and nothing is charged for them → { batch } */
++ cancel: (id, o = {}) => call({ method: 'POST', path: `/api/v1/batches/${enc(id)}/cancel`, idempotencyKey: false }, o),
++ };
++
++ const api = { runs, batches, TERMINAL, CLASSES };
// Direct operations: a run of workflow ai.<op> with the input as the body, waited for by AI.
for (const op of OPS) {
api[op] = (input = {}, o = {}) => call({ method: 'POST', path: `/api/v1/${op}`, query: waitQuery(o),
-- json: { ...input, ...(o.target !== undefined ? { target: o.target } : {}), ...(o.attribution !== undefined ? { attribution: o.attribution } : {}), ...(o.options !== undefined ? { options: o.options } : {}) },
++ json: { ...input, ...(o.target !== undefined ? { target: o.target } : {}), ...(o.attribution !== undefined ? { attribution: o.attribution } : {}), ...(runOptions(o) !== undefined ? { options: runOptions(o) } : {}) },
...writeOpts(o) }, o);
}
return api;
}
--module.exports = { createAiClient, TERMINAL, OPS };
++module.exports = { createAiClient, TERMINAL, OPS, CLASSES };
},
// ── 35: src/jobs.js ─────────────────────────────────────────────────
function (module, exports) {
ddiff --git a/docs/api-surface.json b/docs/api-surface.json
iindex 4c7ff91..e715664 100644
---- a/docs/api-surface.json
++++ b/docs/api-surface.json
@@@ -11,6 +11,7 @@
},
"ai": {
"object": [
++ "CLASSES",
"OPS",
"TERMINAL",
"createAiClient"
@@@ -414,6 +415,7 @@
"createActorClient": "function"
},
"openvibe-sdk/ai": {
++ "CLASSES": "array",
"OPS": "array",
"TERMINAL": "object",
"createAiClient": "function"
ddiff --git a/esm/ai.mjs b/esm/ai.mjs
iindex b13fb68..1e44ce4 100644
---- a/esm/ai.mjs
++++ b/esm/ai.mjs
@@@ -2,6 +2,6 @@
import cjs from '../src/ai.js';
export const {
-- createAiClient, TERMINAL, OPS,
++ createAiClient, TERMINAL, OPS, CLASSES,
} = cjs;
export default cjs;
ddiff --git a/package-lock.json b/package-lock.json
iindex 8e675a6..587a76d 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.97.1",
++ "version": "0.98.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.97.1",
++ "version": "0.98.0",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
ddiff --git a/package.json b/package.json
iindex e0cbeda..5efd634 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.97.1",
++ "version": "0.98.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"types": "types/index.d.ts",
ddiff --git a/src/ai.js b/src/ai.js
iindex b2d3775..ba6c5ff 100644
---- a/src/ai.js
++++ b/src/ai.js
@@@ -6,19 +6,31 @@
*
* const ai = createAiClient(client);
* const { run } = await ai.runs.create('live.stream.describe_frame', { image_url }, { wait: 20000 });
++ * const { run } = await ai.runs.create('blog.write_digest', { items }, { latency: 'background' });
* const done = run.status === 'succeeded' ? run : await ai.runs.waitFor(run.id);
* const summary = await ai.summarize({ text }, { wait: 30000 }); // ai.summarize, waited for
*
++ * A run may ask for the class it may wait in: { latency: 'interactive' | 'background' | 'batch' } and, for
++ * a batch run, { deadlineHours: 1..72 } (how long the answer may take). The class belongs to the workflow:
++ * a caller may ask for a slower class and never for a faster one, and a faster ask is clamped, not refused
++ * — the run answers with the class it really ran in (run.latency_class), the tier it was priced on
++ * (run.service_tier) and, when it was clamped, the class it was asked for (run.latency_clamped).
++ *
* create() answers { run } with 201 (finished within the wait, or served from cache), 202 (still queued
* or running: poll get() or use waitFor()), or 200 (an idempotent replay: `replayed: true`). Pass an
* idempotencyKey so a retried create never runs twice; creates are otherwise not retried. A quota
* refusal or a full queue is a 429 error carrying Retry-After.
++ *
++ * ai.batches is the other door into AI: a batch is many prompts answered later, at half the retail
++ * price (POST /api/v1/batches, GET /api/v1/batches, GET /api/v1/batches/:id, POST
++ * /api/v1/batches/:id/cancel). The answers are not waited for: the batch says where it stands.
*/
const { isOpenVibeError } = require('./core/errors');
const enc = encodeURIComponent;
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);
const OPS = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'embed'];
++const CLASSES = ['interactive', 'background', 'batch'];
function createAiClient(client, defaults = {}) {
const { baseUrl } = defaults;
@@@ -26,16 +38,28 @@ function createAiClient(client, defaults = {}) {
const writeOpts = (o) => (o.idempotencyKey ? { idempotencyKey: o.idempotencyKey } : { idempotencyKey: false });
const waitQuery = (o) => (o.wait != null ? { wait: Math.max(0, Math.floor(o.wait)) } : undefined);
const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && err.status === 404) return null; throw err; });
++ // The options a run carries: what the caller asks for (options.latency: interactive, background or
++ // batch) and, for a batch run, how long the answer may take (options.deadline_hours: 1 to 72). A caller
++ // may ask for a slower class than the workflow allows and never for a faster one; a faster ask is
++ // clamped by the service and the run says so (run.latency_clamped).
++ const runOptions = (o) => {
++ if (o.latency == null && o.deadlineHours == null) return o.options;
++ const options = { ...(o.options || {}) };
++ if (o.latency != null) options.latency = o.latency;
++ if (o.deadlineHours != null) options.deadline_hours = o.deadlineHours;
++ return options;
++ };
const runs = {
-- /** workflow key (e.g. 'wiki.generate_page'), its input; o: { wait (ms), version, idempotencyKey, target, attribution, onBehalfOf, options } */
++ /** workflow key (e.g. 'wiki.generate_page'), its input; o: { wait (ms), version, idempotencyKey, target, attribution, onBehalfOf, latency, deadlineHours, options } */
create(workflow, input = {}, o = {}) {
const body = { workflow, input };
if (o.version != null) body.version = o.version;
if (o.target !== undefined) body.target = o.target;
if (o.attribution !== undefined) body.attribution = o.attribution;
if (o.onBehalfOf !== undefined) body.on_behalf_of = o.onBehalfOf;
-- if (o.options !== undefined) body.options = o.options;
++ const options = runOptions(o);
++ if (options !== undefined) body.options = options;
return call({ method: 'POST', path: '/api/v1/runs', query: waitQuery(o), json: body, ...writeOpts(o) }, o);
},
list: (query = {}, o = {}) => call({ path: '/api/v1/runs', query }, o),
@@@ -44,7 +68,14 @@ function createAiClient(client, defaults = {}) {
retry: (id, o = {}) => call({ method: 'POST', path: `/api/v1/runs/${enc(id)}/retry`, query: waitQuery(o), ...writeOpts(o) }, o),
citations: (id, o = {}) => call({ path: `/api/v1/runs/${enc(id)}/citations` }, o),
addCitations: (id, citations, o = {}) => call({ method: 'POST', path: `/api/v1/runs/${enc(id)}/citations`, json: { citations }, idempotencyKey: false }, o),
-- /** Poll get() until the run finishes (succeeded, failed or cancelled) or `timeoutMs` passes. → the run */
++ /**
++ * Poll get() until the run finishes (succeeded, failed or cancelled) or `timeoutMs` passes. → the run
++ *
++ * A run of the batch class may take hours. When the wait is over and the run is a batch run, the
++ * pending run is returned instead of an error: its status says where it stands (queued, running).
++ * The answer comes later, with the run's completion event (ai.run.succeeded) or the webhook the run
++ * was made with — a caller that wants the answer in hand reads it there, not from a wait.
++ */
async waitFor(id, { intervalMs = 1000, timeoutMs = 120000, signal } = {}) {
const until = Date.now() + timeoutMs;
for (;;) {
@@@ -52,21 +83,41 @@ function createAiClient(client, defaults = {}) {
const run = got && (got.run || got);
if (!run) throw new Error(`run ${id} not found`);
if (TERMINAL.has(run.status)) return run;
-- if (Date.now() + intervalMs > until) throw new Error(`run ${id} still ${run.status} after ${timeoutMs} ms`);
++ if (Date.now() + intervalMs > until) {
++ // A batch run is not late: it may take hours. The pending run is the answer for now.
++ if (run.latency_class === 'batch') return run;
++ throw new Error(`run ${id} still ${run.status} after ${timeoutMs} ms`);
++ }
await new Promise((r) => setTimeout(r, intervalMs));
if (signal && signal.aborted) throw new Error('aborted');
}
},
};
-- const api = { runs, TERMINAL };
++ const batches = {
++ /** The prompts of a batch (a string, or { prompt, model }); o: { model, deadlineHours, idempotencyKey } → { batch } */
++ create(items, o = {}) {
++ const body = { items };
++ if (o.model != null) body.model = o.model;
++ if (o.deadlineHours != null) body.deadline_hours = o.deadlineHours;
++ return call({ method: 'POST', path: '/api/v1/batches', json: body, ...writeOpts(o) }, o);
++ },
++ /** The caller's own batches, newest first → { batches } */
++ list: (query = {}, o = {}) => call({ path: '/api/v1/batches', query }, o),
++ /** One batch with its items → { batch } (null when there is no such batch) */
++ get: (id, o = {}) => orNull(call({ path: `/api/v1/batches/${enc(id)}` }, o)),
++ /** Cancel a batch: the items that had not run are cancelled and nothing is charged for them → { batch } */
++ cancel: (id, o = {}) => call({ method: 'POST', path: `/api/v1/batches/${enc(id)}/cancel`, idempotencyKey: false }, o),
++ };
++
++ const api = { runs, batches, TERMINAL, CLASSES };
// Direct operations: a run of workflow ai.<op> with the input as the body, waited for by AI.
for (const op of OPS) {
api[op] = (input = {}, o = {}) => call({ method: 'POST', path: `/api/v1/${op}`, query: waitQuery(o),
-- json: { ...input, ...(o.target !== undefined ? { target: o.target } : {}), ...(o.attribution !== undefined ? { attribution: o.attribution } : {}), ...(o.options !== undefined ? { options: o.options } : {}) },
++ json: { ...input, ...(o.target !== undefined ? { target: o.target } : {}), ...(o.attribution !== undefined ? { attribution: o.attribution } : {}), ...(runOptions(o) !== undefined ? { options: runOptions(o) } : {}) },
...writeOpts(o) }, o);
}
return api;
}
--module.exports = { createAiClient, TERMINAL, OPS };
++module.exports = { createAiClient, TERMINAL, OPS, CLASSES };
ddiff --git a/src/core/index.js b/src/core/index.js
iindex 693a78b..1f233e6 100644
---- a/src/core/index.js
++++ b/src/core/index.js
@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.97.1';
++const SDK_VERSION = '0.98.0';
module.exports = {
createClient,
ddiff --git a/test/ai-batching.test.js b/test/ai-batching.test.js
nnew file mode 100644
iindex 0000000..a39c960
---- /dev/null
++++ b/test/ai-batching.test.js
@@@ -0,0 +1,77 @@
++'use strict';
++/** BA3 (track BA): the latency classes and the batches of OpenVibe.AI, against a fake AI. */
++const assert = require('node:assert/strict');
++const { stubServer, send, run } = require('./helpers');
++const { createClient } = require('../src/core');
++const { createAiClient } = require('../src/ai');
++
++// The fake AI: a run that stays queued (a batch run takes hours) and the batches queue.
++async function fakeAi() {
++ let polls = 0;
++ return stubServer((req, res, body) => {
++ const u = new URL(req.url, 'http://x');
++ if (req.method === 'POST' && u.pathname === '/api/v1/runs') {
++ const b = JSON.parse(String(body));
++ const batch = b.options && b.options.latency === 'batch';
++ return send(res, 202, { run: { id: batch ? 'run_batch' : 'run_1', status: 'queued', latency_class: b.options ? b.options.latency : 'interactive' } });
++ }
++ if (u.pathname === '/api/v1/runs/run_1') { polls++; return send(res, 200, { run: { id: 'run_1', status: 'running' } }); }
++ if (u.pathname === '/api/v1/runs/run_done') return send(res, 200, { run: { id: 'run_done', status: 'succeeded' } });
++ if (u.pathname === '/api/v1/runs/run_batch') return send(res, 200, { run: { id: 'run_batch', status: 'queued', latency_class: 'batch' } });
++ if (req.method === 'POST' && u.pathname === '/api/v1/batches') return send(res, 201, { batch: { id: 'bat_1', status: 'queued', item_count: 2 } });
++ if (req.method === 'POST' && u.pathname === '/api/v1/batches/bat_1/cancel') return send(res, 200, { batch: { id: 'bat_1', status: 'cancelled' } });
++ if (u.pathname === '/api/v1/batches/bat_1') return send(res, 200, { batch: { id: 'bat_1', status: 'succeeded', items: [{ idx: 0, status: 'succeeded', answer: 'the answer' }] } });
++ if (u.pathname === '/api/v1/batches') return send(res, 200, { batches: [{ id: 'bat_1', status: 'succeeded' }] });
++ return send(res, 404, { code: 'not_found' });
++ });
++}
++
++const client = (srv) => createAiClient(createClient({ baseUrls: { ai: srv.url }, getToken: ({ audience }) => `svc-${audience}` }));
++
++run([
++ ['a run may ask for the class it may wait in, and for a batch a deadline', async () => {
++ const srv = await fakeAi();
++ const ai = client(srv);
++ await ai.runs.create('blog.write_digest', { items: 2 }, { latency: 'batch', deadlineHours: 24, idempotencyKey: 'k-2' });
++ const req = srv.requests[0];
++ assert.equal(req.url, '/api/v1/runs');
++ assert.deepEqual(JSON.parse(String(req.body)), {
++ workflow: 'blog.write_digest', input: { items: 2 },
++ options: { latency: 'batch', deadline_hours: 24 },
++ });
++ assert.equal(req.headers['idempotency-key'], 'k-2');
++ // A run that asks for nothing says nothing: the body is the body it always was.
++ await ai.runs.create('wiki.generate_page', { title: 'X' });
++ assert.deepEqual(JSON.parse(String(srv.requests[1].body)), { workflow: 'wiki.generate_page', input: { title: 'X' } });
++ await srv.close();
++ }],
++ ['waitFor gives a batch run back pending instead of throwing when the wait is over', async () => {
++ const srv = await fakeAi();
++ const ai = client(srv);
++ const pending = await ai.runs.waitFor('run_batch', { intervalMs: 5, timeoutMs: 20 });
++ assert.equal(pending.status, 'queued', 'the run is still queued');
++ assert.equal(pending.latency_class, 'batch', 'and it says which class it is in');
++ // An ordinary run still fails the wait: a caller that waits for it must hear about it.
++ await assert.rejects(ai.runs.waitFor('run_1', { intervalMs: 5, timeoutMs: 20 }), /still running/);
++ await srv.close();
++ }],
++ ['the batches: open, read, list and cancel', async () => {
++ const srv = await fakeAi();
++ const ai = client(srv);
++ const opened = await ai.batches.create(['one prompt', 'another prompt'], { model: 'gpt-4o-mini', deadlineHours: 6, idempotencyKey: 'k-3' });
++ assert.equal(opened.batch.id, 'bat_1');
++ assert.equal(srv.requests[0].url, '/api/v1/batches');
++ assert.deepEqual(JSON.parse(String(srv.requests[0].body)), { items: ['one prompt', 'another prompt'], model: 'gpt-4o-mini', deadline_hours: 6 });
++ assert.equal(srv.requests[0].headers['idempotency-key'], 'k-3');
++ const one = await ai.batches.get('bat_1');
++ assert.equal(one.batch.items[0].answer, 'the answer');
++ const many = await ai.batches.list();
++ assert.equal(many.batches.length, 1);
++ assert.equal(srv.requests[2].url, '/api/v1/batches');
++ const cancelled = await ai.batches.cancel('bat_1');
++ assert.equal(cancelled.batch.status, 'cancelled');
++ assert.equal(srv.requests[3].url, '/api/v1/batches/bat_1/cancel');
++ assert.equal(srv.requests[3].method, 'POST');
++ await srv.close();
++ }],
++]);
ddiff --git a/types/ai.d.ts b/types/ai.d.ts
iindex 5f11198..e970fc8 100644
---- a/types/ai.d.ts
++++ b/types/ai.d.ts
@@@ -1,7 +1,11 @@
import type { OpenVibeClient } from './core';
--export interface AiRun { id: string; status: 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled' | string; workflow?: { key: string; version: number }; output?: unknown; error?: unknown; [field: string]: unknown; }
--export interface RunOptions { wait?: number; version?: number; idempotencyKey?: string; target?: unknown; attribution?: unknown; onBehalfOf?: unknown; options?: Record<string, unknown>; signal?: AbortSignal; }
++export interface AiRun { id: string; status: 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled' | string; workflow?: { key: string; version: number }; output?: unknown; error?: unknown; latency_class?: LatencyClass; service_tier?: ServiceTier | null; latency_clamped?: { asked: LatencyClass; used: LatencyClass } | null; [field: string]: unknown; }
++/** How long the caller may wait: interactive (as soon as the provider can), background (minutes, priced on the cheapest tier that can wait), batch (hours, answered through the provider's own batch API). */
++export type LatencyClass = 'interactive' | 'background' | 'batch';
++/** The tier a run was priced on: the one the provider served (standard, flex, batch) or the one the router chose (offpeak, local). */
++export type ServiceTier = 'standard' | 'flex' | 'batch' | 'offpeak' | 'local';
++export interface RunOptions { wait?: number; version?: number; idempotencyKey?: string; target?: unknown; attribution?: unknown; onBehalfOf?: unknown; /** The class the caller asks for. A slower class than the workflow allows is honoured; a faster one is clamped by the service. */ latency?: LatencyClass; /** For a batch run: how long the answer may take, in hours (1 to 72). */ deadlineHours?: number; options?: Record<string, unknown>; signal?: AbortSignal; }
export interface RunResponse { run: AiRun; replayed?: boolean }
export interface AiRuns {
create(workflow: string, input?: Record<string, unknown>, opts?: RunOptions): Promise<RunResponse>;
@@@ -11,10 +15,38 @@ export interface AiRuns {
retry(id: string, opts?: Pick<RunOptions, 'wait' | 'idempotencyKey' | 'signal'>): Promise<RunResponse>;
citations(id: string, opts?: { signal?: AbortSignal }): Promise<{ citations: Array<Record<string, unknown>> }>;
addCitations(id: string, citations: Array<Record<string, unknown>>, opts?: { signal?: AbortSignal }): Promise<{ citations: Array<Record<string, unknown>> }>;
++ /** Polls until the run finishes; a batch run that is not finished when the wait is over comes back pending (its status says where it stands) instead of throwing. */
waitFor(id: string, opts?: { intervalMs?: number; timeoutMs?: number; signal?: AbortSignal }): Promise<AiRun>;
}
++export interface AiBatchItem {
++ idx: number;
++ status: 'queued' | 'waiting' | 'running' | 'succeeded' | 'failed' | 'expired' | 'cancelled' | string;
++ model?: string | null; run_id?: string | null; answer?: string | null;
++ error_code?: string | null; error_detail?: string | null; reason?: string | null; capacity?: string | null;
++ route_provider?: string | null; route_model?: string | null;
++ cost_nano_usd?: string | null; absorbed_nano_usd?: string | null; charged_nano_usd?: string | null;
++ deadline_at?: string; created_at?: string; updated_at?: string;
++ [field: string]: unknown;
++}
++export interface AiBatch {
++ id: string;
++ status: 'queued' | 'running' | 'succeeded' | 'failed' | 'expired' | 'cancelled' | string;
++ owner_type?: 'user' | 'app' | 'service' | string; owner_id?: string; project_id?: string | null;
++ item_count?: number; items?: AiBatchItem[]; discount?: string;
++ deadline_at?: string; created_at?: string; updated_at?: string; finished_at?: string | null;
++ absorbed_nano_usd?: string | null; ran_on_regular_capacity?: boolean;
++ [field: string]: unknown;
++}
++export interface AiBatches {
++ /** Opens a batch of many prompts, answered later at half the retail price → { batch } */
++ create(items: Array<string | { prompt: string; model?: string }>, opts?: { model?: string; deadlineHours?: number; idempotencyKey?: string; signal?: AbortSignal }): Promise<{ batch: AiBatch }>;
++ list(query?: Record<string, string | number>, opts?: { signal?: AbortSignal }): Promise<{ batches: AiBatch[]; [field: string]: unknown }>;
++ get(id: string, opts?: { signal?: AbortSignal }): Promise<{ batch: AiBatch } | null>;
++ cancel(id: string, opts?: { signal?: AbortSignal }): Promise<{ batch: AiBatch }>;
++}
type Op = (input?: Record<string, unknown>, opts?: Omit<RunOptions, 'version' | 'onBehalfOf'>) => Promise<RunResponse>;
--export interface AiClient { runs: AiRuns; TERMINAL: Set<string>; chat: Op; generate: Op; summarize: Op; classify: Op; extract: Op; enrich: Op; embed: Op; }
++export interface AiClient { runs: AiRuns; batches: AiBatches; TERMINAL: Set<string>; CLASSES: string[]; chat: Op; generate: Op; summarize: Op; classify: Op; extract: Op; enrich: Op; embed: Op; }
export function createAiClient(client: OpenVibeClient, defaults?: { baseUrl?: string }): AiClient;
export const TERMINAL: Set<string>;
export const OPS: string[];
++export const CLASSES: string[];