openvibers/openvibe.ai Commits

BA1: the latency classes and the background tier (#359)

OP OpenVibers committed 6 days ago · 0d7fb8d2f4 · 26 files, +1213 −70

* BA1: the latency classes and the background tier

A run now says how long the caller may wait (interactive, background, batch) and
the router prices it on the tier it really ran on.

The class is the workflow's own, read from the route it uses
(routes.constraints.latency_class); a caller may ask for a slower class with
options.latency and never for a faster one.

A background run looks for capacity that is cheap because it may wait: the
host's own idle model first when the route allows it, then a Flex tier (OpenAI,
DeepInfra), then a provider's off-peak hours (DeepSeek's rule since 2026-08-16,
now read as weekday blocks), otherwise the standard tier. A Flex tier the
provider refuses (429 with no capacity, 400 for a model that does not serve it)
sends the same run once more on the standard tier: counted, logged, never
billed twice.

The cost on a run is what the provider really charged for the tier it really
ran on. The tier is read from the answer itself; a provider that does not say
which tier it served is priced at the list price, because a saving nobody
confirmed is not a saving.

Migration 0044 (expand-only): runs.latency_class, runs.service_tier,
runs.saved_usd, and price_book_offpeak_blocks (a set of weekdays and the hours
inside them). The old single-window rows are still read.

* BA1: keep the route's role and the route's own timeout

Three fixes the CI suite found.

- A route's Live role is read from the route key, so a person's own key answers
  with the model they stored for that role. The background routes carry the role
  too (background.director, background.legacy, background.summary,
  background.vision): the AI viewers director plan runs in the background, but a
  person who gave their key a director model still wants that model to write it.

- The provider's own timeout is a floor, not a ceiling: a route that says it
  outlasts the provider's chat timeout (space.build writes a whole app) keeps
  the room it asked for. The background class raises that ceiling, it never
  lowers it.

- news.summarize_story runs on background.json now, so the run records that
  route. The historical key is still an explicit alias of default.json.

* BA1 review (Opus): local-first opt-in, a faster class clamped, answers inside the caller's wait

- Local-first is opt-in per route (constraints.allow_local). No background route sets it: their text is what
  people read on Trade, Live and the Rundown, and the host's 1.5B model is not measured for it.
- A caller asking for a faster class than the workflow allows gets its run in the workflow's own class
  (options.latency_clamped records it), never a 422 and nothing done.
- Most callers wait (?wait=, up to 60 s) and do not poll after a 202 (Trade, News, MediaHub pics, and Live's
  ai-service without pollMs). A background run now answers inside that wait: its Flex attempt may use the wait
  minus 25 s (at least 10 s); slower, the same run goes again on the standard tier. A caller that polls gives
  Flex all its room.
Tests: latency-classes 15/15 (the opt-in, the clamp, Flex slower than the wait answered on standard in time).

Changes

