openvibers/openvibe.actor Commits

Actor on OpenVibe.AI's router: no provider key, the person's allowance pays (#293)

OP OpenVibers committed 7 days ago · 266925e052 · 8 files, +306 −11

* Actor on OpenVibe.AI's router: no provider key, the person's allowance pays

Every model call of the one loop, the plan, the check, the vision call and the
private-mode answer is now a run of ai.complete on OpenVibe.AI when
ACTOR_AI_ROUTER=1, with Actor's own service token: the tier names a policy on
the router (fast economy, smart balanced, best auto, check economy, vision
auto; ACTOR_AI_MODEL_* overrides, an exact id pins), AI picks the offer and
says which model ran, and ACTOR_AI_MAX_COST_USD caps each call.

The person a task belongs to rides as on_behalf_of even when they chose no
key, so their allowance and Balance pay and the run is theirs; a task with no
person names nobody.

No provider key is read in this mode: ACTOR_DEEPSEEK_API_KEY,
ACTOR_OPENAI_API_KEY, ACTOR_LOCAL_LLM_*, ACTOR_RUNTIME_* and ACTOR_COMPAT_*
can be dropped (kept for a self-hosted Actor, where the router is off). The
cross-family check is one family in this mode and the verdict says so.

Readiness and the agent listing count the runtime as available when the router
is on, and the model listing shows each tier's name on AI.

* Merge remote-tracking branch 'origin/main' into feat/actor-ai-router

# Conflicts:
#	server/config.js

Changes

ddiff --git a/.env.example b/.env.example
iindex 638f357..3ee48c5 100644
---- a/.env.example
++++ b/.env.example
@@@ -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-flash
ACTOR_OPENAI_API_KEY=
ddiff --git a/README.md b/README.md
iindex 5732ead..1a595c3 100644
---- a/README.md
++++ b/README.md
@@@ -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.
ddiff --git a/server/agents/catalog.js b/server/agents/catalog.js
iindex af6ec48..937b875 100644
---- a/server/agents/catalog.js
++++ b/server/agents/catalog.js
@@@ -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;
ddiff --git a/server/config.js b/server/config.js
iindex c4e9c1e..bb66874 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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);
// 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.
ddiff --git a/server/runtime/models.js b/server/runtime/models.js
iindex 319ad81..59f3d1c 100644
---- a/server/runtime/models.js
++++ b/server/runtime/models.js
@@@ -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);
ddiff --git a/server/tasks/engine.js b/server/tasks/engine.js
iindex 56706c9..0e27329 100644
---- a/server/tasks/engine.js
++++ b/server/tasks/engine.js
@@@ -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,
ddiff --git a/test/ai-router.test.js b/test/ai-router.test.js
nnew file mode 100644
iindex 0000000..9876833
---- /dev/null
++++ b/test/ai-router.test.js
@@@ -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');
++})();
ddiff --git a/test/runs-on.test.js b/test/runs-on.test.js
iindex 68dc86e..21b91a0 100644
---- a/test/runs-on.test.js
++++ b/test/runs-on.test.js
@@@ -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 () => {