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)

OP OpenVibers committed 22 days ago · a75121c52f · 9 files, +355 −6

Changes

ddiff --git a/README.md b/README.md
iindex f0c07df..a0705c8 100644
---- a/README.md
++++ b/README.md
@@@ -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 internal
route prefix, on hard-coded loopback service ports outside the local mocks, and on any secret
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
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
@@@ -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)
ddiff --git a/STATUS.json b/STATUS.json
iindex 74b0602..287c934 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/examples/ai-chat/.env.example b/examples/ai-chat/.env.example
nnew file mode 100644
iindex 0000000..a715394
---- /dev/null
++++ b/examples/ai-chat/.env.example
@@@ -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
ddiff --git a/examples/ai-chat/README.md b/examples/ai-chat/README.md
nnew file mode 100644
iindex 0000000..bf88786
---- /dev/null
++++ b/examples/ai-chat/README.md
@@@ -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}}
++```
ddiff --git a/examples/ai-chat/chat.js b/examples/ai-chat/chat.js
nnew file mode 100644
iindex 0000000..4cfff53
---- /dev/null
++++ b/examples/ai-chat/chat.js
@@@ -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 };
ddiff --git a/examples/ai-chat/package.json b/examples/ai-chat/package.json
nnew file mode 100644
iindex 0000000..bfc23c5
---- /dev/null
++++ b/examples/ai-chat/package.json
@@@ -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"
++ }
++}
ddiff --git a/examples/ai-chat/test/smoke.test.js b/examples/ai-chat/test/smoke.test.js
nnew file mode 100644
iindex 0000000..8235e81
---- /dev/null
++++ b/examples/ai-chat/test/smoke.test.js
@@@ -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); });
ddiff --git a/package-lock.json b/package-lock.json
iindex 42681b3..47f58dd 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
ddiff --git a/test/structure.test.js b/test/structure.test.js
iindex 7bc1bab..3de207e 100644
---- a/test/structure.test.js
++++ b/test/structure.test.js
@@@ -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 = [];