openvibers/openvibe.ai Commits
Run budgets: only Codes' own token sets one, and only once (#205)
Changes
@@@ -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),@@@ -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 };}/**@@@ -35,7 +35,9 @@ const chat = (tok, headers = {}) => request(h.base, 'POST', '/v1/chat/completiontok, 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 });
@@ -6,7 +6,8 @@
@@ -6,7 +6,8 @@
* GET /api/v1/run-budgets/:jti what the run may spend and what it has spent
* 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
* 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
* another run's budget. A budget that arrives in the request body of a model call is ignored: there is
* nothing there to forge.
* nothing there to forge.
*/
*/
@@ -25,7 +26,19 @@ function runBudgetsRouter({ runBudgets, auth, log = console, sendError }) {
@@ -25,7 +26,19 @@ function runBudgetsRouter({ runBudgets, auth, log = console, sendError }) {
return jti;
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 {
try {
const jti = jtiOf(req);
const jti = jtiOf(req);
const body = req.body || {};
const body = req.body || {};
@@ -36,6 +49,7 @@ function runBudgetsRouter({ runBudgets, auth, log = console, sendError }) {
@@ -36,6 +49,7 @@ function runBudgetsRouter({ runBudgets, auth, log = console, sendError }) {
expiresAt: body.expires_at == null ? null : Number(body.expires_at),
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) 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({
res.set('Cache-Control', 'no-store').json({
jti: row.jti,
jti: row.jti,
budget_usd: row.budget_usd == null ? null : Number(row.budget_usd),
budget_usd: row.budget_usd == null ? null : Number(row.budget_usd),
@@ -32,8 +32,7 @@ function createRunBudgets(db, { clock = { now: () => Date.now() } } = {}) {
@@ -32,8 +32,7 @@ function createRunBudgets(db, { clock = { now: () => Date.now() } } = {}) {
const getRow = db.prepare('SELECT * FROM run_budgets WHERE jti = ?');
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)
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, ?, ?, ?)
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 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 = ?');
const drop = db.prepare('DELETE FROM run_budgets WHERE jti = ?');
@@ -70,8 +69,13 @@ function createRunBudgets(db, { clock = { now: () => Date.now() } } = {}) {
@@ -70,8 +69,13 @@ function createRunBudgets(db, { clock = { now: () => Date.now() } } = {}) {
const tokens = asLimit(budgetTokens);
const tokens = asLimit(budgetTokens);
if (usd == null && tokens == null) return null;
if (usd == null && tokens == null) return null;
const exp = Math.round(Number(expiresAt)) || (nowSec() + 7200);
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());
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 };
}
}
/**
/**
@@ -35,7 +35,9 @@ const chat = (tok, headers = {}) => request(h.base, 'POST', '/v1/chat/completion
@@ -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 },
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 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 () => {
t.test('boot with the credentials key set', async () => {
h = await boot({
h = await boot({
@@ -61,6 +63,25 @@ t.test('a budget is registered for a run token', async () => {
@@ -61,6 +63,25 @@ t.test('a budget is registered for a run token', async () => {
assert.strictEqual(r.body.budget_tokens, 1000);
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 () => {
t.test('the counter adds up across the requests of the run', async () => {
const jti = 'tok_budget_1';
const jti = 'tok_budget_1';
const tok = runToken(jti, { on_behalf_of: PERSON });
const tok = runToken(jti, { on_behalf_of: PERSON });