ddiff --git a/migrations/0044_latency_classes.sql b/migrations/0044_latency_classes.sql
nnew file mode 100644
iindex 0000000..8036db4
---- /dev/null
++++ b/migrations/0044_latency_classes.sql
@@@ -0,0 +1,43 @@
++-- phase: expand
++-- The latency class of a run, the tier it really ran on, and the off-peak windows as weekday blocks.
++--
++-- A run's latency class says how long the caller may wait: 'interactive' (today's routing), 'background'
++-- (the cheapest tier that answers in minutes) or 'batch' (a deadline, answered later). The class is the
++-- workflow's own, read from the route it uses (routes.constraints.latency_class); a caller may ask for a
++-- slower one (options.latency) and never a faster one.
++--
++-- service_tier is the tier the run really ran on: 'standard', 'flex', 'batch', 'offpeak' or 'local'. The
++-- cost on the run is what the provider really charged for that tier, and saved_usd is what waiting earned
++-- (the standard price minus the real one). A run that never reached a provider has no tier.
++ALTER TABLE runs ADD COLUMN IF NOT EXISTS latency_class text NOT NULL DEFAULT 'interactive';
++ALTER TABLE runs ADD COLUMN IF NOT EXISTS service_tier text;
++ALTER TABLE runs ADD COLUMN IF NOT EXISTS saved_usd double precision NOT NULL DEFAULT 0;
++CREATE INDEX IF NOT EXISTS runs_latency_class_idx ON runs (latency_class, created_at DESC);
++CREATE INDEX IF NOT EXISTS runs_service_tier_idx ON runs (service_tier, provider_key);
++
++-- The price book's off-peak windows as weekday blocks.
++--
++-- The old table holds ONE daily window per (provider, model, field): a start hour and an end hour, and the
++-- window applies every day of the week. That cannot express a rule like DeepSeek's, whose peak hours are
++-- 01:00-04:00 and 06:00-10:00 UTC Monday to Friday (every other hour is off-peak, weekends in full).
++--
++-- This table holds blocks: a set of weekdays and the hours inside them. The old rows are still read (a
++-- provider with only old rows keeps its old window), and the new rows are read on top of them.
++CREATE TABLE IF NOT EXISTS price_book_offpeak_blocks (
++ id bigserial PRIMARY KEY,
++ provider_key text COLLATE "C" NOT NULL,
++ model_key text COLLATE "C" NOT NULL,
++ field text COLLATE "C" NOT NULL CHECK (field IN ('in_per_mtok', 'out_per_mtok', 'cached_per_mtok')),
++ price numeric(20, 6) NOT NULL, -- the provider's own price inside the block
++ weekdays text COLLATE "C" NOT NULL, -- the ISO weekdays the block is on: '1,2,3,4,5' (1 = Monday)
++ start_hour integer NOT NULL CHECK (start_hour BETWEEN 0 AND 23), -- the UTC hour the block opens
++ end_hour integer NOT NULL CHECK (end_hour BETWEEN 0 AND 24), -- the UTC hour it closes (24 = midnight)
++ source text COLLATE "C" NOT NULL, -- the listing the block was read from
++ checked_at date NOT NULL, -- the day it was read
++ created_at timestamptz NOT NULL,
++ updated_at timestamptz NOT NULL
++);
++CREATE UNIQUE INDEX IF NOT EXISTS price_book_offpeak_blocks_key_idx
++ ON price_book_offpeak_blocks (provider_key, model_key, field, weekdays, start_hour, end_hour);
++CREATE INDEX IF NOT EXISTS price_book_offpeak_blocks_lookup_idx
++ ON price_book_offpeak_blocks (provider_key, model_key);
ddiff --git a/server/api/runs.js b/server/api/runs.js
iindex 5866ac1..a2c4dd7 100644
---- a/server/api/runs.js
++++ b/server/api/runs.js
@@@ -64,7 +64,9 @@ function runsRouter({ runs, registry, auth, config, log = console }) {
if (req.principal.kind === 'app') body = appBody(req.principal, body, req.body);
checkNamespace(req, String(body.workflow || ''));
const idem = body.idempotency_key || req.ov.idempotencyKey || undefined;
-- const created = await runs.create({ ...body, idempotency_key: idem }, req.principal, { trace: req.ov.traceId, requestId: req.ov.requestId, abuse: req.abuse });
++ // BA1: the caller's wait goes with the run, so a background run answers inside it (a Flex tier that is
++ // too slow for the wait gives way to the standard tier in time; most callers do not poll after a 202).
++ const created = await runs.create({ ...body, idempotency_key: idem }, req.principal, { trace: req.ov.traceId, requestId: req.ov.requestId, abuse: req.abuse, callerWaitMs: waitMs });
const run = await runs.explained(await runs.wait(created, waitMs));
const status = !created.created ? 200 : (runs.TERMINAL.has(run.status) ? 201 : 202);
if (status === 202) res.setHeader('Location', `/api/v1/runs/${run.id}`);
ddiff --git a/server/app.js b/server/app.js
iindex 656190f..fab4d27 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -34,7 +34,7 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
// GET /metrics answers direct loopback callers only (Track O).
const metrics = instrument(app, { service: 'ai', release: release.release });
app.use(require('openvibe-sdk/service').telemetryMiddleware); // request telemetry: a no-op until server/index.js calls telemetry.init()
-- registerAiGauges(metrics.registry, { runs, registry, pool, balance, gpus, abuse });
++ registerAiGauges(metrics.registry, { runs, registry, pool, balance, gpus, abuse, db });
app.use(http.middleware());
// Plan E5: the signed HttpOnly device id and the request's trust metadata on the model and Exchange APIs.
// The address/device stay in the request: only keyed hashes reach the database (server/abuse/).
ddiff --git a/server/batch/capacity.js b/server/batch/capacity.js
iindex b589123..75e8573 100644
---- a/server/batch/capacity.js
++++ b/server/batch/capacity.js
@@@ -25,6 +25,7 @@
* Every route's cost is per million tokens in nano-USD, the same unit the retail card uses.
*/
const { retailRate } = require('../providers/model-catalog');
++const { insideWindow } = require('../pricebook/offpeak');
/** Half of a nano price, rounded down: the customer is never charged more than half. */
const halfOf = (v) => (v == null ? null : (BigInt(v) / 2n).toString());
@@@ -68,7 +69,7 @@ function within(cost, charge) {
}
/** Is `at` inside a UTC window? A window that wraps the midnight (23-02) is handled. */
--function insideWindow(window, at) {
++function insideWindowLegacy(window, at) {
if (!window) return false;
const hour = new Date(at).getUTCHours();
const start = Number(window.start_hour);
@@@ -140,8 +141,10 @@ async function batchCapacity(model, at, { registry = null, community = null, pri
}
// (c) A paid provider inside a window where its own price is at least half lower than the retail card.
++ // The windows are the price book's: the old single daily window and the weekday blocks (BA1), so a
++ // rule like DeepSeek's (peak 01:00-04:00 and 06:00-10:00 UTC, Monday to Friday) is read as it is.
if (priceBook && typeof priceBook.offPeakRows === 'function') {
-- const rows = await priceBook.offPeakRows();
++ const rows = typeof priceBook.windows === 'function' ? await priceBook.windows() : await priceBook.offPeakRows();
for (const row of rows || []) {
if (String(row.model_key) !== String(model.model_key)) continue;
if (!insideWindow(row, at)) continue;
@@@ -157,7 +160,7 @@ async function batchCapacity(model, at, { registry = null, community = null, pri
model: row.model_key,
source: 'discount-window',
cost,
-- window: { start_hour: Number(row.start_hour), end_hour: Number(row.end_hour) },
++ window: { start_hour: Number(row.start_hour), end_hour: Number(row.end_hour), weekdays: row.weekdays || [] },
});
}
}
@@@ -165,4 +168,4 @@ async function batchCapacity(model, at, { registry = null, community = null, pri
return { charge, routes };
}
--module.exports = { batchCapacity, chargeOf, within, insideWindow, halfOf };
++module.exports = { batchCapacity, chargeOf, within, insideWindow: insideWindowLegacy, halfOf };
ddiff --git a/server/index.js b/server/index.js
iindex 79bae34..795d837 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -80,7 +80,10 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
const bench = createBenchEvidence({ db, registry, config, clock, fetchImpl, log });
// Network owns the 18+ account flag; the adult tier checks it before placement.
const adultStatus = require('./policy/adult-status').createAdultStatus({ config, env, fetchImpl, clock, log });
-- const pool = createProviderPool({ db, registry, config, clock, fetchImpl, env, log, credentialFetch, machineModels: machines, community, bench, variantEvidence, adultStatus });
++ // The price book's off-peak windows (BA1): what a provider's own price is inside the hours it publishes.
++ // The router reads them to know when a provider is at its off-peak price (server/providers/tiers.js).
++ const offPeak = require('./pricebook/offpeak').createOffPeak({ db, clock });
++ const pool = createProviderPool({ db, registry, config, clock, fetchImpl, env, log, credentialFetch, machineModels: machines, community, bench, variantEvidence, adultStatus, offPeak });
// The hourly provider balance probe (server/providers/balance.js): a gauge for the operator alert, started
// only when the service listens (never in tests) and off under a restore drill.
const balance = require('./providers/balance').createBalanceProbe({ registry, config, env, fetchImpl, clock, log });
@@@ -165,8 +168,6 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
// The living price book (plan E1): the weekly job that reads every catalogued provider's own price listing and
// keeps the price the router charges in step with it. A change of more than 20% waits for the owner on the
// operator console. Off in test mode (the tests replay the recorded listings) and off under a drill.
-- // The price book's off-peak windows: what a provider's own price is inside a window it publishes.
-- const offPeak = require('./pricebook/offpeak').createOffPeak({ db, clock });
const priceBook = require('./pricebook').createPriceBook({ db, registry, config, clock, log, offPeak });
// The measured refusal rates (server/refusal/): the weekly job that asks every offer the same published,
// harmless questions and publishes how often it refused them. Due-based, like the price book.
ddiff --git a/server/latency.js b/server/latency.js
nnew file mode 100644
iindex 0000000..361cc95
---- /dev/null
++++ b/server/latency.js
@@@ -0,0 +1,58 @@
++'use strict';
++/**
++ * The latency class of a run: how long the caller may wait, and what the router may do with the wait.
++ *
++ * interactive the default: today's routing, the answer as soon as the provider can give it.
++ * background the answer may take minutes: the router looks for the cheapest tier that can wait
++ * (idle local capacity, a Flex tier, a provider's off-peak hours), and the run is priced
++ * on the tier it really ran on.
++ * batch the answer may take hours: the run is queued and answered through a provider's own
++ * batch API (BA2).
++ *
++ * The class is the workflow's own, read from the route it uses (constraints.latency_class). A caller may
++ * ask for a SLOWER class with options.latency — never a faster one: a background workflow stays in the
++ * background whatever the caller asks for, because the work it does is the work it does.
++ *
++ * The order is the whole rule: interactive < background < batch.
++ */
++const CLASSES = ['interactive', 'background', 'batch'];
++const ORDER = { interactive: 0, background: 1, batch: 2 };
++const DEFAULT = 'interactive';
++
++/**
++ * How long a run of each class may take. A background run waits for a Flex tier, which is slower than the
++ * standard one, so it is given more room; a batch run has a deadline of its own (BA2).
++ */
++const TIMEOUT_MS = { interactive: 30000, background: 180000, batch: 600000 };
++
++/** Is this a class name we know? */
++function isClass(v) { return typeof v === 'string' && Object.prototype.hasOwnProperty.call(ORDER, v); }
++/** Where a class sits in the order (an unknown class is the default). */
++function rank(v) { return isClass(v) ? ORDER[v] : ORDER[DEFAULT]; }
++/** Is `a` at least as slow as `b`? */
++function slowerOrEqual(a, b) { return rank(a) >= rank(b); }
++
++/** The class a route declares: its own constraint, or interactive. */
++function ofRoute(route) {
++ const c = route && route.constraints && route.constraints.latency_class;
++ return isClass(c) ? c : DEFAULT;
++}
++
++/**
++ * The class a run may take: the workflow's own class, or a slower one the caller asked for.
++ * → { class } or { error } (the caller asked for something the workflow does not allow).
++ */
++function resolve(workflowClass, asked) {
++ const base = isClass(workflowClass) ? workflowClass : DEFAULT;
++ if (asked == null || asked === '') return { class: base };
++ if (!isClass(asked)) return { error: `latency must be one of ${CLASSES.join(', ')}` };
++ // Faster than the workflow allows: the run still happens, in the workflow's own class, and says so. A caller that
++ // passes 'interactive' to a background workflow gets its answer, never an error and nothing done (Opus, 11-07).
++ if (!slowerOrEqual(asked, base)) return { class: base, clamped: { asked, used: base } };
++ return { class: asked };
++}
++
++/** The timeout a class gets (the route's own timeout still applies when it is longer). */
++function timeoutMs(v) { return TIMEOUT_MS[isClass(v) ? v : DEFAULT] || TIMEOUT_MS[DEFAULT]; }
++
++module.exports = { CLASSES, ORDER, DEFAULT, TIMEOUT_MS, isClass, rank, slowerOrEqual, ofRoute, resolve, timeoutMs };
ddiff --git a/server/observability.js b/server/observability.js
iindex 8310d16..7ddc835 100644
---- a/server/observability.js
++++ b/server/observability.js
@@@ -88,7 +88,7 @@ function createAiReadiness({ db, registry, pool, keys, runs, release = null }) {
}
/** AI gauges on the openvibe-shared/metrics registry. */
--function registerAiGauges(registry, { runs, registry: ai, pool, balance = null, gpus = null, abuse = null }) {
++function registerAiGauges(registry, { runs, registry: ai, pool, balance = null, gpus = null, abuse = null, db = null }) {
registry.gauge({
name: 'ai_runs', help: 'Runs waiting for a slot (queued) and holding one (running)', labelNames: ['state'],
collect: () => {
@@@ -129,6 +129,26 @@ function registerAiGauges(registry, { runs, registry: ai, pool, balance = null,
name: 'ai_gpu_orphan_instances', help: 'Marketplace GPU instances with our label that no live row owns and that are still waiting to be given back', labelNames: ['marketplace'],
collect: async () => (gpus ? await gpus.orphanCount() : []),
});
++ // BA1: the tier every run really ran on, and what waiting earned. A run's tier is the tier the provider
++ // answered on ('standard', 'flex', 'offpeak', 'local'), never the tier we hoped for.
++ registry.gauge({
++ name: 'ai_runs_by_tier', help: 'Runs by the tier they really ran on', labelNames: ['tier', 'provider'],
++ collect: async () => {
++ if (!db) return [];
++ const rows = await db.prepare(`SELECT service_tier AS tier, provider_key AS provider, COUNT(*)::bigint AS n
++ FROM runs WHERE service_tier IS NOT NULL GROUP BY service_tier, provider_key`).all();
++ return (rows || []).map((r) => ({ labels: { tier: r.tier, provider: r.provider || '' }, value: Number(r.n) || 0 }));
++ },
++ });
++ // What waiting earned: the standard price minus the price the run really paid.
++ registry.gauge({
++ name: 'ai_saved_usd', help: 'What waiting earned: the standard price minus the price the run really paid',
++ collect: async () => {
++ if (!db) return 0;
++ const row = await db.prepare('SELECT COALESCE(SUM(saved_usd), 0) AS saved FROM runs').get();
++ return Number(row && row.saved) || 0;
++ },
++ });
}
module.exports = { createAiReadiness, registerAiGauges };
ddiff --git a/server/pricebook/job.js b/server/pricebook/job.js
iindex 5027caf..abf71fa 100644
---- a/server/pricebook/job.js
++++ b/server/pricebook/job.js
@@@ -163,6 +163,42 @@ function createPriceBook({ db, registry, config = {}, clock = { now: () => Date.
}
}
++ /**
++ * Record the off-peak blocks a provider's listing publishes (BA1): the provider's own price inside the
++ * hours it applies, on the days it applies. DeepSeek's rule is "peak 01:00-04:00 and 06:00-10:00 UTC,
++ * Monday through Friday", which one daily window cannot say.
++ *
++ * A model the listing no longer names loses its blocks: the price book forgets a window the provider's
++ * listing no longer publishes.
++ */
++ async function offPeakBlockWindows(source, off, report, checkedAt) {
++ report.off_peak_blocks = report.off_peak_blocks || [];
++ if (!off || !Array.isArray(off.blocks) || !off.blocks.length || !Object.keys(off.prices || {}).length) return;
++ const rows = await registry.listModels({ provider: source.provider });
++ const written = new Set();
++ for (const row of rows) {
++ const found = findPrice(off.prices, row.model_key);
++ if (!found) continue;
++ for (const field of FIELDS) {
++ const value = found.price[FIELD_NAMES[field]];
++ if (value === null || value === undefined) continue;
++ for (const block of off.blocks) {
++ await offPeak.recordBlock({
++ provider_key: row.provider_key, model_key: row.model_key, field, price: value,
++ weekdays: block.weekdays, start_hour: block.start_hour, end_hour: block.end_hour,
++ source: source.url, checked_at: checkedAt,
++ });
++ report.off_peak_blocks.push({ model: row.model_key, field, price: value, block });
++ }
++ }
++ written.add(row.model_key);
++ }
++ for (const row of rows) {
++ if (written.has(row.model_key)) continue;
++ await offPeak.clearBlocks(row.provider_key, row.model_key);
++ }
++ }
++
/**
* Compare one provider's listing with the prices that are live, and record what changed.
*/
@@@ -327,6 +363,12 @@ function createPriceBook({ db, registry, config = {}, clock = { now: () => Date.
await offPeakWindow(source, source.offPeak(source.kind === 'json' ? JSON.parse(res.body) : res.body), report, checkedAt);
} catch { /* a listing that cannot be read for a window simply has none */ }
}
++ // The weekday blocks of the same listing (BA1): the off-peak hours per day of the week.
++ if (source.offPeakBlocks && offPeak) {
++ try {
++ await offPeakBlockWindows(source, source.offPeakBlocks(source.kind === 'json' ? JSON.parse(res.body) : res.body), report, checkedAt);
++ } catch { /* a listing that cannot be read for blocks simply has none */ }
++ }
report.sources.push({ source: source.key, url: source.url, role: source.role, listed: Object.keys(prices).length });
if (source.role === 'cross-reference') { await crossCheck(prices, readings, report); continue; }
readings[source.provider] = prices;
ddiff --git a/server/pricebook/offpeak.js b/server/pricebook/offpeak.js
iindex 830b758..a8fc553 100644
---- a/server/pricebook/offpeak.js
++++ b/server/pricebook/offpeak.js
@@@ -6,12 +6,51 @@
* A provider's own listing sometimes says so: DeepSeek's pricing page publishes an OFF-PEAK rate beside
* the PEAK one and the hours the off-peak rate applies (UTC). The price book job reads it (the parser in
* server/pricebook/parsers.js) and writes it here; a batch reads it to know when a paid route is cheap
-- * enough to answer a half-price batch (server/batch/capacity.js).
++ * enough to answer a half-price batch (server/batch/capacity.js), and a background run reads it to know
++ * when a provider is at its off-peak price.
++ *
++ * Two shapes are stored, and both are read:
++ *
++ * price_book_offpeak one daily window per (provider, model, field): the hours it applies on
++ * every day of the week. The rows the price book has always written.
++ * price_book_offpeak_blocks blocks: a set of weekdays and the hours inside them, so a rule like
++ * DeepSeek's (peak 01:00-04:00 and 06:00-10:00 UTC Monday to Friday, every
++ * other hour off-peak, weekends in full) can be written down.
*
* A provider the price book has no window for is not eligible for a batch. The network does not guess a
* price it has not read: a route whose real cost is unknown is a route the network cannot afford.
*/
const FIELDS = ['in_per_mtok', 'out_per_mtok', 'cached_per_mtok'];
++/** The ISO weekday of a moment: 1 = Monday ... 7 = Sunday. */
++const isoWeekday = (at) => ((new Date(at).getUTCDay() + 6) % 7) + 1;
++
++/** The weekday list of a block: '1,2,3,4,5' -> [1,2,3,4,5]. An empty list means every day. */
++function parseWeekdays(text) {
++ const out = [];
++ for (const part of String(text == null ? '' : text).split(',')) {
++ const n = Number(part.trim());
++ if (Number.isInteger(n) && n >= 1 && n <= 7) out.push(n);
++ }
++ return [...new Set(out)].sort((a, b) => a - b);
++}
++
++/** The list as it is stored: '1,2,3,4,5'. */
++const formatWeekdays = (days) => (Array.isArray(days) ? days : []).filter((d) => Number.isInteger(d) && d >= 1 && d <= 7).sort((a, b) => a - b).join(',');
++
++/**
++ * Is `at` inside a window? A window that wraps the midnight (23-02) is handled. A window with weekdays
++ * (a block) is only inside on those days; a window without them (the old single window) is inside every day.
++ */
++function insideWindow(window, at) {
++ if (!window) return false;
++ const hour = new Date(at).getUTCHours();
++ const start = Number(window.start_hour);
++ const end = Number(window.end_hour);
++ if (!Number.isFinite(start) || !Number.isFinite(end)) return false;
++ const days = Array.isArray(window.weekdays) ? window.weekdays : [];
++ if (days.length && !days.includes(isoWeekday(at))) return false;
++ return start <= end ? (hour >= start && hour < end) : (hour >= start || hour < end);
++}
function createOffPeak({ db, clock = { now: () => Date.now() } }) {
const iso = () => new Date(clock.now()).toISOString();
@@@ -51,7 +90,106 @@ function createOffPeak({ db, clock = { now: () => Date.now() } }) {
return [...byModel.values()];
}
-- return { record, clear, offPeakRows };
++ // ── The weekday blocks ──────────────────────────────────
++
++ /**
++ * Write one block: the provider's own price inside the hours it applies on the days it applies.
++ * The block replaces any block with the same (provider, model, field, weekdays, hours).
++ */
++ async function recordBlock({ provider_key, model_key, field, price, weekdays, start_hour, end_hour, source, checked_at }) {
++ if (!FIELDS.includes(field)) return null;
++ const days = formatWeekdays(weekdays);
++ if (!days) return null;
++ const at = iso();
++ await db.prepare(`INSERT INTO price_book_offpeak_blocks
++ (provider_key, model_key, field, price, weekdays, start_hour, end_hour, source, checked_at, created_at, updated_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
++ ON CONFLICT (provider_key, model_key, field, weekdays, start_hour, end_hour) DO UPDATE SET price = EXCLUDED.price,
++ source = EXCLUDED.source, checked_at = EXCLUDED.checked_at, updated_at = EXCLUDED.updated_at`)
++ .run(provider_key, model_key, field, price, days, Math.trunc(start_hour), Math.trunc(end_hour), source, checked_at, at, at);
++ return { provider_key, model_key, field, weekdays: days };
++ }
++
++ /** Forget the blocks of one model (the listing no longer publishes them). */
++ async function clearBlocks(provider_key, model_key) {
++ await db.prepare('DELETE FROM price_book_offpeak_blocks WHERE provider_key = ? AND model_key = ?').run(provider_key, model_key);
++ }
++
++ /** Every block the price book has read, grouped by (provider, model, weekdays, hours). */
++ async function offPeakBlocks() {
++ const rows = await db.prepare(`SELECT provider_key, model_key, field, price, weekdays, start_hour, end_hour, source, checked_at
++ FROM price_book_offpeak_blocks`).all().catch(() => []);
++ const byBlock = new Map();
++ for (const row of rows || []) {
++ const key = `${row.provider_key}/${row.model_key}/${row.weekdays}/${row.start_hour}-${row.end_hour}`;
++ if (!byBlock.has(key)) {
++ byBlock.set(key, {
++ provider_key: row.provider_key, model_key: row.model_key, weekdays: parseWeekdays(row.weekdays),
++ start_hour: Number(row.start_hour), end_hour: Number(row.end_hour), source: row.source,
++ checked_at: row.checked_at, input: null, output: null, cached_read: null,
++ });
++ }
++ const entry = byBlock.get(key);
++ if (row.field === 'in_per_mtok') entry.input = Number(row.price);
++ if (row.field === 'out_per_mtok') entry.output = Number(row.price);
++ if (row.field === 'cached_per_mtok') entry.cached_read = Number(row.price);
++ }
++ return [...byBlock.values()];
++ }
++
++ /**
++ * Every window of every model: the old single windows (every day) and the weekday blocks.
++ * A model with both is read twice: the block is the more precise one, and the single window is kept
++ * because the price book wrote it before the blocks existed.
++ */
++ async function windows() {
++ const legacy = (await offPeakRows()).map((w) => ({ ...w, weekdays: [] }));
++ const blocks = await offPeakBlocks();
++ return [...legacy, ...blocks];
++ }
++
++ /**
++ * The provider's own off-peak price for one model at one moment, or null when the model is not at its
++ * off-peak price then (or the price book has never read its price). The prices are the provider's own:
++ * the input, the cached read and the output, per million tokens.
++ */
++ async function priceAt(provider_key, model_key, at) {
++ const rows = await db.prepare(`SELECT provider_key, model_key, field, price, weekdays, start_hour, end_hour
++ FROM price_book_offpeak_blocks WHERE provider_key = ? AND model_key = ?`).all(provider_key, model_key).catch(() => []);
++ const byBlock = new Map();
++ for (const row of rows || []) {
++ const key = `${row.weekdays}/${row.start_hour}-${row.end_hour}`;
++ if (!byBlock.has(key)) {
++ byBlock.set(key, {
++ weekdays: parseWeekdays(row.weekdays), start_hour: Number(row.start_hour), end_hour: Number(row.end_hour),
++ input: null, output: null, cached_read: null,
++ });
++ }
++ const entry = byBlock.get(key);
++ if (row.field === 'in_per_mtok') entry.input = Number(row.price);
++ if (row.field === 'out_per_mtok') entry.output = Number(row.price);
++ if (row.field === 'cached_per_mtok') entry.cached_read = Number(row.price);
++ }
++ for (const entry of byBlock.values()) {
++ if (!insideWindow(entry, at)) continue;
++ if (entry.input == null && entry.output == null && entry.cached_read == null) continue;
++ return entry;
++ }
++ // The old single window still counts: a provider the price book read before the blocks existed.
++ const legacy = await db.prepare(`SELECT field, price, start_hour, end_hour FROM price_book_offpeak WHERE provider_key = ? AND model_key = ?`).all(provider_key, model_key).catch(() => []);
++ if (legacy && legacy.length) {
++ const entry = { weekdays: [], start_hour: Number(legacy[0].start_hour), end_hour: Number(legacy[0].end_hour), input: null, output: null, cached_read: null };
++ for (const row of legacy) {
++ if (row.field === 'in_per_mtok') entry.input = Number(row.price);
++ if (row.field === 'out_per_mtok') entry.output = Number(row.price);
++ if (row.field === 'cached_per_mtok') entry.cached_read = Number(row.price);
++ }
++ if (insideWindow(entry, at)) return entry;
++ }
++ return null;
++ }
++
++ return { record, clear, offPeakRows, recordBlock, clearBlocks, offPeakBlocks, windows, priceAt };
}
--module.exports = { createOffPeak, FIELDS };
++module.exports = { createOffPeak, FIELDS, insideWindow, isoWeekday, parseWeekdays, formatWeekdays };
ddiff --git a/server/pricebook/parsers.js b/server/pricebook/parsers.js
iindex 2838316..7059fd5 100644
---- a/server/pricebook/parsers.js
++++ b/server/pricebook/parsers.js
@@@ -276,6 +276,101 @@ function deepseekOffPeak(text) {
return { window, prices };
}
++/**
++ * The peak hours a listing publishes, as (weekdays, hours).
++ *
++ * DeepSeek's pricing page says, under the price table:
++ *
++ * "Peak hours are 01:00 - 04:00 and 06:00 - 10:00 UTC, Monday through Friday, excluding Chinese
++ * public holidays. All other hours are off-peak, including weekends and Chinese public holidays in full."
++ *
++ * → { weekdays: [1,2,3,4,5], hours: [[1,4],[6,10]] } (the ISO weekdays, 1 = Monday).
++ *
++ * A listing that publishes no peak hours has none: the network never guesses when a provider is cheaper.
++ * Chinese public holidays are off-peak in full; the blocks below cannot know which days those are, so the
++ * price book records the rule the page states and the operator reads the note on the holiday exception.
++ */
++function peakHours(text) {
++ const body = String(text || '');
++ const sentence = /peak hours? are([^.]*)\./i.exec(body);
++ if (!sentence) return null;
++ const part = sentence[1];
++ const hours = [];
++ for (const m of part.matchAll(/(\d{1,2}):(\d{2})\s*(?:-|–|—|to|until)\s*(\d{1,2}):(\d{2})/g)) {
++ const start = Number(m[1]) % 24;
++ const end = Number(m[3]) % 24;
++ if (!Number.isFinite(start) || !Number.isFinite(end)) continue;
++ hours.push([start, end]);
++ }
++ if (!hours.length) return null;
++ const weekdays = weekdaySet(part);
++ if (!weekdays) return null;
++ return { weekdays, hours };
++}
++
++/** The weekdays a phrase names: 'Monday through Friday', 'Monday to Friday', 'weekdays', 'every day'. */
++function weekdaySet(text) {
++ const t = String(text || '').toLowerCase();
++ const names = { monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6, sunday: 7 };
++ const range = /(\w+day)\s*(?:through|to|-|–|—)\s*(\w+day)/.exec(t);
++ if (range && names[range[1]] && names[range[2]]) {
++ const from = names[range[1]];
++ const to = names[range[2]];
++ const out = [];
++ for (let d = from; d <= to; d++) out.push(d);
++ if (out.length) return out;
++ }
++ if (/weekdays|monday through friday|monday to friday/.test(t)) return [1, 2, 3, 4, 5];
++ if (/every day|all days|daily/.test(t)) return [1, 2, 3, 4, 5, 6, 7];
++ const one = Object.entries(names).find(([name]) => new RegExp(`\\b${name}\\b`).test(t));
++ return one ? [one[1]] : null;
++}
++
++/** The hours of a day that no peak window covers, as [start, end) blocks over 0..24. */
++function complementHours(hours) {
++ const covered = [...hours].sort((a, b) => a[0] - b[0]);
++ const out = [];
++ let cursor = 0;
++ for (const [start, end] of covered) {
++ const s = Math.max(0, Math.min(24, start));
++ const e = Math.max(0, Math.min(24, end));
++ if (e <= s) continue; // a window that wraps the midnight is read as the hours it names
++ if (s > cursor) out.push([cursor, s]);
++ cursor = Math.max(cursor, e);
++ }
++ if (cursor < 24) out.push([cursor, 24]);
++ return out;
++}
++
++/**
++ * The off-peak blocks a listing publishes: the hours that are not peak, per weekday set.
++ * → [{ weekdays: [1,2,3,4,5], start_hour: 0, end_hour: 1 }, ...]
++ */
++function offPeakBlocks(text) {
++ const peak = peakHours(text);
++ if (!peak) return [];
++ const all = [1, 2, 3, 4, 5, 6, 7];
++ const out = [];
++ for (const [start, end] of complementHours(peak.hours)) {
++ out.push({ weekdays: peak.weekdays, start_hour: start, end_hour: end });
++ }
++ // The days that are not peak days are off-peak in full.
++ const rest = all.filter((d) => !peak.weekdays.includes(d));
++ if (rest.length) out.push({ weekdays: rest, start_hour: 0, end_hour: 24 });
++ return out;
++}
++
++/**
++ * The off-peak blocks and the prices inside them, as the listing publishes them. DeepSeek's page carries an
++ * OFF-PEAK row for every model beside the PEAK one, and the peak hours the off-peak price applies outside.
++ */
++function deepseekOffPeakBlocks(text) {
++ const prices = deepseekBand(text, 'OFF-PEAK');
++ const blocks = offPeakBlocks(text);
++ if (!blocks.length || !Object.keys(prices).length) return { blocks: [], prices: {} };
++ return { blocks, prices };
++}
++
/**
* Alibaba Model Studio's pricing page (HTML): a table per deployment, with the model id, the deployment scope,
* the token tier and the input and output price. The catalogue holds the international deployment, so a row
@@@ -427,4 +522,5 @@ function refuseless(text) {
module.exports = {
leaf, num, money, price, markdownTables, htmlText, columnOf, normHead, leadingId, trailingId,
glm, abliteration, minimax, deepseek, qwen, kimi, deepinfra, novita, featherless, openrouter, refuseless,
++ peakHours, weekdaySet, complementHours, offPeakBlocks, deepseekOffPeakBlocks,
};
ddiff --git a/server/pricebook/sources.js b/server/pricebook/sources.js
iindex dea3589..5c4d6bd 100644
---- a/server/pricebook/sources.js
++++ b/server/pricebook/sources.js
@@@ -24,6 +24,9 @@ const SOURCES = [
parse: parsers.deepseek,
// The off-peak window the same listing publishes, read for the batch capacity rules.
offPeak: parsers.deepseekOffPeak,
++ // The peak hours it publishes, as off-peak weekday blocks (BA1): one daily window cannot say
++ // "peak 01:00-04:00 and 06:00-10:00 UTC, Monday through Friday".
++ offPeakBlocks: parsers.deepseekOffPeakBlocks,
},
{
key: 'qwen',
ddiff --git a/server/providers/index.js b/server/providers/index.js
iindex ecc0c4c..091b303 100644
---- a/server/providers/index.js
++++ b/server/providers/index.js
@@@ -37,6 +37,8 @@ const { createGeminiProvider } = require('./gemini');
const { createHttpSeamProvider } = require('./http');
const { createWhisperProvider } = require('./whisper');
const rates = require('./rate-cards');
++const tiers = require('./tiers');
++const latency = require('../latency');
const { detectTask, capabilitiesOf, hardNeeds, rank, reasonFor, workKind, leadingText, preferredTarget, preferMatches } = require('./task');
const modelCatalog = require('./model-catalog');
const { createWarmCache } = require('./warm-cache');
@@@ -193,11 +195,20 @@ function credentialCanServe(provider, operation, req) {
return false;
}
/** The model a self key should use for this run: the person's default for the kind, else their stored model. */
++/**
++ * The Live role a route names, so a person's own key answers with the model they stored for that role.
++ *
++ * The background routes (BA1) carry the role too: the AI viewers director plan runs in the background, but a
++ * person who gave their key a director model still wants that model to write it.
++ */
++const ROLE_ROUTE = /^(?:live|background)\.(chat|vision|director|summary|legacy)$/;
++const roleOfRoute = (routeKey) => (ROLE_ROUTE.exec(String(routeKey || '')) || [])[1] || null;
++
function selfModelFor(cred, operation, req, ctx, prefs) {
const kind = workKind(operation, req, ctx);
const choice = prefs && prefs.defaults ? prefs.defaults[kind] : 'auto';
const stored = cred.models || {};
-- const role = ctx.role || ((/^live\.(chat|vision|director|summary|legacy)$/.exec(ctx.routeKey || '') || [])[1]) || null;
++ const role = ctx.role || roleOfRoute(ctx.routeKey);
const model = (choice && choice !== 'auto') ? choice
: (req.image && stored.vision) || (role && stored[role]) || stored[kind] || stored.chat
|| CREDENTIAL_DEFAULT_MODEL[cred.provider] || 'gpt-4o-mini';
@@@ -205,7 +216,7 @@ function selfModelFor(cred, operation, req, ctx, prefs) {
return { model, kind, why };
}
--function createProviderPool({ db, registry, config, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, credentialFetch = null, machineModels = null, community = null, bench = null, variantEvidence = null, adultStatus = null }) {
++function createProviderPool({ db, registry, config, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, credentialFetch = null, machineModels = null, community = null, bench = null, variantEvidence = null, adultStatus = null, offPeak = null }) {
// A person's own machine as their model (credential provider node, server/machines.js).
const machines = machineModels || require('../machines').createMachineModels({ config, env, fetchImpl });
const cache = new Map(); // key -> { stamp, adapter }
@@@ -325,6 +336,85 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
return cards ? rates.costOfUsage(cards, usage) : 0;
}
++ // ── The tier a run is priced on (BA1) ────────────────────────────
++ /**
++ * The order a run that may wait tries its candidates in: the host's own model first, when it is idle
++ * and the route's quality allows it.
++ *
++ * The quality gate: a route that names its model (constraints.model) keeps that model — a small local
++ * model never answers work whose route says which model it wants (The Rundown is written by the creative
++ * model, never by the host's llama.cpp). The authority is the network's choice of provider, not a demand
++ * for a model: the host's own model may still answer first, because it costs the network nothing. The
++ * local model serves short text only (localCanServe), and the attempt loop skips it when the host's one
++ * local slot is busy, so a paid outage never turns into a stampede and a busy host never waits for it.
++ */
++ async function orderForWaiting(list, { route, operation, req }) {
++ const c = (route && route.constraints) || {};
++ if (c.model) return list;
++ // Opt-in per route (Opus, 11-07): the host's small model writes only where a route says its output may come
++ // from one. Without the flag the router's own order stands, so no visible text changes quality.
++ if (c.allow_local !== true) return list;
++ if (!localCanServe(route, operation, req)) return list;
++ const local = [];
++ const rest = [];
++ for (const candidate of list) {
++ const p = await registry.getProvider(candidate.provider);
++ if (p && isLocalModel(p)) local.push(candidate);
++ else rest.push(candidate);
++ }
++ return local.length ? [...local, ...rest] : list;
++ }
++
++ /**
++ * The tier one candidate may run on, for a run that may wait:
++ *
++ * local the host's own model: capacity the network already pays for;
++ * flex a provider that publishes a Flex tier (OpenAI, DeepInfra): slower, cheaper;
++ * offpeak a provider whose own price is lower right now (DeepSeek's off-peak hours);
++ * standard everything else.
++ *
++ * The tier is what the run is priced on, so it is only ever a tier the provider actually sells: a
++ * provider with no Flex multiplier and no off-peak price is a standard run.
++ */
++ async function tierFor({ p, model, operation, at }) {
++ if (!p) return 'standard';
++ if (isLocalModel(p)) return 'local';
++ if (tiers.TIERED_OPS.has(operation) && tiers.multiplierOf(p, 'flex') != null) return 'flex';
++ if (offPeak && model) {
++ const off = await offPeak.priceAt(p.key, model, at);
++ if (off && (off.input != null || off.output != null)) return 'offpeak';
++ }
++ return 'standard';
++ }
++
++ /**
++ * The cost of one call on the tier it really ran on, and what the wait earned: the standard price
++ * minus the real one.
++ *
++ * A tier nobody priced is priced at the standard rate. The network never claims a saving it cannot
++ * see: a provider that does not say which tier it served is priced at the list price.
++ */
++ async function costOnTier({ providerKey, model, usage, tier, p }) {
++ const cards = await rateCardsFor(providerKey, model);
++ if (!cards) return { cost: 0, standard: 0, saved: 0 };
++ const standard = rates.costOfUsage(cards, usage);
++ if (tier === 'standard' || tier === 'local') return { cost: standard, standard, saved: 0 };
++ if (tier === 'offpeak') {
++ const off = offPeak ? await offPeak.priceAt(providerKey, model, clock.now()) : null;
++ if (!off) return { cost: standard, standard, saved: 0 };
++ // The provider's own off-peak price, and only where it is lower than the card's price.
++ const cheaper = (card, price) => (price == null || price > card.unit_price_usd ? card : { ...card, unit_price_usd: price });
++ const offCards = { in: cheaper(cards.in, off.input), cached: cheaper(cards.cached, off.cached_read), out: cheaper(cards.out, off.output), cache_write: cards.cache_write };
++ const cost = rates.costOfUsage(offCards, usage);
++ return { cost, standard, saved: Math.max(0, standard - cost) };
++ }
++ const multiplier = tiers.multiplierOf(p, tier);
++ if (multiplier == null) return { cost: standard, standard, saved: 0 };
++ const tierCards = rates.cardsForTier(cards, { multiplier, tier });
++ const cost = rates.costOfUsage(tierCards, usage);
++ return { cost, standard, saved: Math.max(0, standard - cost) };
++ }
++
// ── Provider state: platform.provider-state@1 per provider, from usage_daily and the breaker ──
const getPeriodUsage = db.prepare(`SELECT day, model_key, SUM(tokens_in)::bigint AS tokens_in, SUM(tokens_out)::bigint AS tokens_out, SUM(tokens_cached)::bigint AS tokens_cached
FROM usage_daily WHERE provider_key = ? AND day >= ? GROUP BY day, model_key`);
@@@ -772,7 +862,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
const completing = operation === 'complete';
if (!completing && !['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich'].includes(operation)) throw new AiError(422, 'credential.unsupported', `a person's own machine answers text only, not ${operation}`);
if (req.image) throw new AiError(422, 'credential.unsupported', 'a person\'s own machine reads no pictures yet');
-- const wanted = ctx.role || ((/^live\.(chat|vision|director|summary|legacy)$/.exec(ctx.routeKey || '') || [])[1]) || null;
++ const wanted = ctx.role || roleOfRoute(ctx.routeKey);
const model = (wanted && cred.models[wanted]) || cred.models.chat;
if (!model) throw new AiError(422, 'credential.no_model', 'the person\'s own machine credential names no model (models.chat)');
const sysText = [...(req.system || []).map((x) => x.text), req.json ? schemaInstruction(req.json) : ''].filter(Boolean).join('\n\n');
@@@ -826,7 +916,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
if (!messaging && !completing && !['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich'].includes(operation)) throw new AiError(422, 'credential.unsupported', `a person's own key is used for text only, not ${operation}`);
const guardedFetch = credentialFetch || require('./guarded-fetch').guardedFetch;
// The person's model for this role: the run's input.role, else the Live role its route names (live.director).
-- const wanted = ctx.role || ((/^live\.(chat|vision|director|summary|legacy)$/.exec(ctx.routeKey || '') || [])[1]) || null;
++ const wanted = ctx.role || roleOfRoute(ctx.routeKey);
const role = wanted && cred.models[wanted] ? wanted : null;
// On the person's Anthropic key the model the caller asked for is theirs to choose; elsewhere the model they stored
// is the one their provider serves (a Claude Code model name does not exist at DeepSeek).
@@@ -1272,6 +1362,15 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
// L36 Y7: the fallback chain stops after three providers.
const maxAttempts = (config.routing && config.routing.maxAttempts) || DEFAULT_MAX_ATTEMPTS;
let attemptsMade = 0;
++ // BA1: a run that may wait (the 'background' class) looks for capacity that is cheap because it may
++ // take minutes: the host's own idle model first, when the route's quality allows it. The provider
++ // order stays the router's own (the authority keeps critical work); what changes is the tier each
++ // candidate runs on, and that is decided per candidate below.
++ const latencyClass = latency.isClass(ctx.latencyClass) ? ctx.latencyClass : latency.DEFAULT;
++ if (latencyClass !== latency.DEFAULT && !exact) {
++ list = await orderForWaiting(list, { route, operation, req });
++ explain.latency_class = latencyClass;
++ }
candidate:
for (let i = 0; i < list.length; i++) {
const c = list[i];
@@@ -1363,12 +1462,24 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
// An image generation outlasts the shared provider's chat timeout (AI_PROVIDER_TIMEOUT_MS): the route's wins. So does a
// route that says so (options.outlast_provider_timeout: space.build writes a whole app, a minute or two). Otherwise
// the provider's timeout stays strict, so a slow provider fails over quickly.
++ // BA1: a run that may wait gets the room its class allows (a Flex tier answers in minutes, not
++ // seconds), so the provider's own chat timeout does not cut it short.
++ const waiting = latencyClass !== latency.DEFAULT;
const outlast = operation === 'image' || Boolean(route.options && route.options.outlast_provider_timeout);
const own = await providerTimeout(p, model);
-- const cap = outlast ? Math.max(own, Number(route.timeout_ms) || 0, operation === 'image' ? 600000 : 0) : (own || 600000);
++ const room = waiting ? Math.max(latency.timeoutMs(latencyClass), Number(route.timeout_ms) || 0) : 0;
++ const cap = (outlast || waiting) ? Math.max(own, Number(route.timeout_ms) || 0, room, operation === 'image' ? 600000 : 0) : (own || 600000);
const timeoutMs = Math.max(1000, Math.min(req.timeoutMs || route.timeout_ms || own || DEFAULT_PROVIDER_TIMEOUT_MS, cap));
const retries = config.providerRetries;
-- for (let attempt = 0; attempt <= retries; attempt++) {
++ // BA1: the tier this candidate runs on. A background run asks for the cheaper tier the
++ // provider sells. A refusal of that tier (a Flex tier with no capacity, or a model that does
++ // not serve it) sends the same run once more on the standard tier: the refusal is counted and
++ // logged, and it produced no tokens, so nothing is billed for it. The batch class has no tier
++ // yet: its own API is BA2, so a batch run is priced at the standard rate until then.
++ const wantedTier = latencyClass === 'background' ? await tierFor({ p, model, operation, at: clock.now() }) : 'standard';
++ let askedTier = wantedTier;
++ let attempt = 0;
++ while (attempt <= retries) {
if (ctx.signal && ctx.signal.aborted) throw new AiError(409, 'run.cancelled', 'run was cancelled');
const started = clock.now();
const t0 = Date.now();
@@@ -1412,11 +1523,17 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
const localMax = Math.max(1, (config.localLlm && config.localLlm.maxTokens) || 512);
callReq = { ...callReq, maxTokens: Math.min(Number(callReq.maxTokens) || localMax, localMax) };
}
++ // The service tier travels with the request (BA1): a Flex tier is slower and cheaper.
++ if (askedTier === 'flex') callReq = { ...callReq, serviceTier: 'flex' };
try {
const fn = a[operation];
if (typeof fn !== 'function') throw new ProviderError(`${operation} not implemented`, { code: 'provider.unsupported' });
statFor(c.provider).calls += p.kind === 'stub' ? 0 : 1; // stub counts its own calls
-- const result = await fn({ ...callReq, ...(onDelta ? { onDelta } : {}), model, signal: ctx.signal, timeoutMs });
++ // BA1: a Flex attempt for a caller that waits gets only the part of the wait it may use, so a
++ // too-slow tier gives way to the standard one in time (ctx.flexBudgetMs, server/runs.js).
++ const flexCapped = askedTier === 'flex' && Number(ctx.flexBudgetMs) > 0;
++ const attemptTimeout = flexCapped ? Math.max(1000, Math.min(timeoutMs, Number(ctx.flexBudgetMs))) : timeoutMs;
++ const result = await fn({ ...callReq, ...(onDelta ? { onDelta } : {}), model, signal: ctx.signal, timeoutMs: attemptTimeout });
if (operation === 'embed' && (!Array.isArray(result.vectors) || result.vectors.length !== req.input.length
|| result.vectors.some(v => !Array.isArray(v) || !v.length || v.some(n => typeof n !== 'number' || !Number.isFinite(n)))))
throw new ProviderError('embedding response has invalid vectors', { code: 'provider.output' });
@@@ -1432,7 +1549,14 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
outputTokens: usage.output || 0, inputTokens: usage.input || 0, cachedTokens: usage.cached || 0 });
// An image is metered per image (usage.output = images), and no rate card prices one yet (an owner/T5
// item): its cost is 0 rather than the image count read as output tokens on the token cards.
-- const cost = operation === 'image' ? 0 : await costOf(c.provider, result.model || model, usage);
++ // BA1: the cost is the tier the answer really ran on, and the wait's saving is the
++ // standard price minus that one. A provider that does not say which tier it served is
++ // priced at the list price: a saving nobody confirmed is not a saving.
++ const ranOn = tiers.tierOfAnswer(result, askedTier);
++ const priced = operation === 'image'
++ ? { cost: 0, standard: 0, saved: 0 }
++ : await costOnTier({ providerKey: c.provider, model: result.model || model, usage, tier: ranOn, p });
++ const cost = priced.cost;
statFor(c.provider).ok++;
await recordSuccess(c.provider);
if (ctx.logRequest) {
@@@ -1456,7 +1580,11 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
explain.attempts = attempts;
explain.openvibe = attemptFacts;
if (local) explain.provider_local = true; // the Exchange receipt labels the offer local (server/exchange/receipts.js)
-- return { result, provider: c.provider, providerKind: p.kind, local, model: result.model || model, fallbackUsed: fallback || selfAttempts.some(a => a.status !== 'skipped'), usage, cost, latencyMs: latency, ttftMs, synthetic: Boolean(result.synthetic || a.synthetic), tried, startedAt: started, explain };
++ // BA1: the tier the run really ran on, and what waiting earned.
++ explain.service_tier = ranOn;
++ if (wantedTier !== 'standard') explain.requested_tier = wantedTier;
++ if (priced.saved > 0) explain.saved_usd = priced.saved;
++ return { result, provider: c.provider, providerKind: p.kind, local, model: result.model || model, fallbackUsed: fallback || selfAttempts.some(a => a.status !== 'skipped'), usage, cost, latencyMs: latency, ttftMs, synthetic: Boolean(result.synthetic || a.synthetic), tried, startedAt: started, serviceTier: ranOn, savedUsd: priced.saved, standardCostUsd: priced.standard, explain };
} catch (err) {
lastErr = err;
if (ctx.signal && ctx.signal.aborted) {
@@@ -1480,7 +1608,19 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
// A timed-out image may still be generated and billed upstream: never pay for it twice.
// An insufficient-balance answer is never retried: the same provider cannot answer until it is topped up.
if (insufficientBalance(err)) resting = true;
-- if (attempt < retries && !insufficientBalance(err) && retryable(err) && !(operation === 'image' && err.code === 'provider.timeout')) { await sleep(config.retryDelayMs + Math.floor(Math.random() * Math.min(700, config.retryDelayMs)), ctx.signal).catch(() => {}); continue; }
++ // BA1: the provider refused the tier we asked for (a Flex tier with no capacity, or a
++ // model that does not serve it). The same run goes once more on the standard tier: the
++ // refusal is counted and logged above, and it produced no tokens, so nothing is billed
++ // for it. The retry does not spend one of the provider's own retries.
++ const flexTooSlow = askedTier === 'flex' && Number(ctx.flexBudgetMs) > 0 && err && err.code === 'provider.timeout' && !(ctx.signal && ctx.signal.aborted);
++ if (askedTier === 'flex' && (tiers.isTierRefusal(err, 'flex') || flexTooSlow)) {
++ askedTier = 'standard';
++ const why = flexTooSlow ? 'the flex tier was slower than the caller waits' : 'the provider refused the flex tier';
++ attempts.push({ key: 'openvibe', provider: c.provider, model, why: chosen || 'the router chose it', status: 'failed', error: why });
++ log.warn(`[ai] ${c.provider}: ${why}; the same run goes again on the standard tier`);
++ continue;
++ }
++ if (attempt < retries && !insufficientBalance(err) && retryable(err) && !(operation === 'image' && err.code === 'provider.timeout')) { attempt += 1; await sleep(config.retryDelayMs + Math.floor(Math.random() * Math.min(700, config.retryDelayMs)), ctx.signal).catch(() => {}); continue; }
break;
}
}
ddiff --git a/server/providers/openai.js b/server/providers/openai.js
iindex 8a391e7..5787054 100644
---- a/server/providers/openai.js
++++ b/server/providers/openai.js
@@@ -9,6 +9,10 @@
* transcribe POST {base}/audio/transcriptions (Whisper-style)
* image POST {base}/images/generations (dall-e-*, gpt-image-1)
*
++ * A chat completion may carry a service tier (`serviceTier`, BA1): a Flex tier is slower and cheaper, and
++ * the provider may refuse it. The answer's own service_tier comes back with the result, so the run is
++ * priced on the tier that really answered.
++ *
* Structured output steps down json_schema -> json_object -> prose for gateways that lack it
* (Live's behaviour); prose JSON is repaired by the engine.
*/
@@@ -23,7 +27,7 @@ const TEXT_OPS = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enric
function isReasoningModel(m) { return /^(gpt-5|o\d)/i.test(m || ''); }
--function buildBody({ model, system, messages, image, json, maxTokens, temperature, cacheKey, baseUrl }, jsonMode) {
++function buildBody({ model, system, messages, image, json, maxTokens, temperature, cacheKey, baseUrl, serviceTier }, jsonMode) {
const msgs = [];
// Stepped down from json_schema: the schema travels in the instructions instead.
const hint = json && jsonMode !== 'schema' ? schemaInstruction(json) : '';
@@@ -47,6 +51,9 @@ function buildBody({ model, system, messages, image, json, maxTokens, temperatur
if (json && jsonMode === 'schema') body.response_format = { type: 'json_schema', json_schema: { name: json.name || 'result', schema: json.schema, strict: json.strict !== false } };
else if (json && jsonMode === 'object') body.response_format = { type: 'json_object' };
if (cacheKey && /api\.openai\.com/i.test(baseUrl)) body.prompt_cache_key = String(cacheKey).slice(0, 64);
++ // The service tier (BA1): a Flex tier is slower and cheaper, and the provider may refuse it. The tier
++ // travels in the request; the answer says which tier it really ran on (the pool prices that one).
++ if (serviceTier) body.service_tier = String(serviceTier);
return body;
}
@@@ -66,7 +73,7 @@ function createOpenAiProvider(record, { apiKey = '', fetchImpl = globalThis.fetc
const j = await postJson(`${baseUrl}/chat/completions`, headers(), body, { signal: req.signal, timeoutMs: req.timeoutMs, fetchImpl });
const msg = j.choices && j.choices[0] && j.choices[0].message;
const text = (msg && typeof msg.content === 'string' ? msg.content : '').trim();
-- return { text, json: null, model: j.model || req.model, usage: usageParser.openAiCompatibleUsage(j.usage || {}) };
++ return { text, json: null, model: j.model || req.model, serviceTier: j.service_tier || null, usage: usageParser.openAiCompatibleUsage(j.usage || {}) };
} catch (e) {
// Gateways without structured-output support: schema -> json_object -> prose.
if (e instanceof ProviderError && e.status === 400 && jsonMode && /response_format|json_schema|schema|strict/i.test(e.message)) {
@@@ -154,6 +161,8 @@ function createOpenAiProvider(record, { apiKey = '', fetchImpl = globalThis.fetc
if (req.maxTokens) body.max_tokens = req.maxTokens;
if (req.temperature != null) body.temperature = req.temperature;
}
++ // The service tier (BA1): a Flex tier travels with the request and the answer says which tier it ran on.
++ if (req.serviceTier) body.service_tier = String(req.serviceTier);
if (typeof req.onDelta === 'function') return await completeStreaming(body, req);
const j = await postJson(`${baseUrl}/chat/completions`, headers(), body, { signal: req.signal, timeoutMs: req.timeoutMs, fetchImpl });
if (!Array.isArray(j.choices) || !j.choices.length) throw new ProviderError('completion has no choices', { code: 'provider.output' });
@@@ -163,6 +172,7 @@ function createOpenAiProvider(record, { apiKey = '', fetchImpl = globalThis.fetc
completion: { choices: j.choices, system_fingerprint: j.system_fingerprint || null },
text: typeof msg.content === 'string' ? msg.content : '',
model: j.model || req.model,
++ serviceTier: j.service_tier || null,
usage: usageParser.openAiCompatibleUsage(u),
};
}
ddiff --git a/server/providers/pool-providers.js b/server/providers/pool-providers.js
iindex c0c98a1..e8b025c 100644
---- a/server/providers/pool-providers.js
++++ b/server/providers/pool-providers.js
@@@ -103,6 +103,10 @@ const PROVIDERS = [
capabilities: ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'json', 'tools'],
provider_access: 'UNVERIFIED',
priority: 40,
++ // DashScope's own batch price: half the price of a normal call, on the Singapore deployment
++ // (https://www.alibabacloud.com/help/en/model-studio/model-pricing, read 2026-11-07). The batch
++ // API itself is BA2; the multiplier is what a batch run would be priced on.
++ tier_rates: { batch: 0.5 },
privacy: privacy({
label: 'anonymized',
terms_url: 'https://www.alibabacloud.com/help/en/model-studio/model-pricing',
@@@ -150,6 +154,9 @@ const PROVIDERS = [
capabilities: ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'json', 'tools'],
provider_access: 'UNVERIFIED',
priority: 40,
++ // Moonshot's own batch price: 60% of the price of a normal call, answered within 12 hours to 7 days
++ // (https://platform.kimi.ai/docs/pricing/chat, read 2026-11-07). The batch API itself is BA2.
++ tier_rates: { batch: 0.6 },
privacy: privacy({
label: 'anonymized',
terms_url: 'https://platform.kimi.ai/docs/pricing/chat',
@@@ -295,6 +302,10 @@ const PROVIDERS = [
capabilities: ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'json', 'tools'],
provider_access: 'RESTRICTED',
priority: 30,
++ // DeepInfra's own tier prices: a Flex tier at 0.8x and a batch at 0.8x of the standard price
++ // (https://deepinfra.com/pricing, read 2026-11-07). The Flex tier is synchronous and is what a
++ // background run asks for; the batch API itself is BA2.
++ tier_rates: { flex: 0.8, batch: 0.8 },
privacy: privacy({
label: 'private',
terms_url: 'https://deepinfra.com/privacy',
ddiff --git a/server/providers/pool-seed.js b/server/providers/pool-seed.js
iindex 0f1b060..6b7cb74 100644
---- a/server/providers/pool-seed.js
++++ b/server/providers/pool-seed.js
@@@ -31,6 +31,8 @@ function recordOf(p) {
// The privacy label of the provider: `private` only where its own terms say it keeps nothing and trains on
// nothing, `anonymized` for everyone else. The wording and the page it was read from travel with it.
privacy: p.privacy,
++ // The tiers the provider itself sells and at what price (BA1): a Flex tier, a batch API.
++ ...(p.tier_rates ? { tier_rates: p.tier_rates } : {}),
...(p.uncensored ? { uncensored: true } : {}),
...(p.role ? { role: p.role } : {}),
},
ddiff --git a/server/providers/rate-cards.js b/server/providers/rate-cards.js
iindex 5a4820a..ed5ee98 100644
---- a/server/providers/rate-cards.js
++++ b/server/providers/rate-cards.js
@@@ -123,6 +123,30 @@ function buildCards({ providerKey, model, row = null, pricing = {}, flat = true
/** Per-million-token prices read off the cards. */
const pricesOf = (cards) => ({ in: cards.in.unit_price_usd, out: cards.out.unit_price_usd, cached: cards.cached.unit_price_usd });
++/**
++ * The same cards at a tier's price: the list price times the tier's multiplier (server/providers/tiers.js).
++ * A multiplier of 1 (or none) leaves the cards alone. The card's own shape is kept exactly as the contract
++ * wants it; only the price moves, and the version says which tier priced it.
++ */
++function applyMultiplier(cards, multiplier, tier = null) {
++ const m = Number(multiplier);
++ if (!Number.isFinite(m) || m === 1 || !cards) return cards;
++ const scale = (c) => (c ? { ...c, unit_price_usd: Number((Number(c.unit_price_usd) * m).toFixed(12)), ...(tier ? { version: `${c.version || 'list'}:${tier}` } : {}) } : c);
++ return { in: scale(cards.in), cached: scale(cards.cached), out: scale(cards.out), cache_write: scale(cards.cache_write) };
++}
++
++/**
++ * The cards of one provider + model at a tier's price: the list price for 'standard', the list price times
++ * the provider's own multiplier for a tier it publishes ('flex', 'batch'), and the list price for a tier
++ * the provider does not publish (the network never prices a run on a tier it cannot price).
++ */
++function cardsForTier(cards, { multiplier = null, tier = 'standard' } = {}) {
++ if (!cards) return null;
++ if (tier === 'standard' || tier === 'local') return cards;
++ if (multiplier == null) return cards;
++ return applyMultiplier(cards, multiplier, tier);
++}
++
/** USD for one call's usage, priced on its cards (input excludes the cached part, as providers bill). */
function costOfUsage(cards, usage = {}) {
const cached = usage.cached || 0;
@@@ -209,4 +233,4 @@ function providerState({ provider, cards, usageRows = [], breaker = 'closed', re
return state;
}
--module.exports = { UNKNOWN_DATE, unverified, metricFor, provenance, tableEntry, buildCards, pricesOf, costOfUsage, periodOf, usageSince, providerState, NONE_LOOKBACK_DAYS, BREAKER_HEALTH };
++module.exports = { UNKNOWN_DATE, unverified, metricFor, provenance, tableEntry, buildCards, pricesOf, applyMultiplier, cardsForTier, costOfUsage, periodOf, usageSince, providerState, NONE_LOOKBACK_DAYS, BREAKER_HEALTH };
ddiff --git a/server/providers/tiers.js b/server/providers/tiers.js
nnew file mode 100644
iindex 0000000..b895d4c
---- /dev/null
++++ b/server/providers/tiers.js
@@@ -0,0 +1,90 @@
++'use strict';
++/**
++ * The tiers a run can be priced on.
++ *
++ * A tier is what the provider really charged for the work:
++ *
++ * standard the list price on the model's card (the price every run is compared against);
++ * flex a slower service tier some providers sell at a lower price (OpenAI, DeepInfra);
++ * batch the provider's own batch API (BA2), at its own price;
++ * offpeak the hours a provider's own price is lower (DeepSeek's off-peak rule);
++ * local capacity the network already pays for (the host's own models): nothing.
++ *
++ * A provider's own row carries the multipliers its own page publishes, in metadata.tier_rates:
++ *
++ * { "flex": 0.5, "batch": 0.5 }
++ *
++ * The price of a tier is the list price times its multiplier. A tier nobody published a multiplier for is
++ * not offered: the network never claims a saving it cannot price, and never prices a run on a tier the
++ * provider did not answer on.
++ *
++ * The tier a run really ran on is read from the answer itself where the provider says it (OpenAI's
++ * response carries its own service_tier). When the provider does not say, the run is priced at the
++ * standard rate: a saving that cannot be seen is not a saving.
++ */
++const TIERS = ['standard', 'flex', 'batch', 'offpeak', 'local'];
++/** The tiers a run may be priced on that are not the standard one. */
++const DISCOUNT_TIERS = ['flex', 'batch', 'offpeak'];
++
++/** The operations that carry a service tier (a chat completion does; an embedding does not). */
++const TIERED_OPS = new Set(['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'complete', 'messages']);
++
++/** The multipliers a provider's row carries, as numbers. A row without them has no tier but the standard. */
++function ratesOf(record) {
++ const raw = record && record.metadata && record.metadata.tier_rates;
++ if (!raw || typeof raw !== 'object') return {};
++ const out = {};
++ for (const [tier, value] of Object.entries(raw)) {
++ if (!DISCOUNT_TIERS.includes(tier)) continue;
++ const n = Number(value);
++ if (Number.isFinite(n) && n > 0 && n <= 1) out[tier] = n;
++ }
++ return out;
++}
++
++/** The multiplier of one tier, or null when the provider does not publish one. */
++function multiplierOf(record, tier) {
++ const rates = ratesOf(record);
++ return Object.prototype.hasOwnProperty.call(rates, tier) ? rates[tier] : null;
++}
++
++/** Does this provider sell this tier at all? */
++function supports(record, tier) { return multiplierOf(record, tier) != null; }
++
++/**
++ * Did the provider refuse the tier we asked for?
++ *
++ * A Flex tier answers in minutes, and the provider may refuse it: OpenAI answers 429 with
++ * `resource_unavailable` when the tier has no capacity, and 400 when the model does not serve that tier.
++ * A refusal is not a failure of the run: the same run is retried once on the standard tier (counted,
++ * logged, and never billed twice, because the refused attempt produced no tokens).
++ */
++function isTierRefusal(err, tier) {
++ if (!err || tier !== 'flex') return false;
++ const status = Number(err.status) || 0;
++ const body = err.body && typeof err.body === 'object' ? err.body : {};
++ const inner = (body.error && typeof body.error === 'object') ? body.error : body;
++ const code = String((inner && (inner.code || inner.type)) || '');
++ const message = String((inner && inner.message) || body.message || err.message || '');
++ const text = `${code} ${message}`.toLowerCase();
++ const namesTheTier = /flex|service[_ -]?tier|tier/.test(text);
++ if (status === 429) return namesTheTier || /resource[_ -]?unavailable/.test(text);
++ if (status === 400) return namesTheTier || /not (supported|available|offered)|unsupported|does not (support|offer)|invalid.*tier/.test(text);
++ return false;
++}
++
++/**
++ * The tier the answer says it ran on. OpenAI's response carries its own `service_tier`; a provider that
++ * says nothing is priced at the standard rate, because a saving nobody confirmed is not a saving.
++ */
++function tierOfAnswer(result, asked) {
++ const said = result && (result.serviceTier || result.service_tier);
++ if (typeof said !== 'string' || !said) return asked === 'flex' ? 'standard' : (asked || 'standard');
++ const t = said.toLowerCase();
++ if (t === 'flex') return 'flex';
++ if (t === 'batch') return 'batch';
++ if (t === 'default' || t === 'standard' || t === 'auto') return 'standard';
++ return t;
++}
++
++module.exports = { TIERS, DISCOUNT_TIERS, TIERED_OPS, ratesOf, multiplierOf, supports, isTierRefusal, tierOfAnswer };
ddiff --git a/server/runs.js b/server/runs.js
iindex 683a991..0e8986e 100644
---- a/server/runs.js
++++ b/server/runs.js
@@@ -22,6 +22,7 @@ const { parseCostUsd } = require('./credits');
const { CACHE_INPUT } = require('./workflows/hooks');
const { preferenceLines } = require('./user-modules');
const { preferredTarget } = require('./providers/task');
++const latency = require('./latency');
const tier = require('./policy/tier');
const schemas = require('./schemas');
const events = require('./events');
@@@ -132,6 +133,9 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
origin: 'ai', workflow: r.workflow_key, workflow_version: r.workflow_version, template_version: r.template_version, route: r.route_key, route_version: r.route_version,
provider: r.provider_key, model: r.model_key, fallback_used: Boolean(r.fallback_used), run_id: r.id, cached_from: r.cached_from, synthetic: Boolean(r.synthetic),
},
++ // BA1: how long the caller may wait, and the tier the run really ran on.
++ latency_class: r.latency_class || latency.DEFAULT,
++ service_tier: r.service_tier || null,
usage: { tokens_in: r.tokens_in, tokens_out: r.tokens_out, cost_usd: r.cost_usd, attempts: r.attempts, usage_sample_ids: r.usage_sample_ids || (r.usage_sample_id ? [r.usage_sample_id] : null) },
explain: parseJson(r.explain, null),
citations_count: r.citations_count,
@@@ -187,7 +191,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
* principal: { sub, subject: {type, id} }
* Returns { run, created, promise? }.
*/
-- async function create(body, principal, { trace = null, requestId = null, retryOf = null, onDelta = null, onRoute = null, abuse: requestAbuse = null, discount = null, exact = null, costCheck = null, communityOfferId = null } = {}) {
++ async function create(body, principal, { trace = null, requestId = null, retryOf = null, onDelta = null, onRoute = null, abuse: requestAbuse = null, discount = null, exact = null, costCheck = null, communityOfferId = null, callerWaitMs = 0 } = {}) {
if (!body || typeof body !== 'object') throw new AiError(400, 'input.invalid', 'JSON body required');
const wfKey = String(body.workflow || '');
const wf = body.version ? await registry.getWorkflow(wfKey, Number(body.version)) : await registry.activeWorkflow(wfKey);
@@@ -252,6 +256,16 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
}
}
const options = body.options && typeof body.options === 'object' ? body.options : {};
++ // BA1: the latency class of this run. The workflow's own class is the route it uses
++ // (constraints.latency_class); a caller may ask for a SLOWER class with options.latency and never a
++ // faster one: a background workflow stays in the background whatever the caller asks for.
++ const workflowClass = latency.ofRoute(route);
++ const askedLatency = options.latency != null ? options.latency : null;
++ const resolvedLatency = latency.resolve(workflowClass, askedLatency);
++ if (resolvedLatency.error) throw new AiError(422, 'input.invalid', resolvedLatency.error, { latency_class: workflowClass });
++ const latencyClass = resolvedLatency.class;
++ // A faster class than the workflow allows was asked for: it runs in its own class, and the run's options say so.
++ if (resolvedLatency.clamped) options.latency_clamped = resolvedLatency.clamped;
// Community capacity is opt-in per request: a person's machine sees the prompt, so only a caller that says so
// may be placed there. The OpenAI and Messages routes set body.community from x-openvibe-community.
let communityOptIn = body.community === true || options.community === true;
@@@ -366,6 +380,11 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
...(pinned ? { routePin: pinned } : {}),
// The preset's policy, and the name it was named by: the router filters the offers with the policy.
...(preset ? { presetPolicy, presetName } : {}),
++ // BA1: the class this run may wait in. The router reads it to pick the tier.
++ latencyClass,
++ // BA1: a caller that waits (?wait=) gets its answer inside the wait: a Flex attempt may use the wait
++ // minus the 25 s a standard retry needs (at least 10 s); a caller that polls gives the tier all its room.
++ ...(latencyClass === 'background' && Number(callerWaitMs) > 0 ? { flexBudgetMs: Math.max(10000, Number(callerWaitMs) - 25000) } : {}),
// POST /v1/chat/completions with stream: true: the upstream's deltas, as they arrive (kept in memory only).
...(typeof onDelta === 'function' ? { onDelta } : {}),
// The offer the router picked, before the first token: the SSE disclosure's first comment (D3).
@@@ -391,6 +410,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
source_service: requester.type === 'service' ? requester.id : null,
target: body.target ? JSON.stringify(body.target) : null, target_key: ctx.targetKey,
input: null, input_hash: inputHash, idempotency_key: idem, trace_id: trace, request_id: requestId, retry_of: retryOf,
++ latency_class: latencyClass,
options: JSON.stringify({ cache: options.cache !== false && credentialSubject === null && !projectProvider && !pinned && !selfKeys && !communityOptIn, debug: Boolean(options.debug), input_retained: kept.full,
...(credentialSubject !== null ? { credential_subject: credentialSubject } : {}), ...(credentialProvider !== null ? { credential_provider: credentialProvider } : {}),
...(selfKeys ? { self_subject: runPerson } : {}),
@@@ -418,9 +438,9 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
const cacheExplain = { objective: 'cache', reasons: ["the answer was already in this scope's cache; no provider was called"], paid_by: 'cache', selected: null, candidates: [], attempts: [] };
await db.tx(async () => {
await db.prepare(`INSERT INTO runs (id, workflow_key, workflow_version, template_key, template_version, route_key, route_version, status, requester_type, requester_id, on_behalf_of, attribution,
-- source_service, target, target_key, input, input_hash, output, model_key, cache_key, cached_from, retry_of, idempotency_key, trace_id, request_id, options, explain, created_at, started_at, finished_at, citations_count)
++ source_service, target, target_key, input, input_hash, output, model_key, cache_key, cached_from, retry_of, idempotency_key, trace_id, request_id, options, explain, latency_class, created_at, started_at, finished_at, citations_count)
VALUES (@id, @workflow_key, @workflow_version, @template_key, @template_version, @route_key, @route_version, 'cached', @requester_type, @requester_id, @on_behalf_of, @attribution,
-- @source_service, @target, @target_key, @input, @input_hash, @output, @model_key, @cache_key, @cached_from, @retry_of, @idempotency_key, @trace_id, @request_id, @options, @explain, @created_at, @created_at, @created_at, 0)`)
++ @source_service, @target, @target_key, @input, @input_hash, @output, @model_key, @cache_key, @cached_from, @retry_of, @idempotency_key, @trace_id, @request_id, @options, @explain, @latency_class, @created_at, @created_at, @created_at, 0)`)
.run({ ...base, output: JSON.stringify(hit.output), model_key: hit.model_key, cache_key: cacheKey, cached_from: hit.run_id, explain: JSON.stringify(cacheExplain) });
const src = await citations(hit.run_id);
if (src.length) await addCitations(id, src.map(c => ({ ...c, provenance: { ...c.provenance, via_cache: hit.run_id } })), 'cache');
@@@ -466,9 +486,9 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
try {
await db.tx(async () => {
await db.prepare(`INSERT INTO runs (id, workflow_key, workflow_version, template_key, template_version, route_key, route_version, status, requester_type, requester_id, on_behalf_of, attribution,
-- source_service, target, target_key, input, input_hash, cache_key, retry_of, idempotency_key, trace_id, request_id, options, created_at)
++ source_service, target, target_key, input, input_hash, cache_key, retry_of, idempotency_key, trace_id, request_id, options, latency_class, created_at)
VALUES (@id, @workflow_key, @workflow_version, @template_key, @template_version, @route_key, @route_version, 'queued', @requester_type, @requester_id, @on_behalf_of, @attribution,
-- @source_service, @target, @target_key, @input, @input_hash, @cache_key, @retry_of, @idempotency_key, @trace_id, @request_id, @options, @created_at)`)
++ @source_service, @target, @target_key, @input, @input_hash, @cache_key, @retry_of, @idempotency_key, @trace_id, @request_id, @options, @latency_class, @created_at)`)
.run({ ...base, cache_key: cacheKey });
await events.runChanged(await getRow(id));
});
@@@ -818,7 +838,10 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
...(selfSubject ? { selfSubject, selfCredentials: selfCredentials || [], selfPreferences } : {}),
...(credential ? { credential, role: input && typeof input.role === 'string' ? input.role : null } : {}),
...(billingDelta ? { onDelta: billingDelta } : {}),
-- ...(onRoute ? { onRoute } : {}) });
++ ...(onRoute ? { onRoute } : {}),
++ // BA1: the class this run may wait in: the router picks the tier from it.
++ latencyClass: ctx.latencyClass || latency.DEFAULT,
++ ...(ctx.flexBudgetMs ? { flexBudgetMs: ctx.flexBudgetMs } : {}) });
// The Exchange remake: an uncensored answer is labelled in the run's own output, whatever its shape, so
// every surface that reads it (the API routes, the console, a product) shows what produced it.
if (r.output && r.label) {
@@@ -831,8 +854,9 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
// The status, its citations and the ai.run.succeeded event commit together.
const done = await db.tx(async () => {
const d = await db.prepare(`UPDATE runs SET status = 'succeeded', output = ?, grounding = ?, synthetic = ?, provider_key = ?, model_key = ?, fallback_used = ?, attempts = ?, tokens_in = ?, tokens_out = ?, cost_usd = ?,
-- route_key = COALESCE(?, route_key), route_version = COALESCE(?, route_version), explain = ?, finished_at = ? WHERE id = ? AND status = 'running'`)
++ service_tier = ?, saved_usd = ?, route_key = COALESCE(?, route_key), route_version = COALESCE(?, route_version), explain = ?, finished_at = ? WHERE id = ? AND status = 'running'`)
.run(JSON.stringify(r.output), JSON.stringify(r.grounding || null), r.synthetic ? 1 : 0, r.provider, r.model, r.fallbackUsed ? 1 : 0, attempts, r.usage.input, r.usage.output, r.cost,
++ r.serviceTier || null, Number(r.savedUsd) || 0,
r.route ? r.route.key : null, r.route ? r.route.version : null, r.explain ? JSON.stringify(r.explain) : null, iso(clock.now()), id);
if (!d.changes) return d;
if (r.citations.length) await addCitations(id, r.citations, 'workflow');
ddiff --git a/server/workflows/engine.js b/server/workflows/engine.js
iindex 8b75f7a..ae558b2 100644
---- a/server/workflows/engine.js
++++ b/server/workflows/engine.js
@@@ -353,6 +353,8 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
let last = null;
const usage = { input: 0, output: 0, cached: 0 };
let cost = 0;
++ // BA1: what the wait earned, over every call the run made (a run may call a provider more than once).
++ let saved = 0;
let exec = null;
let jsonSchema = null;
for (const step of wf.steps) {
@@@ -365,6 +367,7 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
usage.output += exec.usage.output || 0;
usage.cached += exec.usage.cached || 0;
cost += exec.cost || 0;
++ saved += Number(exec.savedUsd) || 0;
}
if (last.jsonSchema) jsonSchema = last.jsonSchema;
}
@@@ -386,6 +389,8 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
provider: exec ? exec.provider : null, model: exec ? exec.model : null, fallbackUsed: Boolean(exec && exec.fallbackUsed),
...(exec && exec.result && exec.result.label ? { contentPolicy: exec.result.policy || 'uncensored', label: exec.result.label } : {}),
usage, cost,
++ // BA1: the tier the answer really ran on, and what waiting for it earned.
++ serviceTier: exec ? exec.serviceTier || null : null, savedUsd: saved,
route: last.route || null,
explain: exec ? exec.explain || null : null,
};
ddiff --git a/server/workflows/live.js b/server/workflows/live.js
iindex 6f60e39..10b35c9 100644
---- a/server/workflows/live.js
++++ b/server/workflows/live.js
@@@ -37,7 +37,7 @@ const templates = [
input_schema: { type: 'object' }, output_schema: { type: 'object' },
system_prompt: '',
user_prompt: 'You are describing an uploaded image/screenshot for a paste titled "{{title}}".\nReply ONLY with compact JSON: {"description":"1-2 sentence description of what the image shows","tags":["3-6","short","lowercase","tags"]}.',
-- default_route: 'live.vision', owner: 'live',
++ default_route: 'background.vision', owner: 'live',
},
{
key: 'live.paste.summarize_text', name: 'Live: summarize a text paste',
@@@ -45,7 +45,7 @@ const templates = [
input_schema: { type: 'object' }, output_schema: { type: 'object' },
system_prompt: '',
user_prompt: 'Summarize what this pasted text is about in one short sentence (max 200 chars), plainly. Title: "{{title}}".\n\n---\n{{snippet}}',
-- default_route: 'live.legacy', owner: 'live',
++ default_route: 'background.legacy', owner: 'live',
},
{
key: 'live.stream.describe_frame', name: 'Live: describe a live-stream frame',
@@@ -53,7 +53,7 @@ const templates = [
input_schema: { type: 'object' }, output_schema: { type: 'object' },
system_prompt: '',
user_prompt: 'This is a frame from a live stream. Describe what is happening (the game, the activity, the scene, text on screen), people included, and call a person "the streamer". Reply ONLY with compact JSON: {"description":"one concise sentence describing what is happening on screen right now","tags":["2-5","short","tags"],"worthy":<true only if this exact frame is genuinely screenshot-worthy on its own: a big reaction, a visual gag, something unusual or funny on screen — false for ordinary gameplay/desktop/chat/talking-head frames>,"title":"<if worthy: a punchy, funny 3-7 word caption naming what happens , people included, else empty>"}.',
-- default_route: 'live.vision', owner: 'live',
++ default_route: 'background.vision', owner: 'live',
},
{
key: 'live.stream.summarize', name: 'Live: stream overview + inferred category',
@@@ -61,7 +61,7 @@ const templates = [
input_schema: { type: 'object' }, output_schema: { type: 'object' },
system_prompt: '',
user_prompt: 'These are timestamped observations from a live stream, in order since it started. Write about the activities, topics and vibe, people included; call a person "the streamer". Reply ONLY with compact JSON: {"overview":"a thorough overview (2-6 sentences) of what this stream has been about overall — the main activities, topics, and vibe across the whole session (not just the latest moment)","category":"<exactly one of: {{categories}} — what this stream mostly IS, judged from the observations; desktop = screen/software/coding, gaming = playing games, irl = camera on a person talking/doing things indoors, outdoors/travel = out in the world>","tags":["3-6","short","lowercase","tags"]}\n{{lines}}{{audio_block}}',
-- default_route: 'live.legacy', owner: 'live',
++ default_route: 'background.legacy', owner: 'live',
},
{
key: 'live.streamer.overview', name: 'Live: public channel description',
@@@ -69,7 +69,7 @@ const templates = [
input_schema: { type: 'object' }, output_schema: { type: 'object' },
system_prompt: '',
user_prompt: 'You are writing the public description of a livestreamer\'s channel. It is shown on their channel page, in search results and on the home page\'s cards, so the first sentence must stand alone as a one-line summary of what they stream. Using only the signals below (aggregated across their streams, VODs and pastes), write 2-4 sentences of plain prose: what they stream (games, topics, activities), what keeps coming back, and the tone of the streams. Start with the streamer\'s name, and after that refer to them by name or as "they"; never guess pronouns.\n\nRules:\n- Plain text only: no lists, bullets, headings, markdown, labels ("Handle:", "Overview:") or @handles.\n- Never guess at their private life, health or location.\n- No moderation notes, warnings or ratings.\n- Do not invent specifics the signals do not support; when the signals are thin, one or two short sentences are enough.\n\n{{ctx}}',
-- default_route: 'live.legacy', owner: 'live',
++ default_route: 'background.legacy', owner: 'live',
},
{
key: 'live.media.overview', name: 'Live: VOD/clip overview from frames + transcript',
@@@ -77,7 +77,7 @@ const templates = [
input_schema: { type: 'object' }, output_schema: { type: 'object' },
system_prompt: '',
user_prompt: 'You are writing an AI overview of a recorded video. Using ONLY the signals below (be concrete, don\'t invent), summarize what the video is about in 2-5 sentences — the main activities, topics, and vibe. Plain prose, no lists or markdown.\n\n{{parts}}',
-- default_route: 'live.legacy', owner: 'live',
++ default_route: 'background.legacy', owner: 'live',
},
{
key: 'live.viewers.plan', name: "Live: AI viewers director",
@@@ -86,7 +86,7 @@ const templates = [
output_schema: {"type": "object", "additionalProperties": false, "properties": {"skip": {"type": "boolean", "description": "true when nothing worth saying right now"}, "notes": {"type": "string", "description": "one short line of director reasoning (logged only)"}, "lines": {"type": "array", "items": {"type": "object", "additionalProperties": false, "properties": {"bot": {"type": "string", "description": "username of the AI viewer that speaks"}, "target": {"type": "string", "enum": ["viewer", "bot", "streamer", "ambient"]}, "reply_to": {"type": ["string", "null"], "description": "username being replied to (viewer or bot), else null"}, "thread": {"type": ["string", "null"], "description": "existing thread id as a string, \"new\" to start one, or null"}, "topic": {"type": ["string", "null"], "description": "topic label when thread is \"new\""}, "delay_ms": {"type": "integer", "minimum": 0, "maximum": 45000}, "text": {"type": "string", "description": "the chat line, in character"}, "reason": {"type": "string", "description": "why this bot says this now (<= 80 chars)"}}, "required": ["bot", "target", "reply_to", "thread", "topic", "delay_ms", "text", "reason"]}}, "threads_close": {"type": "array", "items": {"type": "string"}, "description": "thread ids that reached a natural end"}}, "required": ["skip", "notes", "lines", "threads_close"]},
system_prompt: "You are the DIRECTOR of a cast of AI viewers in a live stream's chat. Each pass you decide which of them speak next, to whom, and what they say — like an improv coach cueing actors. You never speak yourself.\n\nHard rules:\n- Every line must sound like a real, casual chat message from that specific viewer, fully in character and in their typing style. Short. No quotation marks, no stage directions, no meta commentary, never mention being an AI, bot, model, or \"director\".\n- If the streamer addressed chat or named an AI viewer, that MUST be answered first (target \"streamer\"), naturally and specifically about what they said/are doing.\n- Reply to real viewers when it makes sense (target \"viewer\", reply_to = their username). Prefer people flagged first-time / follower / subscriber. Never reply to yourself.\n- Bots may talk to each other (target \"bot\", reply_to = the other bot) to continue an open thread or start one when chat is quiet — keep the bot-to-bot share at or under the target given, and keep each thread moving (a question, a joke callback, a disagreement), then let it end.\n- \"ambient\" lines react to what is happening on stream (heard / seen) — not filler like \"nice stream\".\n- Use the transcript and screen description as the source of truth for what is happening; the timestamps tell you what is recent.\n- Vary who speaks; the same viewer never posts two lines in a row; spread delay_ms so lines arrive over the pass, streamer replies first (small delay).\n- Respect the channel rules, tone, language, blocklist and the word limit. Plain text only; emoji only per the emote policy.\n- Do not repeat anything already said in chat, by anyone. If there is genuinely nothing to add, return skip=true with an empty lines array.\n\n{{stable}}",
user_prompt: "{{volatile}}\n\nPlan the next pass now (at most {{max_lines}} lines). Return JSON only.",
-- default_route: 'live.director', owner: 'live', metadata: { json_name: 'plan' },
++ default_route: 'background.director', owner: 'live', metadata: { json_name: 'plan' },
},
{
key: 'live.viewers.reply', name: "Live: AI viewer fast reply to the streamer",
@@@ -104,7 +104,7 @@ const templates = [
output_schema: {"type": "object", "additionalProperties": false, "properties": {"memories": {"type": "array", "items": {"type": "object", "additionalProperties": false, "properties": {"bot": {"type": "string"}, "memory": {"type": "string"}}, "required": ["bot", "memory"]}}, "channel_memory": {"type": "string", "description": "running jokes / recurring topics for the whole channel, <= 90 words"}}, "required": ["memories", "channel_memory"]},
system_prompt: "",
user_prompt: "You maintain the memories of AI chat personas for one streaming channel.{{#channel_memory}}\n\nCurrent channel memory (running bits): {{channel_memory}}{{/channel_memory}}\n\n{{personas}}\n\nFor each persona, write an updated memory: a tight paragraph (max 90 words) of durable facts — running jokes, who they talk to, opinions they formed, recurring topics. Keep the useful old stuff, drop the trivial. Also update the channel memory (running bits everyone shares).",
-- default_route: 'live.summary', owner: 'live', metadata: { json_name: 'fold' },
++ default_route: 'background.summary', owner: 'live', metadata: { json_name: 'fold' },
},
{
key: 'live.viewers.clone', name: "Live: AI viewer cloned from a real chatter",
@@@ -122,7 +122,7 @@ const templates = [
output_schema: {"type": "object", "additionalProperties": false, "required": ["others", "segments"], "properties": {"others": {"type": "array", "maxItems": 400, "items": {"type": "number"}}, "segments": {"type": "array", "maxItems": 4, "items": {"type": "object", "additionalProperties": false, "required": ["start", "end", "topic", "kind", "title", "what", "take", "quip", "score", "quotes"], "properties": {"start": {"type": "number"}, "end": {"type": "number"}, "topic": {"type": "string"}, "kind": {"type": "string", "enum": ["news", "games", "tech", "life", "music", "sports", "money", "internet", "chat", "other"]}, "title": {"type": "string"}, "what": {"type": "string"}, "take": {"type": ["string", "null"]}, "quip": {"type": "string"}, "score": {"type": "integer", "minimum": 1, "maximum": 5}, "quotes": {"type": "array", "maxItems": 2, "items": {"type": "object", "additionalProperties": false, "required": ["t", "text", "en"], "properties": {"t": {"type": "number"}, "text": {"type": "string"}, "en": {"type": ["string", "null"]}}}}}}}}},
system_prompt: "You are the writers' room of THE RUNDOWN, the part of a small live-streaming site that turns what streamers say on stream into stories people want to read. You get an automatic transcript of a stretch of one live stream and pull out what the streamer actually talked about. The transcript is speech recognition of the stream's whole audio: it mixes the streamer's own voice with game sounds, announcers and characters, videos and songs they play, articles they read aloud and other people, and it mishears words and sometimes the language. Rules: (0) First decide who is speaking on every line, and list in others the t of every line that is NOT the streamer (a game's announcer or characters, a video or clip, a song, an article read aloud, other people); build segments and pick quotes only from the streamer's own lines. (1) Only the streamer's own talk is theirs: their opinions, stories, rants, jokes, plans, reactions and conversations with chat. A game's announcer or character lines (\"You have slain an enemy\", \"First blood\", \"Victory\"), lyrics, ads, a video's narration or an article read aloud are never the streamer; when they react to an article or a video, its subject is the topic and their reaction is the take. A streamer who talks in one language most of the time does not break into short theatrical one-liners in another: dramatic lines in a different language (often English) dropped between their own talk, like \"A great queen needs tenacity.\" or \"Fear what I'll do now.\", are the game's characters, not roleplay or the streamer, so they are never a segment, a take or a quote. In a game stream, short lines in a character's voice (boasts, threats, catchphrases, solemn or poetic lines such as \"Kneel before me or bleed out.\", \"No one can find the queen!\", \"Only those you love can break your heart.\" or \"I would cut off my wings if there were steel strong enough.\") are the game even in English: the streamer is not roleplaying or being poetic. A sports game's commentators (\"We present…\", play-by-play, player names and tactics read out) are the game too. Lines that do not make sense (\"GCC embedded diablo, but controlling topic albanese\") are mistranscriptions: skip them and never invent a meaning for them. (2) Split the stretch into segments, one per thing they talked about, at most 4, in order. Skip stretches that are only gameplay noise, mumbling or garbled text; an empty list is a good answer when nothing was said. Play-by-play is not a segment: reacting to kills, deaths, teammates, plays, rounds or a fight as it happens is the game itself, and the stream's recap covers it. A segment about a game needs something a reader could follow without watching: an opinion (a champion is broken, matchmaking is rigged), a story, a plan, a running bit, an argument. A stretch that is only play-by-play gets at most one segment, and only for a standout moment. Never make a segment that is more of what Earlier in this stream already covered. (3) topic: a short concrete name for the subject, the way a headline names it (\"Supermarket fridges hacked in France\", \"League ranked matchmaking\", \"Moving apartments\"), never vague (\"news\", \"gaming\", \"chatting\", \"the stream\"), never one of the kinds below (\"chat\", \"games\", \"life\"…) and never a mood or a description of the stream (\"Frustration and chaos in the match\", \"Talking with chat and teammates\", \"Mid-game rage\"): name the subject, for a game the game and the thing (\"Briar in League\", \"League autofill\", \"Minecraft base building\"), for something they watched what it was (\"Boxing fight night\"); when nothing more specific fits, name what they were doing (\"Theme park rides\"). When it is the same specific subject as one of the known topics, return that name exactly; never file a segment under a known topic only because it is the same game or the same mood. (4) kind: news, games, tech, life, music, sports, money, internet, chat (talking with chat about the stream itself) or other. (5) title: a punchy headline for the segment, at most 70 characters. what: what they talked about, plainly, one or two sentences. take: their opinion or stance in one sentence, or null when they had none. quip: one line from the desk about the take or the situation, dry and a little savage, the way a late-night host would rib it, at most 160 characters. score: how much a reader who missed the stream would enjoy reading it, 1 (filler) to 5 (the best thing said all night): play-by-play and in-game reactions are 2 at most unless something standout happened, most segments are a 3, and a 4 or 5 needs a real opinion, story or line. (6) quotes: 0 to 2 lines of the streamer's own words, copied character for character from the transcript lines (part of one line is fine), the funniest or most telling, each with its t. A quote is something they said themselves: never a sentence they read out from an article, a post, a tweet, a chat message or a video, even when it is in quotation marks in the source; when unsure, leave it out. When a quote is not in English, en is an English translation; otherwise en is null. (7) start and end are seconds from the lines. Write topic, title, what, take and quip in English whatever language they speak. In what and take, call the streamer by the name given in Streamer, never \"the streamer\" or \"Streamer\". (8) Never invent anything that is not in what you are given: no events, numbers, motives, relationships or words. Never accuse anyone of a crime or of wronging someone; a streamer naming another streamer is them talking, not a fight. Talk about what they said and did, never who they are: no jokes about race, religion, sexuality, gender or disability, no sexual content about anyone, no threats, no self-harm jokes. Swearing is fine. Write every name exactly as given, same spelling and capitals, and call people by their name or \"they\": never guess a pronoun (no he, him, his, she, her). Privacy: never write or quote where anyone is, lives or will be (a named place, venue, event, school, town, address or time zone), their school or workplace, family members' names or when they will be somewhere; say \"a theme park\" or \"later\" instead. If someone seems to be under 18 (school, classmates, parents' rules), the desk is kind: no roasting them, nothing about school, and no quotes about where they are. Output only the JSON.",
user_prompt: "Streamer: {{streamer}}\nStream title: {{title}}\nCategory: {{category}}\nKnown topics (reuse the exact name for the same subject):\n{{topics}}\nEarlier in this stream: {{earlier}}\n\nTranscript lines ([seconds into the stream] text):\n{{lines}}",
-- default_route: 'live.creative', owner: 'live', metadata: { json_name: 'rundown_segment' },
++ default_route: 'background.creative', owner: 'live', metadata: { json_name: 'rundown_segment' },
},
{
key: 'live.rundown.episode', name: "Live: a stream written up as an episode (The Rundown)",
@@@ -131,7 +131,7 @@ const templates = [
output_schema: {"type": "object", "additionalProperties": false, "required": ["title", "logline", "recap", "chapters"], "properties": {"title": {"type": "string"}, "logline": {"type": "string"}, "recap": {"type": "string"}, "chapters": {"type": "array", "maxItems": 10, "items": {"type": "object", "additionalProperties": false, "required": ["index", "title"], "properties": {"index": {"type": "integer", "minimum": 0}, "title": {"type": "string"}}}}}},
system_prompt: "You write the episode guide of THE RUNDOWN, where every live stream is an episode. You get the stream's facts and its segments, what the streamer talked about, in order. Write: title: the episode's title, at most 60 characters, playful and specific to this stream (never just the streamer's name or \"Stream\"). logline: one sentence of at most 180 characters, the hook that makes someone open it. recap: the story of the stream in order, two or three short paragraphs (blank line between them), at most 900 characters in all, with personality: dry, teasing, roasting the takes and the situations, never mean about the person. Quote only from the quotes given, word for word. chapters: up to 8 segments worth listing, each the segment's index and a short chapter title of at most 50 characters. Copy numbers exactly. Never invent anything that is not in what you are given: no events, numbers, motives, relationships or words. Never accuse anyone of a crime or of wronging someone; a streamer naming another streamer is them talking, not a fight. Talk about what they said and did, never who they are: no jokes about race, religion, sexuality, gender or disability, no sexual content about anyone, no threats, no self-harm jokes. Swearing is fine. Write every name exactly as given, same spelling and capitals, and call people by their name or \"they\": never guess a pronoun (no he, him, his, she, her). Privacy: never write or quote where anyone is, lives or will be (a named place, venue, event, school, town, address or time zone), their school or workplace, family members' names or when they will be somewhere; say \"a theme park\" or \"later\" instead. If someone seems to be under 18 (school, classmates, parents' rules), the desk is kind: no roasting them, nothing about school, and no quotes about where they are. Output only the JSON.",
user_prompt: "{{json payload}}",
-- default_route: 'live.creative', owner: 'live', metadata: { json_name: 'rundown_episode' },
++ default_route: 'background.creative', owner: 'live', metadata: { json_name: 'rundown_episode' },
},
{
key: 'live.rundown.topic', name: "Live: a topic's page across streamers (The Rundown)",
@@@ -140,7 +140,7 @@ const templates = [
output_schema: {"type": "object", "additionalProperties": false, "required": ["headline", "summary"], "properties": {"headline": {"type": "string"}, "summary": {"type": "string"}}},
system_prompt: "You run THE RUNDOWN's topic pages. You get one topic and the takes streamers had on it, oldest first. Write: headline: at most 80 characters, punchy, about the topic and the takes. summary: two to four sentences, at most 600 characters: what the subject is, who said what, where they agree or clash, and one dry line from the desk. Quote only from the quotes given, word for word. Never invent anything that is not in what you are given: no events, numbers, motives, relationships or words. Never accuse anyone of a crime or of wronging someone; a streamer naming another streamer is them talking, not a fight. Talk about what they said and did, never who they are: no jokes about race, religion, sexuality, gender or disability, no sexual content about anyone, no threats, no self-harm jokes. Swearing is fine. Write every name exactly as given, same spelling and capitals, and call people by their name or \"they\": never guess a pronoun (no he, him, his, she, her). Privacy: never write or quote where anyone is, lives or will be (a named place, venue, event, school, town, address or time zone), their school or workplace, family members' names or when they will be somewhere; say \"a theme park\" or \"later\" instead. If someone seems to be under 18 (school, classmates, parents' rules), the desk is kind: no roasting them, nothing about school, and no quotes about where they are. Output only the JSON.",
user_prompt: "{{json payload}}",
-- default_route: 'live.creative', owner: 'live', metadata: { json_name: 'rundown_topic' },
++ default_route: 'background.creative', owner: 'live', metadata: { json_name: 'rundown_topic' },
},
{
key: 'live.clips.confirm', name: "Live: is this chat spike clip-worthy",
@@@ -176,7 +176,7 @@ const templates = [
output_schema: {"type": "object", "additionalProperties": false, "required": ["overview"], "properties": {"overview": {"type": "string"}}},
system_prompt: "",
user_prompt: "You are describing a person on a streaming site by fusing two AI summaries about them into ONE cohesive 2-4 sentence overview of who they are overall — both as a STREAMER and as a CHATTER. Be natural, specific, and not repetitive. Use their name or \"they\", never a guessed pronoun. Output ONLY the overview prose with no label or prefix.\n\nAS A STREAMER:\n{{as_streamer}}\n\nAS A CHATTER:\n{{as_chatter}}",
-- default_route: 'live.legacy', owner: 'live', metadata: { json_name: 'person_overview' },
++ default_route: 'background.legacy', owner: 'live', metadata: { json_name: 'person_overview' },
},
{
key: 'live.stream.titles', name: "Live: short titles for past sessions",
@@@ -194,7 +194,7 @@ const templates = [
output_schema: {"type": "object", "additionalProperties": false, "required": ["ranked"], "properties": {"ranked": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["index", "score", "why"], "properties": {"index": {"type": "integer"}, "score": {"type": "integer"}, "why": {"type": "string"}}}}}},
system_prompt: "",
user_prompt: "These are livestream VODs with their AI summaries and popularity stats. Rank the MOST interesting/entertaining/memorable ones for a highlights showcase — favor funny, dramatic, surprising, high-energy, or unusual content over routine \"just chatting / sitting at a desk\" streams. Clips taken and peak viewers are strong signals that something notable happened.\n\n{{list}}\n\nReturn STRICT JSON only, nothing else: {\"ranked\": [{\"index\": <n>, \"score\": <1-100>, \"why\": \"<3-8 words>\"}]} for the top {{top}}, best first.",
-- default_route: 'live.legacy', owner: 'live', metadata: { json_name: 'moment_vod_rank' },
++ default_route: 'background.legacy', owner: 'live', metadata: { json_name: 'moment_vod_rank' },
},
{
key: 'live.hero.slogans', name: 'Live: home hero audiences and quips',
@@@ -203,7 +203,7 @@ const templates = [
output_schema: {"type": "object", "additionalProperties": false, "required": ["audiences", "quips"], "properties": {"audiences": {"type": "array", "items": {"type": "string"}}, "quips": {"type": "array", "items": {"type": "string"}}}},
system_prompt: '',
user_prompt: "You write the rotating hero copy for OpenVibe.Live — a scrappy, open-source, community-run live-streaming site at the heart of the OpenVibe network (\"Open Live Streaming\" — good vibes, no suits). Never call anything \"free\", \"$0\" or \"no cost\". Voice: witty, warm, self-aware, anti-corporate, meme-literate, a little unhinged — but ALWAYS kind, never punching down.\n\nEverything below is REAL data about THIS community right now. Lean into it hard: reference the actual people, running jokes, recurring topics, and memes so the copy feels like an inside joke the community is in on. Reference usernames by name in good fun (no @), and NEVER mock or embarrass anyone. Talk about what they stream and say. This is the first line of the front page: nothing crude or sexual, even when chat jokes that way, and skip usernames that look like keyboard mashes (\"qweqwe\").\n\n=== GLOBAL CHAT VIBE (overview + memory + timeline) ===\n{{global}}\n\n=== PER-USER CHAT ANALYSIS (running jokes / personalities) ===\n{{users}}\n\n=== STREAMERS (what they stream) ===\n{{streamers}}\n\n=== RECENT VODS (what's been on) ===\n{{vods}}\n\n=== ACTIVE USERNAMES you may reference kindly ===\n{{usernames}}\n\n=== TASK ===\nProduce STRICT JSON, exactly this shape and nothing else:\n{\n \"audiences\": [ {{target}} noun phrases naming WHO the site is for. The page prints the fixed headline \"Live streaming for {phrase}\" — you supply ONLY {phrase}. The words \"live\", \"streaming\", \"stream\", \"streamers\" and \"for\" must NOT appear anywhere in a phrase. Good: \"van-dwelling coders\", \"goosely's loyal 3 viewers\", \"crouton connoisseurs\". Bad: \"live streaming for coders\", \"coders live streaming\", \"for coders\". 1-5 words, lowercase (keep usernames' casing), plural people-nouns, no trailing punctuation. Mix on-theme audiences with community in-jokes drawn from the data. ],\n \"quips\": [ {{target}} standalone one-liner taglines, punchy, <= 75 chars. Several should be clear references/memes about the real streamers, VODs, running jokes, or usernames above. ]\n}\nReturn ONLY the JSON object.",
-- default_route: 'live.legacy', owner: 'live', metadata: { json_name: 'hero_slogans' },
++ default_route: 'background.legacy', owner: 'live', metadata: { json_name: 'hero_slogans' },
},
{
key: 'live.easter_egg', name: 'Live: the daily secret code',
@@@ -212,7 +212,7 @@ const templates = [
output_schema: {"type": "object", "additionalProperties": false, "required": ["title", "code", "clues", "effect", "reward"], "properties": {"title": {"type": "string"}, "code": {"type": "array", "items": {"type": "string"}}, "clues": {"type": "array", "items": {"type": "string"}}, "effect": {"type": "string", "enum": ["confetti", "fireworks", "matrix", "rainbow", "shake"]}, "reward": {"type": "string"}}},
system_prompt: '',
user_prompt: "Invent today's secret \"Konami-style\" code for OpenVibe.Live, a hobbyist live-streaming site.{{#vibe}}\nToday's community vibe (for flavour only): {{vibe}}{{/vibe}}\nReturn STRICT JSON only, nothing else:\n{\n \"title\": \"<punchy name for today's secret, 2-5 words>\",\n \"code\": [<5 to 7 tokens IN ORDER, each exactly one of: \"up\",\"down\",\"left\",\"right\", or a single lowercase letter a-z; use at least 2 arrows and at least 1 letter>],\n \"clues\": [<EXACTLY one clue per token, same order, each ≤ 90 chars>],\n \"effect\": \"<one of: confetti, fireworks, matrix, rainbow, shake>\",\n \"reward\": \"<a short, fun congratulatory line shown when a user cracks it>\"\n}\nCLUE RULES (this is a game people must actually be able to win):\n- Clue i describes token i and ONLY token i. Never list the token literally, but make it fair: a normal person should get it in one or two guesses.\n- Arrow clues use direction imagery (\"where the sun rises\" = right, \"toward the sky\" = up, \"the way rain falls\" = down, \"the side your heart is on\" = left).\n- Letter clues MUST quote a common word that starts with that exact letter, in quotes: \"the letter that starts 'goose'\" = g, \"'vibe' begins with it\" = v. Double-check the first letter of the quoted word.\n- Keep the flavour playful and on-theme, but clarity beats cleverness.",
-- default_route: 'live.legacy', owner: 'live', metadata: { json_name: 'easter_egg' },
++ default_route: 'background.legacy', owner: 'live', metadata: { json_name: 'easter_egg' },
},
{
key: 'live.home.star', name: 'Live: Star of OpenVibe',
@@@ -221,7 +221,7 @@ const templates = [
output_schema: {"type": "object", "additionalProperties": false, "required": ["username", "headline", "reason"], "properties": {"username": {"type": "string", "description": "exact username from the candidates"}, "headline": {"type": "string", "description": "≤ 60 chars"}, "reason": {"type": "string", "description": "≤ 170 chars, one sentence"}}},
system_prompt: "You pick today's \"Star of OpenVibe\" — the one streamer OpenVibe.Live (a scrappy, open-source, community-run live-streaming site) rolls out the red carpet for on its home page for the next 24 hours.\nPick from the candidates ONLY (use the exact username). Spread the love: favour people who showed up and put in real hours, grew, got chat talking, or bring something different (a language, a niche, a robot, a vibe) — not just the biggest number. Small streamers who are consistent deserve their day. Never pick anyone in the \"recent stars\" list.\nWrite a \"headline\" (≤ 60 chars, punchy, warm, no quotes) and a \"reason\" (≤ 170 chars, one sentence, second person is fine, reference their real numbers or what they do — no emojis, no hashtags, no sarcasm, never mock). Output only JSON.",
user_prompt: '{{json payload}}',
-- default_route: 'live.summary', owner: 'live', metadata: { json_name: 'home_star' },
++ default_route: 'background.summary', owner: 'live', metadata: { json_name: 'home_star' },
},
{
key: 'live.moments.pick', name: 'Live: pick one moment of a VOD',
@@@ -239,7 +239,7 @@ const templates = [
},
system_prompt: '',
user_prompt: 'Below is a single livestream VOD titled "{{title}}", described by its on-screen TIMELINE (visual scene notes) and its AUDIO TRANSCRIPT, each line timestamped [m:ss]. Titles and descriptions are about what happens.\n\nTIMELINE:\n{{timeline}}\n\nTRANSCRIPT:\n{{transcript}}\n\nSOUNDS HEARD (non-speech audio events):\n{{sounds}}\n\nViewers CLIPPED these timestamps (very strong "this was a highlight" signal): {{clipped}}\nChat activity SPIKED around: {{spikes}}\n\n{{flavor_hint}}\n{{#avoid}}ALREADY USED (a paste or clip exists there) — do NOT pick these or anything within 2 minutes of them: {{avoid}}. Find a DIFFERENT moment.{{/avoid}}\n\nFind the SINGLE most interesting/funny/dramatic/surprising/striking moment in this VOD with something clearly VISIBLE happening. Prefer moments backed by the clip/chat signals when they line up with something notable. NEVER pick a black/dark/loading/blank screen, an intro/BRB card, or a moment with no visible content or activity. {{abstain_hint}}Return STRICT JSON only, nothing else: {"at": "<the m:ss timestamp of the moment, exactly as written in the lines above>", "title": "<specific punchy 3-8 word title, not the stream name, funny, never generic>", "desc": "<one vivid sentence describing the moment>"}',
-- default_route: 'live.legacy', owner: 'live', metadata: { json_name: 'moment_pick' },
++ default_route: 'background.legacy', owner: 'live', metadata: { json_name: 'moment_pick' },
},
{
key: 'live.moments.verify', name: 'Live: is a clip\'s title and description backed by what happened',
@@@ -254,7 +254,7 @@ const templates = [
},
system_prompt: '',
user_prompt: 'A clip is about to be published from the livestream "{{title}}". It is about the moment at [{{at}}].\n\nPROPOSED TITLE: {{clip_title}}\nPROPOSED DESCRIPTION: {{clip_desc}}\n\nEverything known about the minute around it, each line timestamped [m:ss]:\nSCENE NOTES:\n{{timeline}}\n\nSPEECH:\n{{transcript}}\n\nSOUNDS:\n{{sounds}}\n\nCHAT: {{chat}}\n\nCheck the title and the description against these lines only. Answer "supported" when everything they say happens in the lines. Answer "unsupported" when they name an event, an object, an action, a reaction or a person that the lines contradict or never mention, and list each such claim in "unsupported": a claim missing from the lines is unsupported, not unsure. Answer "unsure" only when there are too few lines to judge at all. A funny or punchy wording is fine; an invented event is not. Never guess. Return STRICT JSON only: {"verdict": "supported" | "unsupported" | "unsure", "unsupported": ["<each claim the lines do not back>"], "why": "<a few words>"}',
-- default_route: 'live.legacy', owner: 'live', metadata: { json_name: 'moment_verify' },
++ default_route: 'background.legacy', owner: 'live', metadata: { json_name: 'moment_verify' },
},
{
key: 'live.stream.recap', name: 'Live: after-show report',
ddiff --git a/server/workflows/media.js b/server/workflows/media.js
iindex dcbf02c..ce888e9 100644
---- a/server/workflows/media.js
++++ b/server/workflows/media.js
@@@ -71,6 +71,10 @@ const workflows = [
},
},
steps: [{ kind: 'media_analysis', stt_route: 'live.stt', template: 'media.analyze.overview', local_route: 'media.local', paid_route: 'media.paid' }],
++ // BA1: the route this workflow runs on, and with it the class it may wait in. The step resolves its
++ // own routes (the local model, the paid one, speech-to-text); this is the run's own route and the
++ // class the router reads: nobody watches a VOD's overview arrive, so it is background work.
++ default_route: 'media.analysis',
// Not cached: a result made while the local model was down (or before a budget) must not outlive that, and
// callers keep what they asked for (Live's vod_ai_state/clip_ai_state).
cache_mode: 'none',
ddiff --git a/server/workflows/products.js b/server/workflows/products.js
iindex d7dd00c..212225d 100644
---- a/server/workflows/products.js
++++ b/server/workflows/products.js
@@@ -37,11 +37,11 @@ const templates = [
'Topic: {{topic}}{{#audience}}\nAudience: {{audience}}{{/audience}}\nPages at most: {{max_pages}}\n\n'),
productTemplate('wiki.generate_page', 'Wiki: write a page', 'Write one page of the wiki from the numbered sources, encyclopedic and engaging, the way a well-kept wiki reads. `summary`: one or two plain sentences saying what the subject is. `lead`: the opening paragraph or two, in Markdown. `sections`: follow the requested outline where the sources support it (skip or merge headings they do not), each with a `heading` and a Markdown `body` of one or more full paragraphs (a list where it reads better), each citing its sources; about 500 to 1500 words in all when the sources allow, shorter when they do not. Link other pages of this wiki, and only titles from the list of pages, as [[Exact Title]] the first time each is mentioned. `infobox`: up to twelve key facts as `label` and `value` (for example First appearance, Voiced by, Created by, Release date, Location), each cited; the Wikidata facts are good material. `images`: up to three pictures from the numbered list of pictures that clearly show this page\'s subject, judged by their title and description (never a picture of something or someone else, and none when no picture fits), each with its `index`, a short factual `caption`, and `section`: the heading it illustrates, or lead for the page\'s main picture. Never write source numbers or "[n]" in the text.', 'wiki.write',
'Page title: {{title}}\nWiki topic: {{topic}}{{#outline}}\nRequested outline: {{json outline}}{{/outline}}\n\nPages of this wiki (link only these, as [[Title]]):\n{{pages_block}}\n\n{{#images_block}}Pictures (by number):\n{{images_block}}\n\n{{/images_block}}'),
-- productTemplate('blog.draft_post', 'Blog: draft a post', 'Draft a blog post in Markdown for the brief. Claims of fact must cite sources; opinion and framing need none. If there are no sources, write only what needs no facts and list the facts you would need in gaps.', 'blog.draft',
++ productTemplate('blog.draft_post', 'Blog: draft a post', 'Draft a blog post in Markdown for the brief. Claims of fact must cite sources; opinion and framing need none. If there are no sources, write only what needs no facts and list the facts you would need in gaps.', 'background.json',
'Topic: {{topic}}{{#brief}}\nBrief: {{brief}}{{/brief}}{{#tone}}\nTone: {{tone}}{{/tone}}{{#audience}}\nAudience: {{audience}}{{/audience}}\n\n'),
-- productTemplate('blog.write_digest', 'Blog: write a desk digest', 'Write one digest post for an OpenVibe.Blog desk from the numbered sources (the items its sources published in the period). Pick the items a reader of this desk would most want to know about, about twelve at most, and skip routine, minor or duplicate ones. Group them into two to five sections, most important first. Each section is a list of short paragraphs, one story per paragraph, two to four plain sentences saying what happened and why it matters to this desk\'s readers, exactly as the sources state it; each paragraph lists the numbers of the sources it rests on in its `citations`. Never write source numbers, "(Source n)" or brackets in the text itself. Neutral and readable: no opinion stated as fact, no hype, no advice, no predictions. Name prices, dates and numbers only as the sources give them. List in gaps only what a reader would miss (an item you could not make sense of, a missing date or price). title: a headline naming the one or two biggest stories, at most 90 characters, without the words digest, desk, roundup or snapshot and without a date. dek: one sentence about the period.', 'blog.draft',
++ productTemplate('blog.write_digest', 'Blog: write a desk digest', 'Write one digest post for an OpenVibe.Blog desk from the numbered sources (the items its sources published in the period). Pick the items a reader of this desk would most want to know about, about twelve at most, and skip routine, minor or duplicate ones. Group them into two to five sections, most important first. Each section is a list of short paragraphs, one story per paragraph, two to four plain sentences saying what happened and why it matters to this desk\'s readers, exactly as the sources state it; each paragraph lists the numbers of the sources it rests on in its `citations`. Never write source numbers, "(Source n)" or brackets in the text itself. Neutral and readable: no opinion stated as fact, no hype, no advice, no predictions. Name prices, dates and numbers only as the sources give them. List in gaps only what a reader would miss (an item you could not make sense of, a missing date or price). title: a headline naming the one or two biggest stories, at most 90 characters, without the words digest, desk, roundup or snapshot and without a date. dek: one sentence about the period.', 'background.json',
'Desk: {{desk}}{{#about}}\nThe desk covers: {{about}}{{/about}}\nPeriod: {{period}}\n\n'),
-- productTemplate('news.summarize_story', 'News: summarize a story', 'Write a neutral, factual summary of the story the sources report: a headline, a summary, key points and a timeline, all cited. Attribute contested claims to their source. The tone, when given, changes the length and the explaining, never the facts or the neutrality: brief is two or three sentences and at most three key points; explained also says, from the sources, what the subject is and why it matters, for someone new to it.', 'news.summarize',
++ productTemplate('news.summarize_story', 'News: summarize a story', 'Write a neutral, factual summary of the story the sources report: a headline, a summary, key points and a timeline, all cited. Attribute contested claims to their source. The tone, when given, changes the length and the explaining, never the facts or the neutrality: brief is two or three sentences and at most three key points; explained also says, from the sources, what the subject is and why it matters, for someone new to it.', 'background.json',
'{{#topic}}Topic: {{topic}}\n\n{{/topic}}{{#tone}}Tone: {{tone}}\n\n{{/tone}}'),
productTemplate('news.compare_perspectives', 'News: compare perspectives', 'Group the sources into distinct perspectives on the question, summarize each fairly in its own terms, and list where they agree and disagree. Do not pick a winner.', 'news.summarize',
'Question or topic: {{topic}}\n\n'),
@@@ -51,7 +51,7 @@ const templates = [
'Offer as submitted:\n{{json offer}}\n\n'),
productTemplate('coupons.extract_coupon', 'Coupons: extract coupons', 'Extract every coupon code the sources state, with its restrictions and expiry. If the expiry is not stated, expires_at is null and expiry_known is false. Never mark a code as working; validity is decided by reports, not by you.', 'coupons.extract',
'{{#merchant}}Merchant: {{json merchant}}\n\n{{/merchant}}'),
-- productTemplate('trade.summarize_market_context', 'Trade: market context', 'Summarize the sourced market and filing context for the instrument. Return: summary (two to four plain sentences on what the latest figures and filings show, such as the latest period\'s numbers next to the same period a year before, exactly as the sources state them); observations (one per notable figure or filing: text, observed_at as the period end or date the source gives, and citations); stale_sources (the numbers of sources older than the staleness window); disclaimer (exactly "Informational only; not investment advice."); citations; gaps. Do arithmetic only on numbers the sources give, and say what you compared. Every observation carries the observation time from its source. Informational only: no advice, no predictions, no recommendations, no price targets.', 'trade.summarize',
++ productTemplate('trade.summarize_market_context', 'Trade: market context', 'Summarize the sourced market and filing context for the instrument. Return: summary (two to four plain sentences on what the latest figures and filings show, such as the latest period\'s numbers next to the same period a year before, exactly as the sources state them); observations (one per notable figure or filing: text, observed_at as the period end or date the source gives, and citations); stale_sources (the numbers of sources older than the staleness window); disclaimer (exactly "Informational only; not investment advice."); citations; gaps. Do arithmetic only on numbers the sources give, and say what you compared. Every observation carries the observation time from its source. Informational only: no advice, no predictions, no recommendations, no price targets.', 'background.json',
'Instrument: {{json instrument}}{{#stale_after_hours}}\nStaleness window: {{stale_after_hours}} hours{{/stale_after_hours}}\n\n'),
productTemplate('codes.generate_docs', 'Codes: generate documentation', 'Write developer documentation for the project from the provided source files: an overview and sections in Markdown. Cite the files each section describes. Do not document behaviour the files do not show.', 'codes.generate_docs',
'Project: {{project}}{{#audience}}\nAudience: {{audience}}{{/audience}}\n\n'),
@@@ -59,7 +59,7 @@ const templates = [
'World: {{world}}\nRequest: {{prompt}}\n\n'),
productTemplate('tools.describe', 'Tools: describe a tool', 'Describe one OpenVibe tool for the people who use it, from its descriptor (the numbered sources: its registry entry, inputs, outputs and limits): a one-line summary, what it does, how to use it step by step, what each input and output means, its limits, and a short FAQ. Cite the descriptor fields each statement rests on. Do not claim formats, sizes or features the descriptor does not list; say what is unknown in gaps.', 'tools.describe',
'Tool: {{tool}}{{#audience}}\nAudience: {{audience}}{{/audience}}\n\n'),
-- productTemplate('food.suggest_recipes', 'Food: suggest recipes', 'Suggest up to three simple, low-cost recipes the person can cook, built first from their pantry. An ingredient from the pantry has from_pantry true and needs no citation. Every other ingredient must be one of the foods in the numbered sources (OpenVibe.Food\'s food list) and cite it; if a recipe needs something the sources do not list, leave that recipe out and name the missing food in gaps. State amounts in everyday units. Never state prices, calories, nutrition or health claims: the app works out costs from its own data. Keep steps short and safe (cook meat, poultry and eggs through).', 'food.suggest',
++ productTemplate('food.suggest_recipes', 'Food: suggest recipes', 'Suggest up to three simple, low-cost recipes the person can cook, built first from their pantry. An ingredient from the pantry has from_pantry true and needs no citation. Every other ingredient must be one of the foods in the numbered sources (OpenVibe.Food\'s food list) and cite it; if a recipe needs something the sources do not list, leave that recipe out and name the missing food in gaps. State amounts in everyday units. Never state prices, calories, nutrition or health claims: the app works out costs from its own data. Keep steps short and safe (cook meat, poultry and eggs through).', 'background.json',
'Pantry: {{json pantry}}{{#people}}\nCooking for: {{people}}{{/people}}{{#ask}}\nRequest: {{ask}}{{/ask}}\n\n'),
productTemplate('rent.screen_listing', 'Rent: screen a listing for scam signs', 'Read one rental listing a person posted and say whether a member of staff should look at it for the signs of a rental scam. The signs: asking for a deposit, rent or any payment before the place can be viewed (payment_before_viewing); asking to pay by wire transfer, gift cards, cryptocurrency or a payment app sent to a stranger (unusual_payment); an owner who is abroad or otherwise cannot show the place in person (owner_unavailable); pressure to decide or pay at once, such as many other applicants or today only (pressure); asking for identity documents, a social security number or bank details before any viewing (personal_data_request); moving the conversation to a private email or messaging app while refusing calls or visits (off_platform_only). A low price alone is not a sign: you cannot know the area. Ordinary rules (no pets, references, a deposit paid at signing, proof of income at application) are not signs. Quote the exact words behind each sign you name. When no sign is clearly present, answer allow with signals none; choose review only when the words show at least one sign.', 'rent.screen',
'Listing:\n"""\n{{json listing}}\n"""\n\n'),
@@@ -99,14 +99,14 @@ const workflows = [
key: 'blog.draft_post', name: 'Draft a blog post', namespace: 'blog',
input_schema: OBJ({ topic: STR(300), brief: STR(4000), tone: STR(200), audience: STR(300), sources: SOURCES(0) }, ['topic']),
output_schema: OBJ({ title: STR(200), dek: STR(400), body_markdown: STR(40000), tags: ARR(STR(40), 10), citations: CITES, gaps: GAPS }),
-- default_route: 'blog.draft',
++ default_route: 'background.json',
},
{
// OpenVibe.Blog's AI desks (server/desks.js): a digest of what a desk's sources published, each section cited.
key: 'blog.write_digest', name: 'Write a desk digest', namespace: 'blog',
input_schema: OBJ({ desk: STR(120), about: STR(400), period: STR(120), sources: SOURCES(2) }, ['desk', 'period', 'sources']),
output_schema: OBJ({ title: STR(200), dek: STR(400), sections: ARR(OBJ({ heading: STR(160), paragraphs: ARR(CLAIM, 8) }), 6), citations: CITES, gaps: GAPS }),
-- default_route: 'blog.draft',
++ default_route: 'background.json',
},
{
key: 'news.summarize_story', name: 'Summarize a news story', namespace: 'news',
@@@ -114,7 +114,7 @@ const workflows = [
// someone new to the subject: what it is and why it matters, still only from the sources).
input_schema: OBJ({ topic: STR(300), tone: { enum: ['brief', 'neutral', 'explained'] }, sources: SOURCES(1) }, ['sources']),
output_schema: OBJ({ headline: STR(200), summary: STR(3000), key_points: ARR(CLAIM, 20), timeline: ARR(OBJ({ when: STR(60), what: STR(500), citations: CITES }), 30), citations: CITES, gaps: GAPS }),
-- default_route: 'news.summarize',
++ default_route: 'background.json',
},
{
key: 'news.compare_perspectives', name: 'Compare perspectives on a story', namespace: 'news',
@@@ -162,7 +162,7 @@ const workflows = [
disclaimer: { const: 'Informational only; not investment advice.' },
citations: CITES, gaps: GAPS,
}),
-- default_route: 'trade.summarize',
++ default_route: 'background.json',
},
{
key: 'codes.generate_docs', name: 'Generate documentation from code', namespace: 'codes',
@@@ -199,7 +199,7 @@ const workflows = [
}), 3),
citations: CITES, gaps: GAPS,
}),
-- default_route: 'food.suggest',
++ default_route: 'background.json',
},
{
key: 'rent.screen_listing', name: 'Screen a rental listing for scam signs', namespace: 'rent',
@@@ -267,14 +267,14 @@ templates.push({
input_schema: { type: 'object' }, output_schema: { type: 'object' },
system_prompt: 'You describe one picture for OpenVibe.Pics so people who cannot see it know what it shows. alt: one plain sentence of at most 140 characters saying what the picture shows (never "image of" or "picture of"). caption: one or two sentences with the detail a sighted person would notice first. tags: up to six short lowercase words for what is in it. Describe only what is visible. Never name or guess who a real person is, and never read out personal details shown in it (names, addresses, number plates, phone numbers). If it shows text, say what kind of text it is rather than quoting personal parts. Output only JSON matching the schema.',
user_prompt: '{{#title}}The uploader titled it: {{title}}\n{{/title}}Describe the picture.',
-- default_route: 'pics.vision', owner: 'media-hub', metadata: { json_name: 'pics_describe_image' },
++ default_route: 'background.json', owner: 'media-hub', metadata: { json_name: 'pics_describe_image' },
});
workflows.push({
key: 'pics.describe_image', name: 'Describe a picture', namespace: 'pics',
input_schema: OBJ({ image: IMAGE, title: STR(120) }, ['image']),
output_schema: OBJ({ alt: STR(160), caption: STR(400), tags: ARR(STR(30), 6), gaps: GAPS }),
steps: [{ kind: 'llm', template: 'pics.describe_image', operation: 'generate', output: 'json', image: 'image', image_max_width: 1024, prepare: 'pics.describe_image', params: { max_tokens: 400, temperature: 0.2 } }],
-- default_route: 'pics.vision',
++ default_route: 'background.json',
// A person's picture: never shared between callers (the stored run keeps only the image's hash).
cache_mode: 'none',
metadata: { seed: true },
@@@ -288,14 +288,14 @@ templates.push({
input_schema: { type: 'object' }, output_schema: { type: 'object' },
system_prompt: 'You write for "Is This Real?", OpenVibe.Blog\'s satire desk: absurd, deadpan mock-explainers that treat a ridiculous question as if it were a serious public-health or consumer issue, in the style of an earnest internet explainer that has completely lost the plot. Escalate the absurdity section by section; invented studies, invented institutes and invented experts with obviously silly names are the joke. Rules you never break: the piece must be plainly fiction to any reader; never name, quote or describe a real living person, and never put words in the mouth of a real company, brand, government body or organisation; no jokes about real tragedies, victims, illness, minors, or any group of people by race, religion, gender, sexuality or disability; never give steps, amounts or methods for anything dangerous (drugs, weapons, self-harm, dangerous stunts), even as a joke, and when the topic brushes against one, the punchline is that nobody should try it; no slurs. Profanity is allowed but use it sparingly. title: the question, as a headline. dek: one deadpan sentence in the voice of the piece (never a description of the piece). body_markdown: 350 to 700 words in Markdown with two to four ## sections; no title heading. tags: up to five lower-case words. Output only JSON matching the schema.',
user_prompt: 'Question: {{topic}}{{#angle}}\nAngle: {{angle}}{{/angle}}',
-- default_route: 'blog.draft', owner: 'blog', metadata: { json_name: 'blog_write_satire' },
++ default_route: 'background.json', owner: 'blog', metadata: { json_name: 'blog_write_satire' },
});
workflows.push({
key: 'blog.write_satire', name: 'Write a satire post', namespace: 'blog',
input_schema: OBJ({ topic: STR(300), angle: STR(300) }, ['topic']),
output_schema: OBJ({ title: STR(200), dek: STR(300), body_markdown: STR(12000), tags: ARR(STR(40), 5) }),
steps: [{ kind: 'llm', template: 'blog.write_satire', operation: 'generate', output: 'json', params: { max_tokens: 2400, temperature: 0.9 } }],
-- default_route: 'blog.draft',
++ default_route: 'background.json',
cache_mode: 'private',
metadata: { seed: true },
});
ddiff --git a/server/workflows/seed.js b/server/workflows/seed.js
iindex 77a9b42..2b21e62 100644
---- a/server/workflows/seed.js
++++ b/server/workflows/seed.js
@@@ -78,7 +78,12 @@ function sharedProviderRecord(config, env) {
? ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'json', 'vision']
// image only with AI_IMAGE_MODEL: the Images API is OpenAI-shaped, and image generation is off without it.
: ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'json', 'vision', 'embed', 'transcribe', ...(config.image && config.image.model ? ['image'] : [])],
-- timeout_ms: s.timeoutMs, priority: 10, metadata: { configured_by: 'AI_PROVIDER/AI_BASE_URL/AI_API_KEY/AI_MODEL', provider_name: s.provider || 'openai-compatible', billing_profile: 'metered' },
++ timeout_ms: s.timeoutMs, priority: 10, metadata: { configured_by: 'AI_PROVIDER/AI_BASE_URL/AI_API_KEY/AI_MODEL', provider_name: s.provider || 'openai-compatible', billing_profile: 'metered',
++ // OpenAI's own API sells a Flex tier (about half the price, slower) and a Batch API (half the
++ // price, within 24 hours). A self-hosted gateway does not, so the multipliers belong to
++ // OpenAI's own page only: https://platform.openai.com/docs/guides/flex-processing and
++ // https://platform.openai.com/docs/guides/batch (read 2026-11-07).
++ ...(/api\.openai\.com/i.test(base) ? { tier_rates: { flex: 0.5, batch: 0.5 } } : {}) },
};
}
@@@ -174,6 +179,8 @@ async function seed({ registry, quotas, config, env = process.env, db }) {
}
}
++ // The room a background route gets (BA1): a Flex tier answers in minutes, not seconds.
++ const BACKGROUND_TIMEOUT = 180000;
// Routes. A capability pool covers every provider that can serve the capability, ordered by
// openvibe-sdk/placement (brief §4/§5): no static primary/fallbacks and no AI_MODEL_<ROLE> pins.
// The shared provider is the pool's authority (objective correctness): it serves while it is healthy and
@@@ -198,8 +205,12 @@ async function seed({ registry, quotas, config, env = process.env, db }) {
await registry.seedVersioned('route', 'default.json', pool(60000, { options: { temperature: 0.3 }, max_output_tokens: 2400, response_format: 'json' }));
await registry.seedVersioned('route', 'default.embedding', pool(30000, { 'capability': 'embed', constraints: { objective: 'balanced' } }));
// media.analyze (WS-O task 5): the local model when there is one; the paid route is used only under a media.paid budget.
-- if (config.localLlm && config.localLlm.url) await registry.seedVersioned('route', 'media.local', pool(config.localLlm.timeoutMs, { 'capability': 'summarize', constraints: { objective: 'balanced', provider_scope: 'local' }, options: { temperature: 0.3 }, max_output_tokens: 350 }));
-- await registry.seedVersioned('route', 'media.paid', pool(60000, { 'capability': 'summarize', constraints: { objective: 'correctness', provider_scope: 'paid', ...(authority ? { authority } : {}) }, options: { temperature: 0.3 }, max_output_tokens: 350 }));
++ // Both are background work (BA1): nobody watches a VOD's overview arrive, so the route says it may wait.
++ // The run's own route (media.analysis) exists whatever the host has: it carries the class of the work, and the
++ // step inside it picks the local or the paid route for the answer it actually gives.
++ await registry.seedVersioned('route', 'media.analysis', pool(BACKGROUND_TIMEOUT, { 'capability': 'chat', constraints: { objective: 'balanced', latency_class: 'background' } }));
++ if (config.localLlm && config.localLlm.url) await registry.seedVersioned('route', 'media.local', pool(config.localLlm.timeoutMs, { 'capability': 'summarize', constraints: { objective: 'balanced', latency_class: 'background', provider_scope: 'local' }, options: { temperature: 0.3 }, max_output_tokens: 350 }));
++ await registry.seedVersioned('route', 'media.paid', pool(60000, { 'capability': 'summarize', constraints: { objective: 'correctness', latency_class: 'background', provider_scope: 'paid', ...(authority ? { authority } : {}) }, options: { temperature: 0.3 }, max_output_tokens: 350 }));
// Images (metered per image) only with AI_IMAGE_MODEL: paid providers' registered image models, the shared one first.
if (imageModel) await registry.seedVersioned('route', 'default.image', pool(120000, { 'capability': 'image', constraints: { objective: 'correctness', provider_scope: 'paid', authority: `shared:${imageModel}` } }));
for (const key of HISTORICAL_ROUTES) await registry.seedVersioned('route', key, { primary: { provider: 'shared', model: null }, fallbacks: [], options: {}, max_output_tokens: null, response_format: 'json', timeout_ms: null, alias_of: 'default.json' });
@@@ -213,6 +224,26 @@ async function seed({ registry, quotas, config, env = process.env, db }) {
const creativeModel = env.AI_CREATIVE_MODEL || (shared && shared.kind === 'openai' && /api\.openai\.com/i.test(shared.base_url || '') ? 'gpt-5-mini' : null);
if (creativeModel && !await registry.getModel('shared', creativeModel)) await registry.upsertModel({ provider_key: 'shared', model_key: creativeModel, type: 'chat', supports: { json: true, vision: true } }, { actor: 'seed' });
await registry.seedVersioned('route', 'live.creative', pool(ROLE_TIMEOUT.chat, creativeModel ? { constraints: { objective: 'correctness', latency_class: 'interactive', model: creativeModel, authority: `shared:${creativeModel}` } } : {}));
++ // The background routes (BA1): the same pools as the interactive ones, but the route says the work may
++ // wait (constraints.latency_class 'background'). The router then looks for the cheapest tier that can
++ // answer in minutes: the host's own idle model, a Flex tier, or a provider's off-peak hours. The timeout
++ // is longer, because a Flex tier is slower than the standard one. A workflow's route is what gives it
++ // its class, so a workflow that uses one of these routes runs in the background.
++ //
++ // These routes name NO model: the whole point of a background run is that the cheapest offer that can do
++ // the work may answer it. The authority is still the shared provider (the network's choice). The host's own
++ // idle model answers first only on a route with constraints.allow_local: true, and none of these sets it yet
++ // (Opus, 11-07): their text is what people read on Trade, Live and the Rundown, and a 1.5B model is not measured
++ // for it. A route that names its model (the creative one below) keeps that model.
++ const backgroundPool = (timeoutMs, extra = {}) => ({ 'capability': 'chat', constraints: { objective: 'correctness', latency_class: 'background', ...(authority ? { authority } : {}) }, pinned: [], fallbacks: [], options: {}, max_output_tokens: null, response_format: 'text', timeout_ms: timeoutMs, alias_of: null, ...extra });
++ // Each one carries the Live role it serves (legacy, summary, vision, director), so a person who gave their
++ // own key a model for that role still gets it (server/providers/index.js roleOfRoute).
++ await registry.seedVersioned('route', 'background.legacy', backgroundPool(BACKGROUND_TIMEOUT));
++ await registry.seedVersioned('route', 'background.summary', backgroundPool(BACKGROUND_TIMEOUT));
++ await registry.seedVersioned('route', 'background.vision', backgroundPool(BACKGROUND_TIMEOUT));
++ await registry.seedVersioned('route', 'background.director', backgroundPool(BACKGROUND_TIMEOUT));
++ await registry.seedVersioned('route', 'background.json', backgroundPool(BACKGROUND_TIMEOUT, { options: { temperature: 0.3 }, max_output_tokens: 2400, response_format: 'json' }));
++ await registry.seedVersioned('route', 'background.creative', backgroundPool(BACKGROUND_TIMEOUT, creativeModel ? { constraints: { objective: 'correctness', latency_class: 'background', model: creativeModel, authority: `shared:${creativeModel}` } } : {}));
// Chat's AI members (server/workflows/chat.js): conversation text, so an alias of the chat pool.
// Space's spaces (server/workflows/space.js): the builder writes whole apps, so it gets the creative model and room to
// finish; a space's own AI is conversation text, an alias of the chat pool.
ddiff --git a/test/latency-classes.test.js b/test/latency-classes.test.js
nnew file mode 100644
iindex 0000000..72a7eb6
---- /dev/null
++++ b/test/latency-classes.test.js
@@@ -0,0 +1,394 @@
++'use strict';
++/**
++ * BA1: the latency classes and the tiers a background run waits for.
++ *
++ * interactive the default: the answer as soon as the provider can give it.
++ * background the answer may take minutes: the router asks for the cheapest tier that can wait (a Flex
++ * tier, a provider's off-peak hours, the host's own idle model) and the run is priced on the
++ * tier it really ran on.
++ * batch the answer may take hours (BA2 answers it through a provider's own batch API).
++ *
++ * What these tests hold:
++ *
++ * - a Flex tier is sent, and the run is priced at the Flex price, when the provider says it served one;
++ * - a Flex tier the provider refuses (429 with no capacity, 400 for a model that does not serve it) sends
++ * the SAME run again on the standard tier, once, and the run is billed once, at the standard price;
++ * - a caller may ask for a slower class and never for a faster one;
++ * - DeepSeek's off-peak rule is read by weekday and by hour (peak 01:00-04:00 and 06:00-10:00 UTC, Monday
++ * to Friday; every other hour off-peak, weekends in full), and the old single-window rows are still read;
++ * - the workflows that wait in the background are the ones the plan names.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { boot, request, suite, seamServer, silent, token, ALL } = require('./helpers');
++const { testDb } = require('./db');
++const latency = require('../server/latency');
++const tiers = require('../server/providers/tiers');
++const rates = require('../server/providers/rate-cards');
++const parsers = require('../server/pricebook/parsers');
++const { createOffPeak } = require('../server/pricebook/offpeak');
++const { createPriceBook } = require('../server/pricebook/job');
++const { SOURCES } = require('../server/pricebook/sources');
++const { providerRecords } = require('../server/workflows/seed');
++const { load } = require('../server/config');
++const { PROVIDERS } = require('../server/providers/pool-providers');
++
++const t = suite('latency-classes');
++const FIXTURES = path.join(__dirname, 'fixtures', 'pricebook');
++
++// ── The classes ────────────────────────────────────────────────────────────────
++
++t.test('the class order: a caller may ask for a slower class and never for a faster one', () => {
++ assert.deepStrictEqual(latency.CLASSES, ['interactive', 'background', 'batch']);
++ assert.strictEqual(latency.ofRoute({ constraints: { latency_class: 'background' } }), 'background');
++ assert.strictEqual(latency.ofRoute({ constraints: {} }), 'interactive', 'a route that says nothing is interactive');
++ assert.strictEqual(latency.ofRoute(null), 'interactive');
++
++ assert.deepStrictEqual(latency.resolve('interactive', null), { class: 'interactive' }, 'no question, the workflow\'s own class');
++ assert.deepStrictEqual(latency.resolve('background', 'batch'), { class: 'batch' }, 'a caller may ask for a slower class');
++ assert.deepStrictEqual(latency.resolve('interactive', 'background'), { class: 'background' });
++ assert.deepStrictEqual(latency.resolve('background', 'interactive'), { class: 'background', clamped: { asked: 'interactive', used: 'background' } },
++ 'a background workflow is never made interactive, and the run is not refused: it runs in its own class and says so');
++ assert.ok(latency.resolve('interactive', 'nonsense').error, 'a class nobody knows is refused');
++ assert.strictEqual(latency.timeoutMs('background') > latency.timeoutMs('interactive'), true, 'a background run gets more room');
++});
++
++t.test('a Flex tier is refused only when the provider says so', () => {
++ const refusal = (status, body) => ({ status, body, message: (body && body.error && body.error.message) || '' });
++ assert.ok(tiers.isTierRefusal(refusal(429, { error: { code: 'resource_unavailable', message: 'The flex tier has no capacity right now' } }), 'flex'),
++ 'a 429 with no capacity is a refusal of the tier');
++ assert.ok(tiers.isTierRefusal(refusal(400, { error: { message: 'The model gpt-4o-mini does not support service_tier flex' } }), 'flex'),
++ 'a 400 that names the tier is a refusal of the tier');
++ assert.ok(!tiers.isTierRefusal(refusal(500, { error: { message: 'the server is on fire' } }), 'flex'), 'a plain failure is not a refusal of the tier');
++ assert.ok(!tiers.isTierRefusal(refusal(400, { error: { message: 'the prompt is too long' } }), 'flex'), 'a bad request is the caller\'s, not the tier\'s');
++ // The tier a run is priced on is the one the answer names. A provider that says nothing is priced at the list price.
++ assert.strictEqual(tiers.tierOfAnswer({ serviceTier: 'flex' }, 'flex'), 'flex');
++ assert.strictEqual(tiers.tierOfAnswer({ serviceTier: 'default' }, 'flex'), 'standard');
++ assert.strictEqual(tiers.tierOfAnswer({}, 'flex'), 'standard', 'a provider that does not say is priced at the list price');
++ assert.strictEqual(tiers.tierOfAnswer({}, 'standard'), 'standard');
++});
++
++t.test('a tier is the list price times the multiplier the provider publishes', () => {
++ const cards = rates.buildCards({ providerKey: 'p', model: 'm', row: { cost: { in_per_mtok: 2, out_per_mtok: 4 } }, pricing: {} });
++ assert.strictEqual(rates.costOfUsage(cards, { input: 1000, output: 1000 }), 0.006);
++ const flex = rates.cardsForTier(cards, { multiplier: 0.5, tier: 'flex' });
++ assert.strictEqual(rates.costOfUsage(flex, { input: 1000, output: 1000 }), 0.003, 'half the list price');
++ assert.strictEqual(flex.in.version, 'list:flex', 'the card says which tier priced it');
++ assert.strictEqual(rates.costOfUsage(rates.cardsForTier(cards, { multiplier: null, tier: 'flex' }), { input: 1000, output: 1000 }), 0.006,
++ 'a tier nobody published a multiplier for keeps the list price');
++});
++
++t.test('the providers publish the tier prices they were read from', () => {
++ // OpenAI's own prices: a Flex tier and a batch at half the standard price (read 2026-11-07).
++ const config = load({ AI_PROVIDER: 'openai', AI_BASE_URL: 'https://api.openai.com/v1', AI_MODEL: 'gpt-4o-mini' });
++ const openai = providerRecords(config, {}).find((r) => r.key === 'shared');
++ assert.deepStrictEqual(openai.metadata.tier_rates, { flex: 0.5, batch: 0.5 });
++ // DeepInfra: a Flex tier and a batch at 0.8x. DashScope: a batch at half. Moonshot: a batch at 0.6x.
++ const by = (key) => PROVIDERS.find((p) => p.key === key);
++ assert.deepStrictEqual(by('deepinfra').tier_rates, { flex: 0.8, batch: 0.8 });
++ assert.deepStrictEqual(by('qwen').tier_rates, { batch: 0.5 });
++ assert.deepStrictEqual(by('kimi').tier_rates, { batch: 0.6 });
++ // A provider that publishes no tier price has none: the network never claims a saving it cannot price.
++ assert.strictEqual(by('openrouter').tier_rates, undefined);
++ assert.strictEqual(tiers.multiplierOf({ metadata: {} }, 'flex'), null);
++});
++
++// ── The Flex tier, end to end ─────────────────────────────────────────────────
++
++const ENV = { AI_STUB_FALLBACK: 'false', AI_PROVIDER: 'openai', AI_MODEL: 'gpt-4o-mini', AI_PROVIDER_RETRIES: '0' };
++const answer = (text = 'an answer') => ({ choices: [{ message: { role: 'assistant', content: text }, finish_reason: 'stop' }], usage: { prompt_tokens: 1000, completion_tokens: 1000 } });
++/** A provider that says which tier it served, and only when it served one. */
++const echoes = (body) => (body.service_tier ? { ...answer(), service_tier: body.service_tier } : answer());
++
++/** The shared provider with OpenAI's own tier prices on it (the seeded record carries them on api.openai.com). */
++async function withFlex(h) {
++ const shared = await h.registry.getProvider('shared');
++ await h.registry.upsertProvider({ key: 'shared', metadata: { ...(shared.metadata || {}), tier_rates: { flex: 0.5, batch: 0.5 } } });
++}
++
++t.test('a background run asks for the Flex tier and is priced at the Flex price', async () => {
++ const up = await seamServer((body) => { up.last = body; return echoes(body); });
++ const h = await boot({ env: { ...ENV, AI_BASE_URL: `${up.url}/v1` } });
++ try {
++ await withFlex(h);
++ const route = await h.registry.resolveRoute('background.legacy');
++ assert.strictEqual(route.constraints.latency_class, 'background');
++ const req = { messages: [{ role: 'user', content: 'take your time' }], maxTokens: 50 };
++ const out = await h.pool.execute(route, 'chat', req, { routeKey: route.key, latencyClass: 'background' });
++ assert.strictEqual(up.last.service_tier, 'flex', 'the tier travels with the request');
++ assert.strictEqual(out.serviceTier, 'flex', 'the run is priced on the tier the answer names');
++ // The same call on the standard tier, for the price to compare with.
++ const plain = await h.pool.execute(await h.registry.resolveRoute('default.chat'), 'chat', req, { routeKey: 'default.chat' });
++ assert.strictEqual(plain.serviceTier, 'standard');
++ assert.ok(Math.abs(out.cost - plain.cost / 2) < 1e-12, `the flex run costs half the standard one (${out.cost} vs ${plain.cost})`);
++ assert.ok(Math.abs(out.savedUsd - plain.cost / 2) < 1e-12, 'the wait earned the difference');
++ assert.strictEqual(out.explain.service_tier, 'flex');
++ } finally { await h.stop(); await up.close(); }
++});
++
++t.test('a Flex tier with no capacity: the same run goes again on the standard tier, billed once', async () => {
++ const seen = [];
++ const up = await seamServer((body) => {
++ seen.push(body.service_tier || null);
++ if (body.service_tier === 'flex') return { status: 429, body: { error: { code: 'resource_unavailable', message: 'resource_unavailable: the flex tier has no capacity' } } };
++ return echoes(body);
++ });
++ const h = await boot({ env: { ...ENV, AI_BASE_URL: `${up.url}/v1` } });
++ try {
++ await withFlex(h);
++ const route = await h.registry.resolveRoute('background.legacy');
++ const out = await h.pool.execute(route, 'chat', { messages: [{ role: 'user', content: 'take your time' }], maxTokens: 50 }, { routeKey: route.key, latencyClass: 'background' });
++ assert.deepStrictEqual(seen, ['flex', null], 'the tier was asked for once, then the standard tier');
++ assert.strictEqual(out.serviceTier, 'standard', 'the run ran on the standard tier');
++ const plain = await h.pool.execute(await h.registry.resolveRoute('default.chat'), 'chat', { messages: [{ role: 'user', content: 'hi' }], maxTokens: 50 }, { routeKey: 'default.chat' });
++ assert.strictEqual(out.cost, plain.cost, 'the run is billed once, at the standard price');
++ assert.strictEqual(out.savedUsd, 0, 'a refused tier earns nothing');
++ assert.ok(out.explain.attempts.some((a) => a.status === 'failed' && /flex/.test(String(a.error))), 'the refusal is counted');
++ } finally { await h.stop(); await up.close(); }
++});
++
++t.test('a model that does not serve the tier falls back to the standard tier', async () => {
++ const seen = [];
++ const up = await seamServer((body) => {
++ seen.push(body.service_tier || null);
++ if (body.service_tier === 'flex') return { status: 400, body: { error: { message: 'The model gpt-4o-mini does not support service_tier flex' } } };
++ return echoes(body);
++ });
++ const h = await boot({ env: { ...ENV, AI_BASE_URL: `${up.url}/v1` } });
++ try {
++ await withFlex(h);
++ const route = await h.registry.resolveRoute('background.legacy');
++ const out = await h.pool.execute(route, 'chat', { messages: [{ role: 'user', content: 'take your time' }], maxTokens: 50 }, { routeKey: route.key, latencyClass: 'background' });
++ assert.deepStrictEqual(seen, ['flex', null]);
++ assert.strictEqual(out.serviceTier, 'standard');
++ assert.ok(out.result.text, 'the run answered');
++ } finally { await h.stop(); await up.close(); }
++});
++
++t.test('the host\'s own idle model answers first only on a route that opts in, and a route that names a model keeps it', async () => {
++ const local = await seamServer(() => answer('from the host'));
++ const network = await seamServer(() => answer('from the network'));
++ const h = await boot({ env: { ...ENV, AI_BASE_URL: `${network.url}/v1`, AI_LOCAL_LLM_URL: `${local.url}/v1`, AI_LOCAL_LLM_MODEL: 'local-model', AI_CREATIVE_MODEL: 'gpt-5-mini' } });
++ try {
++ const plain = await h.registry.resolveRoute('background.legacy');
++ // No route opts in today (Opus, 11-07): the text is what people read, so the network's choice answers.
++ const kept = await h.pool.execute(plain, 'chat', { messages: [{ role: 'user', content: 'when you have time' }], maxTokens: 50 }, { routeKey: plain.key, latencyClass: 'background' });
++ assert.strictEqual(kept.provider, 'shared', 'without allow_local the small local model never answers first');
++ const route = { ...plain, constraints: { ...plain.constraints, allow_local: true } };
++ const out = await h.pool.execute(route, 'chat', { messages: [{ role: 'user', content: 'when you have time' }], maxTokens: 50 }, { routeKey: route.key, latencyClass: 'background' });
++ assert.strictEqual(out.provider, 'local', 'a route that opts in: the host\'s own idle model answers first');
++ assert.strictEqual(out.serviceTier, 'local', 'the tier is local: capacity the network already pays for');
++ assert.strictEqual(out.cost, 0, 'capacity the network already pays for costs nothing');
++ // An interactive run keeps the network's choice: the authority serves it.
++ const inter = await h.pool.execute(route, 'chat', { messages: [{ role: 'user', content: 'now' }], maxTokens: 50 }, { routeKey: route.key });
++ assert.strictEqual(inter.provider, 'shared', 'an interactive run is not moved to the local model');
++ // A route that names its model keeps it: a small local model never writes The Rundown.
++ const creative = await h.registry.resolveRoute('background.creative');
++ assert.strictEqual(creative.constraints.model, 'gpt-5-mini');
++ const wrote = await h.pool.execute(creative, 'chat', { messages: [{ role: 'user', content: 'write it' }], maxTokens: 50 }, { routeKey: creative.key, latencyClass: 'background' });
++ assert.strictEqual(wrote.provider, 'shared', 'the route names its model, so the network serves it');
++ } finally { await h.stop(); await local.close(); await network.close(); }
++});
++
++t.test('an interactive run asks for no tier at all', async () => {
++ const up = await seamServer((body) => { up.last = body; return echoes(body); });
++ const h = await boot({ env: { ...ENV, AI_BASE_URL: `${up.url}/v1` } });
++ try {
++ await withFlex(h);
++ const route = await h.registry.resolveRoute('default.chat');
++ const out = await h.pool.execute(route, 'chat', { messages: [{ role: 'user', content: 'now please' }], maxTokens: 50 }, { routeKey: route.key });
++ assert.strictEqual(up.last.service_tier, undefined, 'an interactive run never asks for a tier');
++ assert.strictEqual(out.serviceTier, 'standard');
++ assert.strictEqual(out.savedUsd, 0);
++ } finally { await h.stop(); await up.close(); }
++});
++
++t.test('a caller that waits gets its answer inside the wait: a Flex tier slower than the wait gives way to standard', async () => {
++ const seen = [];
++ const up = await seamServer(async (body) => {
++ seen.push(body.service_tier || null);
++ if (body.service_tier === 'flex') await new Promise((r) => setTimeout(r, 13000)); // slower than the Flex budget
++ return body.messages ? { choices: [{ message: { role: 'assistant', content: '{"title":"Konami","code":["up","down","left","right","a"],"clues":["one","two","three"]}' }, finish_reason: 'stop' }], usage: { prompt_tokens: 100, completion_tokens: 100 } } : {};
++ });
++ const h = await boot({ env: { ...ENV, AI_BASE_URL: `${up.url}/v1` } });
++ try {
++ await withFlex(h);
++ const all = token('all', ALL);
++ const started = Date.now();
++ // The caller waits 30 s: the Flex attempt may use 10 s (the wait minus the 25 s a standard retry needs, at least 10 s).
++ const run = await request(h.base, 'POST', '/api/v1/runs?wait=30000', { tok: all, body: { workflow: 'live.easter_egg', input: {}, options: { cache: false } } });
++ const took = Date.now() - started;
++ assert.strictEqual(run.status, 201, `answered inside the wait (${run.status}, ${took} ms): ${run.text}`);
++ assert.strictEqual(run.body.run.service_tier, 'standard', 'the standard tier answered');
++ assert.deepStrictEqual(seen.slice(0, 2), ['flex', null], 'Flex first, then the same run on standard');
++ assert.ok(took < 25000, `well inside the caller's wait (${took} ms)`);
++ } finally { await h.stop(); await up.close(); }
++});
++
++// ── The class of a run, from the caller's side ─────────────────────────────────
++
++t.test('a caller cannot make a background workflow interactive (it still runs, in its own class), and the run records its class and tier', async () => {
++ const up = await seamServer((body) => {
++ up.last = body;
++ return { choices: [{ message: { role: 'assistant', content: '{"title":"Konami","code":["up","down","left","right","a"],"clues":["where the sun rises","where the sky is","where the water falls","the other way","the letter that starts goose"],"effect":"confetti","reward":"well done"}' }, finish_reason: 'stop' }], usage: { prompt_tokens: 1000, completion_tokens: 1000 }, service_tier: 'flex' };
++ });
++ const h = await boot({ env: { ...ENV, AI_BASE_URL: `${up.url}/v1` } });
++ try {
++ await withFlex(h);
++ const all = token('all', ALL);
++ // A caller may not make the work faster than the workflow's own class: the run still happens, in the
++ // background, and says it was asked for more (never an error and nothing done).
++ const fast = await request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok: all, body: { workflow: 'live.easter_egg', input: {}, options: { latency: 'interactive', cache: false } } });
++ assert.strictEqual(fast.status, 201, `the run happens (${fast.text})`);
++ assert.strictEqual(fast.body.run.latency_class, 'background', 'a background workflow stays in the background');
++ // A slower class is allowed.
++ const slower = await request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok: all, body: { workflow: 'live.easter_egg', input: {}, options: { latency: 'batch', cache: false } } });
++ assert.strictEqual(slower.status, 201, `a slower class is allowed (${slower.text})`);
++ assert.strictEqual(slower.body.run.latency_class, 'batch');
++ // The plain run is background, on the Flex tier, and says so.
++ const run = await request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok: all, body: { workflow: 'live.easter_egg', input: {}, options: { cache: false } } });
++ assert.strictEqual(run.status, 201, run.text);
++ assert.strictEqual(run.body.run.latency_class, 'background');
++ assert.strictEqual(run.body.run.service_tier, 'flex', 'the tier the run really ran on is on the run');
++ const read = await request(h.base, 'GET', `/api/v1/runs/${run.body.run.id}`, { tok: all });
++ assert.strictEqual(read.body.run.service_tier, 'flex', 'the wire shape carries the tier');
++ assert.strictEqual(read.body.run.latency_class, 'background');
++ // The gauges say the same: the tier the run ran on, and what waiting earned.
++ const metrics = await (await fetch(`${h.base}/metrics`)).text();
++ assert.ok(/ai_runs_by_tier\{tier="flex",provider="shared"\} \d+\n/.test(metrics), `the tier is on the metrics:\n${metrics}`);
++ const saved = Number((/ai_saved_usd ([0-9.e-]+)/.exec(metrics) || [])[1]);
++ assert.ok(saved > 0, `the waiting earned something (${saved})`);
++ } finally { await h.stop(); await up.close(); }
++});
++
++// ── The off-peak windows ───────────────────────────────────────────────────────
++
++t.test('DeepSeek: peak hours on weekdays, off-peak everywhere else', async () => {
++ const page = fs.readFileSync(path.join(FIXTURES, 'deepseek.md'), 'utf8');
++ const parsed = parsers.deepseekOffPeakBlocks(page);
++ assert.deepStrictEqual(parsed.blocks, [
++ { weekdays: [1, 2, 3, 4, 5], start_hour: 0, end_hour: 1 },
++ { weekdays: [1, 2, 3, 4, 5], start_hour: 4, end_hour: 6 },
++ { weekdays: [1, 2, 3, 4, 5], start_hour: 10, end_hour: 24 },
++ { weekdays: [6, 7], start_hour: 0, end_hour: 24 },
++ ], 'the off-peak blocks are the hours the page names, and the weekend in full');
++ assert.deepStrictEqual(parsed.prices['deepseek-flash'], { in: 0.15, cached: 0.003, out: 0.6 }, 'the off-peak prices of the page');
++
++ const db = await testDb();
++ try {
++ const off = createOffPeak({ db: db.db });
++ for (const [field, price] of [['in_per_mtok', 0.15], ['out_per_mtok', 0.6], ['cached_per_mtok', 0.003]]) {
++ for (const block of parsed.blocks) await off.recordBlock({ provider_key: 'deepseek', model_key: 'deepseek-flash', field, price, weekdays: block.weekdays, start_hour: block.start_hour, end_hour: block.end_hour, source: 'https://api-docs.deepseek.com/quick_start/pricing.md', checked_at: '2026-11-07' });
++ }
++ const MONDAY = '2026-11-09'; // a Monday
++ const SATURDAY = '2026-11-14'; // a Saturday
++ const at = (day, hour) => `${day}T${String(hour).padStart(2, '0')}:30:00Z`;
++ // Peak: 01:00-04:00 and 06:00-10:00 UTC, Monday to Friday.
++ assert.strictEqual(await off.priceAt('deepseek', 'deepseek-flash', at(MONDAY, 2)), null, 'Monday 02:00 is peak');
++ assert.strictEqual(await off.priceAt('deepseek', 'deepseek-flash', at(MONDAY, 7)), null, 'Monday 07:00 is peak');
++ // Off-peak: every other hour of a weekday.
++ assert.ok(await off.priceAt('deepseek', 'deepseek-flash', at(MONDAY, 0)), 'Monday 00:30 is off-peak');
++ assert.ok(await off.priceAt('deepseek', 'deepseek-flash', at(MONDAY, 5)), 'Monday 05:30 is off-peak');
++ assert.ok(await off.priceAt('deepseek', 'deepseek-flash', at(MONDAY, 12)), 'Monday 12:30 is off-peak');
++ // The weekend is off-peak in full, peak hours included.
++ assert.ok(await off.priceAt('deepseek', 'deepseek-flash', at(SATURDAY, 2)), 'Saturday 02:30 is off-peak');
++ assert.ok(await off.priceAt('deepseek', 'deepseek-flash', at(SATURDAY, 7)), 'Saturday 07:30 is off-peak');
++ // The price inside the window is the provider's own off-peak price.
++ const price = await off.priceAt('deepseek', 'deepseek-flash', at(MONDAY, 12));
++ assert.strictEqual(price.input, 0.15);
++ assert.strictEqual(price.output, 0.6);
++ } finally { await db.close(); }
++});
++
++t.test('the old single-window rows are still read', async () => {
++ const db = await testDb();
++ try {
++ const off = createOffPeak({ db: db.db });
++ await off.record({ provider_key: 'deepseek', model_key: 'deepseek-flash', field: 'in_per_mtok', price: 0.15, start_hour: 23, end_hour: 7, source: 'old-window', checked_at: '2026-08-16' });
++ await off.record({ provider_key: 'deepseek', model_key: 'deepseek-flash', field: 'out_per_mtok', price: 0.6, start_hour: 23, end_hour: 7, source: 'old-window', checked_at: '2026-08-16' });
++ const windows = await off.windows();
++ const legacy = windows.find((w) => w.provider_key === 'deepseek' && w.model_key === 'deepseek-flash');
++ assert.ok(legacy, 'the old window is among the windows');
++ assert.deepStrictEqual(legacy.weekdays, [], 'the old window applies on every day');
++ // A window that wraps the midnight still works, and it still ends where it ends.
++ assert.ok(await off.priceAt('deepseek', 'deepseek-flash', '2026-11-09T23:30:00Z'), '23:30 is inside the old window');
++ assert.ok(await off.priceAt('deepseek', 'deepseek-flash', '2026-11-09T03:30:00Z'), '03:30 is inside the old window');
++ assert.strictEqual(await off.priceAt('deepseek', 'deepseek-flash', '2026-11-09T12:00:00Z'), null, '12:00 is outside the old window');
++ } finally { await db.close(); }
++});
++
++t.test('the price book job writes the blocks it read from the listing', async () => {
++ const h = await boot({ env: { DEEPSEEK_API_KEY: 'test-deepseek-key' } });
++ try {
++ const off = createOffPeak({ db: h.db });
++ const source = SOURCES.find((s) => s.key === 'deepseek');
++ const book = createPriceBook({
++ db: h.db, registry: h.registry, config: { priceBook: { enabled: true }, nodeEnv: 'test' },
++ clock: { now: () => Date.parse('2026-11-13T09:00:00Z') }, log: silent, sources: [source], offPeak: off,
++ transport: async () => ({ status: 200, headers: {}, body: fs.readFileSync(path.join(FIXTURES, 'deepseek.md'), 'utf8') }),
++ });
++ const report = await book.runOnce();
++ assert.ok((report.off_peak_blocks || []).length, 'the job wrote the blocks it read');
++ const blocks = await off.offPeakBlocks();
++ const flash = blocks.find((b) => b.model_key === 'deepseek-flash');
++ assert.ok(flash, 'the flash model has its blocks');
++ assert.deepStrictEqual(flash.weekdays, [1, 2, 3, 4, 5]);
++ assert.strictEqual(flash.input, 0.15, 'the off-peak price of the listing');
++ assert.ok(blocks.some((b) => b.model_key === 'deepseek-v4-pro'), 'every model of the listing has its blocks');
++ } finally { await h.stop(); }
++});
++
++// ── The workflows that wait ────────────────────────────────────────────────────
++
++const BACKGROUND = ['trade.summarize_market_context', 'live.rundown.topic', 'live.rundown.segment', 'live.rundown.episode',
++ 'live.stream.summarize', 'live.stream.describe_frame', 'live.moments.pick', 'live.moments.rank', 'live.moments.verify',
++ 'live.streamer.overview', 'live.person.overview', 'live.media.overview', 'live.viewers.plan', 'live.viewers.fold',
++ 'live.hero.slogans', 'live.home.star', 'live.easter_egg', 'live.paste.describe_image', 'live.paste.summarize_text',
++ 'blog.draft_post', 'blog.write_digest', 'blog.write_satire', 'news.summarize_story', 'food.suggest_recipes',
++ 'media.analyze', 'pics.describe_image'];
++const INTERACTIVE = ['chat.global', 'chat.member.reply', 'chat.member.turn', 'chat.profile', 'ai.chat', 'ai.complete',
++ 'ai.generate', 'live.translate', 'live.viewers.reply', 'live.stream.titles', 'live.stream.recap', 'live.clips.confirm',
++ 'games.generate_lore', 'space.app.ask', 'space.app.build', 'wiki.generate_page', 'news.compare_perspectives'];
++
++t.test('the workflows that wait in the background are the ones the plan names', async () => {
++ const h = await boot({ env: { AI_STUB_FALLBACK: 'false' } });
++ try {
++ const templates = new Map((await h.registry.listTemplates()).map((x) => [x.key, x]));
++ const classOf = async (wf) => {
++ const llm = (wf.steps || []).find((s) => s.kind === 'llm');
++ const tpl = llm ? templates.get(llm.template) : null;
++ const key = (wf.steps[0] && wf.steps[0].route) || (tpl && tpl.default_route) || wf.default_route || null;
++ if (!key) return { key: null, cls: 'interactive' };
++ const route = await h.registry.resolveRoute(key);
++ return { key, cls: route ? ((route.constraints || {}).latency_class || 'interactive') : 'interactive' };
++ };
++ for (const key of BACKGROUND) {
++ const wf = await h.registry.activeWorkflow(key);
++ assert.ok(wf, `${key} is a workflow`);
++ const { key: routeKey, cls } = await classOf(wf);
++ assert.strictEqual(cls, 'background', `${key} runs on ${routeKey}, which must be a background route`);
++ }
++ for (const key of INTERACTIVE) {
++ const wf = await h.registry.activeWorkflow(key);
++ assert.ok(wf, `${key} is a workflow`);
++ const { key: routeKey, cls } = await classOf(wf);
++ assert.strictEqual(cls, 'interactive', `${key} runs on ${routeKey}, which must stay interactive`);
++ }
++ // The modules say the same thing: a template on a background route belongs to a background workflow, and
++ // no other template moved.
++ const BACKGROUND_ROUTES = ['background.legacy', 'background.summary', 'background.vision', 'background.director', 'background.json', 'background.creative', 'media.local', 'media.paid'];
++ for (const mod of [require('../server/workflows/live'), require('../server/workflows/products'), require('../server/workflows/media')]) {
++ for (const tpl of mod.templates) {
++ const onBackground = BACKGROUND_ROUTES.includes(String(tpl.default_route || ''));
++ const users = mod.workflows.filter((w) => (w.steps || []).some((s) => s.template === tpl.key)).map((w) => w.key);
++ for (const wfKey of users) {
++ assert.strictEqual(BACKGROUND.includes(wfKey), onBackground,
++ `${wfKey} uses the template ${tpl.key} on ${tpl.default_route}`);
++ }
++ }
++ }
++ } finally { await h.stop(); }
++});
++
++t.run();
ddiff --git a/test/live-templates.test.js b/test/live-templates.test.js
iindex 5c29f37..b8401e9 100644
---- a/test/live-templates.test.js
++++ b/test/live-templates.test.js
@@@ -203,7 +203,7 @@ t.test("AI text may describe people: no looks ban in the public prose prompts",
t.test('The Rundown: the segment prompt lists the lines and known topics; its output is clamped, kept to the lines and stripped of empty segments', () => {
const tp = (k) => live.templates.find((x) => x.key === k);
const seg = tp('live.rundown.segment');
-- assert.strictEqual(seg.default_route, 'live.creative');
++ assert.strictEqual(seg.default_route, 'background.creative', 'BA1: nobody watches a rundown arrive, so it runs in the background');
assert.match(seg.system_prompt, /announcer or character lines/);
assert.match(seg.system_prompt, /never guess a pronoun/);
assert.match(seg.system_prompt, /never a sentence they read out from an article/, 'a line read aloud is not theirs');
ddiff --git a/test/versioning.test.js b/test/versioning.test.js
iindex 0da1d5d..86ff394 100644
---- a/test/versioning.test.js
++++ b/test/versioning.test.js
@@@ -67,8 +67,10 @@ t.test('route versions and disabling a route', async () => {
t.test('historical route keys are explicit aliases of default.json', async () => {
const r = await request(h.base, 'GET', '/api/v1/routes/news.summarize', { tok });
assert.strictEqual(r.body.route.alias_of, 'default.json');
++ // BA1: news.summarize_story runs in the background now, so the run records the background route. The
++ // historical key itself is still an explicit alias of default.json (the line above).
const run1 = await run({ workflow: 'news.summarize_story', input: { sources: [{ source_type: 'news.article', content: 'x' }] } });
-- assert.strictEqual(run1.body.run.route.key, 'default.json');
++ assert.strictEqual(run1.body.run.route.key, 'background.json');
});
t.test('seed: a code change becomes a new version, unless an admin versioned the key', async () => {