openvibers/openvibe.sdk Commits

openvibe-sdk 0.164.0: ai-meter (#249)

OP OpenVibers committed 12 h ago · 8fb7b26c6e · 14 files, +284 −11

* openvibe-sdk 0.164.0: ai-meter, every product meters its AI calls the same way

* ai-meter: the usage example names no package a browser bundle would follow

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 4d05b06..d14d29c 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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-12
From reading 24 open agent projects (one-system-v2.md). Three new modules, all pure functions:
ddiff --git a/README.md b/README.md
iindex 0fa522b..e6a61a9 100644
---- a/README.md
++++ b/README.md
@@@ -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/tags
After 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)) |
ddiff --git a/STATUS.json b/STATUS.json
iindex 847ec1d..3136695 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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",
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex cef7dab..371070b 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -1,4 +1,4 @@
--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.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,
ddiff --git a/docs/api-surface.json b/docs/api-surface.json
iindex b13e831..f50cc6f 100644
---- a/docs/api-surface.json
++++ b/docs/api-surface.json
@@@ -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"
ddiff --git a/esm/ai-meter.mjs b/esm/ai-meter.mjs
nnew file mode 100644
iindex 0000000..afda6e3
---- /dev/null
++++ b/esm/ai-meter.mjs
@@@ -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;
ddiff --git a/package-lock.json b/package-lock.json
iindex ed6c254..c6cba5a 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
ddiff --git a/package.json b/package.json
iindex 7975f55..e0b0df3 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/scripts/esm.js b/scripts/esm.js
iindex 722f932..1e16fa1 100644
---- a/scripts/esm.js
++++ b/scripts/esm.js
@@@ -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',
ddiff --git a/src/ai-meter.js b/src/ai-meter.js
nnew file mode 100644
iindex 0000000..375830a
---- /dev/null
++++ b/src/ai-meter.js
@@@ -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 };
ddiff --git a/src/core/index.js b/src/core/index.js
iindex bd32a34..78029f9 100644
---- a/src/core/index.js
++++ b/src/core/index.js
@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.163.0';
++const SDK_VERSION = '0.164.0';
module.exports = {
createClient,
ddiff --git a/test/ai-meter.test.js b/test/ai-meter.test.js
nnew file mode 100644
iindex 0000000..6816739
---- /dev/null
++++ b/test/ai-meter.test.js
@@@ -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(); }
++ }],
++]);
ddiff --git a/test/pack.test.js b/test/pack.test.js
iindex 6e5d265..6756cfa 100644
---- a/test/pack.test.js
++++ b/test/pack.test.js
@@@ -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 });
ddiff --git a/types/ai-meter.d.ts b/types/ai-meter.d.ts
nnew file mode 100644
iindex 0000000..7ec3c0a
---- /dev/null
++++ b/types/ai-meter.d.ts
@@@ -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[];