openvibers/openvibe.actor Commits

Free allowance: $0.05 a day per person, shown where a run starts (#294)

OP OpenVibers committed 7 days ago · b2e3f45762 · 12 files, +174 −34

The default day is $0.05 (plan L6196), the plan's number and still above the
most expensive of the 46 measured runs (median $0.0019, p90 $0.013, max $0.041).
A task created without a budget gets what the day has left, so cheap runs leave
room for the next one. The console, the task box and the chat composer show
the remainder (spent plus what open runs hold), and a spent day is refused with
a plain message and the way to add your own model key at /chat/model.

Changes

ddiff --git a/.env.example b/.env.example
iindex 4b59374..638f357 100644
---- a/.env.example
++++ b/.env.example
@@@ -85,7 +85,7 @@ ACTOR_RUNTIME_OUTPUT_USD_PER_MTOK=0
# Money: the free tier per person, and the operator's ceiling over everyone in one UTC day (USD).
#ACTOR_FREE_TASKS_PER_DAY=20
#ACTOR_FREE_PER_TASK_USD=0.05
--#ACTOR_FREE_PER_DAY_USD=0.25
++#ACTOR_FREE_PER_DAY_USD=0.05
#ACTOR_MAX_COST_USD_PER_DAY=2
#ACTOR_MAX_RUNNING=4
#ACTOR_TASK_TIMEOUT_MS=180000
ddiff --git a/README.md b/README.md
iindex 9acd144..5732ead 100644
---- a/README.md
++++ b/README.md
@@@ -84,7 +84,7 @@ The resource index is for OpenVibe.Services on loopback and is blocked at the pu
## Money
--- **Free tier, per person:** `ACTOR_FREE_TASKS_PER_DAY` (20) tasks a day, up to `ACTOR_FREE_PER_TASK_USD` ($0.05) a task and `ACTOR_FREE_PER_DAY_USD` ($0.25) a day.
++- **Free tier, per person:** `ACTOR_FREE_TASKS_PER_DAY` (20) tasks a day, up to `ACTOR_FREE_PER_TASK_USD` ($0.05) a task and `ACTOR_FREE_PER_DAY_USD` ($0.05) a day. Past it, tasks are refused with `actor.budget.day_spent` until midnight UTC; the person can add their own model key at `/chat/model` and keep going.
- **Operator ceiling:** `ACTOR_MAX_COST_USD_PER_DAY` ($2) over everyone; past it, new tasks answer 503 `actor.capacity.spent` until midnight UTC.
- **Budgets are hard:**
- a budget above the tier is refused, never lowered;
ddiff --git a/server/config.js b/server/config.js
iindex cd36189..c4e9c1e 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -178,7 +178,7 @@ function load(env = process.env) {
allowance: {
tasksPerDay: Math.max(0, int(env.ACTOR_FREE_TASKS_PER_DAY, 20)),
perTaskUsd: num(env.ACTOR_FREE_PER_TASK_USD, 0.05),
-- perDayUsd: num(env.ACTOR_FREE_PER_DAY_USD, 0.25),
++ perDayUsd: num(env.ACTOR_FREE_PER_DAY_USD, 0.05),
},
spendCapUsdPerDay: num(env.ACTOR_MAX_COST_USD_PER_DAY, 2),
agents: {
ddiff --git a/server/http/chat-pages.js b/server/http/chat-pages.js
iindex 31423eb..6aaaba7 100644
---- a/server/http/chat-pages.js
++++ b/server/http/chat-pages.js
@@@ -23,6 +23,7 @@ const { html, raw } = require('../render/html');
const { send } = require('../render/layout');
const { markdown } = require('../render/markdown');
const store = require('../tasks/store');
++const { leftLine } = require('../tasks/allowance');
const { createTask } = require('../tasks/create');
const { MODES } = require('../agents/router');
const catalog = require('../agents/catalog');
@@@ -345,7 +346,7 @@ ${acting ? '' : html`<form method="post" action="/tasks/${row.id}/answer" class=
const ATTACH_ICON = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 11.5 12.4 19a5 5 0 0 1-7.1-7.1l8-8a3.4 3.4 0 0 1 4.8 4.8l-7.9 7.9a1.7 1.7 0 0 1-2.4-2.4L15 7"/></svg>';
const ATTACH_ACCEPT = '.txt,.md,.csv,.tsv,.json,.jsonl,.yaml,.yml,.toml,.xml,.html,.css,.js,.ts,.py,.rb,.go,.rs,.java,.c,.cpp,.cs,.php,.sh,.sql,.log,.srt,.vtt,text/*,.pdf,application/pdf,image/png,image/jpeg,image/webp,image/gif';
-- function composer({ parent = null, mode = 'balanced', busy = false, working = null, problem = null, text = '', model = null } = {}) {
++ function composer({ parent = null, mode = 'balanced', busy = false, working = null, problem = null, text = '', model = null, allowance = null } = {}) {
const tools = html`<button type="button" class="cc-attach" data-cc-attach aria-label="Attach files" title="Attach text, code, CSV, JSON, PDFs or images" hidden>${raw(ATTACH_ICON)}</button><input type="file" multiple hidden data-cc-files accept="${ATTACH_ACCEPT}">
${modelChip(model)}
<label class="cc-mode" title="How Actor picks the agent"><span class="sr-only">Mode</span><select name="mode" data-cc-mode>${MODES.map((m) => html`<option value="${m}" title="${MODE_HINT[m] || ''}"${m === mode ? raw(' selected') : ''}>${MODE_LABEL[m] || m}</option>`)}</select></label>
@@@ -362,7 +363,7 @@ ${modelChip(model)}
hidden: { parent },
top: String(html`${problem ? html`<p class="cc-problem" role="alert">${problem}</p>` : ''}<ul class="cc-files" data-cc-chips hidden></ul>`),
tools: String(tools),
-- note: `Actor checks each answer with a second model.${config.allowance ? ` Free: ${config.allowance.tasksPerDay} tasks a day.` : ''}`,
++ note: `Actor checks each answer with a second model.${allowance ? ` ${allowance}` : ''}`,
}));
}
@@@ -383,7 +384,7 @@ ${modelChip(model)}
async function render(req, res, { row = null, problem = null, text = '', status = 200 } = {}) {
const requester = requesterOf(req);
-- const [chats, turns] = await Promise.all([chatsFor(requester), row ? chainOf(row) : []]);
++ const [chats, turns, today] = await Promise.all([chatsFor(requester), row ? chainOf(row) : [], store.spendToday(s, requester)]);
const first = turns[0] || null;
const lastTurn = turns[turns.length - 1] || null;
const busy = Boolean(lastTurn && (OPEN.includes(lastTurn.state) || lastTurn.state === 'needs_you'));
@@@ -402,7 +403,7 @@ ${modelChip(model)}
turns: turnHtmls.map(String),
empty: first ? '' : String(html`<div class="ct-hello"><span class="ct-hello-mark" aria-hidden="true"></span><h2>What should Actor do?</h2><p>It browses, clicks and types for you. Watch it work, steer it, or go do something else.</p>
<ul class="ct-starters">${STARTERS.map(([icon, k, v]) => html`<li><button type="button" data-ovt-starter="${v}">${raw(glyphs.svg(icon, { size: 20 }))}<b>${k}</b><span>${v}</span></button></li>`)}</ul></div>`),
-- composer: String(composer({ parent: lastTurn ? lastTurn.id : null, mode: lastTurn ? lastTurn.mode : 'balanced', busy, working: lastTurn && OPEN.includes(lastTurn.state) ? lastTurn.id : null, problem, text, model })),
++ composer: String(composer({ parent: lastTurn ? lastTurn.id : null, mode: lastTurn ? lastTurn.mode : 'balanced', busy, working: lastTurn && OPEN.includes(lastTurn.state) ? lastTurn.id : null, problem, text, model, allowance: leftLine(config.allowance, today) })),
label: first ? 'Conversation' : 'New chat',
}))}</div>`,
});
@@@ -568,7 +569,7 @@ ${runsSaved ? html`<p class="cm-ok" role="status">Saved. Your next task runs on
const PROBLEM = {
'actor.allowance.exhausted': 'You have used today\'s free tasks. They come back at midnight UTC.',
-- 'actor.budget.day_spent': 'Today\'s budget is spent. It resets at midnight UTC.',
++ 'actor.budget.day_spent': 'You have used today\'s free allowance. It comes back at midnight UTC. Add your own model key at /chat/model, and Actor runs on it without charging the allowance.',
'actor.capacity.spent': 'Actor has used today\'s free capacity across everyone. It resets at midnight UTC.',
};
r.post('/chat', express.urlencoded({ extended: false, limit: '64kb' }), express.json({ limit: '18mb' }), async (req, res) => {
ddiff --git a/server/http/console-pages.js b/server/http/console-pages.js
iindex f012c6e..6d856e6 100644
---- a/server/http/console-pages.js
++++ b/server/http/console-pages.js
@@@ -15,6 +15,7 @@ const { asyncRouter } = require('./router');
const { html, raw, time } = require('../render/html');
const { send } = require('../render/layout');
const store = require('../tasks/store');
++const { leftLine } = require('../tasks/allowance');
const personal = require('../agents/personal');
const approvals = require('../agents/approvals');
const catalog = require('../agents/catalog');
@@@ -69,10 +70,11 @@ function createConsolePages(ctx, { robots = null, computers = null } = {}) {
return from ? `${ask}${ask ? '\n\n' : ''}(On this page: ${from})` : ask;
}
-- function commandBox(start = '') {
++ function commandBox(start = '', today = null) {
return html`<form class="cn-command" method="post" action="/tasks" aria-labelledby="cn-command-h">
<h2 id="cn-command-h" class="cn-sr">Give Actor a task</h2>
<label class="cn-sr" for="cn-task">What should Actor do?</label>
++${today ? html`<p class="cn-note" data-allowance>${leftLine(tier, today)}</p>` : ''}
<textarea id="cn-task" name="task" rows="${start ? '4' : '2'}" maxlength="20000" required${start ? raw(' autofocus') : ''} placeholder="Tell Actor what to do: “check my site's certificate”, “what changed in my repo today?”, “drive my rover to the door”">${start}</textarea>
<div class="cn-command-row"><label class="cn-mode">Mode <select name="mode">${MODES.map((m) => html`<option value="${m}"${m === 'balanced' ? raw(' selected') : ''}>${MODE_LABEL[m]}</option>`)}</select></label>
<button class="cn-run" type="submit">Run it</button></div>
@@@ -181,7 +183,7 @@ ${previewGrid()}`,
scripts: live.length ? ['js/console.js'] : [],
body: html`${live.length ? raw('<noscript><meta http-equiv="refresh" content="10"></noscript>') : ''}<header class="cn-head"${live.length ? raw(' data-live="10"') : ''}><p class="cn-eyebrow">OpenVibe.Actor · console · alpha</p><h1>Console</h1>
<p class="cn-lede">What your agents, computers and robots are doing, and one box to tell them what to do next.</p></header>
--${commandBox(prefill(req))}
++${commandBox(prefill(req), today)}
<div class="cn-grid">
<section class="cn-card cn-wide" aria-labelledby="cn-now"><h2 id="cn-now">Now${live.length ? html` <span class="cn-count">${String(live.length)}</span>` : ''}</h2>
${live.length ? html`<ul class="cn-tasks">${live.map(taskRow)}</ul><p class="cn-note">This page refreshes every 10 seconds while something runs.</p>` : html`<p class="cn-empty">Nothing is running.</p>`}</section>
@@@ -193,7 +195,7 @@ ${nWaiting ? '' : html`<p class="cn-empty">Nothing waits for you.</p>`}
<section class="cn-card" aria-labelledby="cn-today"><h2 id="cn-today">Today</h2>
<p class="cn-stat"><b>${String(today.tasks)}</b> of ${String(tier.tasksPerDay)} free tasks</p>
<div class="cn-meter" role="progressbar" aria-label="Free tasks used today" aria-valuemin="0" aria-valuemax="${String(tier.tasksPerDay)}" aria-valuenow="${String(today.tasks)}"><span style="width:${String(pct(today.tasks, tier.tasksPerDay))}%"></span></div>
--<p class="cn-stat"><b>${usd(today.usd)}</b> of ${usd(tier.perDayUsd)}</p>
++<p class="cn-stat"><b>${usd(today.usd)}</b> of ${usd(tier.perDayUsd)} · ${leftLine(tier, today)}</p>
<div class="cn-meter" role="progressbar" aria-label="Spent today" aria-valuemin="0" aria-valuemax="${String(tier.perDayUsd)}" aria-valuenow="${String(today.usd)}"><span style="width:${String(pct(today.usd, tier.perDayUsd))}%"></span></div>
${spendChart(week, { caption: 'Last 7 days' })}
<p class="cn-more"><a href="/me/spend">Last 30 days</a> · <a href="/tasks">Your tasks</a></p></section>
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 28ade40..1d50f53 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -18,6 +18,7 @@ const { asyncRouter } = require('./router');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const { sameOrigin } = require('./principal');
const { html, raw, table, notice, time, badge } = require('../render/html');
++const { leftLine } = require('../tasks/allowance');
const { foldBlock } = require('../render/short');
const { send } = require('../render/layout');
const { markdown } = require('../render/markdown');
@@@ -398,7 +399,7 @@ function createPageRoutes(ctx) {
const tier = config.allowance;
// ── The task box ─────────────────────────────────────────
-- function taskForm(viewer, { text = '', mode = 'balanced', problem = null } = {}) {
++ function taskForm(viewer, { text = '', mode = 'balanced', problem = null, today = null } = {}) {
// What a chosen template needs (a robot, a computer, a desk, private mode) is said where it is about to run.
const needs = NEEDS.has(text) ? html`<p class="hm-needs">${NEEDS.get(text)}</p>` : '';
if (!(viewer.kind === 'user' && viewer.subject)) {
@@@ -417,6 +418,7 @@ ${problem ? notice(problem, 'warn') : ''}
${MODES.map((m) => html`<label class="mode"><input type="radio" name="mode" value="${m}"${m === mode ? raw(' checked') : ''}><span><b>${MODE_LABEL[m]}</b><small>${MODE_TEXT[m]}</small></span></label>`)}
</fieldset>
<div class="task-actions"><button class="sc-btn sc-primary" type="submit">Run it</button><span class="muted small">Up to ${usd(tier.perTaskUsd)} a task on the free tier; you see the real cost to a fraction of a cent.</span></div>
++${today ? html`<p class="task-allowance muted small">${leftLine(tier, today)}</p>` : ''}
</form>`;
}
@@@ -437,6 +439,8 @@ ${MODES.map((m) => html`<label class="mode"><input type="radio" name="mode" valu
const agents = catalog.list(config);
const tools = await callableTools();
const signed = viewer.kind === 'user' && viewer.subject;
++ // What is left of today's free allowance, where the person is about to start a run.
++ const today = signed ? await store.spendToday(s, `user:${viewer.subject}`) : null;
// A template link (/?task=…&mode=…#task) fills the task box; the page itself stays the canonical /.
if (!form.text && typeof query.task === 'string') form = { text: query.task.slice(0, 2000), mode: MODES.includes(query.mode) ? query.mode : 'balanced' };
// Real numbers (the owner, 2026-11-01: "are you sure this number is correct?"): the agents Actor can pick from
@@@ -450,7 +454,7 @@ ${MODES.map((m) => html`<label class="mode"><input type="radio" name="mode" valu
title: 'Ask once.', accent: 'The right agent does it.',
lede: 'Give Actor a task in plain words. It picks the agent that fits, OpenVibe\'s own or another platform\'s, has a second model check the answer, and shows you what it cost and why. Like OpenRouter, for agents.',
actions: signed ? [{ label: 'Give it a task', href: '#task', primary: true }, { label: 'Your tasks', href: '/tasks' }] : [{ label: 'Sign in to start', href: '/auth/login?next=%2F', primary: true }, { label: 'See the agents', href: '/agents' }],
-- note: `Open source (AGPL-3.0). Free: ${tier.tasksPerDay} tasks a day, up to ${usd(tier.perTaskUsd)} each, no card.`,
++ note: `Open source (AGPL-3.0). Free: ${tier.tasksPerDay} tasks a day and ${usd(tier.perDayUsd)} of model use, no card.`,
aside: { html: String(routerCard(agents)) },
});
// One strip of numbers (it scrolls sideways on a phone instead of stacking four tiles); zero is left out.
@@@ -467,7 +471,7 @@ ${MODES.map((m) => html`<label class="mode"><input type="radio" name="mode" valu
items: TEMPLATES.flatMap((g) => g.items.map((t) => kit.card({ href: templateHref(t), title: t.title, type: g.type, ratio: '3/1', badge: g.badge, corner: t.mode && MODE_LABEL[t.mode] !== g.badge ? MODE_LABEL[t.mode] : '', peek: t.text, share: false }))),
});
// Signed out and nothing chosen yet, the hero's Sign in is the door: the box would only repeat it.
-- const box = signed || form.text ? taskForm(viewer, form)
++ const box = signed || form.text ? taskForm(viewer, { ...form, today })
: html`<p class="sc-lede"><a href="/auth/login?next=%2F%23task">Sign in</a> with your OpenVibe account and ask in plain words, or pick a task below: it waits in the box while you sign in.</p>`;
// A part read once folds (kit.fold). Its anchor sits inside the fold, so a link to it (#tools, #api) opens it.
const fold = ({ id, body, ...o }) => raw(kit.fold({ ...o, body: `<div id="${id}">${body}</div>` }));
@@@ -569,7 +573,7 @@ ${raw(foldBlock({
const list = names.length > 1 ? `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}` : names[0] || '';
return [
['What does OpenVibe.Actor do?', 'You describe a task in plain words. Actor sorts it, picks the agent that can do it best within your budget and mode, runs it while you watch its steps, has a second model check the answer, and shows you the answer with its sources, what it cost and why that agent.'],
-- ['What does it cost?', `The free tier gives ${tier.tasksPerDay} tasks a day, up to ${usd(tier.perTaskUsd)} each, with no card. Every task shows its real cost to a fraction of a cent.`],
++ ['What does it cost?', `The free tier gives ${tier.tasksPerDay} tasks a day and ${usd(tier.perDayUsd)} of model use, with no card. Every task shows its real cost to a fraction of a cent, and what is left of today's allowance is beside the box.`],
list ? ['Which agents does it use?', `${list} are ready to pick from. The agents page lists each one with its price per million tokens and what it can do.`] : null,
['Is the answer checked?', 'Yes. A second model reads every answer before you see it, from another model family where one is available. An answer that fails its check goes to the next agent that can do the task.'],
['What is private mode?', 'Private mode uses only an open model on OpenVibe\'s own server, so the task never leaves OpenVibe. It is for short answers; tasks that need search or tools go to the other agents in the other modes.'],
@@@ -606,7 +610,7 @@ ${raw(foldBlock({
// inside the same allowance as any task.
const FOLLOW_ERROR = {
'actor.allowance.exhausted': 'You have used today\'s free tasks. They come back at midnight UTC.',
-- 'actor.budget.day_spent': 'Today\'s budget is spent. It resets at midnight UTC.',
++ 'actor.budget.day_spent': 'You have used today\'s free allowance. It comes back at midnight UTC. Add your own model key at /chat/model, and Actor runs on it without charging the allowance.',
'actor.capacity.spent': 'Actor has used today\'s free capacity across everyone. It resets at midnight UTC.',
};
r.post('/tasks/:id/follow-up', require('express').urlencoded({ extended: false, limit: '64kb' }), async (req, res) => {
@@@ -638,7 +642,7 @@ ${raw(foldBlock({
};
const forkProblem = (code) => ({
'actor.allowance.exhausted': 'You have used today\'s free tasks. They come back at midnight UTC.',
-- 'actor.budget.day_spent': 'Today\'s budget is spent. It resets at midnight UTC.',
++ 'actor.budget.day_spent': 'You have used today\'s free allowance. It comes back at midnight UTC. Add your own model key at /chat/model, and Actor runs on it without charging the allowance.',
'actor.capacity.spent': 'Actor has used today\'s free capacity across everyone. It resets at midnight UTC.',
acting: 'That step was stopped mid-action, so it cannot be forked. Pick an earlier saved step.',
}[code] || 'That fork could not start. Try again in a moment.');
@@@ -891,8 +895,8 @@ ${enough ? html`<form method="post" action="/tasks/${row.id}/challenge">
page(req, res, {
title: 'Your tasks', crumbs: [{ label: 'Your tasks' }], styles: [showcase.STYLESHEET],
body: html`<h1>Your tasks</h1>
--<p class="muted">Today: ${today.tasks} of ${tier.tasksPerDay} free tasks, ${usd(today.usd)} of ${usd(tier.perDayUsd)}. Tasks are kept ${store.RETENTION_DAYS} days. · <a href="/memory">Memory</a></p>
--${taskForm(req.viewer)}
++<p class="muted">Today: ${today.tasks} of ${tier.tasksPerDay} free tasks, ${usd(today.usd)} of ${usd(tier.perDayUsd)}. ${leftLine(tier, today)} Tasks are kept ${store.RETENTION_DAYS} days. · <a href="/memory">Memory</a></p>
++${taskForm(req.viewer, { today })}
${table(['Task', 'State', 'Agent', 'Cost', 'Started'], pageRows.rows.map((t) => {
const result = t.result ? (typeof t.result === 'string' ? JSON.parse(t.result) : t.result) : null;
return [html`<a href="/tasks/${t.id}">${t.task.length > 90 ? `${t.task.slice(0, 89)}…` : t.task}</a>`, badge(t.state, STATE_KIND[t.state]), result ? (catalog.byId(result.agent) || {}).name || result.agent : '—', usd(t.cost_usd == null ? null : Number(t.cost_usd)), time(t.created_at)];
ddiff --git a/server/tasks/allowance.js b/server/tasks/allowance.js
nnew file mode 100644
iindex 0000000..e2e4ed1
---- /dev/null
++++ b/server/tasks/allowance.js
@@@ -0,0 +1,34 @@
++'use strict';
++
++/**
++ * Today's free allowance (plan L6196), read the way every page where a person starts a run shows it: what is left
++ * after what tasks cost and after what the runs still open may yet spend. One helper, so the console, the task box
++ * and the chat's composer say the same thing.
++ */
++const round = (n) => Math.round(n * 1e6) / 1e6;
++/** Dollars with the cents the allowance actually has: two decimals when exact, up to four when not ($0.0377). */
++const text = (n) => {
++ const v = round(n);
++ if (v === 0) return '$0';
++ const at = (places) => Math.abs(v * 10 ** places - Math.round(v * 10 ** places)) < 1e-9;
++ return `$${v.toFixed(at(2) ? 2 : at(3) ? 3 : 4)}`;
++};
++
++/** { left, used, held } in USD today, from a tier and store.spendToday(). */
++function leftToday(tier, today = {}) {
++ const used = Number(today.usd) || 0;
++ const held = Number(today.reserved) || 0;
++ return { left: Math.max(0, round(tier.perDayUsd - used - held)), used: round(used), held: round(held) };
++}
++
++/** One plain sentence: what is left, and what runs still open are holding. */
++function leftLine(tier, today = {}) {
++ if (!(tier.perDayUsd > 0)) return 'No free allowance is set on this Actor.';
++ const { left, held } = leftToday(tier, today);
++ if (left <= 0) return held > 0
++ ? `The free allowance is in use for today (${text(held)} held by runs in progress).`
++ : 'The free allowance is used for today.';
++ return `${text(left)} of the ${text(tier.perDayUsd)} free allowance left today${held > 0 ? ` (${text(held)} held by runs in progress)` : ''}.`;
++}
++
++module.exports = { leftToday, leftLine, text };
ddiff --git a/server/tasks/create.js b/server/tasks/create.js
iindex 3041ecf..6cd888c 100644
---- a/server/tasks/create.js
++++ b/server/tasks/create.js
@@@ -14,6 +14,8 @@ const { checkWebhookUrl } = require('../net/webhook-post');
const { mintSecret } = require('./webhooks');
const learn = require('../runtime/brain/learn');
++const usd = (n) => (n === 0 ? '$0' : n < 0.01 ? `$${Number(n).toFixed(4)}` : `$${Number(n).toFixed(2)}`);
++
function untilMidnight(nowMs) {
const n = new Date(nowMs);
return Math.ceil((Date.UTC(n.getUTCFullYear(), n.getUTCMonth(), n.getUTCDate() + 1) - n.getTime()) / 1000);
@@@ -23,7 +25,10 @@ async function createTask({ s, config, engine, principal, body, webhooks: webhoo
const v = contracts.validate('actor.task-create-request@1', body);
if (!v.valid) return { status: 422, code: 'actor.task.invalid', detail: (v.errors || []).map((e) => `${e.path || e.instancePath || ''} ${e.message}`.trim()).join('; ').slice(0, 500) || 'invalid request' };
const tier = config.allowance;
-- const perTask = body.budget && body.budget.per_task_usd != null ? body.budget.per_task_usd : tier.perTaskUsd;
++ // A budget the request named is the first word; without one, the task gets up to a tier task's worth and, below
++ // that, what the day has left (so a cheap run leaves room for the next one).
++ const requestedTaskUsd = body.budget && body.budget.per_task_usd != null ? body.budget.per_task_usd : null;
++ const perTask = requestedTaskUsd == null ? tier.perTaskUsd : requestedTaskUsd;
const perDay = body.budget && body.budget.per_day_usd != null ? body.budget.per_day_usd : tier.perDayUsd;
if (perTask > tier.perTaskUsd || perDay > tier.perDayUsd) {
return { status: 422, code: 'actor.budget.over_tier', detail: `The free tier allows up to $${tier.perTaskUsd} per task and $${tier.perDayUsd} a day; paid tiers come with OpenVibe.Billing.` };
@@@ -60,9 +65,11 @@ async function createTask({ s, config, engine, principal, body, webhooks: webhoo
const out = await s.tx(async () => {
const { mine, all } = await store.lockSpendToday(s, principal.requester);
if (mine.tasks >= tier.tasksPerDay) return { status: 429, code: 'actor.allowance.exhausted', detail: `The free tier runs ${tier.tasksPerDay} tasks a day; it resets at midnight UTC.`, retryAfter };
-- if (mine.usd >= perDay || mine.usd + mine.reserved + perTask > perDay + 1e-9) {
-- return { status: 429, code: 'actor.budget.day_spent', detail: `Today's tasks cost $${mine.usd.toFixed(3)} and running ones may use $${mine.reserved.toFixed(3)} of the $${perDay} daily budget; it resets at midnight UTC.`, retryAfter };
++ const remaining = perDay - mine.usd - mine.reserved;
++ if (remaining <= 1e-9 || (requestedTaskUsd != null && requestedTaskUsd > remaining + 1e-9)) {
++ return { status: 429, code: 'actor.budget.day_spent', detail: `You have used today's free allowance (${usd(perDay)}). It comes back at midnight UTC. To keep going now, add your own model key at ${config.baseUrl}/chat/model: Actor runs on it, and it does not count against the free allowance.`, retryAfter };
}
++ const budgetTask = requestedTaskUsd == null ? Math.round(Math.min(perTask, remaining) * 1e6) / 1e6 : requestedTaskUsd;
if (all.usd >= config.spendCapUsdPerDay || all.usd + all.reserved + perTask > config.spendCapUsdPerDay + 1e-9) {
return { status: 503, code: 'actor.capacity.spent', detail: 'Actor has used today\'s free capacity across everyone. It resets at midnight UTC.', retryAfter };
}
@@@ -72,7 +79,7 @@ async function createTask({ s, config, engine, principal, body, webhooks: webhoo
}
const row = await store.insertTask(s, {
id: s.newId('tsk'), requester: principal.requester, via: principal.via || null, project_id: principal.project || null, task: body.task, mode: body.mode || 'balanced', agent: body.agent || null,
-- budget_task: perTask, budget_day: perDay, idem_key: body.idempotency_key || null, idem_hash: body.idempotency_key ? hash : null, created_at: s.iso(),
++ budget_task: budgetTask, budget_day: perDay, idem_key: body.idempotency_key || null, idem_hash: body.idempotency_key ? hash : null, created_at: s.iso(),
webhooks, webhook_secret: ownSecret ? mintSecret() : null, parent_id: parentId,
// A fork (stage V2-9) starts from its parent's checkpoint at this step, with the one change the person
// picked; it is a new task with its own budget and its own clock.
ddiff --git a/server/tasks/store.js b/server/tasks/store.js
iindex bdce477..e8b8aa1 100644
---- a/server/tasks/store.js
++++ b/server/tasks/store.js
@@@ -188,7 +188,9 @@ async function lockSpendToday(s, requester) {
async function spendToday(s, requester) {
const row = await s.db.maybe('SELECT usd, tasks FROM spend_daily WHERE requester = $1 AND day = $2', [requester, dayOf(s.now())]);
-- return { usd: row ? Number(row.usd) : 0, tasks: row ? Number(row.tasks) : 0 };
++ // What runs still open may yet spend: the same reservation the create gate counts, read without locking.
++ const open = await s.db.maybe('SELECT COALESCE(sum(budget_task), 0) AS reserved FROM tasks WHERE requester = $1 AND state = ANY($2)', [requester, OPEN]);
++ return { usd: row ? Number(row.usd) : 0, tasks: row ? Number(row.tasks) : 0, reserved: Number(open.reserved) };
}
/** The last `days` days of a requester's spend, oldest first, each day present (0 when nothing ran). */
ddiff --git a/test/free-allowance.test.js b/test/free-allowance.test.js
nnew file mode 100644
iindex 0000000..8f48ed4
---- /dev/null
++++ b/test/free-allowance.test.js
@@@ -0,0 +1,79 @@
++'use strict';
++/**
++ * The free allowance (plan L6196): $0.05 a day per person by default, one measured Actor run, shown where a person
++ * starts a run, and refused in plain words — with the way to add their own key — once it is spent.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++const SAME = { 'sec-fetch-site': 'same-origin' };
++
++(async () => {
++ const t = await boot({ env: { ACTOR_FREE_PER_DAY_USD: '0.05' } });
++ const store = require('../server/tasks/store');
++
++ await check('the default free allowance is $0.05 a day, the price of about one measured run', async () => {
++ // The default itself, with the test harness's generous day out of the way.
++ const fresh = require('../server/config').load({ NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://openvibe.actor' });
++ assert.strictEqual(fresh.allowance.perDayUsd, 0.05);
++ assert.strictEqual(fresh.allowance.perTaskUsd, 0.05);
++ assert.strictEqual(t.config.allowance.perDayUsd, 0.05);
++ const limits = (await t.get('/limits.json')).json();
++ assert.deepStrictEqual([limits.free_tier.per_day_usd, limits.free_tier.per_task_usd], [0.05, 0.05]);
++ });
++
++ await check('one line says what is left, and what runs still hold', async () => {
++ const { leftLine } = require('../server/tasks/allowance');
++ assert.strictEqual(leftLine({ perDayUsd: 0.05 }, { usd: 0.01, reserved: 0.02 }), '$0.02 of the $0.05 free allowance left today ($0.02 held by runs in progress).');
++ assert.strictEqual(leftLine({ perDayUsd: 0.05 }, { usd: 0.05, reserved: 0 }), 'The free allowance is used for today.');
++ assert.strictEqual(leftLine({ perDayUsd: 0.05 }, { usd: 0, reserved: 0 }), '$0.05 of the $0.05 free allowance left today.');
++ });
++
++ await check('what is left counts what was spent and what open runs may still spend', async () => {
++ const u = t.network.addUser('left');
++ const who = `user:${u.subject}`;
++ await store.addSpend(t.ctx.s, who, 0.01);
++ let today = await store.spendToday(t.ctx.s, who);
++ assert.deepStrictEqual([today.usd, today.reserved], [0.01, 0], 'nothing open yet');
++ await store.insertTask(t.ctx.s, { id: t.ctx.s.newId('tsk'), requester: who, task: 'open', mode: 'balanced', budget_task: 0.02, budget_day: 0.05, created_at: t.ctx.s.iso() });
++ today = await store.spendToday(t.ctx.s, who);
++ assert.strictEqual(today.reserved, 0.02);
++ // The console is where a person starts a run: $0.05 - $0.01 spent - $0.02 held = $0.02 left.
++ assert.strictEqual(typeof require('../server/http/console-pages').createConsolePages, 'function');
++ const consolePage = await t.get('/console', { as: u });
++ assert.strictEqual(consolePage.status, 200);
++ assert.match(consolePage.text, /\$0\.02 of the \$0\.05 free allowance left today \(\$0\.02 held by runs in progress\)/);
++ await t.ctx.s.db.query("UPDATE tasks SET state = 'cancelled' WHERE requester = $1", [who]);
++ });
++
++ await check('past the day\'s allowance a task is refused in plain words, with the way to add a key', async () => {
++ const u = t.network.addUser('spent');
++ await store.addSpend(t.ctx.s, `user:${u.subject}`, 0.05);
++ const r = await t.get('/api/v1/tasks', { as: u, json: { task: 'one more' }, headers: SAME });
++ assert.strictEqual(r.status, 429);
++ assert.strictEqual(r.json().code, 'actor.budget.day_spent');
++ assert.match(r.json().detail, /You have used today's free allowance/);
++ assert.match(r.json().detail, /add your own model key at https:\/\/openvibe\.actor\/chat\/model/);
++ assert.ok(Number(r.headers.get('retry-after')) > 0);
++
++ // The site's own form says the same, where the person typed the task.
++ const page = await t.get('/tasks', { as: u, form: { task: 'one more', mode: 'balanced' }, headers: SAME });
++ assert.strictEqual(page.status, 429);
++ assert.match(page.text, /add your own model key/);
++ assert.match(page.text, /chat\/model/);
++ });
++
++ await check('the chat composer and the task box show the allowance left today', async () => {
++ const u = t.network.addUser('shown');
++ await store.addSpend(t.ctx.s, `user:${u.subject}`, 0.0123);
++ const chat = await t.get('/chat', { as: u });
++ assert.strictEqual(chat.status, 200);
++ assert.match(chat.text, /\$0\.0377 of the \$0\.05 free allowance left today/);
++ const home = await t.get('/', { as: u });
++ assert.strictEqual(home.status, 200);
++ assert.match(home.text, /\$0\.0377 of the \$0\.05 free allowance left today/);
++ });
++
++ await t.close();
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex 8705c4d..c31d942 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -28,6 +28,9 @@ async function boot(opts = {}) {
const providers = await startProviders();
const env = {
NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://openvibe.actor', TRUST_PROXY: '1',
++ // A test boot makes many runs in one "day"; the free allowance is measured in free-allowance.test.js, which
++ // sets its own. This keeps the other suites about what they are about.
++ ACTOR_FREE_PER_DAY_USD: '1000',
OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,
OV_OAUTH_CLIENT_ID: 'actor', OV_OAUTH_CLIENT_SECRET: 'actor-secret', COOKIE_SECURE: 'false',
...providers.env,
ddiff --git a/test/tasks.test.js b/test/tasks.test.js
iindex 5c6a8b8..c1e89d9 100644
---- a/test/tasks.test.js
++++ b/test/tasks.test.js
@@@ -375,17 +375,25 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
const store = require('../server/tasks/store');
const u = t.network.addUser('burst');
const who = `user:${u.subject}`;
-- const { perTaskUsd, perDayUsd } = t.config.allowance;
-- const fit = Math.floor(perDayUsd / perTaskUsd + 1e-9);
-- // `fit` tasks are open (queued) with nothing spent yet: their budgets are reserved.
-- for (let i = 0; i < fit; i++) {
-- await store.insertTask(t.ctx.s, { id: t.ctx.s.newId('tsk'), requester: who, task: `open ${i}`, mode: 'balanced', budget_task: perTaskUsd, budget_day: perDayUsd, created_at: t.ctx.s.iso() });
++ // This boot's day is generous (many suites run here); give this check a day of one task's worth, so filling
++ // it takes one open reservation rather than thousands.
++ const saved = t.config.allowance.perDayUsd;
++ t.config.allowance.perDayUsd = t.config.allowance.perTaskUsd;
++ try {
++ const { perTaskUsd, perDayUsd } = t.config.allowance;
++ const fit = Math.floor(perDayUsd / perTaskUsd + 1e-9);
++ // `fit` tasks are open (queued) with nothing spent yet: their budgets are reserved.
++ for (let i = 0; i < fit; i++) {
++ await store.insertTask(t.ctx.s, { id: t.ctx.s.newId('tsk'), requester: who, task: `open ${i}`, mode: 'balanced', budget_task: perTaskUsd, budget_day: perDayUsd, created_at: t.ctx.s.iso() });
++ }
++ assert.strictEqual((await store.spendToday(t.ctx.s, who)).usd, 0, 'nothing has accrued yet');
++ const r = await create({ task: 'one too many' }, { as: u });
++ assert.strictEqual(r.status, 429, r.text);
++ assert.strictEqual(r.json().code, 'actor.budget.day_spent');
++ } finally {
++ t.config.allowance.perDayUsd = saved;
++ await t.ctx.s.db.query("UPDATE tasks SET state = 'cancelled' WHERE requester = $1", [who]);
}
-- assert.strictEqual((await store.spendToday(t.ctx.s, who)).usd, 0, 'nothing has accrued yet');
-- const r = await create({ task: 'one too many' }, { as: u });
-- assert.strictEqual(r.status, 429, r.text);
-- assert.strictEqual(r.json().code, 'actor.budget.day_spent');
-- await t.ctx.s.db.query("UPDATE tasks SET state = 'cancelled' WHERE requester = $1", [who]);
});
await check('the operator\'s daily ceiling over everyone stops new tasks', async () => {