openvibers/openvibe.sdk Commits
BA3: the latency classes and the batches of OpenVibe.AI (#134)
Changes
@@@ -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@@@ -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` |@@@ -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",@@@ -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) {@@@ -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"@@@ -2,6 +2,6 @@import cjs from '../src/ai.js';export const {-- createAiClient, TERMINAL, OPS,++ createAiClient, TERMINAL, OPS, CLASSES,} = cjs;export default cjs;@@@ -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"@@@ -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",@@@ -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 };@@@ -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,nnew file mode 100644@@@ -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();++ }],++]);@@@ -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[];
@@ -1,5 +1,24 @@
@@ -1,5 +1,24 @@
# Changelog
# 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)
## 0.97.1 (2026-11-07)
- `types/menu.d.ts`: `MenuRequest` gives `url` and `method` the `| undefined` that `node:http`'s
- `types/menu.d.ts`: `MenuRequest` gives `url` and `method` the `| undefined` that `node:http`'s
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Supported browser and server clients for the OpenVibe platform.
> 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.
**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.
**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 @@
@@ -11,7 +11,7 @@
After v0.97.1 is tagged:
After v0.97.1 is tagged:
```bash
```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).
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
@@ -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/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/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/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/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/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` |
| `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` |
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
"stage": "alpha",
"stage": "alpha",
"code": true,
"code": true,
"wave": "W2",
"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",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"deployed": false,
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
"deployment": "none: a package released as git tags; services and apps pin a tag",
@@ -1,4 +1,4 @@
@@ -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
// 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.
// (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 */
/* eslint-disable */
@@ -48,7 +48,7 @@ const { paginate, offsetPager } = __load(7);
@@ -48,7 +48,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
/** 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 = {
module.exports = {
createClient,
createClient,
@@ -4592,19 +4592,31 @@ function (module, exports) {
@@ -4592,19 +4592,31 @@ function (module, exports) {
*
*
* const ai = createAiClient(client);
* const ai = createAiClient(client);
* const { run } = await ai.runs.create('live.stream.describe_frame', { image_url }, { wait: 20000 });
* 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 done = run.status === 'succeeded' ? run : await ai.runs.waitFor(run.id);
* const summary = await ai.summarize({ text }, { wait: 30000 }); // ai.summarize, waited for
* 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
* 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
* 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
* 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.
* 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 { isOpenVibeError } = __load(3);
const enc = encodeURIComponent;
const enc = encodeURIComponent;
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);
const OPS = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'embed'];
const OPS = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'embed'];
+const CLASSES = ['interactive', 'background', 'batch'];
function createAiClient(client, defaults = {}) {
function createAiClient(client, defaults = {}) {
const { baseUrl } = defaults;
const { baseUrl } = defaults;
@@ -4612,16 +4624,28 @@ function createAiClient(client, defaults = {}) {
@@ -4612,16 +4624,28 @@ function createAiClient(client, defaults = {}) {
const writeOpts = (o) => (o.idempotencyKey ? { idempotencyKey: o.idempotencyKey } : { idempotencyKey: false });
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 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; });
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 = {
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 = {}) {
create(workflow, input = {}, o = {}) {
const body = { workflow, input };
const body = { workflow, input };
if (o.version != null) body.version = o.version;
if (o.version != null) body.version = o.version;
if (o.target !== undefined) body.target = o.target;
if (o.target !== undefined) body.target = o.target;
if (o.attribution !== undefined) body.attribution = o.attribution;
if (o.attribution !== undefined) body.attribution = o.attribution;
if (o.onBehalfOf !== undefined) body.on_behalf_of = o.onBehalfOf;
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);
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),
list: (query = {}, o = {}) => call({ path: '/api/v1/runs', query }, o),
@@ -4630,7 +4654,14 @@ function createAiClient(client, defaults = {}) {
@@ -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),
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),
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),
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 } = {}) {
async waitFor(id, { intervalMs = 1000, timeoutMs = 120000, signal } = {}) {
const until = Date.now() + timeoutMs;
const until = Date.now() + timeoutMs;
for (;;) {
for (;;) {
@@ -4638,24 +4669,44 @@ function createAiClient(client, defaults = {}) {
@@ -4638,24 +4669,44 @@ function createAiClient(client, defaults = {}) {
const run = got && (got.run || got);
const run = got && (got.run || got);
if (!run) throw new Error(`run ${id} not found`);
if (!run) throw new Error(`run ${id} not found`);
if (TERMINAL.has(run.status)) return run;
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));
await new Promise((r) => setTimeout(r, intervalMs));
if (signal && signal.aborted) throw new Error('aborted');
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.
// Direct operations: a run of workflow ai.<op> with the input as the body, waited for by AI.
for (const op of OPS) {
for (const op of OPS) {
api[op] = (input = {}, o = {}) => call({ method: 'POST', path: `/api/v1/${op}`, query: waitQuery(o),
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);
...writeOpts(o) }, o);
}
}
return api;
return api;
}
}
-module.exports = { createAiClient, TERMINAL, OPS };
+module.exports = { createAiClient, TERMINAL, OPS, CLASSES };
},
},
// ── 35: src/jobs.js ─────────────────────────────────────────────────
// ── 35: src/jobs.js ─────────────────────────────────────────────────
function (module, exports) {
function (module, exports) {
@@ -11,6 +11,7 @@
@@ -11,6 +11,7 @@
},
},
"ai": {
"ai": {
"object": [
"object": [
+ "CLASSES",
"OPS",
"OPS",
"TERMINAL",
"TERMINAL",
"createAiClient"
"createAiClient"
@@ -414,6 +415,7 @@
@@ -414,6 +415,7 @@
"createActorClient": "function"
"createActorClient": "function"
},
},
"openvibe-sdk/ai": {
"openvibe-sdk/ai": {
+ "CLASSES": "array",
"OPS": "array",
"OPS": "array",
"TERMINAL": "object",
"TERMINAL": "object",
"createAiClient": "function"
"createAiClient": "function"
@@ -2,6 +2,6 @@
@@ -2,6 +2,6 @@
import cjs from '../src/ai.js';
import cjs from '../src/ai.js';
export const {
export const {
- createAiClient, TERMINAL, OPS,
+ createAiClient, TERMINAL, OPS, CLASSES,
} = cjs;
} = cjs;
export default cjs;
export default cjs;
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.97.1",
+ "version": "0.98.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.97.1",
+ "version": "0.98.0",
"license": "MIT",
"license": "MIT",
"bin": {
"bin": {
"openvibe-download": "bin/openvibe-download.js"
"openvibe-download": "bin/openvibe-download.js"
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-sdk",
"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.",
"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",
"main": "index.js",
"types": "types/index.d.ts",
"types": "types/index.d.ts",
@@ -6,19 +6,31 @@
@@ -6,19 +6,31 @@
*
*
* const ai = createAiClient(client);
* const ai = createAiClient(client);
* const { run } = await ai.runs.create('live.stream.describe_frame', { image_url }, { wait: 20000 });
* 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 done = run.status === 'succeeded' ? run : await ai.runs.waitFor(run.id);
* const summary = await ai.summarize({ text }, { wait: 30000 }); // ai.summarize, waited for
* 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
* 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
* 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
* 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.
* 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 { isOpenVibeError } = require('./core/errors');
const enc = encodeURIComponent;
const enc = encodeURIComponent;
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);
const OPS = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'embed'];
const OPS = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'embed'];
+const CLASSES = ['interactive', 'background', 'batch'];
function createAiClient(client, defaults = {}) {
function createAiClient(client, defaults = {}) {
const { baseUrl } = defaults;
const { baseUrl } = defaults;
@@ -26,16 +38,28 @@ function createAiClient(client, defaults = {}) {
@@ -26,16 +38,28 @@ function createAiClient(client, defaults = {}) {
const writeOpts = (o) => (o.idempotencyKey ? { idempotencyKey: o.idempotencyKey } : { idempotencyKey: false });
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 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; });
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 = {
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 = {}) {
create(workflow, input = {}, o = {}) {
const body = { workflow, input };
const body = { workflow, input };
if (o.version != null) body.version = o.version;
if (o.version != null) body.version = o.version;
if (o.target !== undefined) body.target = o.target;
if (o.target !== undefined) body.target = o.target;
if (o.attribution !== undefined) body.attribution = o.attribution;
if (o.attribution !== undefined) body.attribution = o.attribution;
if (o.onBehalfOf !== undefined) body.on_behalf_of = o.onBehalfOf;
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);
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),
list: (query = {}, o = {}) => call({ path: '/api/v1/runs', query }, o),
@@ -44,7 +68,14 @@ function createAiClient(client, defaults = {}) {
@@ -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),
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),
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),
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 } = {}) {
async waitFor(id, { intervalMs = 1000, timeoutMs = 120000, signal } = {}) {
const until = Date.now() + timeoutMs;
const until = Date.now() + timeoutMs;
for (;;) {
for (;;) {
@@ -52,21 +83,41 @@ function createAiClient(client, defaults = {}) {
@@ -52,21 +83,41 @@ function createAiClient(client, defaults = {}) {
const run = got && (got.run || got);
const run = got && (got.run || got);
if (!run) throw new Error(`run ${id} not found`);
if (!run) throw new Error(`run ${id} not found`);
if (TERMINAL.has(run.status)) return run;
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));
await new Promise((r) => setTimeout(r, intervalMs));
if (signal && signal.aborted) throw new Error('aborted');
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.
// Direct operations: a run of workflow ai.<op> with the input as the body, waited for by AI.
for (const op of OPS) {
for (const op of OPS) {
api[op] = (input = {}, o = {}) => call({ method: 'POST', path: `/api/v1/${op}`, query: waitQuery(o),
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);
...writeOpts(o) }, o);
}
}
return api;
return api;
}
}
-module.exports = { createAiClient, TERMINAL, OPS };
+module.exports = { createAiClient, TERMINAL, OPS, CLASSES };
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
/** 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 = {
module.exports = {
createClient,
createClient,
new file mode 100644
new file mode 100644
@@ -0,0 +1,77 @@
@@ -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();
+ }],
+]);
@@ -1,7 +1,11 @@
@@ -1,7 +1,11 @@
import type { OpenVibeClient } from './core';
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 RunResponse { run: AiRun; replayed?: boolean }
export interface AiRuns {
export interface AiRuns {
create(workflow: string, input?: Record<string, unknown>, opts?: RunOptions): Promise<RunResponse>;
create(workflow: string, input?: Record<string, unknown>, opts?: RunOptions): Promise<RunResponse>;
@@ -11,10 +15,38 @@ export interface AiRuns {
@@ -11,10 +15,38 @@ export interface AiRuns {
retry(id: string, opts?: Pick<RunOptions, 'wait' | 'idempotencyKey' | 'signal'>): Promise<RunResponse>;
retry(id: string, opts?: Pick<RunOptions, 'wait' | 'idempotencyKey' | 'signal'>): Promise<RunResponse>;
citations(id: string, opts?: { signal?: AbortSignal }): Promise<{ citations: Array<Record<string, unknown>> }>;
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>> }>;
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>;
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>;
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 function createAiClient(client: OpenVibeClient, defaults?: { baseUrl?: string }): AiClient;
export const TERMINAL: Set<string>;
export const TERMINAL: Set<string>;
export const OPS: string[];
export const OPS: string[];
+export const CLASSES: string[];