openvibers/openvibe.examples Commits
An AI chat example: the official openai npm client on ai.openvibe.services/v1 with a Network app token, plain and streamed, and on the project's own provider by secret name. (#28)
Changes
@@@ -35,6 +35,7 @@ Each example is small, runs on its own and proves one thing with a smoke test.| [oauth-app](examples/oauth-app) | Authorization code + PKCE for a confidential app, exchange on the server, offline app-token verification, session hygiene | `openvibe-sdk/auth` || [chat-bot](examples/chat-bot) | A bot on Chat's WebSocket protocol that stays in its one configured room and always identifies as a bot | Chat `/ws/chat` protocol (no SDK client: Chat is first-party) || [mod-manifest](examples/mod-manifest) | A `mods.mod-manifest@1` validated against the schema, the capability catalog and compatibility ranges | `openvibe-contracts`, `openvibe-sdk/registry` |++| [ai-chat](examples/ai-chat) | The official `openai` npm client on `https://ai.openvibe.services/v1` with a Network app token: plain and streamed Chat Completions, `x_openvibe` run info, the project's own provider by secret name | `openai` (`baseURL`, `apiKey` as a token function), `openvibe-sdk/auth` |Each example folder has a README, a `.env.example`, its own `package.json` and a`test/smoke.test.js`. Configuration is environment variables only (`OV_CLIENT_ID`,@@@ -122,6 +123,7 @@ at the root one: `node --env-file=../../.env upload.js ./logo.png`.| [browser-app](examples/browser-app) | public, redirect `http://localhost:3001/callback` | `media.object.read` | `OV_CLIENT_ID`, `OV_AUDIENCE`, `OV_SCOPE` || [chat-bot](examples/chat-bot) | none: a Live API token (`hbt_…`) of a dedicated bot account | Live token scope `chat` | `OV_CHAT_TOKEN`, one of `OV_CHAT_CHANNEL_USER_ID` / `OV_CHAT_STREAM_ID` || [mod-manifest](examples/mod-manifest) | none | none | `OV_NETWORK_URL` for `--registry` |++| [ai-chat](examples/ai-chat) | confidential | `ai.app.run` | `OV_CLIENT_ID`, `OV_CLIENT_SECRET`, optional `OV_AI_URL`; own provider: `OV_PROVIDER_SECRET` (a secret's name), `OV_PROVIDER_BASE_URL`, `OV_PROVIDER_MODEL` |Rotate a secret in the portal or with `projects.credentials.rotate()`, revoke the app with`projects.apps.revoke()`. Issued tokens end within their 5-minute lifetime.@@@ -264,7 +266,7 @@ Repository checks (`test/`):- `public-surface.test.js` fails on the loopback shared-key header, on the platform's internalroute prefix, on hard-coded loopback service ports outside the local mocks, and on any secretvalue in a `.env.example`.--- `structure.test.js` checks the nine examples are complete and copyable: README with "What it++- `structure.test.js` checks the ten examples are complete and copyable: README with "What itproves" and "Run it against the real platform", a `.env.example` that names every variable thecode reads, MIT license, `openvibe-sdk` pinned to one tag (the root package.json's), no `file:` links; and that`npm run e2e` is outside CI and `npm test` and refuses to start without its variables; and that@@@ -288,7 +290,7 @@ test/public-surface.test.js, test/structure.test.js## Owns- the nine charter examples: vanilla browser app, Node server app, webhook consumer, event-- subscriber, Media uploader, Chat bot, tool/job example, mod manifest example, OAuth app example++ subscriber, Media uploader, Chat bot, tool/job example, mod manifest example, OAuth app example; plus the AI chat example (the openai client on AI's OpenAI-compatible endpoint)- the on-demand end-to-end run (`npm run e2e`) and the sandbox-app setup script- the developer path check (`npm run developer-path`, and `developer-path:mock` in CI)@@@ -9,7 +9,7 @@"deployed": false,"deployment": "none: a repository with CI, not a runtime and not a published package","features": [-- "nine copyable examples: browser app, Node server app, Media uploader, event subscriber, webhook consumer, tool job, OAuth app, chat bot, mod manifest",++ "ten copyable examples: browser app, Node server app, Media uploader, event subscriber, webhook consumer, tool job, OAuth app, chat bot, mod manifest, AI chat (the official openai client on ai.openvibe.services/v1, plain, streamed and the project's own provider)","smoke tests against openvibe-sdk/testing's mock platform, offline","create-openvibe-app scaffolder","npm run e2e against the real platform (on demand)",nnew file mode 100644@@@ -0,0 +1,10 @@++# A CONFIDENTIAL developer app holding ai.app.run (audience openvibe.ai; a sandbox app has it by default).++OV_NETWORK_URL=https://openvibe.network++OV_CLIENT_ID=++OV_CLIENT_SECRET=++OV_AI_URL=https://ai.openvibe.services/v1++# Optional: your project's own OpenAI-format provider instead of AI's router (ADR-056).++# OV_PROVIDER_SECRET is the NAME of a project secret granted to ai for that origin, never the key itself.++# OV_PROVIDER_BASE_URL=https://api.openai.com/v1++# OV_PROVIDER_MODEL=gpt-5-mini++# OV_PROVIDER_SECRET=openai-keynnew file mode 100644@@@ -0,0 +1,69 @@++# AI chat++++Use OpenVibe.AI from the official [`openai`](https://www.npmjs.com/package/openai) npm client: point++its `baseURL` at `https://ai.openvibe.services/v1` and give it a Network app token. Plain answers,++streamed answers, and your project's own provider.++++```bash++node --env-file=.env chat.js "Say hi in five words"++# Hi there, nice to meet!++# run run_01K… on local, $0++++node --env-file=.env chat.js --stream "Count to five"++# One, two, three, four, five.++```++++## What it proves++++- An app token for `openvibe.ai` scoped to `ai.app.run` (in the sandbox allowance: a sandbox app++ holds it without staff). Network tokens last five minutes, so `apiKey` is a **function**++ (`tokens.getToken`): the client calls it before each request and `openvibe-sdk/auth` caches and++ refreshes the token.++- `chat.completions.create()` works unchanged. `model` may be anything (`openvibe` here): AI's router++ picks the upstream, the answer's `model` names the one that ran, and `x_openvibe` adds `run_id`,++ `provider`, `fallback_used` and `cost_usd`.++- `stream: true` streams the upstream's deltas as they arrive; with `stream_options.include_usage`++ the stream ends with `usage`, then `x_openvibe`, then `[DONE]`.++- **Your project's own provider** (ADR-056): set `OV_PROVIDER_SECRET` and the request carries++ `x_openvibe.provider: { base_url, model, secret }`. `secret` is the **name** of a project secret++ (OpenVibe.Services → project → Secrets) granted to `ai` for that base URL's origin; Network++ reveals the key to AI for that one run, and the key never passes through this code. Your project++ pays its provider directly.++- Errors keep OpenAI's shape (`{ error: { message, type, code } }`), so the client's `APIError` has++ `status` and `code` (for example `401 token.missing`, `403 project_secret.refused`).++++## Files++++| File | What |++|---|---|++| `chat.js` | `loadConfig()`, `createAiClient()` (an `OpenAI` instance), `ask()`, `askStreamed()`, CLI |++| `test/smoke.test.js` | plain, streamed, own provider and a refusal, against a stand-in for Network and AI |++++## Run the smoke test++++```bash++npm test++```++++The test hands the `openai` client and the token client a `fetch` that answers like Network's++`/oauth/token` and AI's `/v1/chat/completions` (`server/api/openai.js` on OpenVibe.AI's main):++a `chat.completion`, a chunk stream, OpenAI's error object. Nothing reaches the network.++++## Run it against the real platform++++1. Create a project and a **confidential** sandbox app ([walkthrough](../../README.md#end-to-end-walkthrough));++ `ai.app.run` is in the sandbox allowance.++2. `cp .env.example .env`, fill in `OV_CLIENT_ID` and `OV_CLIENT_SECRET`.++3. `node --env-file=.env chat.js "Say hi"` or `node --env-file=.env chat.js --stream "Count to five"`.++++Production app runs on the router are limited to free and local capacity (a small local model without++tools). For a bigger model or tool calls, add your provider's key as a project secret, grant it to++`ai` for the provider's origin, and set `OV_PROVIDER_SECRET` (plus `OV_PROVIDER_BASE_URL` and++`OV_PROVIDER_MODEL`). A secret that is missing or not granted answers `403 project_secret.refused`;++a base URL that is not `https` answers `400`.++++Without a token the endpoint answers (production, 2026-10-22):++++```json++{"error":{"message":"a service token (audience openvibe.ai) is required","type":"authentication_error","code":"token.missing","param":null}}++```nnew file mode 100644@@@ -0,0 +1,100 @@++#!/usr/bin/env node++'use strict';++/**++ * AI chat: the official `openai` npm client, pointed at OpenVibe.AI's OpenAI-compatible endpoint.++ *++ * node --env-file=.env chat.js "Say hi in five words" # one answer++ * node --env-file=.env chat.js --stream "Count to five" # streamed, deltas as they arrive++ *++ * 1. App token for openvibe.ai (client credentials, scope ai.app.run). Network tokens last five++ * minutes, so the client gets a token function: openvibe-sdk caches it and refreshes it in time.++ * 2. new OpenAI({ baseURL: 'https://ai.openvibe.services/v1', apiKey: <that function> }).++ * 3. chat.completions.create(): `model` may be anything, AI's router picks the upstream; the answer++ * names the model that ran and adds x_openvibe (run_id, provider, cost_usd).++ * 4. With OV_PROVIDER_* set, x_openvibe.provider sends the run to the project's own OpenAI-format API++ * (ADR-056): `secret` is the NAME of a project secret granted to ai; Network reveals the key for++ * that one run, and this code never sees it.++ */++const OpenAI = require('openai');++const { createServiceTokenClient } = require('openvibe-sdk/auth');++++function loadConfig(env = process.env) {++ const missing = ['OV_CLIENT_ID', 'OV_CLIENT_SECRET'].filter((k) => !env[k]);++ if (missing.length) {++ throw Object.assign(new Error(`missing environment variables: ${missing.join(', ')} (see .env.example)`), { code: 'config.missing' });++ }++ const provider = env.OV_PROVIDER_SECRET++ ? { base_url: env.OV_PROVIDER_BASE_URL || 'https://api.openai.com/v1', model: env.OV_PROVIDER_MODEL || 'gpt-5-mini', secret: env.OV_PROVIDER_SECRET }++ : null;++ return {++ network: env.OV_NETWORK_URL || 'https://openvibe.network',++ clientId: env.OV_CLIENT_ID,++ clientSecret: env.OV_CLIENT_SECRET,++ aiUrl: env.OV_AI_URL || 'https://ai.openvibe.services/v1',++ provider,++ };++}++++function createAiClient(config, { fetch } = {}) {++ const tokens = createServiceTokenClient({++ network: config.network, clientId: config.clientId, clientSecret: config.clientSecret, fetch,++ audience: 'openvibe.ai', scope: 'ai.app.run',++ });++ return new OpenAI({++ baseURL: config.aiUrl,++ apiKey: () => tokens.getToken(), // called before each request; cached until a minute before expiry++ ...(fetch ? { fetch } : {}),++ });++}++++/** The request body: the project's own provider rides along as x_openvibe.provider (an extra field). */++function body(config, prompt, extra = {}) {++ return {++ model: 'openvibe',++ messages: [{ role: 'user', content: prompt }],++ ...extra,++ ...(config.provider ? { x_openvibe: { provider: config.provider } } : {}),++ };++}++++async function ask(openai, config, prompt) {++ const answer = await openai.chat.completions.create(body(config, prompt));++ return { text: answer.choices[0].message.content, model: answer.model, openvibe: answer.x_openvibe, usage: answer.usage };++}++++async function askStreamed(openai, config, prompt, onText = (t) => process.stdout.write(t)) {++ const stream = await openai.chat.completions.create(body(config, prompt, { stream: true, stream_options: { include_usage: true } }));++ let text = '';++ let usage = null;++ let openvibe = null;++ for await (const chunk of stream) {++ const piece = chunk.choices && chunk.choices[0] && chunk.choices[0].delta && chunk.choices[0].delta.content;++ if (piece) { text += piece; onText(piece); }++ if (chunk.usage) usage = chunk.usage;++ if (chunk.x_openvibe) openvibe = chunk.x_openvibe;++ }++ return { text, usage, openvibe };++}++++async function main(argv = process.argv.slice(2)) {++ const stream = argv[0] === '--stream';++ const prompt = (stream ? argv.slice(1) : argv).join(' ');++ if (!prompt) { console.error('usage: node chat.js [--stream] <prompt>'); return 2; }++ try {++ const config = loadConfig();++ const openai = createAiClient(config);++ const out = stream ? await askStreamed(openai, config, prompt) : await ask(openai, config, prompt);++ if (!stream) console.log(out.text);++ else process.stdout.write('\n');++ if (out.openvibe) console.error(`run ${out.openvibe.run_id} on ${out.openvibe.provider}, $${out.openvibe.cost_usd}`);++ return 0;++ } catch (err) {++ // The endpoint answers OpenAI's error shape, so the openai client's APIError carries it.++ console.error(`chat failed: ${err.status ? `${err.status} ${err.code || ''} ` : ''}${err.message}`);++ return 1;++ }++}++++if (require.main === module) main().then((code) => { process.exitCode = code; });++++module.exports = { loadConfig, createAiClient, ask, askStreamed, main };nnew file mode 100644@@@ -0,0 +1,19 @@++{++ "name": "openvibe-example-ai-chat",++ "version": "0.1.0",++ "private": true,++ "description": "AI chat: the official openai client pointed at ai.openvibe.services/v1, plain, streamed and on the project's own provider.",++ "license": "MIT",++ "main": "chat.js",++ "scripts": {++ "start": "node --env-file-if-exists=.env chat.js",++ "test": "node test/smoke.test.js"++ },++ "engines": {++ "node": ">=22"++ },++ "dependencies": {++ "openai": "7.42.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.60.0"++ }++}nnew file mode 100644@@@ -0,0 +1,92 @@++'use strict';++/**++ * Smoke test: the openai client against a stand-in for Network's /oauth/token and AI's++ * /v1/chat/completions, answering in the shapes server/api/openai.js answers (chat.completion with++ * x_openvibe, the chunk stream ending in usage, x_openvibe and [DONE], OpenAI's error object). No network.++ */++const assert = require('node:assert/strict');++const { loadConfig, createAiClient, ask, askStreamed } = require('../chat');++++globalThis.fetch = () => { throw new Error('network access in a smoke test'); };++++const calls = [];++let tokenN = 0;++const json = (status, o) => new Response(JSON.stringify(o), { status, headers: { 'content-type': 'application/json' } });++const xo = { run_id: 'run_01TEST', provider: 'local', fallback_used: false, cost_usd: 0 };++++async function fakeFetch(url, init = {}) {++ const u = String(url);++ const headers = new Headers(init.headers);++ if (u === 'https://openvibe.network/oauth/token') {++ const form = new URLSearchParams(String(init.body));++ calls.push({ kind: 'token', audience: form.get('audience'), scope: form.get('scope'), grant: form.get('grant_type') });++ return json(200, { access_token: `tok-${++tokenN}`, token_type: 'Bearer', expires_in: 300, scope: 'ai.app.run' });++ }++ if (u === 'https://ai.openvibe.services/v1/chat/completions') {++ const b = JSON.parse(init.body);++ calls.push({ kind: 'chat', auth: headers.get('authorization'), body: b });++ if (headers.get('authorization') !== 'Bearer tok-1') {++ // The answer production gives today without a token (checked 2026-10-22).++ return json(401, { error: { message: 'a service token (audience openvibe.ai) is required', type: 'authentication_error', code: 'token.missing', param: null } });++ }++ if (b.x_openvibe && b.x_openvibe.provider && b.x_openvibe.provider.secret === 'not-granted') {++ return json(403, { error: { message: 'project secret refused', type: 'authentication_error', code: 'project_secret.refused', param: null } });++ }++ const model = b.x_openvibe && b.x_openvibe.provider ? b.x_openvibe.provider.model : 'qwen-local';++ if (!b.stream) {++ return json(200, { id: 'chatcmpl-run_01TEST', object: 'chat.completion', created: 1, model,++ choices: [{ index: 0, message: { role: 'assistant', content: 'Hello from OpenVibe' }, finish_reason: 'stop' }],++ usage: { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 }, x_openvibe: xo });++ }++ const chunk = (delta, finish = null) => ({ id: 'chatcmpl-run_01TEST', object: 'chat.completion.chunk', created: 1, model, choices: [{ index: 0, delta, finish_reason: finish }] });++ const events = [chunk({ role: 'assistant', content: 'one ' }), chunk({ content: 'two ' }), chunk({ content: 'three' }), chunk({}, 'stop'),++ { id: 'chatcmpl-run_01TEST', object: 'chat.completion.chunk', created: 1, model, choices: [], usage: { prompt_tokens: 4, completion_tokens: 3, total_tokens: 7 } },++ { id: 'chatcmpl-run_01TEST', object: 'chat.completion.chunk', created: 1, model, choices: [], x_openvibe: xo }];++ const sse = events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join('') + 'data: [DONE]\n\n';++ return new Response(sse, { status: 200, headers: { 'content-type': 'text/event-stream; charset=utf-8', 'x-openvibe-run-id': 'run_01TEST' } });++ }++ throw new Error(`unexpected request ${u}`);++}++++(async () => {++ assert.throws(() => loadConfig({}), /missing environment variables: OV_CLIENT_ID, OV_CLIENT_SECRET/);++ const env = { OV_CLIENT_ID: 'app_01TEST', OV_CLIENT_SECRET: 'test-secret-not-real' };++ const config = loadConfig(env);++ assert.equal(config.provider, null);++ const openai = createAiClient(config, { fetch: fakeFetch });++++ // 1. Plain: one token for openvibe.ai (ai.app.run), Bearer on the request, x_openvibe in the answer.++ const plain = await ask(openai, config, 'Say hi');++ assert.equal(plain.text, 'Hello from OpenVibe');++ assert.equal(plain.model, 'qwen-local');++ assert.equal(plain.openvibe.run_id, 'run_01TEST');++ assert.deepEqual(calls[0], { kind: 'token', audience: 'openvibe.ai', scope: 'ai.app.run', grant: 'client_credentials' });++ assert.equal(calls[1].auth, 'Bearer tok-1');++ assert.equal(calls[1].body.model, 'openvibe');++ assert.equal(calls[1].body.x_openvibe, undefined, 'no provider: the router picks');++++ // 2. Streamed: deltas in order, then usage and x_openvibe; the token is reused from the cache.++ const pieces = [];++ const streamed = await askStreamed(openai, config, 'Count', (t) => pieces.push(t));++ assert.deepEqual(pieces, ['one ', 'two ', 'three']);++ assert.equal(streamed.text, 'one two three');++ assert.equal(streamed.usage.total_tokens, 7);++ assert.equal(streamed.openvibe.run_id, 'run_01TEST');++ assert.equal(calls.at(-1).body.stream, true);++ assert.deepEqual(calls.at(-1).body.stream_options, { include_usage: true });++ assert.equal(calls.filter((c) => c.kind === 'token').length, 1, 'one token for both calls');++++ // 3. The project's own provider: x_openvibe.provider carries the secret's NAME, never a key.++ const own = loadConfig({ ...env, OV_PROVIDER_SECRET: 'openai-key', OV_PROVIDER_MODEL: 'gpt-5-mini' });++ tokenN = 0;++ const viaOwn = await ask(createAiClient(own, { fetch: fakeFetch }), own, 'Hi');++ assert.equal(viaOwn.model, 'gpt-5-mini');++ assert.deepEqual(calls.at(-1).body.x_openvibe, { provider: { base_url: 'https://api.openai.com/v1', model: 'gpt-5-mini', secret: 'openai-key' } });++++ // 4. Errors keep OpenAI's shape, so the client's APIError has status and code.++ const refused = loadConfig({ ...env, OV_PROVIDER_SECRET: 'not-granted' });++ tokenN = 0;++ await assert.rejects(ask(createAiClient(refused, { fetch: fakeFetch }), refused, 'Hi'), (err) => err.status === 403 && err.code === 'project_secret.refused');++++ console.log('ai-chat: ok');++})().catch((err) => { console.error(err); process.exit(1); });@@@ -21,6 +21,18 @@"node": ">=22"}},++ "examples/ai-chat": {++ "name": "openvibe-example-ai-chat",++ "version": "0.1.0",++ "license": "MIT",++ "dependencies": {++ "openai": "7.42.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.60.0"++ },++ "engines": {++ "node": ">=22"++ }++ },"examples/browser-app": {"name": "openvibe-example-browser-app","version": "0.2.0",@@@ -190,6 +202,47 @@"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==","license": "MIT"},++ "node_modules/openai": {++ "version": "7.42.0",++ "resolved": "https://registry.npmjs.org/openai/-/openai-7.42.0.tgz",++ "integrity": "sha512-RziM2bxp1k24mTLtunije+OOCGRC+qtK0YNYPWkrTdKJLJ65WYW+rPYxlLbnBzU4WwINFCFa0OjCgbsX+fif/w==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=22.0.0"++ },++ "peerDependencies": {++ "@aws-sdk/credential-provider-node": ">=3.972.0 <4",++ "@smithy/hash-node": ">=4.3.0 <5",++ "@smithy/signature-v4": ">=5.4.0 <6",++ "google-auth-library": ">=9.15.1 <11",++ "undici": ">=5 <9",++ "ws": "^8.21.0",++ "zod": "^3.25 || ^4.0"++ },++ "peerDependenciesMeta": {++ "@aws-sdk/credential-provider-node": {++ "optional": true++ },++ "@smithy/hash-node": {++ "optional": true++ },++ "@smithy/signature-v4": {++ "optional": true++ },++ "google-auth-library": {++ "optional": true++ },++ "undici": {++ "optional": true++ },++ "ws": {++ "optional": true++ },++ "zod": {++ "optional": true++ }++ }++ },"node_modules/openvibe-contracts": {"version": "0.209.0","resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.209.0",@@@ -206,6 +259,10 @@"node": ">=20"}},++ "node_modules/openvibe-example-ai-chat": {++ "resolved": "examples/ai-chat",++ "link": true++ },"node_modules/openvibe-example-browser-app": {"resolved": "examples/browser-app","link": true@@@ -420,7 +477,7 @@"version": "8.21.3","resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz","integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",-- "dev": true,++ "devOptional": true,"license": "MIT","engines": {"node": ">=10.0.0"@@@ -11,13 +11,13 @@ const fs = require('node:fs');const path = require('node:path');const ROOT = path.join(__dirname, '..');--const EXPECTED = ['browser-app', 'chat-bot', 'event-subscriber', 'media-uploader', 'mod-manifest', 'node-server-app', 'oauth-app', 'tool-job', 'webhook-consumer'];++const EXPECTED = ['ai-chat', 'browser-app', 'chat-bot', 'event-subscriber', 'media-uploader', 'mod-manifest', 'node-server-app', 'oauth-app', 'tool-job', 'webhook-consumer'];// The pin every example must share: the repository's own (the pin bot moves them all at once).const ROOT_PKG = require(path.join(ROOT, 'package.json'));const SDK = (ROOT_PKG.dependencies || {})['openvibe-sdk'] || (ROOT_PKG.devDependencies || {})['openvibe-sdk'];const dirs = fs.readdirSync(path.join(ROOT, 'examples')).filter((d) => fs.statSync(path.join(ROOT, 'examples', d)).isDirectory()).sort();--assert.deepEqual(dirs, EXPECTED, 'the nine charter examples');++assert.deepEqual(dirs, EXPECTED, 'the nine charter examples and ai-chat');function sources(dir) {const out = [];
@@ -35,6 +35,7 @@ Each example is small, runs on its own and proves one thing with a smoke test.
@@ -35,6 +35,7 @@ Each example is small, runs on its own and proves one thing with a smoke test.
| [oauth-app](examples/oauth-app) | Authorization code + PKCE for a confidential app, exchange on the server, offline app-token verification, session hygiene | `openvibe-sdk/auth` |
| [oauth-app](examples/oauth-app) | Authorization code + PKCE for a confidential app, exchange on the server, offline app-token verification, session hygiene | `openvibe-sdk/auth` |
| [chat-bot](examples/chat-bot) | A bot on Chat's WebSocket protocol that stays in its one configured room and always identifies as a bot | Chat `/ws/chat` protocol (no SDK client: Chat is first-party) |
| [chat-bot](examples/chat-bot) | A bot on Chat's WebSocket protocol that stays in its one configured room and always identifies as a bot | Chat `/ws/chat` protocol (no SDK client: Chat is first-party) |
| [mod-manifest](examples/mod-manifest) | A `mods.mod-manifest@1` validated against the schema, the capability catalog and compatibility ranges | `openvibe-contracts`, `openvibe-sdk/registry` |
| [mod-manifest](examples/mod-manifest) | A `mods.mod-manifest@1` validated against the schema, the capability catalog and compatibility ranges | `openvibe-contracts`, `openvibe-sdk/registry` |
+| [ai-chat](examples/ai-chat) | The official `openai` npm client on `https://ai.openvibe.services/v1` with a Network app token: plain and streamed Chat Completions, `x_openvibe` run info, the project's own provider by secret name | `openai` (`baseURL`, `apiKey` as a token function), `openvibe-sdk/auth` |
Each example folder has a README, a `.env.example`, its own `package.json` and a
Each example folder has a README, a `.env.example`, its own `package.json` and a
`test/smoke.test.js`. Configuration is environment variables only (`OV_CLIENT_ID`,
`test/smoke.test.js`. Configuration is environment variables only (`OV_CLIENT_ID`,
@@ -122,6 +123,7 @@ at the root one: `node --env-file=../../.env upload.js ./logo.png`.
@@ -122,6 +123,7 @@ at the root one: `node --env-file=../../.env upload.js ./logo.png`.
| [browser-app](examples/browser-app) | public, redirect `http://localhost:3001/callback` | `media.object.read` | `OV_CLIENT_ID`, `OV_AUDIENCE`, `OV_SCOPE` |
| [browser-app](examples/browser-app) | public, redirect `http://localhost:3001/callback` | `media.object.read` | `OV_CLIENT_ID`, `OV_AUDIENCE`, `OV_SCOPE` |
| [chat-bot](examples/chat-bot) | none: a Live API token (`hbt_…`) of a dedicated bot account | Live token scope `chat` | `OV_CHAT_TOKEN`, one of `OV_CHAT_CHANNEL_USER_ID` / `OV_CHAT_STREAM_ID` |
| [chat-bot](examples/chat-bot) | none: a Live API token (`hbt_…`) of a dedicated bot account | Live token scope `chat` | `OV_CHAT_TOKEN`, one of `OV_CHAT_CHANNEL_USER_ID` / `OV_CHAT_STREAM_ID` |
| [mod-manifest](examples/mod-manifest) | none | none | `OV_NETWORK_URL` for `--registry` |
| [mod-manifest](examples/mod-manifest) | none | none | `OV_NETWORK_URL` for `--registry` |
+| [ai-chat](examples/ai-chat) | confidential | `ai.app.run` | `OV_CLIENT_ID`, `OV_CLIENT_SECRET`, optional `OV_AI_URL`; own provider: `OV_PROVIDER_SECRET` (a secret's name), `OV_PROVIDER_BASE_URL`, `OV_PROVIDER_MODEL` |
Rotate a secret in the portal or with `projects.credentials.rotate()`, revoke the app with
Rotate a secret in the portal or with `projects.credentials.rotate()`, revoke the app with
`projects.apps.revoke()`. Issued tokens end within their 5-minute lifetime.
`projects.apps.revoke()`. Issued tokens end within their 5-minute lifetime.
@@ -264,7 +266,7 @@ Repository checks (`test/`):
@@ -264,7 +266,7 @@ Repository checks (`test/`):
- `public-surface.test.js` fails on the loopback shared-key header, on the platform's internal
- `public-surface.test.js` fails on the loopback shared-key header, on the platform's internal
route prefix, on hard-coded loopback service ports outside the local mocks, and on any secret
route prefix, on hard-coded loopback service ports outside the local mocks, and on any secret
value in a `.env.example`.
value in a `.env.example`.
-- `structure.test.js` checks the nine examples are complete and copyable: README with "What it
+- `structure.test.js` checks the ten examples are complete and copyable: README with "What it
proves" and "Run it against the real platform", a `.env.example` that names every variable the
proves" and "Run it against the real platform", a `.env.example` that names every variable the
code reads, MIT license, `openvibe-sdk` pinned to one tag (the root package.json's), no `file:` links; and that
code reads, MIT license, `openvibe-sdk` pinned to one tag (the root package.json's), no `file:` links; and that
`npm run e2e` is outside CI and `npm test` and refuses to start without its variables; and that
`npm run e2e` is outside CI and `npm test` and refuses to start without its variables; and that
@@ -288,7 +290,7 @@ test/public-surface.test.js, test/structure.test.js
@@ -288,7 +290,7 @@ test/public-surface.test.js, test/structure.test.js
## Owns
## Owns
- the nine charter examples: vanilla browser app, Node server app, webhook consumer, event
- the nine charter examples: vanilla browser app, Node server app, webhook consumer, event
- subscriber, Media uploader, Chat bot, tool/job example, mod manifest example, OAuth app example
+ subscriber, Media uploader, Chat bot, tool/job example, mod manifest example, OAuth app example; plus the AI chat example (the openai client on AI's OpenAI-compatible endpoint)
- the on-demand end-to-end run (`npm run e2e`) and the sandbox-app setup script
- the on-demand end-to-end run (`npm run e2e`) and the sandbox-app setup script
- the developer path check (`npm run developer-path`, and `developer-path:mock` in CI)
- the developer path check (`npm run developer-path`, and `developer-path:mock` in CI)
@@ -9,7 +9,7 @@
@@ -9,7 +9,7 @@
"deployed": false,
"deployed": false,
"deployment": "none: a repository with CI, not a runtime and not a published package",
"deployment": "none: a repository with CI, not a runtime and not a published package",
"features": [
"features": [
- "nine copyable examples: browser app, Node server app, Media uploader, event subscriber, webhook consumer, tool job, OAuth app, chat bot, mod manifest",
+ "ten copyable examples: browser app, Node server app, Media uploader, event subscriber, webhook consumer, tool job, OAuth app, chat bot, mod manifest, AI chat (the official openai client on ai.openvibe.services/v1, plain, streamed and the project's own provider)",
"smoke tests against openvibe-sdk/testing's mock platform, offline",
"smoke tests against openvibe-sdk/testing's mock platform, offline",
"create-openvibe-app scaffolder",
"create-openvibe-app scaffolder",
"npm run e2e against the real platform (on demand)",
"npm run e2e against the real platform (on demand)",
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+# A CONFIDENTIAL developer app holding ai.app.run (audience openvibe.ai; a sandbox app has it by default).
+OV_NETWORK_URL=https://openvibe.network
+OV_CLIENT_ID=
+OV_CLIENT_SECRET=
+OV_AI_URL=https://ai.openvibe.services/v1
+# Optional: your project's own OpenAI-format provider instead of AI's router (ADR-056).
+# OV_PROVIDER_SECRET is the NAME of a project secret granted to ai for that origin, never the key itself.
+# OV_PROVIDER_BASE_URL=https://api.openai.com/v1
+# OV_PROVIDER_MODEL=gpt-5-mini
+# OV_PROVIDER_SECRET=openai-key
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -0,0 +1,69 @@
+# AI chat
+
+Use OpenVibe.AI from the official [`openai`](https://www.npmjs.com/package/openai) npm client: point
+its `baseURL` at `https://ai.openvibe.services/v1` and give it a Network app token. Plain answers,
+streamed answers, and your project's own provider.
+
+```bash
+node --env-file=.env chat.js "Say hi in five words"
+# Hi there, nice to meet!
+# run run_01K… on local, $0
+
+node --env-file=.env chat.js --stream "Count to five"
+# One, two, three, four, five.
+```
+
+## What it proves
+
+- An app token for `openvibe.ai` scoped to `ai.app.run` (in the sandbox allowance: a sandbox app
+ holds it without staff). Network tokens last five minutes, so `apiKey` is a **function**
+ (`tokens.getToken`): the client calls it before each request and `openvibe-sdk/auth` caches and
+ refreshes the token.
+- `chat.completions.create()` works unchanged. `model` may be anything (`openvibe` here): AI's router
+ picks the upstream, the answer's `model` names the one that ran, and `x_openvibe` adds `run_id`,
+ `provider`, `fallback_used` and `cost_usd`.
+- `stream: true` streams the upstream's deltas as they arrive; with `stream_options.include_usage`
+ the stream ends with `usage`, then `x_openvibe`, then `[DONE]`.
+- **Your project's own provider** (ADR-056): set `OV_PROVIDER_SECRET` and the request carries
+ `x_openvibe.provider: { base_url, model, secret }`. `secret` is the **name** of a project secret
+ (OpenVibe.Services → project → Secrets) granted to `ai` for that base URL's origin; Network
+ reveals the key to AI for that one run, and the key never passes through this code. Your project
+ pays its provider directly.
+- Errors keep OpenAI's shape (`{ error: { message, type, code } }`), so the client's `APIError` has
+ `status` and `code` (for example `401 token.missing`, `403 project_secret.refused`).
+
+## Files
+
+| File | What |
+|---|---|
+| `chat.js` | `loadConfig()`, `createAiClient()` (an `OpenAI` instance), `ask()`, `askStreamed()`, CLI |
+| `test/smoke.test.js` | plain, streamed, own provider and a refusal, against a stand-in for Network and AI |
+
+## Run the smoke test
+
+```bash
+npm test
+```
+
+The test hands the `openai` client and the token client a `fetch` that answers like Network's
+`/oauth/token` and AI's `/v1/chat/completions` (`server/api/openai.js` on OpenVibe.AI's main):
+a `chat.completion`, a chunk stream, OpenAI's error object. Nothing reaches the network.
+
+## Run it against the real platform
+
+1. Create a project and a **confidential** sandbox app ([walkthrough](../../README.md#end-to-end-walkthrough));
+ `ai.app.run` is in the sandbox allowance.
+2. `cp .env.example .env`, fill in `OV_CLIENT_ID` and `OV_CLIENT_SECRET`.
+3. `node --env-file=.env chat.js "Say hi"` or `node --env-file=.env chat.js --stream "Count to five"`.
+
+Production app runs on the router are limited to free and local capacity (a small local model without
+tools). For a bigger model or tool calls, add your provider's key as a project secret, grant it to
+`ai` for the provider's origin, and set `OV_PROVIDER_SECRET` (plus `OV_PROVIDER_BASE_URL` and
+`OV_PROVIDER_MODEL`). A secret that is missing or not granted answers `403 project_secret.refused`;
+a base URL that is not `https` answers `400`.
+
+Without a token the endpoint answers (production, 2026-10-22):
+
+```json
+{"error":{"message":"a service token (audience openvibe.ai) is required","type":"authentication_error","code":"token.missing","param":null}}
+```
new file mode 100644
new file mode 100644
@@ -0,0 +1,100 @@
@@ -0,0 +1,100 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * AI chat: the official `openai` npm client, pointed at OpenVibe.AI's OpenAI-compatible endpoint.
+ *
+ * node --env-file=.env chat.js "Say hi in five words" # one answer
+ * node --env-file=.env chat.js --stream "Count to five" # streamed, deltas as they arrive
+ *
+ * 1. App token for openvibe.ai (client credentials, scope ai.app.run). Network tokens last five
+ * minutes, so the client gets a token function: openvibe-sdk caches it and refreshes it in time.
+ * 2. new OpenAI({ baseURL: 'https://ai.openvibe.services/v1', apiKey: <that function> }).
+ * 3. chat.completions.create(): `model` may be anything, AI's router picks the upstream; the answer
+ * names the model that ran and adds x_openvibe (run_id, provider, cost_usd).
+ * 4. With OV_PROVIDER_* set, x_openvibe.provider sends the run to the project's own OpenAI-format API
+ * (ADR-056): `secret` is the NAME of a project secret granted to ai; Network reveals the key for
+ * that one run, and this code never sees it.
+ */
+const OpenAI = require('openai');
+const { createServiceTokenClient } = require('openvibe-sdk/auth');
+
+function loadConfig(env = process.env) {
+ const missing = ['OV_CLIENT_ID', 'OV_CLIENT_SECRET'].filter((k) => !env[k]);
+ if (missing.length) {
+ throw Object.assign(new Error(`missing environment variables: ${missing.join(', ')} (see .env.example)`), { code: 'config.missing' });
+ }
+ const provider = env.OV_PROVIDER_SECRET
+ ? { base_url: env.OV_PROVIDER_BASE_URL || 'https://api.openai.com/v1', model: env.OV_PROVIDER_MODEL || 'gpt-5-mini', secret: env.OV_PROVIDER_SECRET }
+ : null;
+ return {
+ network: env.OV_NETWORK_URL || 'https://openvibe.network',
+ clientId: env.OV_CLIENT_ID,
+ clientSecret: env.OV_CLIENT_SECRET,
+ aiUrl: env.OV_AI_URL || 'https://ai.openvibe.services/v1',
+ provider,
+ };
+}
+
+function createAiClient(config, { fetch } = {}) {
+ const tokens = createServiceTokenClient({
+ network: config.network, clientId: config.clientId, clientSecret: config.clientSecret, fetch,
+ audience: 'openvibe.ai', scope: 'ai.app.run',
+ });
+ return new OpenAI({
+ baseURL: config.aiUrl,
+ apiKey: () => tokens.getToken(), // called before each request; cached until a minute before expiry
+ ...(fetch ? { fetch } : {}),
+ });
+}
+
+/** The request body: the project's own provider rides along as x_openvibe.provider (an extra field). */
+function body(config, prompt, extra = {}) {
+ return {
+ model: 'openvibe',
+ messages: [{ role: 'user', content: prompt }],
+ ...extra,
+ ...(config.provider ? { x_openvibe: { provider: config.provider } } : {}),
+ };
+}
+
+async function ask(openai, config, prompt) {
+ const answer = await openai.chat.completions.create(body(config, prompt));
+ return { text: answer.choices[0].message.content, model: answer.model, openvibe: answer.x_openvibe, usage: answer.usage };
+}
+
+async function askStreamed(openai, config, prompt, onText = (t) => process.stdout.write(t)) {
+ const stream = await openai.chat.completions.create(body(config, prompt, { stream: true, stream_options: { include_usage: true } }));
+ let text = '';
+ let usage = null;
+ let openvibe = null;
+ for await (const chunk of stream) {
+ const piece = chunk.choices && chunk.choices[0] && chunk.choices[0].delta && chunk.choices[0].delta.content;
+ if (piece) { text += piece; onText(piece); }
+ if (chunk.usage) usage = chunk.usage;
+ if (chunk.x_openvibe) openvibe = chunk.x_openvibe;
+ }
+ return { text, usage, openvibe };
+}
+
+async function main(argv = process.argv.slice(2)) {
+ const stream = argv[0] === '--stream';
+ const prompt = (stream ? argv.slice(1) : argv).join(' ');
+ if (!prompt) { console.error('usage: node chat.js [--stream] <prompt>'); return 2; }
+ try {
+ const config = loadConfig();
+ const openai = createAiClient(config);
+ const out = stream ? await askStreamed(openai, config, prompt) : await ask(openai, config, prompt);
+ if (!stream) console.log(out.text);
+ else process.stdout.write('\n');
+ if (out.openvibe) console.error(`run ${out.openvibe.run_id} on ${out.openvibe.provider}, $${out.openvibe.cost_usd}`);
+ return 0;
+ } catch (err) {
+ // The endpoint answers OpenAI's error shape, so the openai client's APIError carries it.
+ console.error(`chat failed: ${err.status ? `${err.status} ${err.code || ''} ` : ''}${err.message}`);
+ return 1;
+ }
+}
+
+if (require.main === module) main().then((code) => { process.exitCode = code; });
+
+module.exports = { loadConfig, createAiClient, ask, askStreamed, main };
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+{
+ "name": "openvibe-example-ai-chat",
+ "version": "0.1.0",
+ "private": true,
+ "description": "AI chat: the official openai client pointed at ai.openvibe.services/v1, plain, streamed and on the project's own provider.",
+ "license": "MIT",
+ "main": "chat.js",
+ "scripts": {
+ "start": "node --env-file-if-exists=.env chat.js",
+ "test": "node test/smoke.test.js"
+ },
+ "engines": {
+ "node": ">=22"
+ },
+ "dependencies": {
+ "openai": "7.42.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.60.0"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,92 @@
@@ -0,0 +1,92 @@
+'use strict';
+/**
+ * Smoke test: the openai client against a stand-in for Network's /oauth/token and AI's
+ * /v1/chat/completions, answering in the shapes server/api/openai.js answers (chat.completion with
+ * x_openvibe, the chunk stream ending in usage, x_openvibe and [DONE], OpenAI's error object). No network.
+ */
+const assert = require('node:assert/strict');
+const { loadConfig, createAiClient, ask, askStreamed } = require('../chat');
+
+globalThis.fetch = () => { throw new Error('network access in a smoke test'); };
+
+const calls = [];
+let tokenN = 0;
+const json = (status, o) => new Response(JSON.stringify(o), { status, headers: { 'content-type': 'application/json' } });
+const xo = { run_id: 'run_01TEST', provider: 'local', fallback_used: false, cost_usd: 0 };
+
+async function fakeFetch(url, init = {}) {
+ const u = String(url);
+ const headers = new Headers(init.headers);
+ if (u === 'https://openvibe.network/oauth/token') {
+ const form = new URLSearchParams(String(init.body));
+ calls.push({ kind: 'token', audience: form.get('audience'), scope: form.get('scope'), grant: form.get('grant_type') });
+ return json(200, { access_token: `tok-${++tokenN}`, token_type: 'Bearer', expires_in: 300, scope: 'ai.app.run' });
+ }
+ if (u === 'https://ai.openvibe.services/v1/chat/completions') {
+ const b = JSON.parse(init.body);
+ calls.push({ kind: 'chat', auth: headers.get('authorization'), body: b });
+ if (headers.get('authorization') !== 'Bearer tok-1') {
+ // The answer production gives today without a token (checked 2026-10-22).
+ return json(401, { error: { message: 'a service token (audience openvibe.ai) is required', type: 'authentication_error', code: 'token.missing', param: null } });
+ }
+ if (b.x_openvibe && b.x_openvibe.provider && b.x_openvibe.provider.secret === 'not-granted') {
+ return json(403, { error: { message: 'project secret refused', type: 'authentication_error', code: 'project_secret.refused', param: null } });
+ }
+ const model = b.x_openvibe && b.x_openvibe.provider ? b.x_openvibe.provider.model : 'qwen-local';
+ if (!b.stream) {
+ return json(200, { id: 'chatcmpl-run_01TEST', object: 'chat.completion', created: 1, model,
+ choices: [{ index: 0, message: { role: 'assistant', content: 'Hello from OpenVibe' }, finish_reason: 'stop' }],
+ usage: { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 }, x_openvibe: xo });
+ }
+ const chunk = (delta, finish = null) => ({ id: 'chatcmpl-run_01TEST', object: 'chat.completion.chunk', created: 1, model, choices: [{ index: 0, delta, finish_reason: finish }] });
+ const events = [chunk({ role: 'assistant', content: 'one ' }), chunk({ content: 'two ' }), chunk({ content: 'three' }), chunk({}, 'stop'),
+ { id: 'chatcmpl-run_01TEST', object: 'chat.completion.chunk', created: 1, model, choices: [], usage: { prompt_tokens: 4, completion_tokens: 3, total_tokens: 7 } },
+ { id: 'chatcmpl-run_01TEST', object: 'chat.completion.chunk', created: 1, model, choices: [], x_openvibe: xo }];
+ const sse = events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join('') + 'data: [DONE]\n\n';
+ return new Response(sse, { status: 200, headers: { 'content-type': 'text/event-stream; charset=utf-8', 'x-openvibe-run-id': 'run_01TEST' } });
+ }
+ throw new Error(`unexpected request ${u}`);
+}
+
+(async () => {
+ assert.throws(() => loadConfig({}), /missing environment variables: OV_CLIENT_ID, OV_CLIENT_SECRET/);
+ const env = { OV_CLIENT_ID: 'app_01TEST', OV_CLIENT_SECRET: 'test-secret-not-real' };
+ const config = loadConfig(env);
+ assert.equal(config.provider, null);
+ const openai = createAiClient(config, { fetch: fakeFetch });
+
+ // 1. Plain: one token for openvibe.ai (ai.app.run), Bearer on the request, x_openvibe in the answer.
+ const plain = await ask(openai, config, 'Say hi');
+ assert.equal(plain.text, 'Hello from OpenVibe');
+ assert.equal(plain.model, 'qwen-local');
+ assert.equal(plain.openvibe.run_id, 'run_01TEST');
+ assert.deepEqual(calls[0], { kind: 'token', audience: 'openvibe.ai', scope: 'ai.app.run', grant: 'client_credentials' });
+ assert.equal(calls[1].auth, 'Bearer tok-1');
+ assert.equal(calls[1].body.model, 'openvibe');
+ assert.equal(calls[1].body.x_openvibe, undefined, 'no provider: the router picks');
+
+ // 2. Streamed: deltas in order, then usage and x_openvibe; the token is reused from the cache.
+ const pieces = [];
+ const streamed = await askStreamed(openai, config, 'Count', (t) => pieces.push(t));
+ assert.deepEqual(pieces, ['one ', 'two ', 'three']);
+ assert.equal(streamed.text, 'one two three');
+ assert.equal(streamed.usage.total_tokens, 7);
+ assert.equal(streamed.openvibe.run_id, 'run_01TEST');
+ assert.equal(calls.at(-1).body.stream, true);
+ assert.deepEqual(calls.at(-1).body.stream_options, { include_usage: true });
+ assert.equal(calls.filter((c) => c.kind === 'token').length, 1, 'one token for both calls');
+
+ // 3. The project's own provider: x_openvibe.provider carries the secret's NAME, never a key.
+ const own = loadConfig({ ...env, OV_PROVIDER_SECRET: 'openai-key', OV_PROVIDER_MODEL: 'gpt-5-mini' });
+ tokenN = 0;
+ const viaOwn = await ask(createAiClient(own, { fetch: fakeFetch }), own, 'Hi');
+ assert.equal(viaOwn.model, 'gpt-5-mini');
+ assert.deepEqual(calls.at(-1).body.x_openvibe, { provider: { base_url: 'https://api.openai.com/v1', model: 'gpt-5-mini', secret: 'openai-key' } });
+
+ // 4. Errors keep OpenAI's shape, so the client's APIError has status and code.
+ const refused = loadConfig({ ...env, OV_PROVIDER_SECRET: 'not-granted' });
+ tokenN = 0;
+ await assert.rejects(ask(createAiClient(refused, { fetch: fakeFetch }), refused, 'Hi'), (err) => err.status === 403 && err.code === 'project_secret.refused');
+
+ console.log('ai-chat: ok');
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -21,6 +21,18 @@
@@ -21,6 +21,18 @@
"node": ">=22"
"node": ">=22"
}
}
},
},
+ "examples/ai-chat": {
+ "name": "openvibe-example-ai-chat",
+ "version": "0.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "openai": "7.42.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.60.0"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
"examples/browser-app": {
"examples/browser-app": {
"name": "openvibe-example-browser-app",
"name": "openvibe-example-browser-app",
"version": "0.2.0",
"version": "0.2.0",
@@ -190,6 +202,47 @@
@@ -190,6 +202,47 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/openai": {
+ "version": "7.42.0",
+ "resolved": "https://registry.npmjs.org/openai/-/openai-7.42.0.tgz",
+ "integrity": "sha512-RziM2bxp1k24mTLtunije+OOCGRC+qtK0YNYPWkrTdKJLJ65WYW+rPYxlLbnBzU4WwINFCFa0OjCgbsX+fif/w==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=22.0.0"
+ },
+ "peerDependencies": {
+ "@aws-sdk/credential-provider-node": ">=3.972.0 <4",
+ "@smithy/hash-node": ">=4.3.0 <5",
+ "@smithy/signature-v4": ">=5.4.0 <6",
+ "google-auth-library": ">=9.15.1 <11",
+ "undici": ">=5 <9",
+ "ws": "^8.21.0",
+ "zod": "^3.25 || ^4.0"
+ },
+ "peerDependenciesMeta": {
+ "@aws-sdk/credential-provider-node": {
+ "optional": true
+ },
+ "@smithy/hash-node": {
+ "optional": true
+ },
+ "@smithy/signature-v4": {
+ "optional": true
+ },
+ "google-auth-library": {
+ "optional": true
+ },
+ "undici": {
+ "optional": true
+ },
+ "ws": {
+ "optional": true
+ },
+ "zod": {
+ "optional": true
+ }
+ }
+ },
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
"version": "0.209.0",
"version": "0.209.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.209.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.209.0",
@@ -206,6 +259,10 @@
@@ -206,6 +259,10 @@
"node": ">=20"
"node": ">=20"
}
}
},
},
+ "node_modules/openvibe-example-ai-chat": {
+ "resolved": "examples/ai-chat",
+ "link": true
+ },
"node_modules/openvibe-example-browser-app": {
"node_modules/openvibe-example-browser-app": {
"resolved": "examples/browser-app",
"resolved": "examples/browser-app",
"link": true
"link": true
@@ -420,7 +477,7 @@
@@ -420,7 +477,7 @@
"version": "8.21.3",
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"license": "MIT",
"engines": {
"engines": {
"node": ">=10.0.0"
"node": ">=10.0.0"
@@ -11,13 +11,13 @@ const fs = require('node:fs');
@@ -11,13 +11,13 @@ const fs = require('node:fs');
const path = require('node:path');
const path = require('node:path');
const ROOT = path.join(__dirname, '..');
const ROOT = path.join(__dirname, '..');
-const EXPECTED = ['browser-app', 'chat-bot', 'event-subscriber', 'media-uploader', 'mod-manifest', 'node-server-app', 'oauth-app', 'tool-job', 'webhook-consumer'];
+const EXPECTED = ['ai-chat', 'browser-app', 'chat-bot', 'event-subscriber', 'media-uploader', 'mod-manifest', 'node-server-app', 'oauth-app', 'tool-job', 'webhook-consumer'];
// The pin every example must share: the repository's own (the pin bot moves them all at once).
// The pin every example must share: the repository's own (the pin bot moves them all at once).
const ROOT_PKG = require(path.join(ROOT, 'package.json'));
const ROOT_PKG = require(path.join(ROOT, 'package.json'));
const SDK = (ROOT_PKG.dependencies || {})['openvibe-sdk'] || (ROOT_PKG.devDependencies || {})['openvibe-sdk'];
const SDK = (ROOT_PKG.dependencies || {})['openvibe-sdk'] || (ROOT_PKG.devDependencies || {})['openvibe-sdk'];
const dirs = fs.readdirSync(path.join(ROOT, 'examples')).filter((d) => fs.statSync(path.join(ROOT, 'examples', d)).isDirectory()).sort();
const dirs = fs.readdirSync(path.join(ROOT, 'examples')).filter((d) => fs.statSync(path.join(ROOT, 'examples', d)).isDirectory()).sort();
-assert.deepEqual(dirs, EXPECTED, 'the nine charter examples');
+assert.deepEqual(dirs, EXPECTED, 'the nine charter examples and ai-chat');
function sources(dir) {
function sources(dir) {
const out = [];
const out = [];