openvibers/openvibe.ai Commits

A service may name the person it acts for on the AI router (#311)

OP OpenVibers committed 7 days ago · 0a58389f68 · 8 files, +224 −24

* A service may name the person it acts for on the AI router

A first-party service calling the OpenAI-compatible endpoint had no way to say
whose task a run is: only an app got an attribution, and a run token's
on_behalf_of claim was read into no principal, so the run had no payer and its
cache could be shared across persons.

- auth: carry the run token's on_behalf_of claim into the principal.
- runs: record that person on the run as the body would (payer, allowance and
  cache scope), and refuse a body that names someone else.
- POST /v1/chat/completions: accept x_openvibe.on_behalf_of (a SubjectRef),
  refused for app tokens exactly as the runs API refuses it.

* Only the person's own token may spend their Balance

A first-party service naming a person through on_behalf_of (body,
x_openvibe, or a run token claim) could hold and spend that person's Balance:
real Vibes and Credits, with no authorisation from them.

Now a service-named person draws only their free allowance and cache scope.
Their Balance pays only when the request carries their own session token
(a user token for audience openvibe.ai, no delegation) or, when H1 lands, a
subject assertion they authorised. Otherwise the run uses the calling
service's own run-token budget if it has one (counted against it), and when
there is none it is refused 402 ai.allowance_spent with a plain message.

- auth: accept a person's own token on the run and model routes (kind user,
  namespace ai.*, never a delegation); a person may run only for themselves.
- runs: the payer is the requester's own token or a verified assertion; a
  service-named run keeps the person for allowance, preferences and cache.
- credits: begin() takes personPayer, so no hold of theirs is created.
- routes: ai.allowance_spent maps to 402 in both wire protocols.

Changes

ddiff --git a/server/api/messages.js b/server/api/messages.js
iindex 8923b8f..dbbcbe1 100644
---- a/server/api/messages.js
++++ b/server/api/messages.js
@@@ -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);
ddiff --git a/server/api/openai.js b/server/api/openai.js
iindex 3fea101..83d1e2a 100644
---- a/server/api/openai.js
++++ b/server/api/openai.js
@@@ -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);
ddiff --git a/server/api/runs.js b/server/api/runs.js
iindex 2c47da9..09c5dae 100644
---- a/server/api/runs.js
++++ b/server/api/runs.js
@@@ -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);
ddiff --git a/server/auth.js b/server/auth.js
iindex 4b66f03..4da2339 100644
---- a/server/auth.js
++++ b/server/auth.js
@@@ -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 wording
try {
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 };
ddiff --git a/server/credits.js b/server/credits.js
iindex a1ed4f9..07f9fb9 100644
---- a/server/credits.js
++++ b/server/credits.js
@@@ -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.
ddiff --git a/server/runs.js b/server/runs.js
iindex 75acdc2..7a12f31 100644
---- a/server/runs.js
++++ b/server/runs.js
@@@ -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}`);
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 = { now
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 } : {}),
// 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 = { now
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,
...(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;
ddiff --git a/test/credits-run.test.js b/test/credits-run.test.js
iindex 7d8ae5b..d598ce9 100644
---- a/test/credits-run.test.js
++++ b/test/credits-run.test.js
@@@ -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 N
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' }] },
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');
ddiff --git a/test/openai-compat.test.js b/test/openai-compat.test.js
iindex 5e34127..fdfbb95 100644
---- a/test/openai-compat.test.js
++++ b/test/openai-compat.test.js
@@@ -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);