openvibers/openvibe.ai Commits
A service may name the person it acts for on the AI router (#311)
Changes
@@@ -111,7 +111,7 @@ function createEventStream(res) {return out.done();},fail(message, code) {-- const status = code === 'ai.run_budget_reached' ? 402 : /^(input|validation)\./.test(code || '') ? 400 : /quota|allowance|budget/.test(code || '') ? 429 : 529;++ const status = code === 'ai.run_budget_reached' || code === 'ai.allowance_spent' ? 402 : /^(input|validation)\./.test(code || '') ? 400 : /quota|allowance|budget/.test(code || '') ? 429 : 529;emit('error', { type: 'error', error: { type: errorType(status), message: String(message || 'error').slice(0, 1000), code: code || null } });return out.done();},@@@ -225,7 +225,7 @@ function messagesRouter({ runs, auth, config, registry, log = console }) {}if (run.status === 'failed' || run.status === 'cancelled') {const code = (run.error && run.error.code) || 'run.failed';-- const status = code === 'ai.run_budget_reached' || code === 'billing.insufficient_funds' || code === 'billing.payer_required' ? 402 : /^(input|validation)\./.test(code) ? 400 : /quota|allowance|budget/.test(code) ? 429++ const status = code === 'ai.run_budget_reached' || code === 'ai.allowance_spent' || code === 'billing.insufficient_funds' || code === 'billing.payer_required' ? 402 : /^(input|validation)\./.test(code) ? 400 : /quota|allowance|budget/.test(code) ? 429: code === 'credential.unsupported' ? 422 : code === 'credential.not_found' ? 404: code === 'ai.model_unavailable' ? 400 : code === 'ai.model_not_found' ? 404 : 529;return anthropicError(res, status, (run.error && run.error.detail) || code, null, code);@@@ -22,6 +22,10 @@* OpenAI-format API), model, secret (the name of a project secret granted* to ai for reveal to that origin, ADR-056) }; the key is revealed for* that one run, and the project pays its provider directly++ * x_openvibe.on_behalf_of a first-party service naming the person it acts for (a SubjectRef++ * { type: 'user', id: 'usr_…' }, or the run token's own claim): their free++ * allowance and cache scope, never their Balance. A person's own token++ * runs for themselves and may hold it.** GET /v1/models lists the registry's real models in ai.model@1 shape plus the dynamic aliases (behind* AI_MODEL_CATALOG; off, the legacy single `openvibe` model); GET /v1/models/{id} answers one. Both protocols share@@@ -100,7 +104,7 @@ function createLiveStream(res) {sentAny: () => any,finished: (i) => finishedIdx.has(i),fail(message, code) {-- const status = code === 'ai.run_budget_reached' || code === 'billing.insufficient_funds' || code === 'billing.payer_required' ? 402 : /^(input|validation)\./.test(code || '') ? 400 : /quota|allowance|budget/.test(code || '') ? 429 : 503;++ const status = code === 'ai.run_budget_reached' || code === 'ai.allowance_spent' || code === 'billing.insufficient_funds' || code === 'billing.payer_required' ? 402 : /^(input|validation)\./.test(code || '') ? 400 : /quota|allowance|budget/.test(code || '') ? 429 : 503;write({ error: { message: String(message || 'error').slice(0, 1000), type: status === 400 ? 'invalid_request_error' : status === 429 ? 'rate_limit_error' : status === 402 ? 'insufficient_quota' : 'api_error', code: code || null, param: null } });return out.done();},@@@ -184,6 +188,16 @@ function openaiRouter({ runs, auth, config, registry, log = console }) {// L37: the soft preference (provider, and a model if the caller wants one). A wish, not a pin.if (b.prefer) body = { ...body, prefer: b.prefer };if (req.principal.kind === 'app') body = { ...body, attribution: appAttribution(req.principal) };++ // A first-party service naming the person it acts for (the runs API's on_behalf_of). Their free allowance++ // and cache scope are theirs; their Balance pays only when they run with their own token. An app run is its++ // project's and never names a person, as on the runs API. A run token carries the claim, which the auth layer++ // reads into the principal.++ const obo = b.x_openvibe && typeof b.x_openvibe === 'object' ? b.x_openvibe.on_behalf_of : undefined;++ if (obo !== undefined) {++ if (req.principal.kind === 'app') return openaiError(res, 403, 'an app run is attributed to its project, never to a person', 'capability.denied');++ if (req.principal.kind === 'user' && (!obo || obo.id !== req.principal.subject.id)) return openaiError(res, 403, 'a person runs for themselves, never for someone else', 'capability.denied');++ body = { ...body, on_behalf_of: obo };++ }// The person's own key (Codes C3): x-openvibe-credential names the provider whose key this request uses.const personKey = credentialFor(req);if (personKey.error) return openaiError(res, personKey.status, personKey.error, 'invalid_request_error');@@@ -213,7 +227,7 @@ function openaiRouter({ runs, auth, config, registry, log = console }) {}if (run.status === 'failed' || run.status === 'cancelled') {const code = (run.error && run.error.code) || 'run.failed';-- const status = code === 'ai.run_budget_reached' || code === 'billing.insufficient_funds' || code === 'billing.payer_required' ? 402 : /^(input|validation)\./.test(code) ? 400 : /quota|allowance|budget/.test(code) ? 429++ const status = code === 'ai.run_budget_reached' || code === 'ai.allowance_spent' || code === 'billing.insufficient_funds' || code === 'billing.payer_required' ? 402 : /^(input|validation)\./.test(code) ? 400 : /quota|allowance|budget/.test(code) ? 429: code === 'project_secret.refused' ? 403 : code === 'credential.endpoint_refused' ? 422: code === 'ai.model_unavailable' ? 400 : code === 'ai.model_not_found' ? 404 : 503;return openaiError(res, status, (run.error && run.error.detail) || code, code);@@@ -34,8 +34,8 @@ const { appMayRun, appAttribution, DIRECT_OPS } = require('../apps');function runsRouter({ runs, registry, auth, config, log = console }) {const r = express.Router();-- const create = auth.requireCap(CAPS.runCreate);-- const read = auth.requireCap(CAPS.runRead, CAPS.runCreate, CAPS.usageRead);++ const create = auth.personOrCap(CAPS.runCreate);++ const read = auth.personOrCap(CAPS.runRead, CAPS.runCreate, CAPS.usageRead);// The routes a developer app may use: the six direct operations and GET /runs/:id.const createApp = auth.appOrCap(CAPS.appRun, CAPS.runCreate);const readApp = auth.appOrCap(CAPS.appRun, CAPS.runRead, CAPS.runCreate, CAPS.usageRead);@@@ -22,7 +22,7 @@* routes that do not take one refuse env=sandbox with token.sandbox_refused, as before.*/const { serviceAuth, capabilities, http } = require('openvibe-contracts');--const { jwksClient, jwksStatus } = require('openvibe-sdk/auth');++const { jwksClient, jwksStatus, verifyUserToken } = require('openvibe-sdk/auth');const apps = require('./apps');const CAPS = Object.freeze({@@@ -40,6 +40,14 @@ const CAPS = Object.freeze({configManage: 'ai.config.manage',});++// A person's own Network token (audience openvibe.ai): the same checks server/me.js applies, so a person may run++// and pay for themselves on the model routes. A service, app, module or node token is never a person.++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const NON_PERSON = /^(svc|app|mod|node|agent):/;++const PRINCIPAL_TYPES = ['service', 'app', 'mod', 'node', 'agent'];++// A person runs the model API only: their token may not reach a product's own workflow namespace.++const PERSON_NS = ['ai.*'];++/*** The Network signing keys, through the SDK's process-wide JWKS client (one per URL). A pinned* OV_NETWORK_PUBLIC_KEY PEM skips the fetch entirely; /api/ready reports either as ready.@@@ -145,7 +153,10 @@ function createAuth({ config, log = console }) {}if (!decision.allowed) return http.sendProblem(res, 403, decision.code, { detail: anyOf.length > 1 ? `one of ${anyOf.join(', ')} is required` : decision.reason, ctx });const subject = principalSubject(r.claims.sub);-- req.principal = { sub: r.claims.sub, subject, cap: r.claims.cap, ns: r.claims.ns || [], jti: r.claims.jti, claims: r.claims };++ // A run token names the person the service acts for (Codes C3). Runs read it as the payer and the++ // free allowance's owner; without it a service run would have no account of its own.++ const onBehalfOf = typeof r.claims.on_behalf_of === 'string' ? r.claims.on_behalf_of : null;++ req.principal = { sub: r.claims.sub, subject, onBehalfOf, cap: r.claims.cap, ns: r.claims.ns || [], jti: r.claims.jti, claims: r.claims };return next();} catch (err) {log.error(`[auth] ${req.method} ${req.path}: ${(err && err.stack) || err}`);@@@ -154,13 +165,34 @@ function createAuth({ config, log = console }) {};}++ /**++ * The person behind a user token, or null. A person runs the model API for themselves and pays with their own++ * allowance or Balance; their token must name audience openvibe.ai and must not act for anyone else. Service,++ * app, module, node and delegation tokens are never a person.++ */++ async function personFor(token) {++ let claims;++ try {++ claims = await verifyUserToken(token, {++ ...(config.networkPublicKey ? { publicKey: config.networkPublicKey } : { jwks: config.networkJwksUrl }),++ issuer: config.issuer, audience: config.audience, now: Date.now(), log,++ });++ } catch { return null; }++ if (!claims || NON_PERSON.test(String(claims.sub || '')) || PRINCIPAL_TYPES.includes(claims.actor_type)) return null;++ if (claims.on_behalf_of != null) return null;++ const id = SUBJECT_RE.test(String(claims.subject_id || '')) ? claims.subject_id : (SUBJECT_RE.test(String(claims.sub || '')) ? claims.sub : null);++ if (!id) return null;++ return { kind: 'user', sub: claims.sub, subject: { type: 'user', id }, ns: [...PERSON_NS], cap: claims.cap, claims };++ }++/*** A guard for the routes a developer app may use (ADR-014; ai.app.run). A first-party service* token is judged on anyOf exactly as requireCap; an app token (actor_type app) is judged only on* appCap, and becomes req.principal whose SUBJECT IS ITS PROJECT, so every run, quota, attribution* and free allowance of the app is the project's. A sandbox app token is accepted here and* nowhere else. Sets req.principal.kind = 'app' with appId, projectId, projectKey, env and the-- * app's namespaces.++ * app's namespaces. A person's own token is accepted too (kind 'user', namespace ai.*): they run++ * for themselves, and only their own allowance or Balance can pay.*/function appOrCap(appCap, ...anyOf) {const appCaps = Array.isArray(appCap) ? appCap : [appCap];@@@ -171,7 +203,12 @@ function createAuth({ config, log = console }) {if (!token) return serviceGuard(req, res, next); // token.missing, with requireCap's wordingtry {const r = await verify(token, { acceptSandbox: true });-- if (!r.ok || !isAppClaims(r.claims)) return await serviceGuard(req, res, next);++ if (!r.ok) {++ const person = await personFor(token);++ if (person) { req.principal = person; return next(); }++ return await serviceGuard(req, res, next);++ }++ if (!isAppClaims(r.claims)) return await serviceGuard(req, res, next);const app = apps.appPrincipal(r.claims);if (app.error) return http.sendProblem(res, 401, 'token.invalid_claims', { detail: app.error, ctx });let decision = null;@@@ -193,10 +230,30 @@ function createAuth({ config, log = console }) {};}++ /** A service token on anyOf as requireCap, or a person's own token (who runs for themselves only). */++ function personOrCap(...anyOf) {++ const serviceGuard = requireCap(...anyOf);++ return async function personOrCapGuard(req, res, next) {++ const ctx = req.ov;++ const token = bearer(req);++ if (!token) return serviceGuard(req, res, next);++ try {++ const r = await verify(token);++ if (r.ok) return await serviceGuard(req, res, next);++ const person = await personFor(token);++ if (person) { req.principal = person; return next(); }++ return await serviceGuard(req, res, next);++ } catch (err) {++ log.error(`[auth] ${req.method} ${req.path}: ${(err && err.stack) || err}`);++ if (!res.headersSent) http.sendProblem(res, 500, 'internal.error', { detail: 'internal error', ctx });++ }++ };++ }++/** Does the authenticated principal also hold `id`? (for owner-or-admin checks) */function principalHas(req, id) { return Boolean(req.principal && allows(req.principal.claims, id).allowed); }-- return { verify, requireCap, appOrCap, principalHas };++ return { verify, requireCap, appOrCap, personOrCap, principalHas };}module.exports = { CAPS, createNetworkKeys, createAuth, hasCap, allows, isAppClaims, namespaceAllowed, effectiveNamespaces, principalSubject, bearer };@@@ -433,8 +433,10 @@ function createCredits({ config, db = null, registry = null, env = process.env,* Begin a request's Balance session, or null when there is no payer (the pool then refuses paid offers* that carry a versioned card), when an app names no project, or for a BYOK run (no managed route).*/-- begin({ requestId, ctx = {}, input = {}, attribution = null, onBehalfOf = null, credentialId = null } = {}) {-- const payer = payerOf({ onBehalfOf: onBehalfOf || ctx.onBehalfOf || null, appId: ctx.appId });++ begin({ requestId, ctx = {}, input = {}, attribution = null, onBehalfOf = null, credentialId = null, personPayer = true } = {}) {++ // personPayer false: a service named the person, and only their own session token (or a subject++ // assertion they authorised) may spend their Balance. The run gets no hold of theirs.++ const payer = payerOf({ onBehalfOf: personPayer ? (onBehalfOf || ctx.onBehalfOf || null) : null, appId: ctx.appId });if (!payer) return null;// D12: an app spends its project's Balance; a person's own run is personal, so an attribution is// never turned into a project_id on a user's hold.@@@ -192,6 +192,16 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { nowif (body.version && !['active', 'deprecated'].includes(wf.status)) throw new AiError(409, 'workflow.inactive', `${wfKey} v${wf.version} is ${wf.status}`);const pinned = routePinRequest(body.route, principal, wf);if (pinned && body.credential != null) throw new AiError(422, 'input.invalid', 'a pinned route cannot be combined with a person\'s credential; the pin is the provider');++ // A run token names the person it acts for (Codes C3, `on_behalf_of` in the claims). The run records that++ // person exactly as a body field would, so they pay, the run is theirs and caches never cross persons. The++ // body may repeat the claim but never change it; an app's run is its project's (server/api/runs.js appBody),++ // so the claim is left alone there.++ if (principal.kind !== 'app' && principal.onBehalfOf != null) {++ const claimed = String(principal.onBehalfOf);++ const given = body.on_behalf_of && body.on_behalf_of.id != null ? String(body.on_behalf_of.id) : null;++ if (given != null && given !== claimed) throw new AiError(403, 'capability.denied', 'on_behalf_of does not match the token');++ if (body.on_behalf_of == null) body = { ...body, on_behalf_of: { type: 'user', id: claimed } };++ }validateRefs(body);const input = body.input === undefined ? {} : body.input;const inputJson = stableStringify(input);@@@ -260,7 +270,23 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now// the run is uncached and no network budget or tier holds it: their own key is free to them, and a $0 balance// must never block it. If it falls back to the network, the usage is still recorded after the fact.const personSubject = body.on_behalf_of && body.on_behalf_of.type === 'user' && typeof body.on_behalf_of.id === 'string' ? body.on_behalf_of.id : null;-- const selfKeys = Boolean(credentials && personSubject && credentialSubject === null && !projectProvider && !pinned && await credentials.hasSelf(personSubject));++ // A person running with their own token: the run is theirs. They may only name themselves, and their own++ // allowance or Balance pays. A service naming a person (body, x_openvibe, or a run token claim) draws only++ // that person's free allowance and cache scope, never their Balance, until the person authorises the charge++ // with a subject assertion (H1, openvibe-sdk #122; not accepted yet).++ const personOwnToken = principal.kind === 'user' && requester.type === 'user';++ if (personOwnToken && personSubject && personSubject !== requester.id) {++ throw new AiError(403, 'capability.denied', 'a person runs for themselves, never for someone else');++ }++ const runPerson = personOwnToken ? requester.id : personSubject;++ const personPays = Boolean(runPerson) && personOwnToken;++ // The calling service's own budget (a run token's registered budget): with it, a run for a named person may++ // use paid capacity, counted against that budget; without it, only free capacity serves the run.++ const tokenBudget = runBudgets && principal.jti ? await runBudgets.spent(principal.jti) : null;++ const serviceBudgeted = Boolean(tokenBudget && (tokenBudget.budget_usd != null || tokenBudget.budget_tokens != null));++ // The injected run record names the person a user runs for, so preferences and cache scope follow them.++ if (runPerson && !personSubject) body = { ...body, on_behalf_of: { type: 'user', id: runPerson } };++ const selfKeys = Boolean(credentials && runPerson && credentialSubject === null && !projectProvider && !pinned && await credentials.hasSelf(runPerson));// The budget a Codes run token carries (Codes C3): a run that is over its budget is refused before// any provider call. The budget lives here, not in the token, so the holder of the token cannot raise it.if (runBudgets && principal.jti) {@@@ -278,7 +304,10 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { nowactorKey: subjectKey(body.on_behalf_of), targetKey: entityKey(body.target), attributionKey: entityKey(body.attribution), workflowKey: wf.key,...(app ? { appRun: true, projectId: app.projectId, sandbox: app.sandbox, appId: principal.appId } : {}),// Who pays a Balance hold (D12): the app and its project, else the person a token acts for, else the service.-- onBehalfOf: personSubject || principal.onBehalfOf || null,++ onBehalfOf: runPerson || principal.onBehalfOf || null,++ // Only the person's own token may hold their Balance; a service naming them draws free capacity only++ // (or its own run-token budget). The run still records the person, so preferences and cache follow them.++ personSubject: runPerson || null, personPays, serviceBudgeted,// A credential or a project's own provider never reaches managed supply: no Balance hold for that run....(credentialSubject !== null || projectProvider ? { byok: true } : {}),...(maxCostNano != null ? { maxCostNano: String(maxCostNano) } : {}),@@@ -304,7 +333,8 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { nowinput: null, input_hash: inputHash, idempotency_key: idem, trace_id: trace, request_id: requestId, retry_of: retryOf,options: JSON.stringify({ cache: options.cache !== false && credentialSubject === null && !projectProvider && !pinned && !selfKeys && !communityOptIn, debug: Boolean(options.debug), input_retained: kept.full,...(credentialSubject !== null ? { credential_subject: credentialSubject } : {}), ...(credentialProvider !== null ? { credential_provider: credentialProvider } : {}),-- ...(selfKeys ? { self_subject: personSubject } : {}),++ ...(selfKeys ? { self_subject: runPerson } : {}),++ ...(runPerson ? { person_pays: personPays } : {}),...(projectProvider ? { project_provider: projectProvider } : {}), ...(pinned ? { pinned_offer: pinned } : {}),...(communityOptIn ? { community: true } : {}), ...(prefer ? { prefer } : {}) }), created_at: now,};@@@ -569,10 +599,12 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now// default). A run with no project to bill (a first-party service, a cache hit, BYOK) begins no session// and never touches Billing.if (credits && credits.enabled) {-- credit = credits.begin({ requestId: row.request_id || id, ctx, input, attribution: parseJson(row.attribution, null), onBehalfOf: ctx.onBehalfOf || null });++ credit = credits.begin({ requestId: row.request_id || id, ctx, input, attribution: parseJson(row.attribution, null), onBehalfOf: ctx.onBehalfOf || null, personPayer: ctx.personPays !== false });// No payer (or no project) on a run that is not BYOK: the pool must not serve a paid managed offer-- // with a versioned card, because nobody could be charged for it.-- ctx.creditsRequired = !credit && !ctx.byok;++ // with a versioned card, because nobody could be charged for it. The exception is a calling service's++ // own run-token budget: it pays for the run, counted against that budget, and the person's Balance is++ // never touched.++ ctx.creditsRequired = !credit && !ctx.byok && !ctx.serviceBudgeted;}billingDelta = typeof ctx.onDelta === 'function'? (credit ? (d) => { credit.observe(d); ctx.onDelta(d); } : ctx.onDelta)@@@ -659,8 +691,18 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now} catch (err) {const interrupted = shuttingDown && controller.signal.aborted;const cancelled = !interrupted && (controller.signal.aborted || (err && err.code === 'run.cancelled'));-- const code = interrupted ? 'run.interrupted' : cancelled ? 'run.cancelled' : (err instanceof AiError ? err.code : 'run.internal');-- const detail = interrupted ? 'the service shut down while this run was in progress' : cancelled ? 'cancelled' : (err instanceof AiError ? err.detail : 'internal error');++ let code = interrupted ? 'run.interrupted' : cancelled ? 'run.cancelled' : (err instanceof AiError ? err.code : 'run.internal');++ let detail = interrupted ? 'the service shut down while this run was in progress' : cancelled ? 'cancelled' : (err instanceof AiError ? err.detail : 'internal error');++ // A service naming a person may draw only their free allowance. When that is spent and the service has no++ // budget of its own, say so plainly (402) instead of "no provider could answer": the person's Balance is++ // never touched for a run they did not start themselves.++ const tried = err instanceof AiError && err.extra && Array.isArray(err.extra.tried) ? err.extra.tried : [];++ const payerSkipped = tried.some((t) => t && t.skipped === 'payer_required');++ if (ctx.personPays === false && ctx.personSubject && !ctx.serviceBudgeted++ && (code === 'billing.payer_required' || (code === 'provider.unavailable' && payerSkipped))) {++ code = 'ai.allowance_spent';++ detail = `The free AI allowance for ${ctx.personSubject} is spent, and the calling service has no budget to cover this run. Add a key for the person, or wait for the allowance to reset.`;++ }if (!(err instanceof AiError) && !cancelled) log.error(`[runs] ${id} crashed: ${err && err.stack || err}`);const extra = err instanceof AiError && err.extra ? JSON.stringify(err.extra).slice(0, 2000) : null;const explain = err instanceof AiError && err.extra && err.extra.explain ? JSON.stringify(err.extra.explain) : null;@@@ -7,8 +7,9 @@// provider without a card behaves as before; a sandbox app token never reaches paid supply; with the flag off// nothing changes.const assert = require('assert');++const crypto = require('crypto');const http = require('http');--const { boot, request, token, suite, ALL } = require('./helpers');++const { boot, request, token, suite, ALL, privateKey, ISSUER } = require('./helpers');const t = suite('credits-run');const service = token('live', ALL);@@@ -16,6 +17,13 @@ const ULID = '01JAB2C3D4E5F6G7H8J9K0MNPQ';const PRJ = `prj_${ULID}`;const APP = `app_${ULID}`;const PERSON = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP7';++const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');++/** A person's own Network token (audience openvibe.ai): the subject in sub and subject_id, no actor_type. */++function userToken(audience, subject = PERSON) {++ const now = Math.floor(Date.now() / 1000);++ const input = `${b64({ alg: 'RS256', typ: 'JWT' })}.${b64({ iss: ISSUER, sub: subject, subject_id: subject, aud: [audience], iat: now, exp: now + 3600, jti: `tok_${crypto.randomBytes(8).toString('hex')}` })}`;++ return `${input}.${crypto.sign('RSA-SHA256', Buffer.from(input), privateKey).toString('base64url')}`;++}const appToken = (env) => token('app', ['ai.app.run'], { sub: `app:app_${ULID}`, actorType: 'app', ns: [PRJ, `app.${PRJ}.*`], extra: { project_id: PRJ, env } });const producer = appToken('production');const sandbox = appToken('sandbox');@@@ -203,7 +211,7 @@ t.test('a paid app request holds its project\'s Balance: the hold names the app} finally { await h.stop(); await up.close(); await billing.close(); }});--t.test('a person\'s own run holds their personal Balance: no project_id and no Network call', async () => {++t.test('a service naming a person cannot hold their Balance: no payer, and 402 ai.allowance_spent when no free capacity', async () => {const billing = await fakeBilling();const up = await upstream('ok');const h = await bootWith(billing);@@@ -212,15 +220,73 @@ t.test('a person\'s own run holds their personal Balance: no project_id and no Nconst r = await request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok: service, body: {workflow: 'ai.complete', input: { messages: [{ role: 'user', content: 'hello as me' }] },on_behalf_of: { type: 'user', id: PERSON } } });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.body.run.status, 'failed');++ assert.strictEqual(r.body.run.error.code, 'ai.allowance_spent');++ assert.match(r.body.run.error.detail, /free AI allowance/);++ assert.strictEqual(billing.calls.filter((c) => c.path === '/internal/credits/holds').length, 0, 'never a hold of their Balance');++ assert.strictEqual(billing.settlements.size, 0);++ assert.strictEqual(up.calls, 0, 'the paid offer was not served to a run nobody pays for');++ // The OpenAI route answers the same code as a 402.++ const viaChat = await chat(h, { tok: service, body: { x_openvibe: { on_behalf_of: { type: 'user', id: PERSON } } } });++ assert.strictEqual(viaChat.status, 402, viaChat.text);++ assert.strictEqual(viaChat.body.error.code, 'ai.allowance_spent');++ } finally { await h.stop(); await up.close(); await billing.close(); }++});++++t.test('a person\'s own token holds their personal Balance: no project_id and no Network call', async () => {++ const billing = await fakeBilling();++ const up = await upstream('ok');++ const h = await bootWith(billing);++ try {++ await seed(h, up);++ const r = await request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok: userToken('openvibe.ai'), body: {++ workflow: 'ai.complete', input: { messages: [{ role: 'user', content: 'hello as me' }] } } });assert.ok(r.status < 300, r.text);++ assert.strictEqual(r.body.run.status, 'succeeded', JSON.stringify(r.body.run.error));++ assert.deepStrictEqual(r.body.run.on_behalf_of, { type: 'user', id: PERSON }, 'the run is theirs');++ // The OpenAI-compatible route accepts the same person token and holds the same personal Balance.++ const viaChat = await chat(h, { tok: userToken('openvibe.ai') });++ assert.strictEqual(viaChat.status, 200, viaChat.text);const holds = billing.calls.filter((c) => c.path === '/internal/credits/holds');-- assert.strictEqual(holds.length, 1);++ assert.strictEqual(holds.length, 2);assert.deepStrictEqual(holds[0].body.subject, { type: 'user', id: PERSON });assert.ok(!('project_id' in holds[0].body), 'a personal hold has no project_id');assert.strictEqual(billing.calls.filter((c) => c.path.includes('/internal/projects/')).length, 0, 'Network was never called');} finally { await h.stop(); await up.close(); await billing.close(); }});++t.test('a person\'s token may run only for themselves, and only the model API', async () => {++ const billing = await fakeBilling();++ const up = await upstream('ok');++ const h = await bootWith(billing);++ try {++ await seed(h, up);++ const someoneElse = await request(h.base, 'POST', '/api/v1/runs', { tok: userToken('openvibe.ai'), body: {++ workflow: 'ai.complete', input: { messages: [{ role: 'user', content: 'x' }] },++ on_behalf_of: { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNP1' } } });++ assert.strictEqual(someoneElse.status, 403, someoneElse.text);++ const product = await request(h.base, 'POST', '/api/v1/runs', { tok: userToken('openvibe.ai'), body: { workflow: 'live.viewers.line', input: { role: 'chat', user: 'x' } } });++ assert.strictEqual(product.status, 403, product.text);++ assert.strictEqual(product.body.code, 'capability.namespace_denied');++ } finally { await h.stop(); await up.close(); await billing.close(); }++});++++t.test('a subject assertion is not accepted until H1 lands (openvibe-sdk #122)', async () => {++ // The header exists in SDK #122. Until it is released and AI pins it, a service naming a person gets no Balance.++ const billing = await fakeBilling();++ const up = await upstream('ok');++ const h = await bootWith(billing);++ try {++ await seed(h, up);++ const r = await request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok: service,++ headers: { 'x-ov-subject-assertion': 'eyJhbGciOiJFUzI1NiJ9.e30.c2ln' },++ body: { workflow: 'ai.complete', input: { messages: [{ role: 'user', content: 'hello as me' }] }, on_behalf_of: { type: 'user', id: PERSON } } });++ assert.strictEqual(r.body.run.error.code, 'ai.allowance_spent');++ assert.strictEqual(billing.calls.filter((c) => c.path === '/internal/credits/holds').length, 0);++ } finally { await h.stop(); await up.close(); await billing.close(); }++});++t.test('insufficient credit: a 402 billing.insufficient_funds, and the provider is never called', async () => {const billing = await fakeBilling();const up = await upstream('ok');@@@ -97,6 +97,25 @@ t.test('an app token works, and its run is its project\'s', async () => {assert.match(JSON.stringify(run.body.run.attribution || {}), new RegExp(PRJ));});++t.test('a service acting for a person names them: the run is theirs (the claim, or the x_openvibe extension)', async () => {++ const USR = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';++ const acting = token('actor', ALL, { extra: { on_behalf_of: USR } });++ const claimed = await post({ model: 'openvibe', messages: [{ role: 'user', content: 'For a person' }] }, acting);++ assert.strictEqual(claimed.status, 200, claimed.text);++ const runFor = async (id) => (await request(h.base, 'GET', `/api/v1/runs/${id}`, { tok: service })).body.run;++ assert.deepStrictEqual((await runFor(claimed.body.x_openvibe.run_id)).on_behalf_of, { type: 'user', id: USR }, 'the token\'s claim is the run\'s person');++ const supplied = await post({ model: 'openvibe', messages: [{ role: 'user', content: 'For a person' }], x_openvibe: { on_behalf_of: { type: 'user', id: USR } } });++ assert.strictEqual(supplied.status, 200, supplied.text);++ assert.deepStrictEqual((await runFor(supplied.body.x_openvibe.run_id)).on_behalf_of, { type: 'user', id: USR }, 'the extension is the run\'s person');++ const bad = await post({ model: 'openvibe', messages: [{ role: 'user', content: 'x' }], x_openvibe: { on_behalf_of: { type: 'user', id: 'nobody' } } });++ assert.strictEqual(bad.status, 400, 'a bad SubjectRef is refused before the run');++ assert.strictEqual(bad.body.error.type, 'invalid_request_error');++ const swapped = await post({ model: 'openvibe', messages: [{ role: 'user', content: 'x' }], x_openvibe: { on_behalf_of: { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNP1' } } }, acting);++ assert.deepStrictEqual([swapped.status, swapped.body.error.code], [403, 'capability.denied'], 'a run token cannot name someone else');++ const denied = await post({ model: 'openvibe', messages: [{ role: 'user', content: 'x' }], x_openvibe: { on_behalf_of: { type: 'user', id: USR } } }, app);++ assert.deepStrictEqual([denied.status, denied.body.error.code], [403, 'capability.denied'], 'an app run never names a person');++});++t.test('errors come in OpenAI\'s shape: no token, no messages, n > 1', async () => {const none = await request(h.base, 'POST', '/v1/chat/completions', { body: { messages: [{ role: 'user', content: 'x' }] } });assert.strictEqual(none.status, 401);
@@ -111,7 +111,7 @@ function createEventStream(res) {
@@ -111,7 +111,7 @@ function createEventStream(res) {
return out.done();
return out.done();
},
},
fail(message, code) {
fail(message, code) {
- const status = code === 'ai.run_budget_reached' ? 402 : /^(input|validation)\./.test(code || '') ? 400 : /quota|allowance|budget/.test(code || '') ? 429 : 529;
+ const status = code === 'ai.run_budget_reached' || code === 'ai.allowance_spent' ? 402 : /^(input|validation)\./.test(code || '') ? 400 : /quota|allowance|budget/.test(code || '') ? 429 : 529;
emit('error', { type: 'error', error: { type: errorType(status), message: String(message || 'error').slice(0, 1000), code: code || null } });
emit('error', { type: 'error', error: { type: errorType(status), message: String(message || 'error').slice(0, 1000), code: code || null } });
return out.done();
return out.done();
},
},
@@ -225,7 +225,7 @@ function messagesRouter({ runs, auth, config, registry, log = console }) {
@@ -225,7 +225,7 @@ function messagesRouter({ runs, auth, config, registry, log = console }) {
}
}
if (run.status === 'failed' || run.status === 'cancelled') {
if (run.status === 'failed' || run.status === 'cancelled') {
const code = (run.error && run.error.code) || 'run.failed';
const code = (run.error && run.error.code) || 'run.failed';
- const status = code === 'ai.run_budget_reached' || code === 'billing.insufficient_funds' || code === 'billing.payer_required' ? 402 : /^(input|validation)\./.test(code) ? 400 : /quota|allowance|budget/.test(code) ? 429
+ const status = code === 'ai.run_budget_reached' || code === 'ai.allowance_spent' || code === 'billing.insufficient_funds' || code === 'billing.payer_required' ? 402 : /^(input|validation)\./.test(code) ? 400 : /quota|allowance|budget/.test(code) ? 429
: code === 'credential.unsupported' ? 422 : code === 'credential.not_found' ? 404
: code === 'credential.unsupported' ? 422 : code === 'credential.not_found' ? 404
: code === 'ai.model_unavailable' ? 400 : code === 'ai.model_not_found' ? 404 : 529;
: code === 'ai.model_unavailable' ? 400 : code === 'ai.model_not_found' ? 404 : 529;
return anthropicError(res, status, (run.error && run.error.detail) || code, null, code);
return anthropicError(res, status, (run.error && run.error.detail) || code, null, code);
@@ -22,6 +22,10 @@
@@ -22,6 +22,10 @@
* OpenAI-format API), model, secret (the name of a project secret granted
* OpenAI-format API), model, secret (the name of a project secret granted
* to ai for reveal to that origin, ADR-056) }; the key is revealed for
* to ai for reveal to that origin, ADR-056) }; the key is revealed for
* that one run, and the project pays its provider directly
* that one run, and the project pays its provider directly
+ * x_openvibe.on_behalf_of a first-party service naming the person it acts for (a SubjectRef
+ * { type: 'user', id: 'usr_…' }, or the run token's own claim): their free
+ * allowance and cache scope, never their Balance. A person's own token
+ * runs for themselves and may hold it.
*
*
* GET /v1/models lists the registry's real models in ai.model@1 shape plus the dynamic aliases (behind
* GET /v1/models lists the registry's real models in ai.model@1 shape plus the dynamic aliases (behind
* AI_MODEL_CATALOG; off, the legacy single `openvibe` model); GET /v1/models/{id} answers one. Both protocols share
* AI_MODEL_CATALOG; off, the legacy single `openvibe` model); GET /v1/models/{id} answers one. Both protocols share
@@ -100,7 +104,7 @@ function createLiveStream(res) {
@@ -100,7 +104,7 @@ function createLiveStream(res) {
sentAny: () => any,
sentAny: () => any,
finished: (i) => finishedIdx.has(i),
finished: (i) => finishedIdx.has(i),
fail(message, code) {
fail(message, code) {
- const status = code === 'ai.run_budget_reached' || code === 'billing.insufficient_funds' || code === 'billing.payer_required' ? 402 : /^(input|validation)\./.test(code || '') ? 400 : /quota|allowance|budget/.test(code || '') ? 429 : 503;
+ const status = code === 'ai.run_budget_reached' || code === 'ai.allowance_spent' || code === 'billing.insufficient_funds' || code === 'billing.payer_required' ? 402 : /^(input|validation)\./.test(code || '') ? 400 : /quota|allowance|budget/.test(code || '') ? 429 : 503;
write({ error: { message: String(message || 'error').slice(0, 1000), type: status === 400 ? 'invalid_request_error' : status === 429 ? 'rate_limit_error' : status === 402 ? 'insufficient_quota' : 'api_error', code: code || null, param: null } });
write({ error: { message: String(message || 'error').slice(0, 1000), type: status === 400 ? 'invalid_request_error' : status === 429 ? 'rate_limit_error' : status === 402 ? 'insufficient_quota' : 'api_error', code: code || null, param: null } });
return out.done();
return out.done();
},
},
@@ -184,6 +188,16 @@ function openaiRouter({ runs, auth, config, registry, log = console }) {
@@ -184,6 +188,16 @@ function openaiRouter({ runs, auth, config, registry, log = console }) {
// L37: the soft preference (provider, and a model if the caller wants one). A wish, not a pin.
// L37: the soft preference (provider, and a model if the caller wants one). A wish, not a pin.
if (b.prefer) body = { ...body, prefer: b.prefer };
if (b.prefer) body = { ...body, prefer: b.prefer };
if (req.principal.kind === 'app') body = { ...body, attribution: appAttribution(req.principal) };
if (req.principal.kind === 'app') body = { ...body, attribution: appAttribution(req.principal) };
+ // A first-party service naming the person it acts for (the runs API's on_behalf_of). Their free allowance
+ // and cache scope are theirs; their Balance pays only when they run with their own token. An app run is its
+ // project's and never names a person, as on the runs API. A run token carries the claim, which the auth layer
+ // reads into the principal.
+ const obo = b.x_openvibe && typeof b.x_openvibe === 'object' ? b.x_openvibe.on_behalf_of : undefined;
+ if (obo !== undefined) {
+ if (req.principal.kind === 'app') return openaiError(res, 403, 'an app run is attributed to its project, never to a person', 'capability.denied');
+ if (req.principal.kind === 'user' && (!obo || obo.id !== req.principal.subject.id)) return openaiError(res, 403, 'a person runs for themselves, never for someone else', 'capability.denied');
+ body = { ...body, on_behalf_of: obo };
+ }
// The person's own key (Codes C3): x-openvibe-credential names the provider whose key this request uses.
// The person's own key (Codes C3): x-openvibe-credential names the provider whose key this request uses.
const personKey = credentialFor(req);
const personKey = credentialFor(req);
if (personKey.error) return openaiError(res, personKey.status, personKey.error, 'invalid_request_error');
if (personKey.error) return openaiError(res, personKey.status, personKey.error, 'invalid_request_error');
@@ -213,7 +227,7 @@ function openaiRouter({ runs, auth, config, registry, log = console }) {
@@ -213,7 +227,7 @@ function openaiRouter({ runs, auth, config, registry, log = console }) {
}
}
if (run.status === 'failed' || run.status === 'cancelled') {
if (run.status === 'failed' || run.status === 'cancelled') {
const code = (run.error && run.error.code) || 'run.failed';
const code = (run.error && run.error.code) || 'run.failed';
- const status = code === 'ai.run_budget_reached' || code === 'billing.insufficient_funds' || code === 'billing.payer_required' ? 402 : /^(input|validation)\./.test(code) ? 400 : /quota|allowance|budget/.test(code) ? 429
+ const status = code === 'ai.run_budget_reached' || code === 'ai.allowance_spent' || code === 'billing.insufficient_funds' || code === 'billing.payer_required' ? 402 : /^(input|validation)\./.test(code) ? 400 : /quota|allowance|budget/.test(code) ? 429
: code === 'project_secret.refused' ? 403 : code === 'credential.endpoint_refused' ? 422
: code === 'project_secret.refused' ? 403 : code === 'credential.endpoint_refused' ? 422
: code === 'ai.model_unavailable' ? 400 : code === 'ai.model_not_found' ? 404 : 503;
: code === 'ai.model_unavailable' ? 400 : code === 'ai.model_not_found' ? 404 : 503;
return openaiError(res, status, (run.error && run.error.detail) || code, code);
return openaiError(res, status, (run.error && run.error.detail) || code, code);
@@ -34,8 +34,8 @@ const { appMayRun, appAttribution, DIRECT_OPS } = require('../apps');
@@ -34,8 +34,8 @@ const { appMayRun, appAttribution, DIRECT_OPS } = require('../apps');
function runsRouter({ runs, registry, auth, config, log = console }) {
function runsRouter({ runs, registry, auth, config, log = console }) {
const r = express.Router();
const r = express.Router();
- const create = auth.requireCap(CAPS.runCreate);
- const read = auth.requireCap(CAPS.runRead, CAPS.runCreate, CAPS.usageRead);
+ const create = auth.personOrCap(CAPS.runCreate);
+ const read = auth.personOrCap(CAPS.runRead, CAPS.runCreate, CAPS.usageRead);
// The routes a developer app may use: the six direct operations and GET /runs/:id.
// The routes a developer app may use: the six direct operations and GET /runs/:id.
const createApp = auth.appOrCap(CAPS.appRun, CAPS.runCreate);
const createApp = auth.appOrCap(CAPS.appRun, CAPS.runCreate);
const readApp = auth.appOrCap(CAPS.appRun, CAPS.runRead, CAPS.runCreate, CAPS.usageRead);
const readApp = auth.appOrCap(CAPS.appRun, CAPS.runRead, CAPS.runCreate, CAPS.usageRead);
@@ -22,7 +22,7 @@
@@ -22,7 +22,7 @@
* routes that do not take one refuse env=sandbox with token.sandbox_refused, as before.
* routes that do not take one refuse env=sandbox with token.sandbox_refused, as before.
*/
*/
const { serviceAuth, capabilities, http } = require('openvibe-contracts');
const { serviceAuth, capabilities, http } = require('openvibe-contracts');
-const { jwksClient, jwksStatus } = require('openvibe-sdk/auth');
+const { jwksClient, jwksStatus, verifyUserToken } = require('openvibe-sdk/auth');
const apps = require('./apps');
const apps = require('./apps');
const CAPS = Object.freeze({
const CAPS = Object.freeze({
@@ -40,6 +40,14 @@ const CAPS = Object.freeze({
@@ -40,6 +40,14 @@ const CAPS = Object.freeze({
configManage: 'ai.config.manage',
configManage: 'ai.config.manage',
});
});
+// A person's own Network token (audience openvibe.ai): the same checks server/me.js applies, so a person may run
+// and pay for themselves on the model routes. A service, app, module or node token is never a person.
+const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const NON_PERSON = /^(svc|app|mod|node|agent):/;
+const PRINCIPAL_TYPES = ['service', 'app', 'mod', 'node', 'agent'];
+// A person runs the model API only: their token may not reach a product's own workflow namespace.
+const PERSON_NS = ['ai.*'];
+
/**
/**
* The Network signing keys, through the SDK's process-wide JWKS client (one per URL). A pinned
* The Network signing keys, through the SDK's process-wide JWKS client (one per URL). A pinned
* OV_NETWORK_PUBLIC_KEY PEM skips the fetch entirely; /api/ready reports either as ready.
* OV_NETWORK_PUBLIC_KEY PEM skips the fetch entirely; /api/ready reports either as ready.
@@ -145,7 +153,10 @@ function createAuth({ config, log = console }) {
@@ -145,7 +153,10 @@ function createAuth({ config, log = console }) {
}
}
if (!decision.allowed) return http.sendProblem(res, 403, decision.code, { detail: anyOf.length > 1 ? `one of ${anyOf.join(', ')} is required` : decision.reason, ctx });
if (!decision.allowed) return http.sendProblem(res, 403, decision.code, { detail: anyOf.length > 1 ? `one of ${anyOf.join(', ')} is required` : decision.reason, ctx });
const subject = principalSubject(r.claims.sub);
const subject = principalSubject(r.claims.sub);
- req.principal = { sub: r.claims.sub, subject, cap: r.claims.cap, ns: r.claims.ns || [], jti: r.claims.jti, claims: r.claims };
+ // A run token names the person the service acts for (Codes C3). Runs read it as the payer and the
+ // free allowance's owner; without it a service run would have no account of its own.
+ const onBehalfOf = typeof r.claims.on_behalf_of === 'string' ? r.claims.on_behalf_of : null;
+ req.principal = { sub: r.claims.sub, subject, onBehalfOf, cap: r.claims.cap, ns: r.claims.ns || [], jti: r.claims.jti, claims: r.claims };
return next();
return next();
} catch (err) {
} catch (err) {
log.error(`[auth] ${req.method} ${req.path}: ${(err && err.stack) || err}`);
log.error(`[auth] ${req.method} ${req.path}: ${(err && err.stack) || err}`);
@@ -154,13 +165,34 @@ function createAuth({ config, log = console }) {
@@ -154,13 +165,34 @@ function createAuth({ config, log = console }) {
};
};
}
}
+ /**
+ * The person behind a user token, or null. A person runs the model API for themselves and pays with their own
+ * allowance or Balance; their token must name audience openvibe.ai and must not act for anyone else. Service,
+ * app, module, node and delegation tokens are never a person.
+ */
+ async function personFor(token) {
+ let claims;
+ try {
+ claims = await verifyUserToken(token, {
+ ...(config.networkPublicKey ? { publicKey: config.networkPublicKey } : { jwks: config.networkJwksUrl }),
+ issuer: config.issuer, audience: config.audience, now: Date.now(), log,
+ });
+ } catch { return null; }
+ if (!claims || NON_PERSON.test(String(claims.sub || '')) || PRINCIPAL_TYPES.includes(claims.actor_type)) return null;
+ if (claims.on_behalf_of != null) return null;
+ const id = SUBJECT_RE.test(String(claims.subject_id || '')) ? claims.subject_id : (SUBJECT_RE.test(String(claims.sub || '')) ? claims.sub : null);
+ if (!id) return null;
+ return { kind: 'user', sub: claims.sub, subject: { type: 'user', id }, ns: [...PERSON_NS], cap: claims.cap, claims };
+ }
+
/**
/**
* A guard for the routes a developer app may use (ADR-014; ai.app.run). A first-party service
* A guard for the routes a developer app may use (ADR-014; ai.app.run). A first-party service
* token is judged on anyOf exactly as requireCap; an app token (actor_type app) is judged only on
* token is judged on anyOf exactly as requireCap; an app token (actor_type app) is judged only on
* appCap, and becomes req.principal whose SUBJECT IS ITS PROJECT, so every run, quota, attribution
* appCap, and becomes req.principal whose SUBJECT IS ITS PROJECT, so every run, quota, attribution
* and free allowance of the app is the project's. A sandbox app token is accepted here and
* and free allowance of the app is the project's. A sandbox app token is accepted here and
* nowhere else. Sets req.principal.kind = 'app' with appId, projectId, projectKey, env and the
* nowhere else. Sets req.principal.kind = 'app' with appId, projectId, projectKey, env and the
- * app's namespaces.
+ * app's namespaces. A person's own token is accepted too (kind 'user', namespace ai.*): they run
+ * for themselves, and only their own allowance or Balance can pay.
*/
*/
function appOrCap(appCap, ...anyOf) {
function appOrCap(appCap, ...anyOf) {
const appCaps = Array.isArray(appCap) ? appCap : [appCap];
const appCaps = Array.isArray(appCap) ? appCap : [appCap];
@@ -171,7 +203,12 @@ function createAuth({ config, log = console }) {
@@ -171,7 +203,12 @@ function createAuth({ config, log = console }) {
if (!token) return serviceGuard(req, res, next); // token.missing, with requireCap's wording
if (!token) return serviceGuard(req, res, next); // token.missing, with requireCap's wording
try {
try {
const r = await verify(token, { acceptSandbox: true });
const r = await verify(token, { acceptSandbox: true });
- if (!r.ok || !isAppClaims(r.claims)) return await serviceGuard(req, res, next);
+ if (!r.ok) {
+ const person = await personFor(token);
+ if (person) { req.principal = person; return next(); }
+ return await serviceGuard(req, res, next);
+ }
+ if (!isAppClaims(r.claims)) return await serviceGuard(req, res, next);
const app = apps.appPrincipal(r.claims);
const app = apps.appPrincipal(r.claims);
if (app.error) return http.sendProblem(res, 401, 'token.invalid_claims', { detail: app.error, ctx });
if (app.error) return http.sendProblem(res, 401, 'token.invalid_claims', { detail: app.error, ctx });
let decision = null;
let decision = null;
@@ -193,10 +230,30 @@ function createAuth({ config, log = console }) {
@@ -193,10 +230,30 @@ function createAuth({ config, log = console }) {
};
};
}
}
+ /** A service token on anyOf as requireCap, or a person's own token (who runs for themselves only). */
+ function personOrCap(...anyOf) {
+ const serviceGuard = requireCap(...anyOf);
+ return async function personOrCapGuard(req, res, next) {
+ const ctx = req.ov;
+ const token = bearer(req);
+ if (!token) return serviceGuard(req, res, next);
+ try {
+ const r = await verify(token);
+ if (r.ok) return await serviceGuard(req, res, next);
+ const person = await personFor(token);
+ if (person) { req.principal = person; return next(); }
+ return await serviceGuard(req, res, next);
+ } catch (err) {
+ log.error(`[auth] ${req.method} ${req.path}: ${(err && err.stack) || err}`);
+ if (!res.headersSent) http.sendProblem(res, 500, 'internal.error', { detail: 'internal error', ctx });
+ }
+ };
+ }
+
/** Does the authenticated principal also hold `id`? (for owner-or-admin checks) */
/** Does the authenticated principal also hold `id`? (for owner-or-admin checks) */
function principalHas(req, id) { return Boolean(req.principal && allows(req.principal.claims, id).allowed); }
function principalHas(req, id) { return Boolean(req.principal && allows(req.principal.claims, id).allowed); }
- return { verify, requireCap, appOrCap, principalHas };
+ return { verify, requireCap, appOrCap, personOrCap, principalHas };
}
}
module.exports = { CAPS, createNetworkKeys, createAuth, hasCap, allows, isAppClaims, namespaceAllowed, effectiveNamespaces, principalSubject, bearer };
module.exports = { CAPS, createNetworkKeys, createAuth, hasCap, allows, isAppClaims, namespaceAllowed, effectiveNamespaces, principalSubject, bearer };
@@ -433,8 +433,10 @@ function createCredits({ config, db = null, registry = null, env = process.env,
@@ -433,8 +433,10 @@ function createCredits({ config, db = null, registry = null, env = process.env,
* Begin a request's Balance session, or null when there is no payer (the pool then refuses paid offers
* Begin a request's Balance session, or null when there is no payer (the pool then refuses paid offers
* that carry a versioned card), when an app names no project, or for a BYOK run (no managed route).
* that carry a versioned card), when an app names no project, or for a BYOK run (no managed route).
*/
*/
- begin({ requestId, ctx = {}, input = {}, attribution = null, onBehalfOf = null, credentialId = null } = {}) {
- const payer = payerOf({ onBehalfOf: onBehalfOf || ctx.onBehalfOf || null, appId: ctx.appId });
+ begin({ requestId, ctx = {}, input = {}, attribution = null, onBehalfOf = null, credentialId = null, personPayer = true } = {}) {
+ // personPayer false: a service named the person, and only their own session token (or a subject
+ // assertion they authorised) may spend their Balance. The run gets no hold of theirs.
+ const payer = payerOf({ onBehalfOf: personPayer ? (onBehalfOf || ctx.onBehalfOf || null) : null, appId: ctx.appId });
if (!payer) return null;
if (!payer) return null;
// D12: an app spends its project's Balance; a person's own run is personal, so an attribution is
// D12: an app spends its project's Balance; a person's own run is personal, so an attribution is
// never turned into a project_id on a user's hold.
// never turned into a project_id on a user's hold.
@@ -192,6 +192,16 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -192,6 +192,16 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
if (body.version && !['active', 'deprecated'].includes(wf.status)) throw new AiError(409, 'workflow.inactive', `${wfKey} v${wf.version} is ${wf.status}`);
if (body.version && !['active', 'deprecated'].includes(wf.status)) throw new AiError(409, 'workflow.inactive', `${wfKey} v${wf.version} is ${wf.status}`);
const pinned = routePinRequest(body.route, principal, wf);
const pinned = routePinRequest(body.route, principal, wf);
if (pinned && body.credential != null) throw new AiError(422, 'input.invalid', 'a pinned route cannot be combined with a person\'s credential; the pin is the provider');
if (pinned && body.credential != null) throw new AiError(422, 'input.invalid', 'a pinned route cannot be combined with a person\'s credential; the pin is the provider');
+ // A run token names the person it acts for (Codes C3, `on_behalf_of` in the claims). The run records that
+ // person exactly as a body field would, so they pay, the run is theirs and caches never cross persons. The
+ // body may repeat the claim but never change it; an app's run is its project's (server/api/runs.js appBody),
+ // so the claim is left alone there.
+ if (principal.kind !== 'app' && principal.onBehalfOf != null) {
+ const claimed = String(principal.onBehalfOf);
+ const given = body.on_behalf_of && body.on_behalf_of.id != null ? String(body.on_behalf_of.id) : null;
+ if (given != null && given !== claimed) throw new AiError(403, 'capability.denied', 'on_behalf_of does not match the token');
+ if (body.on_behalf_of == null) body = { ...body, on_behalf_of: { type: 'user', id: claimed } };
+ }
validateRefs(body);
validateRefs(body);
const input = body.input === undefined ? {} : body.input;
const input = body.input === undefined ? {} : body.input;
const inputJson = stableStringify(input);
const inputJson = stableStringify(input);
@@ -260,7 +270,23 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -260,7 +270,23 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
// the run is uncached and no network budget or tier holds it: their own key is free to them, and a $0 balance
// the run is uncached and no network budget or tier holds it: their own key is free to them, and a $0 balance
// must never block it. If it falls back to the network, the usage is still recorded after the fact.
// must never block it. If it falls back to the network, the usage is still recorded after the fact.
const personSubject = body.on_behalf_of && body.on_behalf_of.type === 'user' && typeof body.on_behalf_of.id === 'string' ? body.on_behalf_of.id : null;
const personSubject = body.on_behalf_of && body.on_behalf_of.type === 'user' && typeof body.on_behalf_of.id === 'string' ? body.on_behalf_of.id : null;
- const selfKeys = Boolean(credentials && personSubject && credentialSubject === null && !projectProvider && !pinned && await credentials.hasSelf(personSubject));
+ // A person running with their own token: the run is theirs. They may only name themselves, and their own
+ // allowance or Balance pays. A service naming a person (body, x_openvibe, or a run token claim) draws only
+ // that person's free allowance and cache scope, never their Balance, until the person authorises the charge
+ // with a subject assertion (H1, openvibe-sdk #122; not accepted yet).
+ const personOwnToken = principal.kind === 'user' && requester.type === 'user';
+ if (personOwnToken && personSubject && personSubject !== requester.id) {
+ throw new AiError(403, 'capability.denied', 'a person runs for themselves, never for someone else');
+ }
+ const runPerson = personOwnToken ? requester.id : personSubject;
+ const personPays = Boolean(runPerson) && personOwnToken;
+ // The calling service's own budget (a run token's registered budget): with it, a run for a named person may
+ // use paid capacity, counted against that budget; without it, only free capacity serves the run.
+ const tokenBudget = runBudgets && principal.jti ? await runBudgets.spent(principal.jti) : null;
+ const serviceBudgeted = Boolean(tokenBudget && (tokenBudget.budget_usd != null || tokenBudget.budget_tokens != null));
+ // The injected run record names the person a user runs for, so preferences and cache scope follow them.
+ if (runPerson && !personSubject) body = { ...body, on_behalf_of: { type: 'user', id: runPerson } };
+ const selfKeys = Boolean(credentials && runPerson && credentialSubject === null && !projectProvider && !pinned && await credentials.hasSelf(runPerson));
// The budget a Codes run token carries (Codes C3): a run that is over its budget is refused before
// The budget a Codes run token carries (Codes C3): a run that is over its budget is refused before
// any provider call. The budget lives here, not in the token, so the holder of the token cannot raise it.
// any provider call. The budget lives here, not in the token, so the holder of the token cannot raise it.
if (runBudgets && principal.jti) {
if (runBudgets && principal.jti) {
@@ -278,7 +304,10 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -278,7 +304,10 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
actorKey: subjectKey(body.on_behalf_of), targetKey: entityKey(body.target), attributionKey: entityKey(body.attribution), workflowKey: wf.key,
actorKey: subjectKey(body.on_behalf_of), targetKey: entityKey(body.target), attributionKey: entityKey(body.attribution), workflowKey: wf.key,
...(app ? { appRun: true, projectId: app.projectId, sandbox: app.sandbox, appId: principal.appId } : {}),
...(app ? { appRun: true, projectId: app.projectId, sandbox: app.sandbox, appId: principal.appId } : {}),
// Who pays a Balance hold (D12): the app and its project, else the person a token acts for, else the service.
// Who pays a Balance hold (D12): the app and its project, else the person a token acts for, else the service.
- onBehalfOf: personSubject || principal.onBehalfOf || null,
+ onBehalfOf: runPerson || principal.onBehalfOf || null,
+ // Only the person's own token may hold their Balance; a service naming them draws free capacity only
+ // (or its own run-token budget). The run still records the person, so preferences and cache follow them.
+ personSubject: runPerson || null, personPays, serviceBudgeted,
// A credential or a project's own provider never reaches managed supply: no Balance hold for that run.
// A credential or a project's own provider never reaches managed supply: no Balance hold for that run.
...(credentialSubject !== null || projectProvider ? { byok: true } : {}),
...(credentialSubject !== null || projectProvider ? { byok: true } : {}),
...(maxCostNano != null ? { maxCostNano: String(maxCostNano) } : {}),
...(maxCostNano != null ? { maxCostNano: String(maxCostNano) } : {}),
@@ -304,7 +333,8 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -304,7 +333,8 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
input: null, input_hash: inputHash, idempotency_key: idem, trace_id: trace, request_id: requestId, retry_of: retryOf,
input: null, input_hash: inputHash, idempotency_key: idem, trace_id: trace, request_id: requestId, retry_of: retryOf,
options: JSON.stringify({ cache: options.cache !== false && credentialSubject === null && !projectProvider && !pinned && !selfKeys && !communityOptIn, debug: Boolean(options.debug), input_retained: kept.full,
options: JSON.stringify({ cache: options.cache !== false && credentialSubject === null && !projectProvider && !pinned && !selfKeys && !communityOptIn, debug: Boolean(options.debug), input_retained: kept.full,
...(credentialSubject !== null ? { credential_subject: credentialSubject } : {}), ...(credentialProvider !== null ? { credential_provider: credentialProvider } : {}),
...(credentialSubject !== null ? { credential_subject: credentialSubject } : {}), ...(credentialProvider !== null ? { credential_provider: credentialProvider } : {}),
- ...(selfKeys ? { self_subject: personSubject } : {}),
+ ...(selfKeys ? { self_subject: runPerson } : {}),
+ ...(runPerson ? { person_pays: personPays } : {}),
...(projectProvider ? { project_provider: projectProvider } : {}), ...(pinned ? { pinned_offer: pinned } : {}),
...(projectProvider ? { project_provider: projectProvider } : {}), ...(pinned ? { pinned_offer: pinned } : {}),
...(communityOptIn ? { community: true } : {}), ...(prefer ? { prefer } : {}) }), created_at: now,
...(communityOptIn ? { community: true } : {}), ...(prefer ? { prefer } : {}) }), created_at: now,
};
};
@@ -569,10 +599,12 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -569,10 +599,12 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
// default). A run with no project to bill (a first-party service, a cache hit, BYOK) begins no session
// default). A run with no project to bill (a first-party service, a cache hit, BYOK) begins no session
// and never touches Billing.
// and never touches Billing.
if (credits && credits.enabled) {
if (credits && credits.enabled) {
- credit = credits.begin({ requestId: row.request_id || id, ctx, input, attribution: parseJson(row.attribution, null), onBehalfOf: ctx.onBehalfOf || null });
+ credit = credits.begin({ requestId: row.request_id || id, ctx, input, attribution: parseJson(row.attribution, null), onBehalfOf: ctx.onBehalfOf || null, personPayer: ctx.personPays !== false });
// No payer (or no project) on a run that is not BYOK: the pool must not serve a paid managed offer
// No payer (or no project) on a run that is not BYOK: the pool must not serve a paid managed offer
- // with a versioned card, because nobody could be charged for it.
- ctx.creditsRequired = !credit && !ctx.byok;
+ // with a versioned card, because nobody could be charged for it. The exception is a calling service's
+ // own run-token budget: it pays for the run, counted against that budget, and the person's Balance is
+ // never touched.
+ ctx.creditsRequired = !credit && !ctx.byok && !ctx.serviceBudgeted;
}
}
billingDelta = typeof ctx.onDelta === 'function'
billingDelta = typeof ctx.onDelta === 'function'
? (credit ? (d) => { credit.observe(d); ctx.onDelta(d); } : ctx.onDelta)
? (credit ? (d) => { credit.observe(d); ctx.onDelta(d); } : ctx.onDelta)
@@ -659,8 +691,18 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -659,8 +691,18 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
} catch (err) {
} catch (err) {
const interrupted = shuttingDown && controller.signal.aborted;
const interrupted = shuttingDown && controller.signal.aborted;
const cancelled = !interrupted && (controller.signal.aborted || (err && err.code === 'run.cancelled'));
const cancelled = !interrupted && (controller.signal.aborted || (err && err.code === 'run.cancelled'));
- const code = interrupted ? 'run.interrupted' : cancelled ? 'run.cancelled' : (err instanceof AiError ? err.code : 'run.internal');
- const detail = interrupted ? 'the service shut down while this run was in progress' : cancelled ? 'cancelled' : (err instanceof AiError ? err.detail : 'internal error');
+ let code = interrupted ? 'run.interrupted' : cancelled ? 'run.cancelled' : (err instanceof AiError ? err.code : 'run.internal');
+ let detail = interrupted ? 'the service shut down while this run was in progress' : cancelled ? 'cancelled' : (err instanceof AiError ? err.detail : 'internal error');
+ // A service naming a person may draw only their free allowance. When that is spent and the service has no
+ // budget of its own, say so plainly (402) instead of "no provider could answer": the person's Balance is
+ // never touched for a run they did not start themselves.
+ const tried = err instanceof AiError && err.extra && Array.isArray(err.extra.tried) ? err.extra.tried : [];
+ const payerSkipped = tried.some((t) => t && t.skipped === 'payer_required');
+ if (ctx.personPays === false && ctx.personSubject && !ctx.serviceBudgeted
+ && (code === 'billing.payer_required' || (code === 'provider.unavailable' && payerSkipped))) {
+ code = 'ai.allowance_spent';
+ detail = `The free AI allowance for ${ctx.personSubject} is spent, and the calling service has no budget to cover this run. Add a key for the person, or wait for the allowance to reset.`;
+ }
if (!(err instanceof AiError) && !cancelled) log.error(`[runs] ${id} crashed: ${err && err.stack || err}`);
if (!(err instanceof AiError) && !cancelled) log.error(`[runs] ${id} crashed: ${err && err.stack || err}`);
const extra = err instanceof AiError && err.extra ? JSON.stringify(err.extra).slice(0, 2000) : null;
const extra = err instanceof AiError && err.extra ? JSON.stringify(err.extra).slice(0, 2000) : null;
const explain = err instanceof AiError && err.extra && err.extra.explain ? JSON.stringify(err.extra.explain) : null;
const explain = err instanceof AiError && err.extra && err.extra.explain ? JSON.stringify(err.extra.explain) : null;
@@ -7,8 +7,9 @@
@@ -7,8 +7,9 @@
// provider without a card behaves as before; a sandbox app token never reaches paid supply; with the flag off
// provider without a card behaves as before; a sandbox app token never reaches paid supply; with the flag off
// nothing changes.
// nothing changes.
const assert = require('assert');
const assert = require('assert');
+const crypto = require('crypto');
const http = require('http');
const http = require('http');
-const { boot, request, token, suite, ALL } = require('./helpers');
+const { boot, request, token, suite, ALL, privateKey, ISSUER } = require('./helpers');
const t = suite('credits-run');
const t = suite('credits-run');
const service = token('live', ALL);
const service = token('live', ALL);
@@ -16,6 +17,13 @@ const ULID = '01JAB2C3D4E5F6G7H8J9K0MNPQ';
@@ -16,6 +17,13 @@ const ULID = '01JAB2C3D4E5F6G7H8J9K0MNPQ';
const PRJ = `prj_${ULID}`;
const PRJ = `prj_${ULID}`;
const APP = `app_${ULID}`;
const APP = `app_${ULID}`;
const PERSON = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP7';
const PERSON = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP7';
+const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
+/** A person's own Network token (audience openvibe.ai): the subject in sub and subject_id, no actor_type. */
+function userToken(audience, subject = PERSON) {
+ const now = Math.floor(Date.now() / 1000);
+ const input = `${b64({ alg: 'RS256', typ: 'JWT' })}.${b64({ iss: ISSUER, sub: subject, subject_id: subject, aud: [audience], iat: now, exp: now + 3600, jti: `tok_${crypto.randomBytes(8).toString('hex')}` })}`;
+ return `${input}.${crypto.sign('RSA-SHA256', Buffer.from(input), privateKey).toString('base64url')}`;
+}
const appToken = (env) => token('app', ['ai.app.run'], { sub: `app:app_${ULID}`, actorType: 'app', ns: [PRJ, `app.${PRJ}.*`], extra: { project_id: PRJ, env } });
const appToken = (env) => token('app', ['ai.app.run'], { sub: `app:app_${ULID}`, actorType: 'app', ns: [PRJ, `app.${PRJ}.*`], extra: { project_id: PRJ, env } });
const producer = appToken('production');
const producer = appToken('production');
const sandbox = appToken('sandbox');
const sandbox = appToken('sandbox');
@@ -203,7 +211,7 @@ t.test('a paid app request holds its project\'s Balance: the hold names the app
@@ -203,7 +211,7 @@ t.test('a paid app request holds its project\'s Balance: the hold names the app
} finally { await h.stop(); await up.close(); await billing.close(); }
} finally { await h.stop(); await up.close(); await billing.close(); }
});
});
-t.test('a person\'s own run holds their personal Balance: no project_id and no Network call', async () => {
+t.test('a service naming a person cannot hold their Balance: no payer, and 402 ai.allowance_spent when no free capacity', async () => {
const billing = await fakeBilling();
const billing = await fakeBilling();
const up = await upstream('ok');
const up = await upstream('ok');
const h = await bootWith(billing);
const h = await bootWith(billing);
@@ -212,15 +220,73 @@ t.test('a person\'s own run holds their personal Balance: no project_id and no N
@@ -212,15 +220,73 @@ t.test('a person\'s own run holds their personal Balance: no project_id and no N
const r = await request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok: service, body: {
const r = await request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok: service, body: {
workflow: 'ai.complete', input: { messages: [{ role: 'user', content: 'hello as me' }] },
workflow: 'ai.complete', input: { messages: [{ role: 'user', content: 'hello as me' }] },
on_behalf_of: { type: 'user', id: PERSON } } });
on_behalf_of: { type: 'user', id: PERSON } } });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.body.run.status, 'failed');
+ assert.strictEqual(r.body.run.error.code, 'ai.allowance_spent');
+ assert.match(r.body.run.error.detail, /free AI allowance/);
+ assert.strictEqual(billing.calls.filter((c) => c.path === '/internal/credits/holds').length, 0, 'never a hold of their Balance');
+ assert.strictEqual(billing.settlements.size, 0);
+ assert.strictEqual(up.calls, 0, 'the paid offer was not served to a run nobody pays for');
+ // The OpenAI route answers the same code as a 402.
+ const viaChat = await chat(h, { tok: service, body: { x_openvibe: { on_behalf_of: { type: 'user', id: PERSON } } } });
+ assert.strictEqual(viaChat.status, 402, viaChat.text);
+ assert.strictEqual(viaChat.body.error.code, 'ai.allowance_spent');
+ } finally { await h.stop(); await up.close(); await billing.close(); }
+});
+
+t.test('a person\'s own token holds their personal Balance: no project_id and no Network call', async () => {
+ const billing = await fakeBilling();
+ const up = await upstream('ok');
+ const h = await bootWith(billing);
+ try {
+ await seed(h, up);
+ const r = await request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok: userToken('openvibe.ai'), body: {
+ workflow: 'ai.complete', input: { messages: [{ role: 'user', content: 'hello as me' }] } } });
assert.ok(r.status < 300, r.text);
assert.ok(r.status < 300, r.text);
+ assert.strictEqual(r.body.run.status, 'succeeded', JSON.stringify(r.body.run.error));
+ assert.deepStrictEqual(r.body.run.on_behalf_of, { type: 'user', id: PERSON }, 'the run is theirs');
+ // The OpenAI-compatible route accepts the same person token and holds the same personal Balance.
+ const viaChat = await chat(h, { tok: userToken('openvibe.ai') });
+ assert.strictEqual(viaChat.status, 200, viaChat.text);
const holds = billing.calls.filter((c) => c.path === '/internal/credits/holds');
const holds = billing.calls.filter((c) => c.path === '/internal/credits/holds');
- assert.strictEqual(holds.length, 1);
+ assert.strictEqual(holds.length, 2);
assert.deepStrictEqual(holds[0].body.subject, { type: 'user', id: PERSON });
assert.deepStrictEqual(holds[0].body.subject, { type: 'user', id: PERSON });
assert.ok(!('project_id' in holds[0].body), 'a personal hold has no project_id');
assert.ok(!('project_id' in holds[0].body), 'a personal hold has no project_id');
assert.strictEqual(billing.calls.filter((c) => c.path.includes('/internal/projects/')).length, 0, 'Network was never called');
assert.strictEqual(billing.calls.filter((c) => c.path.includes('/internal/projects/')).length, 0, 'Network was never called');
} finally { await h.stop(); await up.close(); await billing.close(); }
} finally { await h.stop(); await up.close(); await billing.close(); }
});
});
+t.test('a person\'s token may run only for themselves, and only the model API', async () => {
+ const billing = await fakeBilling();
+ const up = await upstream('ok');
+ const h = await bootWith(billing);
+ try {
+ await seed(h, up);
+ const someoneElse = await request(h.base, 'POST', '/api/v1/runs', { tok: userToken('openvibe.ai'), body: {
+ workflow: 'ai.complete', input: { messages: [{ role: 'user', content: 'x' }] },
+ on_behalf_of: { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNP1' } } });
+ assert.strictEqual(someoneElse.status, 403, someoneElse.text);
+ const product = await request(h.base, 'POST', '/api/v1/runs', { tok: userToken('openvibe.ai'), body: { workflow: 'live.viewers.line', input: { role: 'chat', user: 'x' } } });
+ assert.strictEqual(product.status, 403, product.text);
+ assert.strictEqual(product.body.code, 'capability.namespace_denied');
+ } finally { await h.stop(); await up.close(); await billing.close(); }
+});
+
+t.test('a subject assertion is not accepted until H1 lands (openvibe-sdk #122)', async () => {
+ // The header exists in SDK #122. Until it is released and AI pins it, a service naming a person gets no Balance.
+ const billing = await fakeBilling();
+ const up = await upstream('ok');
+ const h = await bootWith(billing);
+ try {
+ await seed(h, up);
+ const r = await request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok: service,
+ headers: { 'x-ov-subject-assertion': 'eyJhbGciOiJFUzI1NiJ9.e30.c2ln' },
+ body: { workflow: 'ai.complete', input: { messages: [{ role: 'user', content: 'hello as me' }] }, on_behalf_of: { type: 'user', id: PERSON } } });
+ assert.strictEqual(r.body.run.error.code, 'ai.allowance_spent');
+ assert.strictEqual(billing.calls.filter((c) => c.path === '/internal/credits/holds').length, 0);
+ } finally { await h.stop(); await up.close(); await billing.close(); }
+});
+
t.test('insufficient credit: a 402 billing.insufficient_funds, and the provider is never called', async () => {
t.test('insufficient credit: a 402 billing.insufficient_funds, and the provider is never called', async () => {
const billing = await fakeBilling();
const billing = await fakeBilling();
const up = await upstream('ok');
const up = await upstream('ok');
@@ -97,6 +97,25 @@ t.test('an app token works, and its run is its project\'s', async () => {
@@ -97,6 +97,25 @@ t.test('an app token works, and its run is its project\'s', async () => {
assert.match(JSON.stringify(run.body.run.attribution || {}), new RegExp(PRJ));
assert.match(JSON.stringify(run.body.run.attribution || {}), new RegExp(PRJ));
});
});
+t.test('a service acting for a person names them: the run is theirs (the claim, or the x_openvibe extension)', async () => {
+ const USR = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+ const acting = token('actor', ALL, { extra: { on_behalf_of: USR } });
+ const claimed = await post({ model: 'openvibe', messages: [{ role: 'user', content: 'For a person' }] }, acting);
+ assert.strictEqual(claimed.status, 200, claimed.text);
+ const runFor = async (id) => (await request(h.base, 'GET', `/api/v1/runs/${id}`, { tok: service })).body.run;
+ assert.deepStrictEqual((await runFor(claimed.body.x_openvibe.run_id)).on_behalf_of, { type: 'user', id: USR }, 'the token\'s claim is the run\'s person');
+ const supplied = await post({ model: 'openvibe', messages: [{ role: 'user', content: 'For a person' }], x_openvibe: { on_behalf_of: { type: 'user', id: USR } } });
+ assert.strictEqual(supplied.status, 200, supplied.text);
+ assert.deepStrictEqual((await runFor(supplied.body.x_openvibe.run_id)).on_behalf_of, { type: 'user', id: USR }, 'the extension is the run\'s person');
+ const bad = await post({ model: 'openvibe', messages: [{ role: 'user', content: 'x' }], x_openvibe: { on_behalf_of: { type: 'user', id: 'nobody' } } });
+ assert.strictEqual(bad.status, 400, 'a bad SubjectRef is refused before the run');
+ assert.strictEqual(bad.body.error.type, 'invalid_request_error');
+ const swapped = await post({ model: 'openvibe', messages: [{ role: 'user', content: 'x' }], x_openvibe: { on_behalf_of: { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNP1' } } }, acting);
+ assert.deepStrictEqual([swapped.status, swapped.body.error.code], [403, 'capability.denied'], 'a run token cannot name someone else');
+ const denied = await post({ model: 'openvibe', messages: [{ role: 'user', content: 'x' }], x_openvibe: { on_behalf_of: { type: 'user', id: USR } } }, app);
+ assert.deepStrictEqual([denied.status, denied.body.error.code], [403, 'capability.denied'], 'an app run never names a person');
+});
+
t.test('errors come in OpenAI\'s shape: no token, no messages, n > 1', async () => {
t.test('errors come in OpenAI\'s shape: no token, no messages, n > 1', async () => {
const none = await request(h.base, 'POST', '/v1/chat/completions', { body: { messages: [{ role: 'user', content: 'x' }] } });
const none = await request(h.base, 'POST', '/v1/chat/completions', { body: { messages: [{ role: 'user', content: 'x' }] } });
assert.strictEqual(none.status, 401);
assert.strictEqual(none.status, 401);