openvibers/openvibe.actor Commits
Actor on OpenVibe.AI's router: no provider key, the person's allowance pays (#293)
Changes
@@@ -37,7 +37,28 @@ ACTOR_EVENTS_SECRET=ACTOR_EVENTS_URL=#ACTOR_EVENTS_ENDPOINT=http://127.0.0.1:4950/internal/events--# The model providers behind the agents (server/agents/catalog.js). An agent without its key is listed as unavailable.++# OpenVibe.AI: the AI layer of this product (the owner, 11-06: every product on the one router). With++# ACTOR_AI_ROUTER=1 every model call is a run of ai.complete on AI, which picks the offer by price, latency and++# policy and says which model ran; Actor then needs NO provider key below. The person a task belongs to rides as++# on_behalf_of, so their allowance and their Balance pay; the run is theirs.++# Production sets this; a self-hosted Actor leaves it off and keeps its own model below.++ACTOR_AI_ROUTER=++# The token is Actor's service token for audience openvibe.ai (ai.run.create, namespace ai.complete).++# OV_AI_URL=https://ai.openvibe.services++# ACTOR_AI_TIMEOUT_MS=180000++# ACTOR_AI_WAIT_MS=120000++# Each tier's policy on the router (economy | balanced | fast | auto | private), or an exact id++# (<org>/<model>@<revision>) when AI's catalog is on.++# ACTOR_AI_MODEL_FAST=openvibe/economy++# ACTOR_AI_MODEL_SMART=openvibe/balanced++# ACTOR_AI_MODEL_BEST=openvibe/auto++# ACTOR_AI_MODEL_CHECK=openvibe/economy++# ACTOR_AI_MODEL_VISION=openvibe/auto++# The per-call ceiling (USD, exact decimal) AI holds against the person's Balance. Unset: no cap.++# ACTOR_AI_MAX_COST_USD=++++# The model providers behind the agents (server/agents/catalog.js). Unused when ACTOR_AI_ROUTER=1; keep them for a++# self-hosted Actor (docs/self-host.md). An agent without its key is listed as unavailable.ACTOR_DEEPSEEK_API_KEY=# ACTOR_DEEPSEEK_MODEL=deepseek-flashACTOR_OPENAI_API_KEY=@@@ -139,7 +139,8 @@ The resource index is for OpenVibe.Services on loopback and is blocked at the pu- **Limits:** 5 asks an hour per person and 60 across everyone (`ACTOR_CHAT_ASKS_PER_HOUR`, `ACTOR_CHAT_ASKS_ALL_PER_HOUR`). Past a limit, one reply says so and further asks are dropped quietly.- **Data:** `actor_chat_asks` (migration 0009) is part of the person's export and erasure.- `ACTOR_CHAT_ASKS=0` turns it off.--- **The model providers**: DeepSeek (`ACTOR_DEEPSEEK_API_KEY`, `api.deepseek.com`), OpenAI (`ACTOR_OPENAI_API_KEY`, `api.openai.com/v1`) for the web-search agent and the cross-family check, and an OpenAI-compatible local server (`ACTOR_LOCAL_LLM_URL`) for private mode.++- **Models: OpenVibe.AI's router** (`ACTOR_AI_ROUTER=1`, set in production). Every model call is a run of `ai.complete` on OpenVibe.AI with Actor's service token (audience `openvibe.ai`, `ai.run.create`); each tier names a policy on it — `fast` `openvibe/economy`, `smart` `openvibe/balanced`, `best` `openvibe/auto`, `check` `openvibe/economy`, `vision` `openvibe/auto` (`ACTOR_AI_MODEL_*`) — and AI picks the offer by price, latency, uptime and policy, fails over, and says which model ran. The person a task belongs to rides as `on_behalf_of`: their allowance and their Balance pay, and the run is theirs. `ACTOR_AI_MAX_COST_USD` caps each call. **Actor then holds no provider key**: `ACTOR_DEEPSEEK_API_KEY`, `ACTOR_OPENAI_API_KEY`, `ACTOR_LOCAL_LLM_URL`/`ACTOR_LOCAL_LLM_MODEL`, `ACTOR_RUNTIME_*` and `ACTOR_COMPAT_*` are unused (the cross-family check is one family now: the verdict says so). A person can still bring their own key, sealed in OpenVibe.AI.++- **The model providers** (a self-hosted Actor, router off): DeepSeek (`ACTOR_DEEPSEEK_API_KEY`, `api.deepseek.com`), OpenAI (`ACTOR_OPENAI_API_KEY`, `api.openai.com/v1`) for the web-search agent and the cross-family check, and an OpenAI-compatible local server (`ACTOR_LOCAL_LLM_URL`) for private mode.**Programs:** poppler-utils' `pdftotext` reads the text of a PDF attached in the chat (`ACTOR_PDFTOTEXT` names another path); without it a PDF is kept but its text is not read, and the run is told so.@@@ -178,7 +179,7 @@ A self-hosted Actor ([docs/self-host.md](docs/self-host.md)): `npm run selfhost:See [.env.example](.env.example). Required in production:- `OV_OAUTH_CLIENT_SECRET` (the `actor` principal; `server/setup/service-principal.js create actor` on the Network writes it);- `BASE_URL`, `DATABASE_URL` and `DATABASE_DIRECT_URL`;--- at least one agent: `ACTOR_DEEPSEEK_API_KEY`, `ACTOR_OPENAI_API_KEY`, or `ACTOR_LOCAL_LLM_URL` + `ACTOR_LOCAL_LLM_MODEL`.++- at least one agent: `ACTOR_AI_ROUTER=1` (OpenVibe.AI's router), or `ACTOR_DEEPSEEK_API_KEY`, `ACTOR_OPENAI_API_KEY`, or `ACTOR_LOCAL_LLM_URL` + `ACTOR_LOCAL_LLM_MODEL`.Readiness requires the database and at least one available agent.@@@ -102,9 +102,17 @@ function effective(agent, config) {};}++/** Is the OpenVibe.AI router this server's model layer? (ACTOR_AI_ROUTER, with a Network client secret.) */++function routerOn(config) {++ const ai = config && config.ai;++ return Boolean(ai && ai.router && ai.url && ai.clientSecret);++}++/** Is the agent usable with this configuration? { available, reason }. */function availability(agent, config) {const p = config.providers || {};++ // The runtime's model comes from OpenVibe.AI's router: this server holds no provider key at all.++ if (agent.id === 'openvibe-runtime' && routerOn(config)) return { available: true };if (agent.id === 'openvibe-runtime' && runtimeOverride(config)) return { available: true };if (agent.providerKey === 'deepseek') return p.deepseek && p.deepseek.apiKey ? { available: true } : { available: false, reason: 'no DeepSeek key is configured on this server' };if (agent.providerKey === 'openai') return p.openai && p.openai.apiKey ? { available: true } : { available: false, reason: 'no OpenAI key is configured on this server' };@@@ -118,6 +126,7 @@ function availability(agent, config) {/** The model an agent runs with this configuration (for the listing and the task's explanation). */function modelOf(agent, config) {++ if (agent.id === 'openvibe-runtime' && routerOn(config)) return config.ai.models && config.ai.models.smart ? config.ai.models.smart : 'openvibe';if (agent.id === 'openvibe-runtime' && runtimeOverride(config)) return runtimeOverride(config).model;const p = (config.providers || {})[agent.providerKey];return p && p.model ? p.model : undefined;@@@ -12,6 +12,7 @@ const int = (v, def) => (Number.isFinite(parseInt(v, 10)) ? parseInt(v, 10) : deconst num = (v, def) => (v != null && v !== '' && Number.isFinite(Number(v)) ? Number(v) : def);// An optional price: undefined when nobody typed one, so the provider's published card is used.const optNum = (v) => (v != null && v !== '' && Number.isFinite(Number(v)) && Number(v) >= 0 ? Number(v) : undefined);++const bool = (v) => /^(1|true|on|yes)$/i.test(String(v == null ? '' : v).trim());// A JSON list from the environment (the connector servers OpenVibe offers); a broken value is no list, never a crash.const jsonList = (v) => { if (!v) return []; try { const p = JSON.parse(v); return Array.isArray(p) ? p : []; } catch { return []; } };@@@ -82,10 +83,10 @@ function load(env = process.env) {},cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },-- // OpenVibe.AI (plan L55 C3): the model gateway a person's own keys live in. Actor holds NO provider key of-- // its own for this: it asks AI to run the call, and AI answers with the key the person chose (their own,-- // sealed in AI's vault) or with OpenVibe's own keys. The token is Actor's service token for audience-- // openvibe.ai (the grant: ai.run.create, namespace ai.complete); the person is named in the request.++ // OpenVibe.AI (plan L55 C3): the model gateway a person's own keys live in, and — with the router on — the++ // model layer of this product. Actor then holds NO provider key at all: every model call is a run of++ // ai.complete on AI, which picks the offer (price, latency, policy) for each tier and says which model ran.++ // The token is Actor's service token for audience openvibe.ai (the grant: ai.run.create, namespace ai.complete).ai: {url: trim(env.OV_AI_URL || 'https://ai.openvibe.services'),audience: 'openvibe.ai',@@@ -93,6 +94,23 @@ function load(env = process.env) {clientId: env.OV_OAUTH_CLIENT_ID || 'actor',clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',timeoutMs: Math.max(5000, int(env.ACTOR_AI_TIMEOUT_MS, 180_000)),++ waitMs: Math.max(1000, int(env.ACTOR_AI_WAIT_MS, 120_000)),++ // The AI layer of every product: with this on, the tiers below run on AI's router and the operator's++ // provider keys (ACTOR_DEEPSEEK_API_KEY, ACTOR_OPENAI_API_KEY, …) are not used. Off by default so a++ // self-hosted Actor keeps its own model choices; production sets ACTOR_AI_ROUTER=1.++ router: bool(env.ACTOR_AI_ROUTER),++ // Each tier's policy on the router: floor and ceiling in one name (economy = cheapest healthy, fast =++ // quickest, balanced = price and latency, auto = the router's own, private = OpenVibe-run/local only).++ // An exact id (<org>/<model>@<revision>) pins a model when AI's catalog is on.++ models: {++ fast: env.ACTOR_AI_MODEL_FAST || 'openvibe/economy',++ smart: env.ACTOR_AI_MODEL_SMART || 'openvibe/balanced',++ best: env.ACTOR_AI_MODEL_BEST || 'openvibe/auto',++ check: env.ACTOR_AI_MODEL_CHECK || 'openvibe/economy',++ vision: env.ACTOR_AI_MODEL_VISION || 'openvibe/auto',++ },++ // The per-call liability ceiling (USD, exact decimal) AI holds against the person's Balance. Unset: no cap.++ maxCostUsd: optNum(env.ACTOR_AI_MAX_COST_USD),// The AI Exchange catalog (PLAN G-P9), off by default: GET /v1/models from OpenVibe.AI, cached, to offer// its aliases and exact ids where a person picks the model their tasks run on. Off, the model field is// the plain one it was before.@@@ -32,6 +32,9 @@const { AsyncLocalStorage } = require('node:async_hooks');const { serviceAuth } = require('openvibe-contracts');++const { createClient: createSdkClient } = require('openvibe-sdk/core');++const { createAiClient } = require('openvibe-sdk/ai');++const { createServiceTokenClient } = require('openvibe-sdk/auth');const { chatClient, priceUsage, readStream, ProviderError } = require('../agents/providers');const aiCatalogLib = require('./ai-catalog');@@@ -61,9 +64,9 @@ const SMALL = { test: (m) => !FULL.has(String(m)) && SMALL_RE.test(String(m)) };const TIERS = ['fast', 'smart', 'best', 'check', 'vision'];const PROVIDERS = ['openai', 'deepseek', 'runtime', 'compatible', 'local'];--// OpenVibe.AI (plan L55 C3): the gateway, NOT one of the operator's providers. It is never resolved as a tier (a--// person who did not choose it keeps today's behaviour), and it holds no key of its own: the call goes to AI, and AI--// answers with the person's own key (sealed in AI's vault) or with OpenVibe's.++// OpenVibe.AI (plan L55 C3): the gateway, NOT one of the operator's providers. It holds no key of its own. With++// ACTOR_AI_ROUTER on it IS the model layer (every tier is a run of ai.complete on AI's router); with it off, it is++// still reached when a person chooses their own key (sealed in AI's vault).const AI_PROVIDER = 'openvibe-ai';/** Parse a pinned tier value: "openai:gpt-5.1", or a bare model on the first configured provider. */@@@ -126,6 +129,27 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =return { available: true };}++ // ── OpenVibe.AI's router (the AI layer of this product) ────────────────────────────────────────++ // With ACTOR_AI_ROUTER on, every tier is a name on AI's router (a policy alias, or an exact id) and no provider++ // key is read. The token is Actor's own service token; the person the task belongs to rides as on_behalf_of, so++ // their allowance and their Balance pay and the run is theirs.++ const routerModels = ai.models && typeof ai.models === 'object' ? ai.models : {};++ const routerOn = () => Boolean(ai.router) && availability(AI_PROVIDER).available;++ const routerModel = (tier) => String(routerModels[tier] || 'openvibe');++ let routerClients = null;++ function routerClient() {++ if (routerClients) return routerClients;++ if (!routerOn()) return null;++ const tokens = createServiceTokenClient({++ tokenUrl: `${config.networkInternalUrl || config.networkUrl}/oauth/token`,++ clientId: ai.clientId, clientSecret: ai.clientSecret,++ audience: ai.audience || 'openvibe.ai', scope: ai.scope || 'ai.run.create', fetch: fetchImpl,++ });++ const client = createSdkClient({ baseUrl: trimSlash(ai.url), getToken: async () => tokens.getToken(), fetch: fetchImpl, retries: 0 });++ routerClients = { tokens, client, ai: createAiClient(client, { baseUrl: trimSlash(ai.url) }) };++ return routerClients;++ }++async function listAt(key) {const e = endpoints[key];if (!e.baseUrl) return [];@@@ -189,6 +213,9 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =// when one offers a model. If none does, the pin stands (the verdict says it was not cross-family).if (!(avoidFamily && endpoints[key].family === avoidFamily)) return descriptor(tier, key, pin.model);}++ // The router is the model layer: the tier's name on it is the policy (or the exact id), and AI says which++ // model actually ran. A pin above wins, so an operator can still move one tier back to a provider of theirs.++ if (routerOn()) return descriptor(tier, AI_PROVIDER, routerModel(tier), { available: true });for (const key of order) {if (!availability(key).available) continue;if (avoidFamily && endpoints[key].family === avoidFamily) continue;@@@ -422,6 +449,107 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =cost_usd: Number(x.cost_usd) || 0,};}++ /**++ * One call through OpenVibe.AI's router (ACTOR_AI_ROUTER): a run of ai.complete, Actor's service token, the++ * person's id in on_behalf_of. No provider key is touched here — AI holds the offers, prices and failover.++ *++ * The tier's name (openvibe/economy|balanced|fast|auto|private, or an exact id) is the policy. The answer's++ * model is whatever AI says ran, never the wish.++ */++ async function chatViaRouter({ tier, messages = [], tools = null, json = false, maxTokens = 4000, signal, onDelta = null, subject = null, target = null }) {++ const c = routerClient();++ if (!c) throw new ModelError('OpenVibe.AI is not configured on this server (no Network client secret)', { code: 'actor.model.unavailable' });++ const input = {++ messages,++ model: routerModel(tier),++ ...(tools && tools.length ? { tools, tool_choice: 'auto' } : {}),++ ...(json ? { response_format: { type: 'json_object' } } : {}),++ max_tokens: Math.max(1, Math.min(Number(maxTokens) || 1500, 16000)),++ temperature: 0.2,++ ...(ai.maxCostUsd != null ? { max_cost_usd: ai.maxCostUsd } : {}),++ };++ const body = {++ workflow: 'ai.complete', input,++ ...(subject ? { on_behalf_of: { type: 'user', id: String(subject) } } : {}),++ ...(target ? { target } : {}),++ };++ const timeoutMs = Math.max(5000, Number(ai.timeoutMs) || 180_000);++ const sig = typeof AbortSignal.any === 'function'++ ? AbortSignal.any([...(signal ? [signal] : []), AbortSignal.timeout(timeoutMs)])++ : (signal || AbortSignal.timeout(timeoutMs));++ let run;++ try {++ if (onDelta) run = await routerStream(c, body, { onDelta, signal: sig });++ else {++ const created = await c.ai.runs.create('ai.complete', input, {++ wait: Math.max(1000, Number(ai.waitMs) || 120_000),++ ...(subject ? { onBehalfOf: { type: 'user', id: String(subject) } } : {}),++ ...(target ? { target } : {}), options: { cache: false }, signal: sig,++ });++ run = created.run;++ if (!['succeeded', 'cached', 'failed', 'cancelled'].includes(run.status)) run = await c.ai.runs.waitFor(run.id, { timeoutMs, signal: sig });++ }++ } catch (err) {++ if (signal && signal.aborted) throw new ProviderError('cancelled', { code: 'actor.task.cancelled' });++ if (err instanceof ProviderError || err instanceof ModelError) throw err;++ const code = String((err && err.code) || '');++ if (/timeout|deadline|aborted/.test(code)) throw new ProviderError('OpenVibe.AI did not answer in time', { code: 'actor.provider.unreachable' });++ throw new ProviderError(`OpenVibe.AI: ${String((err && (err.message || err.code)) || 'request failed').slice(0, 300)}`, { status: err && err.status, code: 'actor.provider.error' });++ }++ if (run.status === 'failed' || run.status === 'cancelled') {++ const e = (run.error && (run.error.detail || run.error.code)) || run.status;++ throw new ProviderError(`OpenVibe.AI's run ${run.status}: ${String(e).slice(0, 300)}`, { code: String((run.error && run.error.code) || 'actor.provider.error') });++ }++ if (!run.output || !Array.isArray(run.output.choices)) throw new ProviderError('OpenVibe.AI answered without a completion');++ const first = run.output.choices[0] || {};++ const message = first.message || { role: 'assistant', content: '' };++ const u = run.usage || {};++ return {++ message,++ text: typeof message.content === 'string' ? message.content : '',++ toolCalls: Array.isArray(message.tool_calls) ? message.tool_calls : [],++ finish: first.finish_reason || null,++ model: run.output.model || (run.provenance && run.provenance.model) || input.model,++ usage: { input: u.tokens_in || 0, cached: u.tokens_cached || 0, output: u.tokens_out || 0 },++ cost_usd: Number(u.cost_usd) || 0,++ };++ }++++ /** The router's streamed run (POST /api/v1/runs?stream=1): `delta` events as they come, then the run. */++ async function routerStream(c, body, { onDelta, signal }) {++ const { response } = await c.client.request({++ method: 'POST', path: '/api/v1/runs', query: { stream: 1, wait: Math.max(1000, Number(ai.waitMs) || 120_000) },++ json: body, responseType: 'response', idempotencyKey: false, retries: 0, signal,++ });++ if (!response || !response.body) throw new ProviderError('OpenVibe.AI sent no stream');++ const decoder = new TextDecoder();++ let buf = '', event = 'message', data = [], run = null, failure = null;++ const take = () => {++ if (!data.length) { event = 'message'; return; }++ const raw = data.join('\n'); data = [];++ const name = event; event = 'message';++ let j = null;++ try { j = JSON.parse(raw); } catch { return; }++ if (name === 'delta' && j && typeof j.text === 'string') { try { onDelta(j.text); } catch { /* the listener's problem */ } }++ else if (name === 'run' && j && j.run) run = j.run;++ else if (name === 'error') failure = j || {};++ };++ for await (const chunk of response.body) {++ buf += decoder.decode(chunk, { stream: true });++ let i;++ while ((i = buf.indexOf('\n')) >= 0) {++ let line = buf.slice(0, i); buf = buf.slice(i + 1);++ if (line.endsWith('\r')) line = line.slice(0, -1);++ if (!line) { take(); continue; }++ if (line.startsWith('event:')) event = line.slice(6).trim();++ else if (line.startsWith('data:')) data.push(line.slice(5).replace(/^ /, ''));++ }++ }++ if (failure) throw new ProviderError(`OpenVibe.AI: ${String(failure.message || failure.code || 'stream error').slice(0, 300)}`, { code: String(failure.code || 'actor.provider.error') });++ if (!run) throw new ProviderError('OpenVibe.AI ended the stream without the run');++ return run;++ }++/** One model call. The caller checks the budget first and meters cost_usd after; this layer only prices it. */async function chat({ tier = 'smart', messages = null, tools = null, json = false, maxTokens = 4000, signal, avoidFamily = null, provider = null, model = null, onDelta = null } = {}) {// The person's own key (plan L55 C3): every model call of this run goes to OpenVibe.AI with it.@@@ -430,6 +558,11 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =const r = await chatViaAi({ choice, messages, tools, json, maxTokens, signal, onDelta });return { ...r, tier, provider: AI_PROVIDER, family: AI_PROVIDER };}++ // The router (the AI layer): the tier names a policy on AI and the person's allowance pays. No provider key.++ if (routerOn()) {++ const r = await chatViaRouter({ tier, messages, tools, json, maxTokens, signal, onDelta, subject: choice && choice.subject, target: choice && choice.target });++ return { ...r, tier, provider: AI_PROVIDER, family: AI_PROVIDER };++ }await warm();if (provider && !PROVIDERS.includes(provider)) throw new ModelError(`unknown model provider "${provider}"`, { code: 'actor.model.provider' });const d = provider && model ? descriptor(tier, provider, model) : resolve(tier, { avoidFamily });@@@ -462,6 +595,8 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =out.push({ provider: key, model, family: endpoints[key].family });};for (const key of order) for (const model of candidatesFor(key)) add(key, model);++ // The router: each tier's name on AI (a policy or a pin), so the listing says what this server can reach.++ if (routerOn()) for (const tier of TIERS) add(AI_PROVIDER, routerModel(tier));// A tier the operator pinned is reachable whether or not the provider's list mentions it.for (const value of Object.values(cfg.tiers || {})) {const pin = parsePin(value);@@@ -362,6 +362,13 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =// "Runs on: my computer": the model on the person's computer, whatever else they chose above it (a// model id they gave for their computer is kept).if (runsOn === 'computer' && !(choice && choice.provider === 'node')) choice = modelPrefs.computerChoice(row.requester);++ // The person the task belongs to rides on every router call (on_behalf_of): their allowance pays,++ // even when they chose no key. A task with no person (a watch, a webhook) names nobody, and AI++ // serves it from free or untolled capacity only.++ if (!choice || !choice.subject) {++ const subject = String(row.requester || '').startsWith('user:') ? row.requester.slice(5) : null;++ if (subject) choice = { ...(choice || {}), subject };++ }try {out = await withChoice(models, choice, async () => loop.run({task: row.task, requester: fromChat ? null : row.requester, taskId: id, mode: row.mode, signal,nnew file mode 100644@@@ -0,0 +1,101 @@++'use strict';++/**++ * The AI layer (ACTOR_AI_ROUTER): every tier is a run of ai.complete on OpenVibe.AI's router, with Actor's own++ * service token. No provider key is read, the person the task belongs to rides as on_behalf_of (their allowance++ * pays), the tier's name is the policy, and the answer's model is whatever AI says ran. Also the streamed run.++ */++const assert = require('assert');++const configLib = require('../server/config');++const { createModels } = require('../server/runtime/models');++++const USR = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';++const quiet = { log() {}, warn() {} };++const reply = (status, obj) => ({ ok: status >= 200 && status < 300, status, headers: new Headers({ 'content-type': 'application/json' }), json: async () => obj, text: async () => JSON.stringify(obj) });++const headerOf = (headers, name) => (headers && typeof headers.get === 'function' ? headers.get(name) : (headers || {})[name]);++++function fakeAi({ answer = 'Hello from the router', model = 'deepseek/deepseek-chat' } = {}) {++ const calls = [];++ const fetchImpl = async (url, opts = {}) => {++ const u = String(url);++ if (u.includes('/oauth/token')) return reply(200, { access_token: 'tok_router', token_type: 'Bearer', expires_in: 300, scope: 'ai.run.create' });++ calls.push({ url: u, headers: opts.headers, body: opts.body ? JSON.parse(opts.body) : null });++ if (u.includes('stream=1')) {++ const enc = new TextEncoder();++ const body = new ReadableStream({++ start(c) {++ for (const piece of ['Hel', 'lo from', ' the router']) c.enqueue(enc.encode(`event: delta\ndata: ${JSON.stringify({ text: piece })}\n\n`));++ c.enqueue(enc.encode(`event: run\ndata: ${JSON.stringify({ run: { id: 'run_s', status: 'succeeded', output: { choices: [{ message: { role: 'assistant', content: answer }, finish_reason: 'stop' }], model }, usage: { tokens_in: 9, tokens_out: 4, cost_usd: 0.00004 }, provenance: { model } } })}\n\n`));++ c.close();++ },++ });++ return new Response(body, { status: 200, headers: { 'content-type': 'text/event-stream' } });++ }++ if (u.includes('/api/v1/runs')) {++ return reply(201, { run: { id: 'run_1', status: 'succeeded', output: { choices: [{ message: { role: 'assistant', content: answer }, finish_reason: 'stop' }], model }, usage: { tokens_in: 12, tokens_out: 4, cost_usd: 0.00003 }, provenance: { model } } });++ }++ return reply(404, {});++ };++ return { fetchImpl, calls };++}++++const env = {++ NODE_ENV: 'test',++ OV_NETWORK_URL: 'https://network.test', OV_NETWORK_INTERNAL_URL: 'https://network.internal',++ OV_OAUTH_CLIENT_ID: 'actor', OV_OAUTH_CLIENT_SECRET: 'actor-secret',++ OV_AI_URL: 'https://ai.test', ACTOR_AI_ROUTER: '1',++ // No provider key at all: the router is the model layer.++ ACTOR_DEEPSEEK_API_KEY: '', ACTOR_OPENAI_API_KEY: '', ACTOR_LOCAL_LLM_URL: '',++};++++const results = [];++const ok = async (name, fn) => { try { await fn(); console.log(` ✓ ${name}`); } catch (e) { results.push(e); console.log(` ✗ ${name}\n ${(e.stack || String(e)).split('\n').slice(0, 6).join('\n ')}`); } };++++(async () => {++ await ok('every tier resolves to its policy on the router, and a pinned tier still wins', async () => {++ const models = createModels({ config: configLib.load(env), fetchImpl: fakeAi().fetchImpl, log: quiet });++ assert.deepStrictEqual([models.resolve('fast').provider, models.resolve('fast').model, models.resolve('fast').available], ['openvibe-ai', 'openvibe/economy', true]);++ assert.strictEqual(models.resolve('smart').model, 'openvibe/balanced');++ assert.strictEqual(models.resolve('best').model, 'openvibe/auto');++ assert.strictEqual(models.resolve('check').model, 'openvibe/economy');++ const pinned = createModels({ config: configLib.load({ ...env, ACTOR_AI_MODEL_SMART: 'openvibe/private' }), fetchImpl: fakeAi().fetchImpl, log: quiet });++ assert.strictEqual(pinned.resolve('smart').model, 'openvibe/private');++ assert.ok(models.modelList().some((m) => m.provider === 'openvibe-ai' && m.model === 'openvibe/balanced'));++ });++++ await ok('a call is a run of ai.complete: service token, tier policy, the person in on_behalf_of, AI\'s price', async () => {++ const { fetchImpl, calls } = fakeAi();++ const models = createModels({ config: configLib.load(env), fetchImpl, log: quiet });++ const r = await models.withChoice({ subject: USR }, () => models.chat({ tier: 'smart', messages: [{ role: 'user', content: 'Say hello' }] }));++ assert.strictEqual(r.text, 'Hello from the router');++ assert.strictEqual(r.provider, 'openvibe-ai');++ assert.strictEqual(r.model, 'deepseek/deepseek-chat', 'the model that ran, not the wish');++ assert.ok(Math.abs(r.cost_usd - 0.00003) < 1e-12, 'AI prices the call');++ const run = calls.find((c) => c.url.includes('/api/v1/runs'));++ assert.ok(run, 'a run was made');++ assert.strictEqual(headerOf(run.headers, 'authorization'), 'Bearer tok_router');++ assert.strictEqual(run.body.workflow, 'ai.complete');++ assert.strictEqual(run.body.input.model, 'openvibe/balanced');++ assert.deepStrictEqual(run.body.on_behalf_of, { type: 'user', id: USR });++ assert.strictEqual(run.body.input.messages[0].content, 'Say hello');++ assert.ok(!calls.some((c) => /api\.(deepseek|openai)/.test(c.url)), 'no provider key or address');++ });++++ await ok('a streamed call sends the deltas as they come and ends with the run', async () => {++ const { fetchImpl, calls } = fakeAi();++ const models = createModels({ config: configLib.load(env), fetchImpl, log: quiet });++ const pieces = [];++ const r = await models.withChoice({ subject: USR }, () => models.chat({ tier: 'smart', messages: [{ role: 'user', content: 'Stream' }], onDelta: (t) => pieces.push(t) }));++ assert.deepStrictEqual(pieces, ['Hel', 'lo from', ' the router']);++ assert.strictEqual(r.text, 'Hello from the router');++ assert.ok(calls.some((c) => c.url.includes('stream=1')), 'the streamed run endpoint');++ });++++ await ok('without the router a keyless server has no model, as before', async () => {++ const models = createModels({ config: configLib.load({ ...env, ACTOR_AI_ROUTER: '0' }), fetchImpl: fakeAi().fetchImpl, log: quiet });++ assert.strictEqual(models.resolve('smart').available, false);++ await assert.rejects(() => models.chat({ tier: 'smart', messages: [{ role: 'user', content: 'x' }] }), /model|provider/i);++ });++++ if (results.length) { console.error(`${results.length} failed`); process.exit(1); }++ console.log('ai-router: all passed');++})();@@@ -37,10 +37,11 @@ function aiModels(calls) {(async () => {const calls = [];++ const seenChoices = [];const models = aiModels(calls);// The loop, reduced to one model call: what matters here is which model the run's calls go to.const loop = {-- run: async ({ task }) => { const r = await models.chat({ tier: 'smart', messages: [{ role: 'user', content: task }] }); return { text: r.text, state: {} }; },++ run: async ({ task }) => { seenChoices.push(models.currentChoice()); const r = await models.chat({ tier: 'smart', messages: [{ role: 'user', content: task }] }); return { text: r.text, state: {} }; },close: async () => {}, planText: () => '', escalate: async () => null, revise: async () => null,};const t = await boot({ models, loop });@@@ -81,6 +82,8 @@ function aiModels(calls) {const row = await t.waitFor(made.json().id);assert.strictEqual(row.state, 'succeeded', JSON.stringify([row.error_code, row.error_detail]));assert.ok(calls.every((c) => !c.headers['x-openvibe-credential']), 'no person credential');++ // The AI layer (ACTOR_AI_ROUTER) needs the person on every call even with no key chosen: on_behalf_of.++ assert.strictEqual(seenChoices[seenChoices.length - 1].subject, kim.subject || kim.id, 'the run names its person for the router');});await check('private mode stays on OpenVibe\'s own open model even when it is set to the computer', async () => {
@@ -37,7 +37,28 @@ ACTOR_EVENTS_SECRET=
@@ -37,7 +37,28 @@ ACTOR_EVENTS_SECRET=
ACTOR_EVENTS_URL=
ACTOR_EVENTS_URL=
#ACTOR_EVENTS_ENDPOINT=http://127.0.0.1:4950/internal/events
#ACTOR_EVENTS_ENDPOINT=http://127.0.0.1:4950/internal/events
-# The model providers behind the agents (server/agents/catalog.js). An agent without its key is listed as unavailable.
+# OpenVibe.AI: the AI layer of this product (the owner, 11-06: every product on the one router). With
+# ACTOR_AI_ROUTER=1 every model call is a run of ai.complete on AI, which picks the offer by price, latency and
+# policy and says which model ran; Actor then needs NO provider key below. The person a task belongs to rides as
+# on_behalf_of, so their allowance and their Balance pay; the run is theirs.
+# Production sets this; a self-hosted Actor leaves it off and keeps its own model below.
+ACTOR_AI_ROUTER=
+# The token is Actor's service token for audience openvibe.ai (ai.run.create, namespace ai.complete).
+# OV_AI_URL=https://ai.openvibe.services
+# ACTOR_AI_TIMEOUT_MS=180000
+# ACTOR_AI_WAIT_MS=120000
+# Each tier's policy on the router (economy | balanced | fast | auto | private), or an exact id
+# (<org>/<model>@<revision>) when AI's catalog is on.
+# ACTOR_AI_MODEL_FAST=openvibe/economy
+# ACTOR_AI_MODEL_SMART=openvibe/balanced
+# ACTOR_AI_MODEL_BEST=openvibe/auto
+# ACTOR_AI_MODEL_CHECK=openvibe/economy
+# ACTOR_AI_MODEL_VISION=openvibe/auto
+# The per-call ceiling (USD, exact decimal) AI holds against the person's Balance. Unset: no cap.
+# ACTOR_AI_MAX_COST_USD=
+
+# The model providers behind the agents (server/agents/catalog.js). Unused when ACTOR_AI_ROUTER=1; keep them for a
+# self-hosted Actor (docs/self-host.md). An agent without its key is listed as unavailable.
ACTOR_DEEPSEEK_API_KEY=
ACTOR_DEEPSEEK_API_KEY=
# ACTOR_DEEPSEEK_MODEL=deepseek-flash
# ACTOR_DEEPSEEK_MODEL=deepseek-flash
ACTOR_OPENAI_API_KEY=
ACTOR_OPENAI_API_KEY=
@@ -139,7 +139,8 @@ The resource index is for OpenVibe.Services on loopback and is blocked at the pu
@@ -139,7 +139,8 @@ The resource index is for OpenVibe.Services on loopback and is blocked at the pu
- **Limits:** 5 asks an hour per person and 60 across everyone (`ACTOR_CHAT_ASKS_PER_HOUR`, `ACTOR_CHAT_ASKS_ALL_PER_HOUR`). Past a limit, one reply says so and further asks are dropped quietly.
- **Limits:** 5 asks an hour per person and 60 across everyone (`ACTOR_CHAT_ASKS_PER_HOUR`, `ACTOR_CHAT_ASKS_ALL_PER_HOUR`). Past a limit, one reply says so and further asks are dropped quietly.
- **Data:** `actor_chat_asks` (migration 0009) is part of the person's export and erasure.
- **Data:** `actor_chat_asks` (migration 0009) is part of the person's export and erasure.
- `ACTOR_CHAT_ASKS=0` turns it off.
- `ACTOR_CHAT_ASKS=0` turns it off.
-- **The model providers**: DeepSeek (`ACTOR_DEEPSEEK_API_KEY`, `api.deepseek.com`), OpenAI (`ACTOR_OPENAI_API_KEY`, `api.openai.com/v1`) for the web-search agent and the cross-family check, and an OpenAI-compatible local server (`ACTOR_LOCAL_LLM_URL`) for private mode.
+- **Models: OpenVibe.AI's router** (`ACTOR_AI_ROUTER=1`, set in production). Every model call is a run of `ai.complete` on OpenVibe.AI with Actor's service token (audience `openvibe.ai`, `ai.run.create`); each tier names a policy on it — `fast` `openvibe/economy`, `smart` `openvibe/balanced`, `best` `openvibe/auto`, `check` `openvibe/economy`, `vision` `openvibe/auto` (`ACTOR_AI_MODEL_*`) — and AI picks the offer by price, latency, uptime and policy, fails over, and says which model ran. The person a task belongs to rides as `on_behalf_of`: their allowance and their Balance pay, and the run is theirs. `ACTOR_AI_MAX_COST_USD` caps each call. **Actor then holds no provider key**: `ACTOR_DEEPSEEK_API_KEY`, `ACTOR_OPENAI_API_KEY`, `ACTOR_LOCAL_LLM_URL`/`ACTOR_LOCAL_LLM_MODEL`, `ACTOR_RUNTIME_*` and `ACTOR_COMPAT_*` are unused (the cross-family check is one family now: the verdict says so). A person can still bring their own key, sealed in OpenVibe.AI.
+- **The model providers** (a self-hosted Actor, router off): DeepSeek (`ACTOR_DEEPSEEK_API_KEY`, `api.deepseek.com`), OpenAI (`ACTOR_OPENAI_API_KEY`, `api.openai.com/v1`) for the web-search agent and the cross-family check, and an OpenAI-compatible local server (`ACTOR_LOCAL_LLM_URL`) for private mode.
**Programs:** poppler-utils' `pdftotext` reads the text of a PDF attached in the chat (`ACTOR_PDFTOTEXT` names another path); without it a PDF is kept but its text is not read, and the run is told so.
**Programs:** poppler-utils' `pdftotext` reads the text of a PDF attached in the chat (`ACTOR_PDFTOTEXT` names another path); without it a PDF is kept but its text is not read, and the run is told so.
@@ -178,7 +179,7 @@ A self-hosted Actor ([docs/self-host.md](docs/self-host.md)): `npm run selfhost:
@@ -178,7 +179,7 @@ A self-hosted Actor ([docs/self-host.md](docs/self-host.md)): `npm run selfhost:
See [.env.example](.env.example). Required in production:
See [.env.example](.env.example). Required in production:
- `OV_OAUTH_CLIENT_SECRET` (the `actor` principal; `server/setup/service-principal.js create actor` on the Network writes it);
- `OV_OAUTH_CLIENT_SECRET` (the `actor` principal; `server/setup/service-principal.js create actor` on the Network writes it);
- `BASE_URL`, `DATABASE_URL` and `DATABASE_DIRECT_URL`;
- `BASE_URL`, `DATABASE_URL` and `DATABASE_DIRECT_URL`;
-- at least one agent: `ACTOR_DEEPSEEK_API_KEY`, `ACTOR_OPENAI_API_KEY`, or `ACTOR_LOCAL_LLM_URL` + `ACTOR_LOCAL_LLM_MODEL`.
+- at least one agent: `ACTOR_AI_ROUTER=1` (OpenVibe.AI's router), or `ACTOR_DEEPSEEK_API_KEY`, `ACTOR_OPENAI_API_KEY`, or `ACTOR_LOCAL_LLM_URL` + `ACTOR_LOCAL_LLM_MODEL`.
Readiness requires the database and at least one available agent.
Readiness requires the database and at least one available agent.
@@ -102,9 +102,17 @@ function effective(agent, config) {
@@ -102,9 +102,17 @@ function effective(agent, config) {
};
};
}
}
+/** Is the OpenVibe.AI router this server's model layer? (ACTOR_AI_ROUTER, with a Network client secret.) */
+function routerOn(config) {
+ const ai = config && config.ai;
+ return Boolean(ai && ai.router && ai.url && ai.clientSecret);
+}
+
/** Is the agent usable with this configuration? { available, reason }. */
/** Is the agent usable with this configuration? { available, reason }. */
function availability(agent, config) {
function availability(agent, config) {
const p = config.providers || {};
const p = config.providers || {};
+ // The runtime's model comes from OpenVibe.AI's router: this server holds no provider key at all.
+ if (agent.id === 'openvibe-runtime' && routerOn(config)) return { available: true };
if (agent.id === 'openvibe-runtime' && runtimeOverride(config)) return { available: true };
if (agent.id === 'openvibe-runtime' && runtimeOverride(config)) return { available: true };
if (agent.providerKey === 'deepseek') return p.deepseek && p.deepseek.apiKey ? { available: true } : { available: false, reason: 'no DeepSeek key is configured on this server' };
if (agent.providerKey === 'deepseek') return p.deepseek && p.deepseek.apiKey ? { available: true } : { available: false, reason: 'no DeepSeek key is configured on this server' };
if (agent.providerKey === 'openai') return p.openai && p.openai.apiKey ? { available: true } : { available: false, reason: 'no OpenAI key is configured on this server' };
if (agent.providerKey === 'openai') return p.openai && p.openai.apiKey ? { available: true } : { available: false, reason: 'no OpenAI key is configured on this server' };
@@ -118,6 +126,7 @@ function availability(agent, config) {
@@ -118,6 +126,7 @@ function availability(agent, config) {
/** The model an agent runs with this configuration (for the listing and the task's explanation). */
/** The model an agent runs with this configuration (for the listing and the task's explanation). */
function modelOf(agent, config) {
function modelOf(agent, config) {
+ if (agent.id === 'openvibe-runtime' && routerOn(config)) return config.ai.models && config.ai.models.smart ? config.ai.models.smart : 'openvibe';
if (agent.id === 'openvibe-runtime' && runtimeOverride(config)) return runtimeOverride(config).model;
if (agent.id === 'openvibe-runtime' && runtimeOverride(config)) return runtimeOverride(config).model;
const p = (config.providers || {})[agent.providerKey];
const p = (config.providers || {})[agent.providerKey];
return p && p.model ? p.model : undefined;
return p && p.model ? p.model : undefined;
@@ -12,6 +12,7 @@ const int = (v, def) => (Number.isFinite(parseInt(v, 10)) ? parseInt(v, 10) : de
@@ -12,6 +12,7 @@ const int = (v, def) => (Number.isFinite(parseInt(v, 10)) ? parseInt(v, 10) : de
const num = (v, def) => (v != null && v !== '' && Number.isFinite(Number(v)) ? Number(v) : def);
const num = (v, def) => (v != null && v !== '' && Number.isFinite(Number(v)) ? Number(v) : def);
// An optional price: undefined when nobody typed one, so the provider's published card is used.
// An optional price: undefined when nobody typed one, so the provider's published card is used.
const optNum = (v) => (v != null && v !== '' && Number.isFinite(Number(v)) && Number(v) >= 0 ? Number(v) : undefined);
const optNum = (v) => (v != null && v !== '' && Number.isFinite(Number(v)) && Number(v) >= 0 ? Number(v) : undefined);
+const bool = (v) => /^(1|true|on|yes)$/i.test(String(v == null ? '' : v).trim());
// A JSON list from the environment (the connector servers OpenVibe offers); a broken value is no list, never a crash.
// A JSON list from the environment (the connector servers OpenVibe offers); a broken value is no list, never a crash.
const jsonList = (v) => { if (!v) return []; try { const p = JSON.parse(v); return Array.isArray(p) ? p : []; } catch { return []; } };
const jsonList = (v) => { if (!v) return []; try { const p = JSON.parse(v); return Array.isArray(p) ? p : []; } catch { return []; } };
@@ -82,10 +83,10 @@ function load(env = process.env) {
@@ -82,10 +83,10 @@ function load(env = process.env) {
},
},
cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },
cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },
- // OpenVibe.AI (plan L55 C3): the model gateway a person's own keys live in. Actor holds NO provider key of
- // its own for this: it asks AI to run the call, and AI answers with the key the person chose (their own,
- // sealed in AI's vault) or with OpenVibe's own keys. The token is Actor's service token for audience
- // openvibe.ai (the grant: ai.run.create, namespace ai.complete); the person is named in the request.
+ // OpenVibe.AI (plan L55 C3): the model gateway a person's own keys live in, and — with the router on — the
+ // model layer of this product. Actor then holds NO provider key at all: every model call is a run of
+ // ai.complete on AI, which picks the offer (price, latency, policy) for each tier and says which model ran.
+ // The token is Actor's service token for audience openvibe.ai (the grant: ai.run.create, namespace ai.complete).
ai: {
ai: {
url: trim(env.OV_AI_URL || 'https://ai.openvibe.services'),
url: trim(env.OV_AI_URL || 'https://ai.openvibe.services'),
audience: 'openvibe.ai',
audience: 'openvibe.ai',
@@ -93,6 +94,23 @@ function load(env = process.env) {
@@ -93,6 +94,23 @@ function load(env = process.env) {
clientId: env.OV_OAUTH_CLIENT_ID || 'actor',
clientId: env.OV_OAUTH_CLIENT_ID || 'actor',
clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
timeoutMs: Math.max(5000, int(env.ACTOR_AI_TIMEOUT_MS, 180_000)),
timeoutMs: Math.max(5000, int(env.ACTOR_AI_TIMEOUT_MS, 180_000)),
+ waitMs: Math.max(1000, int(env.ACTOR_AI_WAIT_MS, 120_000)),
+ // The AI layer of every product: with this on, the tiers below run on AI's router and the operator's
+ // provider keys (ACTOR_DEEPSEEK_API_KEY, ACTOR_OPENAI_API_KEY, …) are not used. Off by default so a
+ // self-hosted Actor keeps its own model choices; production sets ACTOR_AI_ROUTER=1.
+ router: bool(env.ACTOR_AI_ROUTER),
+ // Each tier's policy on the router: floor and ceiling in one name (economy = cheapest healthy, fast =
+ // quickest, balanced = price and latency, auto = the router's own, private = OpenVibe-run/local only).
+ // An exact id (<org>/<model>@<revision>) pins a model when AI's catalog is on.
+ models: {
+ fast: env.ACTOR_AI_MODEL_FAST || 'openvibe/economy',
+ smart: env.ACTOR_AI_MODEL_SMART || 'openvibe/balanced',
+ best: env.ACTOR_AI_MODEL_BEST || 'openvibe/auto',
+ check: env.ACTOR_AI_MODEL_CHECK || 'openvibe/economy',
+ vision: env.ACTOR_AI_MODEL_VISION || 'openvibe/auto',
+ },
+ // The per-call liability ceiling (USD, exact decimal) AI holds against the person's Balance. Unset: no cap.
+ maxCostUsd: optNum(env.ACTOR_AI_MAX_COST_USD),
// The AI Exchange catalog (PLAN G-P9), off by default: GET /v1/models from OpenVibe.AI, cached, to offer
// The AI Exchange catalog (PLAN G-P9), off by default: GET /v1/models from OpenVibe.AI, cached, to offer
// its aliases and exact ids where a person picks the model their tasks run on. Off, the model field is
// its aliases and exact ids where a person picks the model their tasks run on. Off, the model field is
// the plain one it was before.
// the plain one it was before.
@@ -32,6 +32,9 @@
@@ -32,6 +32,9 @@
const { AsyncLocalStorage } = require('node:async_hooks');
const { AsyncLocalStorage } = require('node:async_hooks');
const { serviceAuth } = require('openvibe-contracts');
const { serviceAuth } = require('openvibe-contracts');
+const { createClient: createSdkClient } = require('openvibe-sdk/core');
+const { createAiClient } = require('openvibe-sdk/ai');
+const { createServiceTokenClient } = require('openvibe-sdk/auth');
const { chatClient, priceUsage, readStream, ProviderError } = require('../agents/providers');
const { chatClient, priceUsage, readStream, ProviderError } = require('../agents/providers');
const aiCatalogLib = require('./ai-catalog');
const aiCatalogLib = require('./ai-catalog');
@@ -61,9 +64,9 @@ const SMALL = { test: (m) => !FULL.has(String(m)) && SMALL_RE.test(String(m)) };
@@ -61,9 +64,9 @@ const SMALL = { test: (m) => !FULL.has(String(m)) && SMALL_RE.test(String(m)) };
const TIERS = ['fast', 'smart', 'best', 'check', 'vision'];
const TIERS = ['fast', 'smart', 'best', 'check', 'vision'];
const PROVIDERS = ['openai', 'deepseek', 'runtime', 'compatible', 'local'];
const PROVIDERS = ['openai', 'deepseek', 'runtime', 'compatible', 'local'];
-// OpenVibe.AI (plan L55 C3): the gateway, NOT one of the operator's providers. It is never resolved as a tier (a
-// person who did not choose it keeps today's behaviour), and it holds no key of its own: the call goes to AI, and AI
-// answers with the person's own key (sealed in AI's vault) or with OpenVibe's.
+// OpenVibe.AI (plan L55 C3): the gateway, NOT one of the operator's providers. It holds no key of its own. With
+// ACTOR_AI_ROUTER on it IS the model layer (every tier is a run of ai.complete on AI's router); with it off, it is
+// still reached when a person chooses their own key (sealed in AI's vault).
const AI_PROVIDER = 'openvibe-ai';
const AI_PROVIDER = 'openvibe-ai';
/** Parse a pinned tier value: "openai:gpt-5.1", or a bare model on the first configured provider. */
/** Parse a pinned tier value: "openai:gpt-5.1", or a bare model on the first configured provider. */
@@ -126,6 +129,27 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =
@@ -126,6 +129,27 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =
return { available: true };
return { available: true };
}
}
+ // ── OpenVibe.AI's router (the AI layer of this product) ────────────────────────────────────────
+ // With ACTOR_AI_ROUTER on, every tier is a name on AI's router (a policy alias, or an exact id) and no provider
+ // key is read. The token is Actor's own service token; the person the task belongs to rides as on_behalf_of, so
+ // their allowance and their Balance pay and the run is theirs.
+ const routerModels = ai.models && typeof ai.models === 'object' ? ai.models : {};
+ const routerOn = () => Boolean(ai.router) && availability(AI_PROVIDER).available;
+ const routerModel = (tier) => String(routerModels[tier] || 'openvibe');
+ let routerClients = null;
+ function routerClient() {
+ if (routerClients) return routerClients;
+ if (!routerOn()) return null;
+ const tokens = createServiceTokenClient({
+ tokenUrl: `${config.networkInternalUrl || config.networkUrl}/oauth/token`,
+ clientId: ai.clientId, clientSecret: ai.clientSecret,
+ audience: ai.audience || 'openvibe.ai', scope: ai.scope || 'ai.run.create', fetch: fetchImpl,
+ });
+ const client = createSdkClient({ baseUrl: trimSlash(ai.url), getToken: async () => tokens.getToken(), fetch: fetchImpl, retries: 0 });
+ routerClients = { tokens, client, ai: createAiClient(client, { baseUrl: trimSlash(ai.url) }) };
+ return routerClients;
+ }
+
async function listAt(key) {
async function listAt(key) {
const e = endpoints[key];
const e = endpoints[key];
if (!e.baseUrl) return [];
if (!e.baseUrl) return [];
@@ -189,6 +213,9 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =
@@ -189,6 +213,9 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =
// when one offers a model. If none does, the pin stands (the verdict says it was not cross-family).
// when one offers a model. If none does, the pin stands (the verdict says it was not cross-family).
if (!(avoidFamily && endpoints[key].family === avoidFamily)) return descriptor(tier, key, pin.model);
if (!(avoidFamily && endpoints[key].family === avoidFamily)) return descriptor(tier, key, pin.model);
}
}
+ // The router is the model layer: the tier's name on it is the policy (or the exact id), and AI says which
+ // model actually ran. A pin above wins, so an operator can still move one tier back to a provider of theirs.
+ if (routerOn()) return descriptor(tier, AI_PROVIDER, routerModel(tier), { available: true });
for (const key of order) {
for (const key of order) {
if (!availability(key).available) continue;
if (!availability(key).available) continue;
if (avoidFamily && endpoints[key].family === avoidFamily) continue;
if (avoidFamily && endpoints[key].family === avoidFamily) continue;
@@ -422,6 +449,107 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =
@@ -422,6 +449,107 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =
cost_usd: Number(x.cost_usd) || 0,
cost_usd: Number(x.cost_usd) || 0,
};
};
}
}
+ /**
+ * One call through OpenVibe.AI's router (ACTOR_AI_ROUTER): a run of ai.complete, Actor's service token, the
+ * person's id in on_behalf_of. No provider key is touched here — AI holds the offers, prices and failover.
+ *
+ * The tier's name (openvibe/economy|balanced|fast|auto|private, or an exact id) is the policy. The answer's
+ * model is whatever AI says ran, never the wish.
+ */
+ async function chatViaRouter({ tier, messages = [], tools = null, json = false, maxTokens = 4000, signal, onDelta = null, subject = null, target = null }) {
+ const c = routerClient();
+ if (!c) throw new ModelError('OpenVibe.AI is not configured on this server (no Network client secret)', { code: 'actor.model.unavailable' });
+ const input = {
+ messages,
+ model: routerModel(tier),
+ ...(tools && tools.length ? { tools, tool_choice: 'auto' } : {}),
+ ...(json ? { response_format: { type: 'json_object' } } : {}),
+ max_tokens: Math.max(1, Math.min(Number(maxTokens) || 1500, 16000)),
+ temperature: 0.2,
+ ...(ai.maxCostUsd != null ? { max_cost_usd: ai.maxCostUsd } : {}),
+ };
+ const body = {
+ workflow: 'ai.complete', input,
+ ...(subject ? { on_behalf_of: { type: 'user', id: String(subject) } } : {}),
+ ...(target ? { target } : {}),
+ };
+ const timeoutMs = Math.max(5000, Number(ai.timeoutMs) || 180_000);
+ const sig = typeof AbortSignal.any === 'function'
+ ? AbortSignal.any([...(signal ? [signal] : []), AbortSignal.timeout(timeoutMs)])
+ : (signal || AbortSignal.timeout(timeoutMs));
+ let run;
+ try {
+ if (onDelta) run = await routerStream(c, body, { onDelta, signal: sig });
+ else {
+ const created = await c.ai.runs.create('ai.complete', input, {
+ wait: Math.max(1000, Number(ai.waitMs) || 120_000),
+ ...(subject ? { onBehalfOf: { type: 'user', id: String(subject) } } : {}),
+ ...(target ? { target } : {}), options: { cache: false }, signal: sig,
+ });
+ run = created.run;
+ if (!['succeeded', 'cached', 'failed', 'cancelled'].includes(run.status)) run = await c.ai.runs.waitFor(run.id, { timeoutMs, signal: sig });
+ }
+ } catch (err) {
+ if (signal && signal.aborted) throw new ProviderError('cancelled', { code: 'actor.task.cancelled' });
+ if (err instanceof ProviderError || err instanceof ModelError) throw err;
+ const code = String((err && err.code) || '');
+ if (/timeout|deadline|aborted/.test(code)) throw new ProviderError('OpenVibe.AI did not answer in time', { code: 'actor.provider.unreachable' });
+ throw new ProviderError(`OpenVibe.AI: ${String((err && (err.message || err.code)) || 'request failed').slice(0, 300)}`, { status: err && err.status, code: 'actor.provider.error' });
+ }
+ if (run.status === 'failed' || run.status === 'cancelled') {
+ const e = (run.error && (run.error.detail || run.error.code)) || run.status;
+ throw new ProviderError(`OpenVibe.AI's run ${run.status}: ${String(e).slice(0, 300)}`, { code: String((run.error && run.error.code) || 'actor.provider.error') });
+ }
+ if (!run.output || !Array.isArray(run.output.choices)) throw new ProviderError('OpenVibe.AI answered without a completion');
+ const first = run.output.choices[0] || {};
+ const message = first.message || { role: 'assistant', content: '' };
+ const u = run.usage || {};
+ return {
+ message,
+ text: typeof message.content === 'string' ? message.content : '',
+ toolCalls: Array.isArray(message.tool_calls) ? message.tool_calls : [],
+ finish: first.finish_reason || null,
+ model: run.output.model || (run.provenance && run.provenance.model) || input.model,
+ usage: { input: u.tokens_in || 0, cached: u.tokens_cached || 0, output: u.tokens_out || 0 },
+ cost_usd: Number(u.cost_usd) || 0,
+ };
+ }
+
+ /** The router's streamed run (POST /api/v1/runs?stream=1): `delta` events as they come, then the run. */
+ async function routerStream(c, body, { onDelta, signal }) {
+ const { response } = await c.client.request({
+ method: 'POST', path: '/api/v1/runs', query: { stream: 1, wait: Math.max(1000, Number(ai.waitMs) || 120_000) },
+ json: body, responseType: 'response', idempotencyKey: false, retries: 0, signal,
+ });
+ if (!response || !response.body) throw new ProviderError('OpenVibe.AI sent no stream');
+ const decoder = new TextDecoder();
+ let buf = '', event = 'message', data = [], run = null, failure = null;
+ const take = () => {
+ if (!data.length) { event = 'message'; return; }
+ const raw = data.join('\n'); data = [];
+ const name = event; event = 'message';
+ let j = null;
+ try { j = JSON.parse(raw); } catch { return; }
+ if (name === 'delta' && j && typeof j.text === 'string') { try { onDelta(j.text); } catch { /* the listener's problem */ } }
+ else if (name === 'run' && j && j.run) run = j.run;
+ else if (name === 'error') failure = j || {};
+ };
+ for await (const chunk of response.body) {
+ buf += decoder.decode(chunk, { stream: true });
+ let i;
+ while ((i = buf.indexOf('\n')) >= 0) {
+ let line = buf.slice(0, i); buf = buf.slice(i + 1);
+ if (line.endsWith('\r')) line = line.slice(0, -1);
+ if (!line) { take(); continue; }
+ if (line.startsWith('event:')) event = line.slice(6).trim();
+ else if (line.startsWith('data:')) data.push(line.slice(5).replace(/^ /, ''));
+ }
+ }
+ if (failure) throw new ProviderError(`OpenVibe.AI: ${String(failure.message || failure.code || 'stream error').slice(0, 300)}`, { code: String(failure.code || 'actor.provider.error') });
+ if (!run) throw new ProviderError('OpenVibe.AI ended the stream without the run');
+ return run;
+ }
+
/** One model call. The caller checks the budget first and meters cost_usd after; this layer only prices it. */
/** One model call. The caller checks the budget first and meters cost_usd after; this layer only prices it. */
async function chat({ tier = 'smart', messages = null, tools = null, json = false, maxTokens = 4000, signal, avoidFamily = null, provider = null, model = null, onDelta = null } = {}) {
async function chat({ tier = 'smart', messages = null, tools = null, json = false, maxTokens = 4000, signal, avoidFamily = null, provider = null, model = null, onDelta = null } = {}) {
// The person's own key (plan L55 C3): every model call of this run goes to OpenVibe.AI with it.
// The person's own key (plan L55 C3): every model call of this run goes to OpenVibe.AI with it.
@@ -430,6 +558,11 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =
@@ -430,6 +558,11 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =
const r = await chatViaAi({ choice, messages, tools, json, maxTokens, signal, onDelta });
const r = await chatViaAi({ choice, messages, tools, json, maxTokens, signal, onDelta });
return { ...r, tier, provider: AI_PROVIDER, family: AI_PROVIDER };
return { ...r, tier, provider: AI_PROVIDER, family: AI_PROVIDER };
}
}
+ // The router (the AI layer): the tier names a policy on AI and the person's allowance pays. No provider key.
+ if (routerOn()) {
+ const r = await chatViaRouter({ tier, messages, tools, json, maxTokens, signal, onDelta, subject: choice && choice.subject, target: choice && choice.target });
+ return { ...r, tier, provider: AI_PROVIDER, family: AI_PROVIDER };
+ }
await warm();
await warm();
if (provider && !PROVIDERS.includes(provider)) throw new ModelError(`unknown model provider "${provider}"`, { code: 'actor.model.provider' });
if (provider && !PROVIDERS.includes(provider)) throw new ModelError(`unknown model provider "${provider}"`, { code: 'actor.model.provider' });
const d = provider && model ? descriptor(tier, provider, model) : resolve(tier, { avoidFamily });
const d = provider && model ? descriptor(tier, provider, model) : resolve(tier, { avoidFamily });
@@ -462,6 +595,8 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =
@@ -462,6 +595,8 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =
out.push({ provider: key, model, family: endpoints[key].family });
out.push({ provider: key, model, family: endpoints[key].family });
};
};
for (const key of order) for (const model of candidatesFor(key)) add(key, model);
for (const key of order) for (const model of candidatesFor(key)) add(key, model);
+ // The router: each tier's name on AI (a policy or a pin), so the listing says what this server can reach.
+ if (routerOn()) for (const tier of TIERS) add(AI_PROVIDER, routerModel(tier));
// A tier the operator pinned is reachable whether or not the provider's list mentions it.
// A tier the operator pinned is reachable whether or not the provider's list mentions it.
for (const value of Object.values(cfg.tiers || {})) {
for (const value of Object.values(cfg.tiers || {})) {
const pin = parsePin(value);
const pin = parsePin(value);
@@ -362,6 +362,13 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
@@ -362,6 +362,13 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
// "Runs on: my computer": the model on the person's computer, whatever else they chose above it (a
// "Runs on: my computer": the model on the person's computer, whatever else they chose above it (a
// model id they gave for their computer is kept).
// model id they gave for their computer is kept).
if (runsOn === 'computer' && !(choice && choice.provider === 'node')) choice = modelPrefs.computerChoice(row.requester);
if (runsOn === 'computer' && !(choice && choice.provider === 'node')) choice = modelPrefs.computerChoice(row.requester);
+ // The person the task belongs to rides on every router call (on_behalf_of): their allowance pays,
+ // even when they chose no key. A task with no person (a watch, a webhook) names nobody, and AI
+ // serves it from free or untolled capacity only.
+ if (!choice || !choice.subject) {
+ const subject = String(row.requester || '').startsWith('user:') ? row.requester.slice(5) : null;
+ if (subject) choice = { ...(choice || {}), subject };
+ }
try {
try {
out = await withChoice(models, choice, async () => loop.run({
out = await withChoice(models, choice, async () => loop.run({
task: row.task, requester: fromChat ? null : row.requester, taskId: id, mode: row.mode, signal,
task: row.task, requester: fromChat ? null : row.requester, taskId: id, mode: row.mode, signal,
new file mode 100644
new file mode 100644
@@ -0,0 +1,101 @@
@@ -0,0 +1,101 @@
+'use strict';
+/**
+ * The AI layer (ACTOR_AI_ROUTER): every tier is a run of ai.complete on OpenVibe.AI's router, with Actor's own
+ * service token. No provider key is read, the person the task belongs to rides as on_behalf_of (their allowance
+ * pays), the tier's name is the policy, and the answer's model is whatever AI says ran. Also the streamed run.
+ */
+const assert = require('assert');
+const configLib = require('../server/config');
+const { createModels } = require('../server/runtime/models');
+
+const USR = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+const quiet = { log() {}, warn() {} };
+const reply = (status, obj) => ({ ok: status >= 200 && status < 300, status, headers: new Headers({ 'content-type': 'application/json' }), json: async () => obj, text: async () => JSON.stringify(obj) });
+const headerOf = (headers, name) => (headers && typeof headers.get === 'function' ? headers.get(name) : (headers || {})[name]);
+
+function fakeAi({ answer = 'Hello from the router', model = 'deepseek/deepseek-chat' } = {}) {
+ const calls = [];
+ const fetchImpl = async (url, opts = {}) => {
+ const u = String(url);
+ if (u.includes('/oauth/token')) return reply(200, { access_token: 'tok_router', token_type: 'Bearer', expires_in: 300, scope: 'ai.run.create' });
+ calls.push({ url: u, headers: opts.headers, body: opts.body ? JSON.parse(opts.body) : null });
+ if (u.includes('stream=1')) {
+ const enc = new TextEncoder();
+ const body = new ReadableStream({
+ start(c) {
+ for (const piece of ['Hel', 'lo from', ' the router']) c.enqueue(enc.encode(`event: delta\ndata: ${JSON.stringify({ text: piece })}\n\n`));
+ c.enqueue(enc.encode(`event: run\ndata: ${JSON.stringify({ run: { id: 'run_s', status: 'succeeded', output: { choices: [{ message: { role: 'assistant', content: answer }, finish_reason: 'stop' }], model }, usage: { tokens_in: 9, tokens_out: 4, cost_usd: 0.00004 }, provenance: { model } } })}\n\n`));
+ c.close();
+ },
+ });
+ return new Response(body, { status: 200, headers: { 'content-type': 'text/event-stream' } });
+ }
+ if (u.includes('/api/v1/runs')) {
+ return reply(201, { run: { id: 'run_1', status: 'succeeded', output: { choices: [{ message: { role: 'assistant', content: answer }, finish_reason: 'stop' }], model }, usage: { tokens_in: 12, tokens_out: 4, cost_usd: 0.00003 }, provenance: { model } } });
+ }
+ return reply(404, {});
+ };
+ return { fetchImpl, calls };
+}
+
+const env = {
+ NODE_ENV: 'test',
+ OV_NETWORK_URL: 'https://network.test', OV_NETWORK_INTERNAL_URL: 'https://network.internal',
+ OV_OAUTH_CLIENT_ID: 'actor', OV_OAUTH_CLIENT_SECRET: 'actor-secret',
+ OV_AI_URL: 'https://ai.test', ACTOR_AI_ROUTER: '1',
+ // No provider key at all: the router is the model layer.
+ ACTOR_DEEPSEEK_API_KEY: '', ACTOR_OPENAI_API_KEY: '', ACTOR_LOCAL_LLM_URL: '',
+};
+
+const results = [];
+const ok = async (name, fn) => { try { await fn(); console.log(` ✓ ${name}`); } catch (e) { results.push(e); console.log(` ✗ ${name}\n ${(e.stack || String(e)).split('\n').slice(0, 6).join('\n ')}`); } };
+
+(async () => {
+ await ok('every tier resolves to its policy on the router, and a pinned tier still wins', async () => {
+ const models = createModels({ config: configLib.load(env), fetchImpl: fakeAi().fetchImpl, log: quiet });
+ assert.deepStrictEqual([models.resolve('fast').provider, models.resolve('fast').model, models.resolve('fast').available], ['openvibe-ai', 'openvibe/economy', true]);
+ assert.strictEqual(models.resolve('smart').model, 'openvibe/balanced');
+ assert.strictEqual(models.resolve('best').model, 'openvibe/auto');
+ assert.strictEqual(models.resolve('check').model, 'openvibe/economy');
+ const pinned = createModels({ config: configLib.load({ ...env, ACTOR_AI_MODEL_SMART: 'openvibe/private' }), fetchImpl: fakeAi().fetchImpl, log: quiet });
+ assert.strictEqual(pinned.resolve('smart').model, 'openvibe/private');
+ assert.ok(models.modelList().some((m) => m.provider === 'openvibe-ai' && m.model === 'openvibe/balanced'));
+ });
+
+ await ok('a call is a run of ai.complete: service token, tier policy, the person in on_behalf_of, AI\'s price', async () => {
+ const { fetchImpl, calls } = fakeAi();
+ const models = createModels({ config: configLib.load(env), fetchImpl, log: quiet });
+ const r = await models.withChoice({ subject: USR }, () => models.chat({ tier: 'smart', messages: [{ role: 'user', content: 'Say hello' }] }));
+ assert.strictEqual(r.text, 'Hello from the router');
+ assert.strictEqual(r.provider, 'openvibe-ai');
+ assert.strictEqual(r.model, 'deepseek/deepseek-chat', 'the model that ran, not the wish');
+ assert.ok(Math.abs(r.cost_usd - 0.00003) < 1e-12, 'AI prices the call');
+ const run = calls.find((c) => c.url.includes('/api/v1/runs'));
+ assert.ok(run, 'a run was made');
+ assert.strictEqual(headerOf(run.headers, 'authorization'), 'Bearer tok_router');
+ assert.strictEqual(run.body.workflow, 'ai.complete');
+ assert.strictEqual(run.body.input.model, 'openvibe/balanced');
+ assert.deepStrictEqual(run.body.on_behalf_of, { type: 'user', id: USR });
+ assert.strictEqual(run.body.input.messages[0].content, 'Say hello');
+ assert.ok(!calls.some((c) => /api\.(deepseek|openai)/.test(c.url)), 'no provider key or address');
+ });
+
+ await ok('a streamed call sends the deltas as they come and ends with the run', async () => {
+ const { fetchImpl, calls } = fakeAi();
+ const models = createModels({ config: configLib.load(env), fetchImpl, log: quiet });
+ const pieces = [];
+ const r = await models.withChoice({ subject: USR }, () => models.chat({ tier: 'smart', messages: [{ role: 'user', content: 'Stream' }], onDelta: (t) => pieces.push(t) }));
+ assert.deepStrictEqual(pieces, ['Hel', 'lo from', ' the router']);
+ assert.strictEqual(r.text, 'Hello from the router');
+ assert.ok(calls.some((c) => c.url.includes('stream=1')), 'the streamed run endpoint');
+ });
+
+ await ok('without the router a keyless server has no model, as before', async () => {
+ const models = createModels({ config: configLib.load({ ...env, ACTOR_AI_ROUTER: '0' }), fetchImpl: fakeAi().fetchImpl, log: quiet });
+ assert.strictEqual(models.resolve('smart').available, false);
+ await assert.rejects(() => models.chat({ tier: 'smart', messages: [{ role: 'user', content: 'x' }] }), /model|provider/i);
+ });
+
+ if (results.length) { console.error(`${results.length} failed`); process.exit(1); }
+ console.log('ai-router: all passed');
+})();
@@ -37,10 +37,11 @@ function aiModels(calls) {
@@ -37,10 +37,11 @@ function aiModels(calls) {
(async () => {
(async () => {
const calls = [];
const calls = [];
+ const seenChoices = [];
const models = aiModels(calls);
const models = aiModels(calls);
// The loop, reduced to one model call: what matters here is which model the run's calls go to.
// The loop, reduced to one model call: what matters here is which model the run's calls go to.
const loop = {
const loop = {
- run: async ({ task }) => { const r = await models.chat({ tier: 'smart', messages: [{ role: 'user', content: task }] }); return { text: r.text, state: {} }; },
+ run: async ({ task }) => { seenChoices.push(models.currentChoice()); const r = await models.chat({ tier: 'smart', messages: [{ role: 'user', content: task }] }); return { text: r.text, state: {} }; },
close: async () => {}, planText: () => '', escalate: async () => null, revise: async () => null,
close: async () => {}, planText: () => '', escalate: async () => null, revise: async () => null,
};
};
const t = await boot({ models, loop });
const t = await boot({ models, loop });
@@ -81,6 +82,8 @@ function aiModels(calls) {
@@ -81,6 +82,8 @@ function aiModels(calls) {
const row = await t.waitFor(made.json().id);
const row = await t.waitFor(made.json().id);
assert.strictEqual(row.state, 'succeeded', JSON.stringify([row.error_code, row.error_detail]));
assert.strictEqual(row.state, 'succeeded', JSON.stringify([row.error_code, row.error_detail]));
assert.ok(calls.every((c) => !c.headers['x-openvibe-credential']), 'no person credential');
assert.ok(calls.every((c) => !c.headers['x-openvibe-credential']), 'no person credential');
+ // The AI layer (ACTOR_AI_ROUTER) needs the person on every call even with no key chosen: on_behalf_of.
+ assert.strictEqual(seenChoices[seenChoices.length - 1].subject, kim.subject || kim.id, 'the run names its person for the router');
});
});
await check('private mode stays on OpenVibe\'s own open model even when it is set to the computer', async () => {
await check('private mode stays on OpenVibe\'s own open model even when it is set to the computer', async () => {