openvibers/openvibe.sdk Commits
openvibe-sdk 0.164.0: ai-meter (#249)
Changes
@@@ -1,5 +1,19 @@# Changelog++## 0.164.0 — 2026-11-12++++- **New `openvibe-sdk/ai-meter`:** every product meters its AI calls the same way (ai-metrics.md; Contracts 0.421.0++ `ai.call.recorded@1` 1.1.0 and `ai.call-report@1`).++ - `startCall()` measures each call on its own clock: TTFT and latency less any queue, tokens per second after the++ first token (or Ollama's own decode time), tool calls, fallbacks, the error class, and the charged price when the++ provider gives one.++ - `normalize()` reads the usage of OpenAI chat completions and Responses, Anthropic Messages, Gemini and Ollama.++ The input it reports is the uncached input, with cache reads and writes and reasoning tokens apart.++ - `createMeterClient()` batches the records to AI's `POST /api/v1/usage/calls` with the service token.++ - It retries a network error, a 408, 425, 429 or 5xx with backoff, and asks for a new token after a 401.++ - A batch refused for any other reason is dropped and counted, never sent again.++ - AI prices any call that brings no price and publishes it.++## 0.163.0 — 2026-11-12From reading 24 open agent projects (one-system-v2.md). Three new modules, all pure functions:@@@ -8,12 +8,12 @@**Install and test:** `npm ci && npm test` (Node 20 or newer)```bash--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.163.0++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.164.0```> Supported browser and server clients for the OpenVibe platform.--**Status:** alpha, v0.163.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.++**Status:** alpha, v0.164.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.@@@ -22,7 +22,7 @@ npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tagsAfter v0.97.1 is tagged:```bash--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.163.0++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.164.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).@@@ -226,6 +226,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client| `openvibe-sdk/agents/rules` | both | Permissions as data (agents.permission-rule@1): `decide(rules, call, { attended, level })` (the last matching rule wins; with none, the level; unattended work never waits; the floor always asks, and unattended it is denied), `fromBlueprint(doc)` (only the tools it names, never `.git/hooks` or its own config, its sandbox and network), `matches` || `openvibe-sdk/agents/router` | both | Pay for the strong model only when it matters (RouteLLM's shape over OpenVibe Decisions): `question(step)`, `probability(answer)`, `route({ p, threshold, small, strong })`, `calibrate(history, { strongShare })` (the threshold that sends that share strong), `outcomes(history)` (the quality-cost points), `chain(step, { pick, pin, ask })` || `openvibe-sdk/agents/metrics` | both | What a run measured (agents.run@1 1.4.0 metrics): `createRecorder()` (charged cost first, an estimate apart, unknown is null; tokens with cache; steps; latency; a named exit; evidence), `passAtK`, `passHatK`, `flakeRate` |++| `openvibe-sdk/ai-meter` | both | Every product meters its AI calls the same way (ai.call.recorded@1 1.1.0, ai.call-report@1): `startCall({ system, provider, model, run_id|turn_id, operation, task, task_id, harness, place })` → `queued(ms)`, `firstToken()`, `tool()`, `end({ usage, outcome, charged_usd, fallbacks, error_class })` (TTFT, latency, tokens per second after the first token); `normalize(usage)` (OpenAI chat and Responses, Anthropic, Gemini, Ollama; the uncached input); `createMeterClient({ aiUrl, tokenClient })` (batches to AI's POST /api/v1/usage/calls, retries what can be retried, never resends a refused batch) || `openvibe-sdk/agents/api` | server | The Run API from a product's own store: `createRunApi({ product, subject, store, prefix })` returns `handle(req, res, next)` for Express or any Node http server, serving `GET/POST /runs`, `GET /runs/:id`, `…/steps`, `…/events` (server-sent events, from the store's `follow` or by reading new steps), `POST …/messages`, `…/stop`, `…/approvals/:approval`. The store has `list, get, steps, start, say, stop, answer, follow?`; a method it lacks answers 409. No subject is 401, another person's run 404, a start without words 422; every run carries its ref; a store's own `{ status, code }` errors go out as they are and anything else is a 500 that says nothing of the store; a mandate from a caller is dropped (intake only) || `openvibe-sdk/agents` | both | `createAgentsClient(client, {baseUrl?, service?, audience?})`: `run(id)` fetches an agent run snapshot (null when missing), `events(id, {lastEventId, signal})` follows the resumable AG-UI event stream, `post(id, approval)` answers an approval request || `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, prefer})` (+ `.wait()`), `jobs`; `ToolRunError`, `isToolRunError()`; `prefer` picks the worker placement (`auto`, `home`, `host`) ([details](docs/tools-routing.md)) |@@@ -3,7 +3,7 @@"stage": "alpha","code": true,"wave": "W2",-- "version": "0.163.0",++ "version": "0.164.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.163.0, MIT). Do not edit.++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.164.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, trust, levels, modules, realtime, media, composer, cam, mediaUpload, ui, uiLayout, community, homeFeed, feed, views, bot, search, bench, meme, vision, themes, elements, design, redact, chat, ai, credentials, jobs, agents, frame, tools, projects, geo, history, userCard, player, agentSheet). No server-only code: no service tokens, JWT verification, events, identity./* eslint-disable */@@@ -68,7 +68,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.163.0';++const SDK_VERSION = '0.164.0';module.exports = {createClient,@@@ -745,6 +745,15 @@"TERMINAL": "object","createAiClient": "function"},++ "openvibe-sdk/ai-meter": {++ "OPERATIONS": "array",++ "OUTCOMES": "array",++ "PLACES": "array",++ "createMeterClient": "function",++ "normalize": "function",++ "startCall": "function",++ "tokensPerSecond": "function"++ },"openvibe-sdk/app-signin": {"createAppSignIn": "function","normalizeScope": "function"nnew file mode 100644@@@ -0,0 +1,8 @@++// Generated by scripts/esm.js from src/ai-meter.js. Do not edit.++import cjs from '../src/ai-meter.js';++++export const {++ startCall, normalize, tokensPerSecond, createMeterClient, OPERATIONS, PLACES,++ OUTCOMES,++} = cjs;++export default cjs;@@@ -1,12 +1,12 @@{"name": "openvibe-sdk",-- "version": "0.163.0",++ "version": "0.164.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-sdk",-- "version": "0.163.0",++ "version": "0.164.0","license": "MIT","bin": {"openvibe-download": "bin/openvibe-download.js"@@@ -1,6 +1,6 @@{"name": "openvibe-sdk",-- "version": "0.163.0",++ "version": "0.164.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",@@@ -344,6 +344,11 @@"import": "./esm/agents-metrics.mjs","default": "./src/agents/metrics.js"},++ "./ai-meter": {++ "types": "./types/ai-meter.d.ts",++ "import": "./esm/ai-meter.mjs",++ "default": "./src/ai-meter.js"++ },"./agents/api": {"types": "./types/agents-api.d.ts","import": "./esm/agents-api.mjs",@@@ -80,6 +80,7 @@ const ENTRIES = {'agents-loop.mjs': '../src/agents/loop.js','agents-dispatch.mjs': '../src/agents/dispatch.js','agents-api.mjs': '../src/agents/api.js',++ 'ai-meter.mjs': '../src/ai-meter.js','agents-rules.mjs': '../src/agents/rules.js','agents-router.mjs': '../src/agents/router.js','agents-metrics.mjs': '../src/agents/metrics.js',nnew file mode 100644@@@ -0,0 +1,154 @@++'use strict';++/**++ * openvibe-sdk/ai-meter — every product meters its AI calls the same way (ai-metrics.md; ai.call.recorded@1 1.1.0 and++ * ai.call-report@1). A call that did not go through OpenVibe.AI (Actor's own model calls, a Codes harness turn, a local++ * model on a person's machine, Vibe) is recorded here and sent to AI's POST /api/v1/usage/calls, which prices it when it++ * brings no price and publishes it, so every AI call on the network is in one meter.++ *++ * (meter is this module, openvibe-sdk/ai-meter)++ * const call = meter.startCall({ system: 'actor', turn_id, provider: 'deepseek', model, operation: 'chat', task: 'actor.task', task_id, streamed: true });++ * …on the first streamed token: call.firstToken(); on each tool call the model asks for: call.tool();++ * const record = call.end({ usage: providerUsage, outcome: 'succeeded' }); // ai.call-report@1's item++ * client.record(record);++ *++ * normalize(usage) → { input, output, reasoning, cache_read, cache_write, ollama } from OpenAI (chat and++ * Responses), Anthropic, Gemini and Ollama usage objects; INPUT is the uncached input++ * tokensPerSecond(output, ms) → the decode speed, or null when too short to measure++ * createMeterClient({ aiUrl, tokenClient, fetch, batch, flushMs, maxQueue, log })++ * → { record(rec), flush(), close(), stats() }: batches of up to 100, retried with backoff++ * on a network error, 408, 425, 429 or 5xx; a 401 asks the token client again; any++ * other refusal drops that batch and counts it (a call is never sent twice: AI keys++ * each by system + run or turn id anyway).++ * Server and browser: no I/O but the client's fetch.++ */++++const OPERATIONS = ['chat', 'completion', 'embedding', 'image', 'image_edit', 'speech', 'transcription', 'translation', 'rerank', 'moderation', 'decision', 'realtime', 'batch', 'tool', 'other'];++const PLACES = ['provider', 'machine', 'community', 'plan', 'host'];++const OUTCOMES = ['succeeded', 'failed', 'cancelled', 'cached'];++const n = (v) => (Number.isFinite(Number(v)) && Number(v) >= 0 ? Math.round(Number(v)) : 0);++++/** Usage from any provider's shape, with INPUT the uncached input tokens (as ai.call.recorded@1 counts them). */++function normalize(u) {++ if (!u || typeof u !== 'object') return { input: 0, output: 0, reasoning: 0, cache_read: 0, cache_write: 0 };++ // OpenAI chat completions (and compatible providers; DeepSeek names its cache hits prompt_cache_hit_tokens).++ if (u.prompt_tokens != null || u.completion_tokens != null) {++ const cached = n((u.prompt_tokens_details && u.prompt_tokens_details.cached_tokens) || u.prompt_cache_hit_tokens);++ return { input: Math.max(0, n(u.prompt_tokens) - cached), output: n(u.completion_tokens), reasoning: n(u.completion_tokens_details && u.completion_tokens_details.reasoning_tokens), cache_read: cached, cache_write: 0 };++ }++ // OpenAI Responses API: input_tokens includes the cached ones, given in input_tokens_details.++ if (u.input_tokens != null && (u.input_tokens_details || u.output_tokens_details)) {++ const cached = n(u.input_tokens_details && u.input_tokens_details.cached_tokens);++ return { input: Math.max(0, n(u.input_tokens) - cached), output: n(u.output_tokens), reasoning: n(u.output_tokens_details && u.output_tokens_details.reasoning_tokens), cache_read: cached, cache_write: 0 };++ }++ // Anthropic Messages: input_tokens already excludes the cache, read and written apart.++ if (u.input_tokens != null || u.output_tokens != null) {++ return { input: n(u.input_tokens), output: n(u.output_tokens), reasoning: 0, cache_read: n(u.cache_read_input_tokens), cache_write: n(u.cache_creation_input_tokens) };++ }++ // Gemini usageMetadata.++ if (u.promptTokenCount != null || u.candidatesTokenCount != null) {++ const cached = n(u.cachedContentTokenCount);++ return { input: Math.max(0, n(u.promptTokenCount) - cached), output: n(u.candidatesTokenCount), reasoning: n(u.thoughtsTokenCount), cache_read: cached, cache_write: 0 };++ }++ // Ollama: counts and its own decode time (nanoseconds).++ if (u.prompt_eval_count != null || u.eval_count != null) {++ return { input: n(u.prompt_eval_count), output: n(u.eval_count), reasoning: 0, cache_read: 0, cache_write: 0, ollama: { eval_ms: u.eval_duration != null ? Number(u.eval_duration) / 1e6 : null } };++ }++ // Already normalized.++ return { input: n(u.input), output: n(u.output), reasoning: n(u.reasoning), cache_read: n(u.cache_read), cache_write: n(u.cache_write) };++}++++/** Output tokens per second over the decode time; null below 8 tokens or 200 ms (noise, not speed). */++function tokensPerSecond(output, ms) {++ if (!(output >= 8) || !(ms >= 200)) return null;++ return Math.round((output / (ms / 1000)) * 10) / 10;++}++++function startCall(o = {}) {++ const now = typeof o.now === 'function' ? o.now : () => Date.now();++ const started = now();++ let first = null; let queue = null; let tools = 0;++ if (!o.system || !o.provider || !o.model) throw new TypeError('startCall: system, provider and model are required');++ if (o.run_id == null && o.turn_id == null) throw new TypeError('startCall: run_id or turn_id is required (the idempotency key)');++ const call = {++ queued(ms) { queue = n(ms); return call; },++ firstToken() { if (first == null) first = now(); return call; },++ tool(count = 1) { tools += n(count); return call; },++ end(e = {}) {++ const end = now();++ const t = normalize(e.usage);++ const outcome = OUTCOMES.includes(e.outcome) ? e.outcome : 'succeeded';++ const decodeMs = t.ollama && t.ollama.eval_ms != null ? t.ollama.eval_ms : (first != null ? end - first : null);++ const rec = {++ system: String(o.system).slice(0, 100), provider: String(o.provider).slice(0, 120), model: String(o.model).slice(0, 200),++ ...(o.run_id != null ? { run_id: String(o.run_id) } : { turn_id: String(o.turn_id) }),++ surface: String(o.surface || o.task || o.system), outcome, at: new Date(end).toISOString(),++ input: t.input, output: t.output, reasoning: t.reasoning, cache_read: t.cache_read, cache_write: t.cache_write,++ latency_ms: Math.max(0, end - started - (queue || 0)), ttft_ms: first != null ? Math.max(0, first - started - (queue || 0)) : null,++ attempts: 1 + n(e.fallbacks),++ streamed: o.streamed === true || first != null, tps: decodeMs != null ? tokensPerSecond(t.output, decodeMs) : null,++ };++ if (OPERATIONS.includes(o.operation)) rec.operation = o.operation;++ if (PLACES.includes(o.place)) rec.place = o.place;++ for (const k of ['task', 'task_id', 'harness', 'region', 'subject', 'attribution', 'paid_by', 'effort']) if (o[k] != null) rec[k] = o[k];++ if (queue != null) rec.queue_ms = queue;++ if (tools) rec.tool_calls = tools;++ if (e.fallbacks != null) rec.fallbacks = n(e.fallbacks);++ if (e.cache_hit != null) rec.cache_hit = !!e.cache_hit;++ if (e.error_class) rec.error_class = String(e.error_class).slice(0, 60);++ if (e.task_ms != null) rec.task_ms = n(e.task_ms);++ if (e.charged_usd != null && Number.isFinite(Number(e.charged_usd))) { rec.charged_usd = Number(e.charged_usd); rec.price_source = 'charged'; }++ if (e.images != null) rec.images = n(e.images);++ if (e.audio_seconds != null) rec.audio_seconds = Number(e.audio_seconds) || 0;++ return rec;++ },++ };++ return call;++}++++function createMeterClient({ aiUrl, tokenClient = null, fetch: fetchImpl = globalThis.fetch, batch = 50, flushMs = 2000, maxQueue = 5000, log = console, audience = 'openvibe.ai' } = {}) {++ if (!aiUrl) throw new TypeError('createMeterClient: aiUrl is required');++ const base = String(aiUrl).replace(/\/+$/, '');++ const size = Math.max(1, Math.min(100, Number(batch) || 50));++ const queue = [];++ const stats = { sent: 0, dropped: 0, rejected: 0, retried: 0 };++ let timer = null; let busy = null; let backoff = 0; let closed = false;++ const schedule = (ms) => { if (!timer && !closed) { timer = setTimeout(() => { timer = null; flush().catch(() => {}); }, ms); if (timer.unref) timer.unref(); } };++ function record(rec) {++ if (!rec || typeof rec !== 'object') return false;++ queue.push(rec);++ while (queue.length > maxQueue) { queue.shift(); stats.dropped += 1; }++ if (queue.length >= size) flush().catch(() => {}); else schedule(flushMs);++ return true;++ }++ async function send(calls, retry401 = true) {++ const headers = { 'content-type': 'application/json' };++ if (tokenClient && typeof tokenClient.getToken === 'function') headers.authorization = `Bearer ${await tokenClient.getToken({ audience })}`;++ const res = await fetchImpl(`${base}/api/v1/usage/calls`, { method: 'POST', headers, body: JSON.stringify({ calls }) });++ if (res.status === 401 && retry401 && tokenClient && typeof tokenClient.invalidate === 'function') { tokenClient.invalidate(); return send(calls, false); }++ return res.status;++ }++ async function flush() {++ if (busy) return busy;++ busy = (async () => {++ while (queue.length) {++ const calls = queue.slice(0, size);++ let status;++ try { status = await send(calls); } catch { status = 0; }++ if (status >= 200 && status < 300) { queue.splice(0, calls.length); stats.sent += calls.length; backoff = 0; continue; }++ if (status === 0 || status === 408 || status === 425 || status === 429 || status >= 500 || status === 401) {++ stats.retried += 1;++ backoff = Math.min(60000, backoff ? backoff * 2 : 1000);++ schedule(backoff);++ return;++ }++ queue.splice(0, calls.length); stats.rejected += calls.length;++ if (log && log.warn) log.warn(`[ai-meter] AI refused ${calls.length} call record(s): HTTP ${status}`);++ }++ })().finally(() => { busy = null; });++ return busy;++ }++ async function close() { closed = true; if (timer) { clearTimeout(timer); timer = null; } await flush(); }++ return { record, flush, close, stats: () => ({ ...stats, queued: queue.length }) };++}++++module.exports = { startCall, normalize, tokensPerSecond, createMeterClient, OPERATIONS, PLACES, OUTCOMES };@@@ -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.163.0';++const SDK_VERSION = '0.164.0';module.exports = {createClient,nnew file mode 100644@@@ -0,0 +1,68 @@++'use strict';++// openvibe-sdk/ai-meter (ai-metrics.md): usage from every provider's shape, TTFT and tokens per second from a call's own++// clock, records that fit ai.call-report@1, and a client that batches them to AI, retries what can be retried and never++// sends a refused batch again.++const assert = require('node:assert/strict');++const { run, stubServer } = require('./helpers');++const meter = require('../src/ai-meter');++++run([++ ['normalize: OpenAI chat and Responses, Anthropic, Gemini, Ollama; INPUT is the uncached input', () => {++ assert.deepEqual(meter.normalize({ prompt_tokens: 1000, completion_tokens: 200, prompt_tokens_details: { cached_tokens: 600 }, completion_tokens_details: { reasoning_tokens: 50 } }), { input: 400, output: 200, reasoning: 50, cache_read: 600, cache_write: 0 });++ assert.deepEqual(meter.normalize({ prompt_tokens: 900, completion_tokens: 10, prompt_cache_hit_tokens: 800 }).cache_read, 800, 'DeepSeek’s name for a cache hit');++ assert.deepEqual(meter.normalize({ input_tokens: 1000, output_tokens: 300, input_tokens_details: { cached_tokens: 900 }, output_tokens_details: { reasoning_tokens: 120 } }), { input: 100, output: 300, reasoning: 120, cache_read: 900, cache_write: 0 });++ assert.deepEqual(meter.normalize({ input_tokens: 40, output_tokens: 250, cache_read_input_tokens: 5000, cache_creation_input_tokens: 1200 }), { input: 40, output: 250, reasoning: 0, cache_read: 5000, cache_write: 1200 });++ assert.deepEqual(meter.normalize({ promptTokenCount: 2000, candidatesTokenCount: 100, cachedContentTokenCount: 1500, thoughtsTokenCount: 30 }), { input: 500, output: 100, reasoning: 30, cache_read: 1500, cache_write: 0 });++ const o = meter.normalize({ prompt_eval_count: 300, eval_count: 120, eval_duration: 9.6e9 });++ assert.deepEqual([o.input, o.output, o.ollama.eval_ms], [300, 120, 9600]);++ assert.deepEqual(meter.normalize(null), { input: 0, output: 0, reasoning: 0, cache_read: 0, cache_write: 0 });++ }],++ ['a call: TTFT and latency from its own clock, less its queue; tokens per second after the first token', () => {++ let t = 10000;++ const call = meter.startCall({ system: 'actor', turn_id: 'tsk_1:3', provider: 'deepseek', model: 'deepseek-v4-flash', operation: 'chat', task: 'actor.task', task_id: 'tsk_1', harness: 'openvibe', place: 'provider', now: () => t });++ call.queued(200);++ t = 10800; call.firstToken();++ t = 11000; call.tool(); call.firstToken();++ t = 13800;++ const r = call.end({ usage: { prompt_tokens: 1200, completion_tokens: 300, prompt_tokens_details: { cached_tokens: 1000 } }, outcome: 'succeeded', fallbacks: 1 });++ assert.deepEqual([r.ttft_ms, r.latency_ms, r.tps, r.streamed, r.tool_calls, r.queue_ms, r.attempts, r.fallbacks], [600, 3600, 100, true, 1, 200, 2, 1]);++ assert.deepEqual([r.input, r.output, r.cache_read, r.operation, r.task, r.task_id, r.place, r.turn_id], [200, 300, 1000, 'chat', 'actor.task', 'tsk_1', 'provider', 'tsk_1:3']);++ assert.equal(r.charged_usd, undefined, 'no price: AI prices it');++ }],++ ['a call: Ollama’s own decode time; a charged price; a failure; too short to measure', () => {++ const local = meter.startCall({ system: 'node', run_id: 'cmd_1', provider: 'ollama', model: 'qwen2.5:3b', place: 'machine' }).end({ usage: { prompt_eval_count: 300, eval_count: 120, eval_duration: 9.6e9 } });++ assert.equal(local.tps, 12.5);++ assert.equal(local.ttft_ms, null, 'not streamed: no TTFT');++ const paid = meter.startCall({ system: 'actor', run_id: 'r2', provider: 'openrouter', model: 'x' }).end({ usage: {}, charged_usd: 0.0042 });++ assert.deepEqual([paid.charged_usd, paid.price_source], [0.0042, 'charged']);++ const bad = meter.startCall({ system: 'actor', run_id: 'r3', provider: 'p', model: 'm' }).end({ outcome: 'failed', error_class: 'rate_limited' });++ assert.deepEqual([bad.outcome, bad.error_class], ['failed', 'rate_limited']);++ assert.equal(meter.tokensPerSecond(5, 1000), null);++ assert.equal(meter.tokensPerSecond(100, 100), null);++ assert.throws(() => meter.startCall({ system: 'actor', provider: 'p', model: 'm' }), /run_id or turn_id/);++ }],++ ['the client: batches to AI with the service token; retries a 503; drops a refused batch once', async () => {++ const seen = [];++ let answer = 503;++ const srv = await stubServer(async (req, res, body) => {++ seen.push({ url: req.url, auth: req.headers.authorization, n: JSON.parse(body.toString()).calls.length });++ res.writeHead(answer, { 'content-type': 'application/json' }); res.end('{}');++ });++ try {++ const tokenClient = { getToken: async () => 'tok', invalidate() {} };++ const client = meter.createMeterClient({ aiUrl: srv.url, tokenClient, batch: 3, flushMs: 10000 });++ for (let i = 0; i < 3; i++) client.record({ system: 'actor', run_id: `r${i}`, provider: 'p', model: 'm', outcome: 'succeeded', at: new Date().toISOString() });++ await client.flush();++ assert.equal(seen[0].url, '/api/v1/usage/calls');++ assert.equal(seen[0].auth, 'Bearer tok');++ assert.equal(client.stats().queued, 3, 'a 503 keeps them');++ answer = 202;++ await client.flush();++ assert.deepEqual([client.stats().sent, client.stats().queued], [3, 0]);++ answer = 422;++ client.record({ system: 'actor', run_id: 'bad', provider: 'p', model: 'm', outcome: 'succeeded', at: 'x' });++ await client.close();++ assert.deepEqual([client.stats().rejected, client.stats().queued], [1, 0], 'refused once, never again');++ } finally { await srv.close(); }++ }],++]);@@@ -72,7 +72,7 @@ run([})().catch((err) => { console.error(err); process.exit(1); });`;const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));-- assert.equal(subpaths.length, 119); // 0.163.0: agents/rules, agents/router and agents/metrics are the 117th to 119th. // 0.162.0: agents/blueprint is the 116th. // 0.160.0: agents/loop, agents/dispatch and agents/api are the 113th to 115th. // 0.152.0 train: elements (#227) and ui-layout (#226) add the 110th and 111th; // composer, feed and agents add the 104th-106th after user-card; the 0.148.0 train's codes, player and the rest bring 109; elements brings 110.++ assert.equal(subpaths.length, 120); // 0.164.0: ai-meter is the 120th. // 0.163.0: agents/rules, agents/router and agents/metrics are the 117th to 119th. // 0.162.0: agents/blueprint is the 116th. // 0.160.0: agents/loop, agents/dispatch and agents/api are the 113th to 115th. // 0.152.0 train: elements (#227) and ui-layout (#226) add the 110th and 111th; // composer, feed and agents add the 104th-106th after user-card; the 0.148.0 train's codes, player and the rest bring 109; elements brings 110.assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');} finally {fs.rmSync(tmp, { recursive: true, force: true });nnew file mode 100644@@@ -0,0 +1,13 @@++// openvibe-sdk/ai-meter: every product meters its AI calls the same way (ai.call-report@1).++export type Operation = 'chat' | 'completion' | 'embedding' | 'image' | 'image_edit' | 'speech' | 'transcription' | 'translation' | 'rerank' | 'moderation' | 'decision' | 'realtime' | 'batch' | 'tool' | 'other';++export type Place = 'provider' | 'machine' | 'community' | 'plan' | 'host';++export interface Tokens { input: number; output: number; reasoning: number; cache_read: number; cache_write: number; ollama?: { eval_ms: number | null } }++export interface CallRecord { system: string; provider: string; model: string; run_id?: string; turn_id?: string; surface: string; outcome: 'succeeded' | 'failed' | 'cancelled' | 'cached'; at: string; input: number; output: number; reasoning: number; cache_read: number; cache_write: number; latency_ms: number; ttft_ms: number | null; attempts: number; streamed: boolean; tps: number | null; [k: string]: unknown }++export interface Call { queued(ms: number): Call; firstToken(): Call; tool(count?: number): Call; end(e?: { usage?: unknown; outcome?: string; fallbacks?: number; cache_hit?: boolean; error_class?: string; task_ms?: number; charged_usd?: number; images?: number; audio_seconds?: number }): CallRecord; }++export function startCall(o: { system: string; provider: string; model: string; run_id?: string; turn_id?: string; operation?: Operation; task?: string; task_id?: string; harness?: string; place?: Place; surface?: string; streamed?: boolean; region?: string; subject?: unknown; attribution?: string; paid_by?: string; effort?: string; now?: () => number }): Call;++export function normalize(usage: unknown): Tokens;++export function tokensPerSecond(output: number, ms: number): number | null;++export function createMeterClient(o: { aiUrl: string; tokenClient?: { getToken(ctx?: { audience?: string }): Promise<string>; invalidate?(): void } | null; fetch?: typeof fetch; batch?: number; flushMs?: number; maxQueue?: number; log?: { warn?(m: string): void }; audience?: string }): { record(rec: CallRecord): boolean; flush(): Promise<void>; close(): Promise<void>; stats(): { sent: number; dropped: number; rejected: number; retried: number; queued: number } };++export const OPERATIONS: Operation[];++export const PLACES: Place[];++export const OUTCOMES: string[];
@@ -1,5 +1,19 @@
@@ -1,5 +1,19 @@
# Changelog
# Changelog
+## 0.164.0 — 2026-11-12
+
+- **New `openvibe-sdk/ai-meter`:** every product meters its AI calls the same way (ai-metrics.md; Contracts 0.421.0
+ `ai.call.recorded@1` 1.1.0 and `ai.call-report@1`).
+ - `startCall()` measures each call on its own clock: TTFT and latency less any queue, tokens per second after the
+ first token (or Ollama's own decode time), tool calls, fallbacks, the error class, and the charged price when the
+ provider gives one.
+ - `normalize()` reads the usage of OpenAI chat completions and Responses, Anthropic Messages, Gemini and Ollama.
+ The input it reports is the uncached input, with cache reads and writes and reasoning tokens apart.
+ - `createMeterClient()` batches the records to AI's `POST /api/v1/usage/calls` with the service token.
+ - It retries a network error, a 408, 425, 429 or 5xx with backoff, and asks for a new token after a 401.
+ - A batch refused for any other reason is dropped and counted, never sent again.
+ - AI prices any call that brings no price and publishes it.
+
## 0.163.0 — 2026-11-12
## 0.163.0 — 2026-11-12
From reading 24 open agent projects (one-system-v2.md). Three new modules, all pure functions:
From reading 24 open agent projects (one-system-v2.md). Three new modules, all pure functions:
@@ -8,12 +8,12 @@
@@ -8,12 +8,12 @@
**Install and test:** `npm ci && npm test` (Node 20 or newer)
**Install and test:** `npm ci && npm test` (Node 20 or newer)
```bash
```bash
-npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.163.0
+npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.164.0
```
```
> Supported browser and server clients for the OpenVibe platform.
> Supported browser and server clients for the OpenVibe platform.
-**Status:** alpha, v0.163.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.
+**Status:** alpha, v0.164.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.
@@ -22,7 +22,7 @@ npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags
@@ -22,7 +22,7 @@ npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags
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.163.0
+npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.164.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).
@@ -226,6 +226,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
@@ -226,6 +226,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| `openvibe-sdk/agents/rules` | both | Permissions as data (agents.permission-rule@1): `decide(rules, call, { attended, level })` (the last matching rule wins; with none, the level; unattended work never waits; the floor always asks, and unattended it is denied), `fromBlueprint(doc)` (only the tools it names, never `.git/hooks` or its own config, its sandbox and network), `matches` |
| `openvibe-sdk/agents/rules` | both | Permissions as data (agents.permission-rule@1): `decide(rules, call, { attended, level })` (the last matching rule wins; with none, the level; unattended work never waits; the floor always asks, and unattended it is denied), `fromBlueprint(doc)` (only the tools it names, never `.git/hooks` or its own config, its sandbox and network), `matches` |
| `openvibe-sdk/agents/router` | both | Pay for the strong model only when it matters (RouteLLM's shape over OpenVibe Decisions): `question(step)`, `probability(answer)`, `route({ p, threshold, small, strong })`, `calibrate(history, { strongShare })` (the threshold that sends that share strong), `outcomes(history)` (the quality-cost points), `chain(step, { pick, pin, ask })` |
| `openvibe-sdk/agents/router` | both | Pay for the strong model only when it matters (RouteLLM's shape over OpenVibe Decisions): `question(step)`, `probability(answer)`, `route({ p, threshold, small, strong })`, `calibrate(history, { strongShare })` (the threshold that sends that share strong), `outcomes(history)` (the quality-cost points), `chain(step, { pick, pin, ask })` |
| `openvibe-sdk/agents/metrics` | both | What a run measured (agents.run@1 1.4.0 metrics): `createRecorder()` (charged cost first, an estimate apart, unknown is null; tokens with cache; steps; latency; a named exit; evidence), `passAtK`, `passHatK`, `flakeRate` |
| `openvibe-sdk/agents/metrics` | both | What a run measured (agents.run@1 1.4.0 metrics): `createRecorder()` (charged cost first, an estimate apart, unknown is null; tokens with cache; steps; latency; a named exit; evidence), `passAtK`, `passHatK`, `flakeRate` |
+| `openvibe-sdk/ai-meter` | both | Every product meters its AI calls the same way (ai.call.recorded@1 1.1.0, ai.call-report@1): `startCall({ system, provider, model, run_id|turn_id, operation, task, task_id, harness, place })` → `queued(ms)`, `firstToken()`, `tool()`, `end({ usage, outcome, charged_usd, fallbacks, error_class })` (TTFT, latency, tokens per second after the first token); `normalize(usage)` (OpenAI chat and Responses, Anthropic, Gemini, Ollama; the uncached input); `createMeterClient({ aiUrl, tokenClient })` (batches to AI's POST /api/v1/usage/calls, retries what can be retried, never resends a refused batch) |
| `openvibe-sdk/agents/api` | server | The Run API from a product's own store: `createRunApi({ product, subject, store, prefix })` returns `handle(req, res, next)` for Express or any Node http server, serving `GET/POST /runs`, `GET /runs/:id`, `…/steps`, `…/events` (server-sent events, from the store's `follow` or by reading new steps), `POST …/messages`, `…/stop`, `…/approvals/:approval`. The store has `list, get, steps, start, say, stop, answer, follow?`; a method it lacks answers 409. No subject is 401, another person's run 404, a start without words 422; every run carries its ref; a store's own `{ status, code }` errors go out as they are and anything else is a 500 that says nothing of the store; a mandate from a caller is dropped (intake only) |
| `openvibe-sdk/agents/api` | server | The Run API from a product's own store: `createRunApi({ product, subject, store, prefix })` returns `handle(req, res, next)` for Express or any Node http server, serving `GET/POST /runs`, `GET /runs/:id`, `…/steps`, `…/events` (server-sent events, from the store's `follow` or by reading new steps), `POST …/messages`, `…/stop`, `…/approvals/:approval`. The store has `list, get, steps, start, say, stop, answer, follow?`; a method it lacks answers 409. No subject is 401, another person's run 404, a start without words 422; every run carries its ref; a store's own `{ status, code }` errors go out as they are and anything else is a 500 that says nothing of the store; a mandate from a caller is dropped (intake only) |
| `openvibe-sdk/agents` | both | `createAgentsClient(client, {baseUrl?, service?, audience?})`: `run(id)` fetches an agent run snapshot (null when missing), `events(id, {lastEventId, signal})` follows the resumable AG-UI event stream, `post(id, approval)` answers an approval request |
| `openvibe-sdk/agents` | both | `createAgentsClient(client, {baseUrl?, service?, audience?})`: `run(id)` fetches an agent run snapshot (null when missing), `events(id, {lastEventId, signal})` follows the resumable AG-UI event stream, `post(id, approval)` answers an approval request |
| `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, prefer})` (+ `.wait()`), `jobs`; `ToolRunError`, `isToolRunError()`; `prefer` picks the worker placement (`auto`, `home`, `host`) ([details](docs/tools-routing.md)) |
| `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, prefer})` (+ `.wait()`), `jobs`; `ToolRunError`, `isToolRunError()`; `prefer` picks the worker placement (`auto`, `home`, `host`) ([details](docs/tools-routing.md)) |
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
"stage": "alpha",
"stage": "alpha",
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
- "version": "0.163.0",
+ "version": "0.164.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.163.0, MIT). Do not edit.
+// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.164.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, trust, levels, modules, realtime, media, composer, cam, mediaUpload, ui, uiLayout, community, homeFeed, feed, views, bot, search, bench, meme, vision, themes, elements, design, redact, chat, ai, credentials, jobs, agents, frame, tools, projects, geo, history, userCard, player, agentSheet). No server-only code: no service tokens, JWT verification, events, identity.
// (auth, locker, registry, trust, levels, modules, realtime, media, composer, cam, mediaUpload, ui, uiLayout, community, homeFeed, feed, views, bot, search, bench, meme, vision, themes, elements, design, redact, chat, ai, credentials, jobs, agents, frame, tools, projects, geo, history, userCard, player, agentSheet). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
/* eslint-disable */
@@ -68,7 +68,7 @@ const { paginate, offsetPager } = __load(7);
@@ -68,7 +68,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.163.0';
+const SDK_VERSION = '0.164.0';
module.exports = {
module.exports = {
createClient,
createClient,
@@ -745,6 +745,15 @@
@@ -745,6 +745,15 @@
"TERMINAL": "object",
"TERMINAL": "object",
"createAiClient": "function"
"createAiClient": "function"
},
},
+ "openvibe-sdk/ai-meter": {
+ "OPERATIONS": "array",
+ "OUTCOMES": "array",
+ "PLACES": "array",
+ "createMeterClient": "function",
+ "normalize": "function",
+ "startCall": "function",
+ "tokensPerSecond": "function"
+ },
"openvibe-sdk/app-signin": {
"openvibe-sdk/app-signin": {
"createAppSignIn": "function",
"createAppSignIn": "function",
"normalizeScope": "function"
"normalizeScope": "function"
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
+// Generated by scripts/esm.js from src/ai-meter.js. Do not edit.
+import cjs from '../src/ai-meter.js';
+
+export const {
+ startCall, normalize, tokensPerSecond, createMeterClient, OPERATIONS, PLACES,
+ OUTCOMES,
+} = cjs;
+export default cjs;
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.163.0",
+ "version": "0.164.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.163.0",
+ "version": "0.164.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.163.0",
+ "version": "0.164.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",
@@ -344,6 +344,11 @@
@@ -344,6 +344,11 @@
"import": "./esm/agents-metrics.mjs",
"import": "./esm/agents-metrics.mjs",
"default": "./src/agents/metrics.js"
"default": "./src/agents/metrics.js"
},
},
+ "./ai-meter": {
+ "types": "./types/ai-meter.d.ts",
+ "import": "./esm/ai-meter.mjs",
+ "default": "./src/ai-meter.js"
+ },
"./agents/api": {
"./agents/api": {
"types": "./types/agents-api.d.ts",
"types": "./types/agents-api.d.ts",
"import": "./esm/agents-api.mjs",
"import": "./esm/agents-api.mjs",
@@ -80,6 +80,7 @@ const ENTRIES = {
@@ -80,6 +80,7 @@ const ENTRIES = {
'agents-loop.mjs': '../src/agents/loop.js',
'agents-loop.mjs': '../src/agents/loop.js',
'agents-dispatch.mjs': '../src/agents/dispatch.js',
'agents-dispatch.mjs': '../src/agents/dispatch.js',
'agents-api.mjs': '../src/agents/api.js',
'agents-api.mjs': '../src/agents/api.js',
+ 'ai-meter.mjs': '../src/ai-meter.js',
'agents-rules.mjs': '../src/agents/rules.js',
'agents-rules.mjs': '../src/agents/rules.js',
'agents-router.mjs': '../src/agents/router.js',
'agents-router.mjs': '../src/agents/router.js',
'agents-metrics.mjs': '../src/agents/metrics.js',
'agents-metrics.mjs': '../src/agents/metrics.js',
new file mode 100644
new file mode 100644
@@ -0,0 +1,154 @@
@@ -0,0 +1,154 @@
+'use strict';
+/**
+ * openvibe-sdk/ai-meter — every product meters its AI calls the same way (ai-metrics.md; ai.call.recorded@1 1.1.0 and
+ * ai.call-report@1). A call that did not go through OpenVibe.AI (Actor's own model calls, a Codes harness turn, a local
+ * model on a person's machine, Vibe) is recorded here and sent to AI's POST /api/v1/usage/calls, which prices it when it
+ * brings no price and publishes it, so every AI call on the network is in one meter.
+ *
+ * (meter is this module, openvibe-sdk/ai-meter)
+ * const call = meter.startCall({ system: 'actor', turn_id, provider: 'deepseek', model, operation: 'chat', task: 'actor.task', task_id, streamed: true });
+ * …on the first streamed token: call.firstToken(); on each tool call the model asks for: call.tool();
+ * const record = call.end({ usage: providerUsage, outcome: 'succeeded' }); // ai.call-report@1's item
+ * client.record(record);
+ *
+ * normalize(usage) → { input, output, reasoning, cache_read, cache_write, ollama } from OpenAI (chat and
+ * Responses), Anthropic, Gemini and Ollama usage objects; INPUT is the uncached input
+ * tokensPerSecond(output, ms) → the decode speed, or null when too short to measure
+ * createMeterClient({ aiUrl, tokenClient, fetch, batch, flushMs, maxQueue, log })
+ * → { record(rec), flush(), close(), stats() }: batches of up to 100, retried with backoff
+ * on a network error, 408, 425, 429 or 5xx; a 401 asks the token client again; any
+ * other refusal drops that batch and counts it (a call is never sent twice: AI keys
+ * each by system + run or turn id anyway).
+ * Server and browser: no I/O but the client's fetch.
+ */
+
+const OPERATIONS = ['chat', 'completion', 'embedding', 'image', 'image_edit', 'speech', 'transcription', 'translation', 'rerank', 'moderation', 'decision', 'realtime', 'batch', 'tool', 'other'];
+const PLACES = ['provider', 'machine', 'community', 'plan', 'host'];
+const OUTCOMES = ['succeeded', 'failed', 'cancelled', 'cached'];
+const n = (v) => (Number.isFinite(Number(v)) && Number(v) >= 0 ? Math.round(Number(v)) : 0);
+
+/** Usage from any provider's shape, with INPUT the uncached input tokens (as ai.call.recorded@1 counts them). */
+function normalize(u) {
+ if (!u || typeof u !== 'object') return { input: 0, output: 0, reasoning: 0, cache_read: 0, cache_write: 0 };
+ // OpenAI chat completions (and compatible providers; DeepSeek names its cache hits prompt_cache_hit_tokens).
+ if (u.prompt_tokens != null || u.completion_tokens != null) {
+ const cached = n((u.prompt_tokens_details && u.prompt_tokens_details.cached_tokens) || u.prompt_cache_hit_tokens);
+ return { input: Math.max(0, n(u.prompt_tokens) - cached), output: n(u.completion_tokens), reasoning: n(u.completion_tokens_details && u.completion_tokens_details.reasoning_tokens), cache_read: cached, cache_write: 0 };
+ }
+ // OpenAI Responses API: input_tokens includes the cached ones, given in input_tokens_details.
+ if (u.input_tokens != null && (u.input_tokens_details || u.output_tokens_details)) {
+ const cached = n(u.input_tokens_details && u.input_tokens_details.cached_tokens);
+ return { input: Math.max(0, n(u.input_tokens) - cached), output: n(u.output_tokens), reasoning: n(u.output_tokens_details && u.output_tokens_details.reasoning_tokens), cache_read: cached, cache_write: 0 };
+ }
+ // Anthropic Messages: input_tokens already excludes the cache, read and written apart.
+ if (u.input_tokens != null || u.output_tokens != null) {
+ return { input: n(u.input_tokens), output: n(u.output_tokens), reasoning: 0, cache_read: n(u.cache_read_input_tokens), cache_write: n(u.cache_creation_input_tokens) };
+ }
+ // Gemini usageMetadata.
+ if (u.promptTokenCount != null || u.candidatesTokenCount != null) {
+ const cached = n(u.cachedContentTokenCount);
+ return { input: Math.max(0, n(u.promptTokenCount) - cached), output: n(u.candidatesTokenCount), reasoning: n(u.thoughtsTokenCount), cache_read: cached, cache_write: 0 };
+ }
+ // Ollama: counts and its own decode time (nanoseconds).
+ if (u.prompt_eval_count != null || u.eval_count != null) {
+ return { input: n(u.prompt_eval_count), output: n(u.eval_count), reasoning: 0, cache_read: 0, cache_write: 0, ollama: { eval_ms: u.eval_duration != null ? Number(u.eval_duration) / 1e6 : null } };
+ }
+ // Already normalized.
+ return { input: n(u.input), output: n(u.output), reasoning: n(u.reasoning), cache_read: n(u.cache_read), cache_write: n(u.cache_write) };
+}
+
+/** Output tokens per second over the decode time; null below 8 tokens or 200 ms (noise, not speed). */
+function tokensPerSecond(output, ms) {
+ if (!(output >= 8) || !(ms >= 200)) return null;
+ return Math.round((output / (ms / 1000)) * 10) / 10;
+}
+
+function startCall(o = {}) {
+ const now = typeof o.now === 'function' ? o.now : () => Date.now();
+ const started = now();
+ let first = null; let queue = null; let tools = 0;
+ if (!o.system || !o.provider || !o.model) throw new TypeError('startCall: system, provider and model are required');
+ if (o.run_id == null && o.turn_id == null) throw new TypeError('startCall: run_id or turn_id is required (the idempotency key)');
+ const call = {
+ queued(ms) { queue = n(ms); return call; },
+ firstToken() { if (first == null) first = now(); return call; },
+ tool(count = 1) { tools += n(count); return call; },
+ end(e = {}) {
+ const end = now();
+ const t = normalize(e.usage);
+ const outcome = OUTCOMES.includes(e.outcome) ? e.outcome : 'succeeded';
+ const decodeMs = t.ollama && t.ollama.eval_ms != null ? t.ollama.eval_ms : (first != null ? end - first : null);
+ const rec = {
+ system: String(o.system).slice(0, 100), provider: String(o.provider).slice(0, 120), model: String(o.model).slice(0, 200),
+ ...(o.run_id != null ? { run_id: String(o.run_id) } : { turn_id: String(o.turn_id) }),
+ surface: String(o.surface || o.task || o.system), outcome, at: new Date(end).toISOString(),
+ input: t.input, output: t.output, reasoning: t.reasoning, cache_read: t.cache_read, cache_write: t.cache_write,
+ latency_ms: Math.max(0, end - started - (queue || 0)), ttft_ms: first != null ? Math.max(0, first - started - (queue || 0)) : null,
+ attempts: 1 + n(e.fallbacks),
+ streamed: o.streamed === true || first != null, tps: decodeMs != null ? tokensPerSecond(t.output, decodeMs) : null,
+ };
+ if (OPERATIONS.includes(o.operation)) rec.operation = o.operation;
+ if (PLACES.includes(o.place)) rec.place = o.place;
+ for (const k of ['task', 'task_id', 'harness', 'region', 'subject', 'attribution', 'paid_by', 'effort']) if (o[k] != null) rec[k] = o[k];
+ if (queue != null) rec.queue_ms = queue;
+ if (tools) rec.tool_calls = tools;
+ if (e.fallbacks != null) rec.fallbacks = n(e.fallbacks);
+ if (e.cache_hit != null) rec.cache_hit = !!e.cache_hit;
+ if (e.error_class) rec.error_class = String(e.error_class).slice(0, 60);
+ if (e.task_ms != null) rec.task_ms = n(e.task_ms);
+ if (e.charged_usd != null && Number.isFinite(Number(e.charged_usd))) { rec.charged_usd = Number(e.charged_usd); rec.price_source = 'charged'; }
+ if (e.images != null) rec.images = n(e.images);
+ if (e.audio_seconds != null) rec.audio_seconds = Number(e.audio_seconds) || 0;
+ return rec;
+ },
+ };
+ return call;
+}
+
+function createMeterClient({ aiUrl, tokenClient = null, fetch: fetchImpl = globalThis.fetch, batch = 50, flushMs = 2000, maxQueue = 5000, log = console, audience = 'openvibe.ai' } = {}) {
+ if (!aiUrl) throw new TypeError('createMeterClient: aiUrl is required');
+ const base = String(aiUrl).replace(/\/+$/, '');
+ const size = Math.max(1, Math.min(100, Number(batch) || 50));
+ const queue = [];
+ const stats = { sent: 0, dropped: 0, rejected: 0, retried: 0 };
+ let timer = null; let busy = null; let backoff = 0; let closed = false;
+ const schedule = (ms) => { if (!timer && !closed) { timer = setTimeout(() => { timer = null; flush().catch(() => {}); }, ms); if (timer.unref) timer.unref(); } };
+ function record(rec) {
+ if (!rec || typeof rec !== 'object') return false;
+ queue.push(rec);
+ while (queue.length > maxQueue) { queue.shift(); stats.dropped += 1; }
+ if (queue.length >= size) flush().catch(() => {}); else schedule(flushMs);
+ return true;
+ }
+ async function send(calls, retry401 = true) {
+ const headers = { 'content-type': 'application/json' };
+ if (tokenClient && typeof tokenClient.getToken === 'function') headers.authorization = `Bearer ${await tokenClient.getToken({ audience })}`;
+ const res = await fetchImpl(`${base}/api/v1/usage/calls`, { method: 'POST', headers, body: JSON.stringify({ calls }) });
+ if (res.status === 401 && retry401 && tokenClient && typeof tokenClient.invalidate === 'function') { tokenClient.invalidate(); return send(calls, false); }
+ return res.status;
+ }
+ async function flush() {
+ if (busy) return busy;
+ busy = (async () => {
+ while (queue.length) {
+ const calls = queue.slice(0, size);
+ let status;
+ try { status = await send(calls); } catch { status = 0; }
+ if (status >= 200 && status < 300) { queue.splice(0, calls.length); stats.sent += calls.length; backoff = 0; continue; }
+ if (status === 0 || status === 408 || status === 425 || status === 429 || status >= 500 || status === 401) {
+ stats.retried += 1;
+ backoff = Math.min(60000, backoff ? backoff * 2 : 1000);
+ schedule(backoff);
+ return;
+ }
+ queue.splice(0, calls.length); stats.rejected += calls.length;
+ if (log && log.warn) log.warn(`[ai-meter] AI refused ${calls.length} call record(s): HTTP ${status}`);
+ }
+ })().finally(() => { busy = null; });
+ return busy;
+ }
+ async function close() { closed = true; if (timer) { clearTimeout(timer); timer = null; } await flush(); }
+ return { record, flush, close, stats: () => ({ ...stats, queued: queue.length }) };
+}
+
+module.exports = { startCall, normalize, tokensPerSecond, createMeterClient, OPERATIONS, PLACES, OUTCOMES };
@@ -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.163.0';
+const SDK_VERSION = '0.164.0';
module.exports = {
module.exports = {
createClient,
createClient,
new file mode 100644
new file mode 100644
@@ -0,0 +1,68 @@
@@ -0,0 +1,68 @@
+'use strict';
+// openvibe-sdk/ai-meter (ai-metrics.md): usage from every provider's shape, TTFT and tokens per second from a call's own
+// clock, records that fit ai.call-report@1, and a client that batches them to AI, retries what can be retried and never
+// sends a refused batch again.
+const assert = require('node:assert/strict');
+const { run, stubServer } = require('./helpers');
+const meter = require('../src/ai-meter');
+
+run([
+ ['normalize: OpenAI chat and Responses, Anthropic, Gemini, Ollama; INPUT is the uncached input', () => {
+ assert.deepEqual(meter.normalize({ prompt_tokens: 1000, completion_tokens: 200, prompt_tokens_details: { cached_tokens: 600 }, completion_tokens_details: { reasoning_tokens: 50 } }), { input: 400, output: 200, reasoning: 50, cache_read: 600, cache_write: 0 });
+ assert.deepEqual(meter.normalize({ prompt_tokens: 900, completion_tokens: 10, prompt_cache_hit_tokens: 800 }).cache_read, 800, 'DeepSeek’s name for a cache hit');
+ assert.deepEqual(meter.normalize({ input_tokens: 1000, output_tokens: 300, input_tokens_details: { cached_tokens: 900 }, output_tokens_details: { reasoning_tokens: 120 } }), { input: 100, output: 300, reasoning: 120, cache_read: 900, cache_write: 0 });
+ assert.deepEqual(meter.normalize({ input_tokens: 40, output_tokens: 250, cache_read_input_tokens: 5000, cache_creation_input_tokens: 1200 }), { input: 40, output: 250, reasoning: 0, cache_read: 5000, cache_write: 1200 });
+ assert.deepEqual(meter.normalize({ promptTokenCount: 2000, candidatesTokenCount: 100, cachedContentTokenCount: 1500, thoughtsTokenCount: 30 }), { input: 500, output: 100, reasoning: 30, cache_read: 1500, cache_write: 0 });
+ const o = meter.normalize({ prompt_eval_count: 300, eval_count: 120, eval_duration: 9.6e9 });
+ assert.deepEqual([o.input, o.output, o.ollama.eval_ms], [300, 120, 9600]);
+ assert.deepEqual(meter.normalize(null), { input: 0, output: 0, reasoning: 0, cache_read: 0, cache_write: 0 });
+ }],
+ ['a call: TTFT and latency from its own clock, less its queue; tokens per second after the first token', () => {
+ let t = 10000;
+ const call = meter.startCall({ system: 'actor', turn_id: 'tsk_1:3', provider: 'deepseek', model: 'deepseek-v4-flash', operation: 'chat', task: 'actor.task', task_id: 'tsk_1', harness: 'openvibe', place: 'provider', now: () => t });
+ call.queued(200);
+ t = 10800; call.firstToken();
+ t = 11000; call.tool(); call.firstToken();
+ t = 13800;
+ const r = call.end({ usage: { prompt_tokens: 1200, completion_tokens: 300, prompt_tokens_details: { cached_tokens: 1000 } }, outcome: 'succeeded', fallbacks: 1 });
+ assert.deepEqual([r.ttft_ms, r.latency_ms, r.tps, r.streamed, r.tool_calls, r.queue_ms, r.attempts, r.fallbacks], [600, 3600, 100, true, 1, 200, 2, 1]);
+ assert.deepEqual([r.input, r.output, r.cache_read, r.operation, r.task, r.task_id, r.place, r.turn_id], [200, 300, 1000, 'chat', 'actor.task', 'tsk_1', 'provider', 'tsk_1:3']);
+ assert.equal(r.charged_usd, undefined, 'no price: AI prices it');
+ }],
+ ['a call: Ollama’s own decode time; a charged price; a failure; too short to measure', () => {
+ const local = meter.startCall({ system: 'node', run_id: 'cmd_1', provider: 'ollama', model: 'qwen2.5:3b', place: 'machine' }).end({ usage: { prompt_eval_count: 300, eval_count: 120, eval_duration: 9.6e9 } });
+ assert.equal(local.tps, 12.5);
+ assert.equal(local.ttft_ms, null, 'not streamed: no TTFT');
+ const paid = meter.startCall({ system: 'actor', run_id: 'r2', provider: 'openrouter', model: 'x' }).end({ usage: {}, charged_usd: 0.0042 });
+ assert.deepEqual([paid.charged_usd, paid.price_source], [0.0042, 'charged']);
+ const bad = meter.startCall({ system: 'actor', run_id: 'r3', provider: 'p', model: 'm' }).end({ outcome: 'failed', error_class: 'rate_limited' });
+ assert.deepEqual([bad.outcome, bad.error_class], ['failed', 'rate_limited']);
+ assert.equal(meter.tokensPerSecond(5, 1000), null);
+ assert.equal(meter.tokensPerSecond(100, 100), null);
+ assert.throws(() => meter.startCall({ system: 'actor', provider: 'p', model: 'm' }), /run_id or turn_id/);
+ }],
+ ['the client: batches to AI with the service token; retries a 503; drops a refused batch once', async () => {
+ const seen = [];
+ let answer = 503;
+ const srv = await stubServer(async (req, res, body) => {
+ seen.push({ url: req.url, auth: req.headers.authorization, n: JSON.parse(body.toString()).calls.length });
+ res.writeHead(answer, { 'content-type': 'application/json' }); res.end('{}');
+ });
+ try {
+ const tokenClient = { getToken: async () => 'tok', invalidate() {} };
+ const client = meter.createMeterClient({ aiUrl: srv.url, tokenClient, batch: 3, flushMs: 10000 });
+ for (let i = 0; i < 3; i++) client.record({ system: 'actor', run_id: `r${i}`, provider: 'p', model: 'm', outcome: 'succeeded', at: new Date().toISOString() });
+ await client.flush();
+ assert.equal(seen[0].url, '/api/v1/usage/calls');
+ assert.equal(seen[0].auth, 'Bearer tok');
+ assert.equal(client.stats().queued, 3, 'a 503 keeps them');
+ answer = 202;
+ await client.flush();
+ assert.deepEqual([client.stats().sent, client.stats().queued], [3, 0]);
+ answer = 422;
+ client.record({ system: 'actor', run_id: 'bad', provider: 'p', model: 'm', outcome: 'succeeded', at: 'x' });
+ await client.close();
+ assert.deepEqual([client.stats().rejected, client.stats().queued], [1, 0], 'refused once, never again');
+ } finally { await srv.close(); }
+ }],
+]);
@@ -72,7 +72,7 @@ run([
@@ -72,7 +72,7 @@ run([
})().catch((err) => { console.error(err); process.exit(1); });`;
})().catch((err) => { console.error(err); process.exit(1); });`;
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));
assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));
- assert.equal(subpaths.length, 119); // 0.163.0: agents/rules, agents/router and agents/metrics are the 117th to 119th. // 0.162.0: agents/blueprint is the 116th. // 0.160.0: agents/loop, agents/dispatch and agents/api are the 113th to 115th. // 0.152.0 train: elements (#227) and ui-layout (#226) add the 110th and 111th; // composer, feed and agents add the 104th-106th after user-card; the 0.148.0 train's codes, player and the rest bring 109; elements brings 110.
+ assert.equal(subpaths.length, 120); // 0.164.0: ai-meter is the 120th. // 0.163.0: agents/rules, agents/router and agents/metrics are the 117th to 119th. // 0.162.0: agents/blueprint is the 116th. // 0.160.0: agents/loop, agents/dispatch and agents/api are the 113th to 115th. // 0.152.0 train: elements (#227) and ui-layout (#226) add the 110th and 111th; // composer, feed and agents add the 104th-106th after user-card; the 0.148.0 train's codes, player and the rest bring 109; elements brings 110.
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
} finally {
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(tmp, { recursive: true, force: true });
new file mode 100644
new file mode 100644
@@ -0,0 +1,13 @@
@@ -0,0 +1,13 @@
+// openvibe-sdk/ai-meter: every product meters its AI calls the same way (ai.call-report@1).
+export type Operation = 'chat' | 'completion' | 'embedding' | 'image' | 'image_edit' | 'speech' | 'transcription' | 'translation' | 'rerank' | 'moderation' | 'decision' | 'realtime' | 'batch' | 'tool' | 'other';
+export type Place = 'provider' | 'machine' | 'community' | 'plan' | 'host';
+export interface Tokens { input: number; output: number; reasoning: number; cache_read: number; cache_write: number; ollama?: { eval_ms: number | null } }
+export interface CallRecord { system: string; provider: string; model: string; run_id?: string; turn_id?: string; surface: string; outcome: 'succeeded' | 'failed' | 'cancelled' | 'cached'; at: string; input: number; output: number; reasoning: number; cache_read: number; cache_write: number; latency_ms: number; ttft_ms: number | null; attempts: number; streamed: boolean; tps: number | null; [k: string]: unknown }
+export interface Call { queued(ms: number): Call; firstToken(): Call; tool(count?: number): Call; end(e?: { usage?: unknown; outcome?: string; fallbacks?: number; cache_hit?: boolean; error_class?: string; task_ms?: number; charged_usd?: number; images?: number; audio_seconds?: number }): CallRecord; }
+export function startCall(o: { system: string; provider: string; model: string; run_id?: string; turn_id?: string; operation?: Operation; task?: string; task_id?: string; harness?: string; place?: Place; surface?: string; streamed?: boolean; region?: string; subject?: unknown; attribution?: string; paid_by?: string; effort?: string; now?: () => number }): Call;
+export function normalize(usage: unknown): Tokens;
+export function tokensPerSecond(output: number, ms: number): number | null;
+export function createMeterClient(o: { aiUrl: string; tokenClient?: { getToken(ctx?: { audience?: string }): Promise<string>; invalidate?(): void } | null; fetch?: typeof fetch; batch?: number; flushMs?: number; maxQueue?: number; log?: { warn?(m: string): void }; audience?: string }): { record(rec: CallRecord): boolean; flush(): Promise<void>; close(): Promise<void>; stats(): { sent: number; dropped: number; rejected: number; retried: number; queued: number } };
+export const OPERATIONS: Operation[];
+export const PLACES: Place[];
+export const OUTCOMES: string[];