openvibers/openvibe.actor Commits
Free allowance: $0.05 a day per person, shown where a run starts (#294)
Changes
@@@ -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@@@ -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;@@@ -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: {@@@ -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 onconst 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) => {@@@ -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>@@@ -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" valuconst 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" valutitle: '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" valuitems: 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)];nnew file mode 100644@@@ -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 };@@@ -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: webhooconst 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: webhooconst 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.@@@ -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). */nnew file mode 100644@@@ -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); });@@@ -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,@@@ -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 () => {
@@ -85,7 +85,7 @@ ACTOR_RUNTIME_OUTPUT_USD_PER_MTOK=0
@@ -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).
# 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_TASKS_PER_DAY=20
#ACTOR_FREE_PER_TASK_USD=0.05
#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_COST_USD_PER_DAY=2
#ACTOR_MAX_RUNNING=4
#ACTOR_MAX_RUNNING=4
#ACTOR_TASK_TIMEOUT_MS=180000
#ACTOR_TASK_TIMEOUT_MS=180000
@@ -84,7 +84,7 @@ The resource index is for OpenVibe.Services on loopback and is blocked at the pu
@@ -84,7 +84,7 @@ The resource index is for OpenVibe.Services on loopback and is blocked at the pu
## Money
## 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.
- **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:**
- **Budgets are hard:**
- a budget above the tier is refused, never lowered;
- a budget above the tier is refused, never lowered;
@@ -178,7 +178,7 @@ function load(env = process.env) {
@@ -178,7 +178,7 @@ function load(env = process.env) {
allowance: {
allowance: {
tasksPerDay: Math.max(0, int(env.ACTOR_FREE_TASKS_PER_DAY, 20)),
tasksPerDay: Math.max(0, int(env.ACTOR_FREE_TASKS_PER_DAY, 20)),
perTaskUsd: num(env.ACTOR_FREE_PER_TASK_USD, 0.05),
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),
spendCapUsdPerDay: num(env.ACTOR_MAX_COST_USD_PER_DAY, 2),
agents: {
agents: {
@@ -23,6 +23,7 @@ const { html, raw } = require('../render/html');
@@ -23,6 +23,7 @@ const { html, raw } = require('../render/html');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
const { markdown } = require('../render/markdown');
const { markdown } = require('../render/markdown');
const store = require('../tasks/store');
const store = require('../tasks/store');
+const { leftLine } = require('../tasks/allowance');
const { createTask } = require('../tasks/create');
const { createTask } = require('../tasks/create');
const { MODES } = require('../agents/router');
const { MODES } = require('../agents/router');
const catalog = require('../agents/catalog');
const catalog = require('../agents/catalog');
@@ -345,7 +346,7 @@ ${acting ? '' : html`<form method="post" action="/tasks/${row.id}/answer" class=
@@ -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_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';
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}">
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)}
${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>
<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)}
@@ -362,7 +363,7 @@ ${modelChip(model)}
hidden: { parent },
hidden: { parent },
top: String(html`${problem ? html`<p class="cc-problem" role="alert">${problem}</p>` : ''}<ul class="cc-files" data-cc-chips hidden></ul>`),
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),
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)}
@@ -383,7 +384,7 @@ ${modelChip(model)}
async function render(req, res, { row = null, problem = null, text = '', status = 200 } = {}) {
async function render(req, res, { row = null, problem = null, text = '', status = 200 } = {}) {
const requester = requesterOf(req);
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 first = turns[0] || null;
const lastTurn = turns[turns.length - 1] || null;
const lastTurn = turns[turns.length - 1] || null;
const busy = Boolean(lastTurn && (OPEN.includes(lastTurn.state) || lastTurn.state === 'needs_you'));
const busy = Boolean(lastTurn && (OPEN.includes(lastTurn.state) || lastTurn.state === 'needs_you'));
@@ -402,7 +403,7 @@ ${modelChip(model)}
@@ -402,7 +403,7 @@ ${modelChip(model)}
turns: turnHtmls.map(String),
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>
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>`),
<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',
label: first ? 'Conversation' : 'New chat',
}))}</div>`,
}))}</div>`,
});
});
@@ -568,7 +569,7 @@ ${runsSaved ? html`<p class="cm-ok" role="status">Saved. Your next task runs on
@@ -568,7 +569,7 @@ ${runsSaved ? html`<p class="cm-ok" role="status">Saved. Your next task runs on
const PROBLEM = {
const PROBLEM = {
'actor.allowance.exhausted': 'You have used today\'s free tasks. They come back at midnight UTC.',
'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.',
'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) => {
r.post('/chat', express.urlencoded({ extended: false, limit: '64kb' }), express.json({ limit: '18mb' }), async (req, res) => {
@@ -15,6 +15,7 @@ const { asyncRouter } = require('./router');
@@ -15,6 +15,7 @@ const { asyncRouter } = require('./router');
const { html, raw, time } = require('../render/html');
const { html, raw, time } = require('../render/html');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
const store = require('../tasks/store');
const store = require('../tasks/store');
+const { leftLine } = require('../tasks/allowance');
const personal = require('../agents/personal');
const personal = require('../agents/personal');
const approvals = require('../agents/approvals');
const approvals = require('../agents/approvals');
const catalog = require('../agents/catalog');
const catalog = require('../agents/catalog');
@@ -69,10 +70,11 @@ function createConsolePages(ctx, { robots = null, computers = null } = {}) {
@@ -69,10 +70,11 @@ function createConsolePages(ctx, { robots = null, computers = null } = {}) {
return from ? `${ask}${ask ? '\n\n' : ''}(On this page: ${from})` : ask;
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">
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>
<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>
<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>
<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>
<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>
<button class="cn-run" type="submit">Run it</button></div>
@@ -181,7 +183,7 @@ ${previewGrid()}`,
@@ -181,7 +183,7 @@ ${previewGrid()}`,
scripts: live.length ? ['js/console.js'] : [],
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>
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>
<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">
<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>
<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>
${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>`}
@@ -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>
<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>
<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>
<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>
<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' })}
${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>
<p class="cn-more"><a href="/me/spend">Last 30 days</a> · <a href="/tasks">Your tasks</a></p></section>
@@ -18,6 +18,7 @@ const { asyncRouter } = require('./router');
@@ -18,6 +18,7 @@ const { asyncRouter } = require('./router');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const { sameOrigin } = require('./principal');
const { sameOrigin } = require('./principal');
const { html, raw, table, notice, time, badge } = require('../render/html');
const { html, raw, table, notice, time, badge } = require('../render/html');
+const { leftLine } = require('../tasks/allowance');
const { foldBlock } = require('../render/short');
const { foldBlock } = require('../render/short');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
const { markdown } = require('../render/markdown');
const { markdown } = require('../render/markdown');
@@ -398,7 +399,7 @@ function createPageRoutes(ctx) {
@@ -398,7 +399,7 @@ function createPageRoutes(ctx) {
const tier = config.allowance;
const tier = config.allowance;
// ── The task box ─────────────────────────────────────────
// ── 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.
// 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>` : '';
const needs = NEEDS.has(text) ? html`<p class="hm-needs">${NEEDS.get(text)}</p>` : '';
if (!(viewer.kind === 'user' && viewer.subject)) {
if (!(viewer.kind === 'user' && viewer.subject)) {
@@ -417,6 +418,7 @@ ${problem ? notice(problem, 'warn') : ''}
@@ -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>`)}
${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>
</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>
<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>`;
</form>`;
}
}
@@ -437,6 +439,8 @@ ${MODES.map((m) => html`<label class="mode"><input type="radio" name="mode" valu
@@ -437,6 +439,8 @@ ${MODES.map((m) => html`<label class="mode"><input type="radio" name="mode" valu
const agents = catalog.list(config);
const agents = catalog.list(config);
const tools = await callableTools();
const tools = await callableTools();
const signed = viewer.kind === 'user' && viewer.subject;
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 /.
// 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' };
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
// 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
@@ -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.',
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.',
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' }],
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)) },
aside: { html: String(routerCard(agents)) },
});
});
// One strip of numbers (it scrolls sideways on a phone instead of stacking four tiles); zero is left out.
// 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
@@ -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 }))),
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.
// 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>`;
: 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.
// 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>` }));
const fold = ({ id, body, ...o }) => raw(kit.fold({ ...o, body: `<div id="${id}">${body}</div>` }));
@@ -569,7 +573,7 @@ ${raw(foldBlock({
@@ -569,7 +573,7 @@ ${raw(foldBlock({
const list = names.length > 1 ? `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}` : names[0] || '';
const list = names.length > 1 ? `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}` : names[0] || '';
return [
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 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,
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.'],
['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.'],
['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({
@@ -606,7 +610,7 @@ ${raw(foldBlock({
// inside the same allowance as any task.
// inside the same allowance as any task.
const FOLLOW_ERROR = {
const FOLLOW_ERROR = {
'actor.allowance.exhausted': 'You have used today\'s free tasks. They come back at midnight UTC.',
'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.',
'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) => {
r.post('/tasks/:id/follow-up', require('express').urlencoded({ extended: false, limit: '64kb' }), async (req, res) => {
@@ -638,7 +642,7 @@ ${raw(foldBlock({
@@ -638,7 +642,7 @@ ${raw(foldBlock({
};
};
const forkProblem = (code) => ({
const forkProblem = (code) => ({
'actor.allowance.exhausted': 'You have used today\'s free tasks. They come back at midnight UTC.',
'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.',
'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.',
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.');
}[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">
@@ -891,8 +895,8 @@ ${enough ? html`<form method="post" action="/tasks/${row.id}/challenge">
page(req, res, {
page(req, res, {
title: 'Your tasks', crumbs: [{ label: 'Your tasks' }], styles: [showcase.STYLESHEET],
title: 'Your tasks', crumbs: [{ label: 'Your tasks' }], styles: [showcase.STYLESHEET],
body: html`<h1>Your tasks</h1>
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) => {
${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;
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)];
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)];
new file mode 100644
new file mode 100644
@@ -0,0 +1,34 @@
@@ -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 };
@@ -14,6 +14,8 @@ const { checkWebhookUrl } = require('../net/webhook-post');
@@ -14,6 +14,8 @@ const { checkWebhookUrl } = require('../net/webhook-post');
const { mintSecret } = require('./webhooks');
const { mintSecret } = require('./webhooks');
const learn = require('../runtime/brain/learn');
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) {
function untilMidnight(nowMs) {
const n = new Date(nowMs);
const n = new Date(nowMs);
return Math.ceil((Date.UTC(n.getUTCFullYear(), n.getUTCMonth(), n.getUTCDate() + 1) - n.getTime()) / 1000);
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
@@ -23,7 +25,10 @@ async function createTask({ s, config, engine, principal, body, webhooks: webhoo
const v = contracts.validate('actor.task-create-request@1', body);
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' };
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 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;
const perDay = body.budget && body.budget.per_day_usd != null ? body.budget.per_day_usd : tier.perDayUsd;
if (perTask > tier.perTaskUsd || perDay > 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.` };
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
@@ -60,9 +65,11 @@ async function createTask({ s, config, engine, principal, body, webhooks: webhoo
const out = await s.tx(async () => {
const out = await s.tx(async () => {
const { mine, all } = await store.lockSpendToday(s, principal.requester);
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.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) {
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 };
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
@@ -72,7 +79,7 @@ async function createTask({ s, config, engine, principal, body, webhooks: webhoo
}
}
const row = await store.insertTask(s, {
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,
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,
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
// 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.
// picked; it is a new task with its own budget and its own clock.
@@ -188,7 +188,9 @@ async function lockSpendToday(s, requester) {
@@ -188,7 +188,9 @@ async function lockSpendToday(s, requester) {
async function spendToday(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())]);
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). */
/** The last `days` days of a requester's spend, oldest first, each day present (0 when nothing ran). */
new file mode 100644
new file mode 100644
@@ -0,0 +1,79 @@
@@ -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); });
@@ -28,6 +28,9 @@ async function boot(opts = {}) {
@@ -28,6 +28,9 @@ async function boot(opts = {}) {
const providers = await startProviders();
const providers = await startProviders();
const env = {
const env = {
NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://openvibe.actor', TRUST_PROXY: '1',
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_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,
OV_OAUTH_CLIENT_ID: 'actor', OV_OAUTH_CLIENT_SECRET: 'actor-secret', COOKIE_SECURE: 'false',
OV_OAUTH_CLIENT_ID: 'actor', OV_OAUTH_CLIENT_SECRET: 'actor-secret', COOKIE_SECURE: 'false',
...providers.env,
...providers.env,
@@ -375,17 +375,25 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
@@ -375,17 +375,25 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
const store = require('../server/tasks/store');
const store = require('../server/tasks/store');
const u = t.network.addUser('burst');
const u = t.network.addUser('burst');
const who = `user:${u.subject}`;
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 () => {
await check('the operator\'s daily ceiling over everyone stops new tasks', async () => {