openvibers/openvibe.ai Commits

Run budgets: only Codes' own token sets one, and only once (#205)

OP OpenVibers committed 14 days ago · 81caff0cc8 · 3 files, +45 −6

A run token is a service token for svc:codes with on_behalf_of the person, and it holds ai.run.create because it
calls the models. The budget write was guarded by ai.run.create alone and was an upsert. So the agent the budget
limits could PUT its own jti with any budget_usd, and the cap was gone.

- PUT /api/v1/run-budgets/:jti now refuses any token acting for a person (on_behalf_of) and any subject but the
  minting service (AI_RUN_BUDGET_WRITERS, default svc:codes).
- The first registration wins. The same numbers again are an idempotent 200; other numbers are a 409
  (ai.run_budget.exists), never an update.

The two new tests fail against the previous code: the run token raising its own budget, and a second registration
with other numbers.

Changes

ddiff --git a/server/api/run-budgets.js b/server/api/run-budgets.js
iindex fa0926f..f126621 100644
---- a/server/api/run-budgets.js
++++ b/server/api/run-budgets.js
@@@ -6,7 +6,8 @@
* GET /api/v1/run-budgets/:jti what the run may spend and what it has spent
*
* The jti is the run token's own id, signed inside the token by Network: the service that mints the token
-- * is the one that registers its budget, and the holder of the token can neither change the budget nor name
++ * is the one that registers its budget (only `svc:codes`, never a token acting for a person), once: a second
++ * registration with other numbers is refused (409), so the holder of the token can neither change the budget nor name
* another run's budget. A budget that arrives in the request body of a model call is ignored: there is
* nothing there to forge.
*/
@@@ -25,7 +26,19 @@ function runBudgetsRouter({ runBudgets, auth, log = console, sendError }) {
return jti;
};
-- r.put('/api/v1/run-budgets/:jti', guard, async (req, res) => {
++ // Only the service that mints run tokens writes a budget, with its own token: a run token (on_behalf_of a
++ // person, held by the agent the budget limits) passes ai.run.create too, and must never reach this write, or the
++ // agent could raise its own cap (10-30 review of #204).
++ const WRITERS = new Set(String(process.env.AI_RUN_BUDGET_WRITERS || 'svc:codes').split(',').map((x) => x.trim()).filter(Boolean));
++ const writer = (req, res, next) => {
++ const c = (req.principal && req.principal.claims) || {};
++ if (c.on_behalf_of || !WRITERS.has(String(c.sub || ''))) {
++ return sendError(res, new AiError(403, 'capability.denied', 'only the service that mints run tokens registers their budgets'), req.ov, log);
++ }
++ return next();
++ };
++
++ r.put('/api/v1/run-budgets/:jti', guard, writer, async (req, res) => {
try {
const jti = jtiOf(req);
const body = req.body || {};
@@@ -36,6 +49,7 @@ function runBudgetsRouter({ runBudgets, auth, log = console, sendError }) {
expiresAt: body.expires_at == null ? null : Number(body.expires_at),
});
if (!row) throw new AiError(422, 'input.invalid', 'a budget needs dollars or tokens');
++ if (row.conflict) throw new AiError(409, 'ai.run_budget.exists', 'this run token already has a different budget; a budget is set once, when the token is minted');
res.set('Cache-Control', 'no-store').json({
jti: row.jti,
budget_usd: row.budget_usd == null ? null : Number(row.budget_usd),
ddiff --git a/server/run-budgets.js b/server/run-budgets.js
iindex 4118f5a..c1b7a65 100644
---- a/server/run-budgets.js
++++ b/server/run-budgets.js
@@@ -32,8 +32,7 @@ function createRunBudgets(db, { clock = { now: () => Date.now() } } = {}) {
const getRow = db.prepare('SELECT * FROM run_budgets WHERE jti = ?');
const put = db.prepare(`INSERT INTO run_budgets (jti, budget_usd, budget_tokens, spent_usd, spent_tokens, expires_at, created_at, updated_at)
VALUES (?, ?, ?, 0, 0, ?, ?, ?)
-- ON CONFLICT (jti) DO UPDATE SET budget_usd = excluded.budget_usd, budget_tokens = excluded.budget_tokens,
-- expires_at = excluded.expires_at, updated_at = excluded.updated_at`);
++ ON CONFLICT (jti) DO NOTHING`);
const bump = db.prepare('UPDATE run_budgets SET spent_usd = spent_usd + ?, spent_tokens = spent_tokens + ?, updated_at = ? WHERE jti = ?');
const drop = db.prepare('DELETE FROM run_budgets WHERE jti = ?');
@@@ -70,8 +69,13 @@ function createRunBudgets(db, { clock = { now: () => Date.now() } } = {}) {
const tokens = asLimit(budgetTokens);
if (usd == null && tokens == null) return null;
const exp = Math.round(Number(expiresAt)) || (nowSec() + 7200);
++ // Set once: the first registration wins. The same numbers again are an idempotent retry; other numbers are a
++ // conflict the caller is told about, never an update.
await put.run(jti, usd, tokens == null ? null : Math.round(tokens), exp, nowIso(), nowIso());
-- return await getRow.get(jti); // the row as it is stored: a budget that expired already is still a budget
++ const row = await getRow.get(jti); // the row as it is stored: a budget that expired already is still a budget
++ const same = row && Number(row.budget_usd == null ? -1 : row.budget_usd) === (usd == null ? -1 : usd)
++ && Number(row.budget_tokens == null ? -1 : row.budget_tokens) === (tokens == null ? -1 : Math.round(tokens));
++ return same ? row : { ...row, conflict: true };
}
/**
ddiff --git a/test/run-budgets.test.js b/test/run-budgets.test.js
iindex 687d5db..341cadf 100644
---- a/test/run-budgets.test.js
++++ b/test/run-budgets.test.js
@@@ -35,7 +35,9 @@ const chat = (tok, headers = {}) => request(h.base, 'POST', '/v1/chat/completion
tok, body: { model: 'openvibe', messages: [{ role: 'user', content: 'Say hello' }] }, headers: { 'x-openvibe-credential': 'openai', ...headers },
});
const budgetOf = (jti) => request(h.base, 'GET', `/api/v1/run-budgets/${jti}`, { tok: manager });
--const setBudget = (jti, body) => request(h.base, 'PUT', `/api/v1/run-budgets/${jti}`, { tok: manager, body });
++// Codes registers a budget with its own service token (client credentials: no on_behalf_of), never with a run token.
++const codesService = token('codes', ALL);
++const setBudget = (jti, body, tok = codesService) => request(h.base, 'PUT', `/api/v1/run-budgets/${jti}`, { tok, body });
t.test('boot with the credentials key set', async () => {
h = await boot({
@@@ -61,6 +63,25 @@ t.test('a budget is registered for a run token', async () => {
assert.strictEqual(r.body.budget_tokens, 1000);
});
++t.test('the run token itself cannot raise its own budget', async () => {
++ // A run token passes ai.run.create (it calls the models), so the write route must look further: it acts for a
++ // person, and only the minting service may write. Without this an agent could set budget_usd to anything.
++ const own = runToken('tok_budget_1', { on_behalf_of: PERSON });
++ const r = await setBudget('tok_budget_1', { budget_usd: 1e9 }, own);
++ assert.strictEqual(r.status, 403, r.text);
++ const other = await setBudget('tok_budget_1', { budget_usd: 1e9 }, service);
++ assert.strictEqual(other.status, 403, 'another service is not the minting one either: ' + other.text);
++ assert.strictEqual((await budgetOf('tok_budget_1')).body.budget_usd, 5, 'the budget is unchanged');
++});
++
++t.test('a budget is set once: the same numbers again are fine, other numbers are refused', async () => {
++ const same = await setBudget('tok_budget_1', { budget_usd: 5, budget_tokens: 1000, expires_at: Math.floor(Date.now() / 1000) + 600 });
++ assert.strictEqual(same.status, 200, same.text);
++ const more = await setBudget('tok_budget_1', { budget_usd: 50, budget_tokens: 1000 });
++ assert.strictEqual(more.status, 409, more.text);
++ assert.strictEqual((await budgetOf('tok_budget_1')).body.budget_usd, 5);
++});
++
t.test('the counter adds up across the requests of the run', async () => {
const jti = 'tok_budget_1';
const tok = runToken(jti, { on_behalf_of: PERSON });