openvibers/openvibe.ai Commits
BA3: batch-class runs wait for a provider's own batch API (#363)
Changes
@@@ -84,7 +84,7 @@ function createBatcher({ db, runs, registry, credits = null, config = {}, clock// The batch service is attached after the batcher is built (the service needs the batcher too).const native = (config.batches && config.batches.native) || {};const settings = {-- enabled: true,++ enabled: !(config.batches && config.batches.enabled === false),// How many requests one provider batch carries (the provider's own limit is the ceiling; this is// the network's own, so a file is never bigger than it needs to be).maxItemsPerGroup: 1000,@@@ -196,6 +196,90 @@ function createBatcher({ db, runs, registry, credits = null, config = {}, clock};}++ /** What a restarted service needs of the run's context: scalars only, never a function or a token. */++ function ctxSnapshot(ctx) {++ const out = {};++ for (const k of ['appRun', 'projectId', 'sandbox', 'appId', 'onBehalfOf', 'personSubject', 'personPays', 'serviceBudgeted',++ 'freeUse', 'byok', 'maxCostNano', 'communityOptIn', 'contentPolicy', 'presetPolicy', 'presetName']) {++ if (ctx && ctx[k] !== undefined) out[k] = ctx[k];++ }++ return out;++ }++++ /**++ * BA3: where a batch-class run waits. The run's own workflow has one provider step (the engine built its++ * request); the route's offers are tried in order, and the first provider whose own batch API is enabled++ * and whose real batch price fits inside the half of the retail card the customer is charged takes it.++ * A payer whose Balance cannot cover the run is refused here, before the provider is given the work.++ *++ * → { provider, model, body, prompt, stepKind, operation, maxTokens, holdCard, data } or null (no++ * eligible provider: the run runs in the background class and says it was clamped).++ */++ async function planRun({ prepared = null, offers = [], principal = {}, ctx = {}, input = null, requestId = null, maxCostNano = null } = {}) {++ if (!settings.enabled || !prepared || typeof prepared.bodyFor !== 'function' || !prepared.route) return null;++ const capacity = require('./capacity'); // lazy: capacity requires this module for its cards++ for (const offer of offers || []) {++ if (!offer || !offer.provider || !offer.model) continue;++ const cfg = providerConfig(offer.provider);++ // Only a provider whose batch API is enabled and whose adapter speaks the OpenAI batch shape.++ if (!cfg || !cfg.enabled || cfg.adapter !== 'openai-compatible') continue;++ const provider = await registry.getProvider(offer.provider);++ if (!provider || provider.status !== 'active') continue;++ const cards = await cardsFor(offer.provider, offer.model);++ if (!cards || !cards.batch) continue;++ // The same money rule as capacity route (d): the provider's real batch price fits inside the++ // retail card the run is charged at.++ const charge = capacity.chargeOf(cards.row);++ if (!charge || !capacity.within(cards.batch.nano_usd_per_m, charge)) continue;++ let body;++ try { body = prepared.bodyFor(offer.model); } catch { continue; }++ if (!body || !Array.isArray(body.messages) || !body.messages.length) continue;++ body.model = offer.model;++ // BA2's probe: a real hold, released at once. A payer who cannot cover the run is refused now,++ // so the network never owes a provider for an answer nobody can pay for.++ if (credits && typeof credits.probe === 'function') {++ const verdict = await credits.probe({++ requestId: requestId || String(ctx.runId || ''),++ ctx: payerCtx(principal, { discount: 'half', maxCostNano, requestId }),++ input, card: cards.retail, provider: offer.provider, model: offer.model, access: cards.access,++ maxTokens: prepared.maxTokens,++ });++ if (!verdict.ok) throw new AiError(402, String(verdict.reason || 'billing.insufficient_funds'), String(verdict.detail || 'Not enough Balance'));++ }++ return {++ provider: offer.provider, model: offer.model, body, prompt: prepared.prompt || '',++ stepKind: prepared.stepKind, operation: prepared.operation, maxTokens: prepared.maxTokens,++ holdCard: cards.retail,++ data: {++ principal: planOf({ principal, input, card: cards.retail, maxTokens: prepared.maxTokens, discount: 'half', maxCostNano, requestId, servicePaid: Boolean(ctx.serviceBudgeted) }).principal,++ input: input || null, card: cards.retail, maxTokens: Number(prepared.maxTokens) || 4096,++ discount: 'half', maxCostNano: maxCostNano == null ? null : String(maxCostNano),++ requestId: requestId || null, route: null, charge: null,++ runLevel: true, stepKind: prepared.stepKind, ctx: ctxSnapshot(ctx),++ },++ };++ }++ return null;++ }++++ /**++ * BA3: put a run that already exists (created by runs.create) into the queue, with the deadline its++ * caller asked for. The run itself is never created here: the API saw it at once, and the answer is++ * written onto that same run when the provider answers.++ */++ async function enqueueRun({ runId, plan, deadlineHours = null } = {}) {++ if (!runId || !plan || !plan.body) throw new AiError(500, 'batch.no_run', 'a batch-class run needs its own run');++ const hours = Number(deadlineHours) > 0 ? Math.max(1, Math.min(72, Math.trunc(Number(deadlineHours)))) : Number(settings.deadlineHours) || 24;++ const deadline = new Date(clock.now() + hours * 3600 * 1000).toISOString();++ const id = `bni_${crypto.randomBytes(10).toString('hex')}`;++ const data = { ...(plan.data || {}), deadlineHours: hours };++ await db.prepare(`INSERT INTO batch_queue (id, run_id, kind, batch_id, idx, provider, model, prompt, request_body, hold_card, plan, status, deadline_at, created_at, updated_at)++ VALUES (?, ?, 'service', NULL, NULL, ?, ?, ?, ?, ?, ?, 'queued', ?, ?, ?)`)++ .run(id, runId, plan.provider, plan.model, plan.prompt || '', JSON.stringify(plan.body),++ plan.holdCard ? JSON.stringify(plan.holdCard) : null, JSON.stringify(data), deadline, iso(), iso());++ return { id, runId };++ }++/*** Put one item in the queue. The run is created at once (so the customer sees it, and its latency* class says 'batch'), but it waits for the provider instead of running.@@@ -244,8 +328,11 @@ function createBatcher({ db, runs, registry, credits = null, config = {}, clockif (running) return { skipped: 'running' };running = true;try {++ // BA3: an item whose deadline passed before the provider took it is never sent: it re-routes++ // to the ordinary (background) path now, exactly once.++ const rerouted = await reroutePending('the deadline passed before the provider took the batch');const groups = await db.prepare(`SELECT provider, model, COUNT(*) AS n, MIN(created_at) AS oldest-- FROM batch_queue WHERE status = 'queued' GROUP BY provider, model`).all();++ FROM batch_queue WHERE status = 'queued' AND deadline_at > ?::timestamptz GROUP BY provider, model`).all(iso());const sent = [];for (const group of groups || []) {const age = clock.now() - new Date(group.oldest).getTime();@@@ -253,7 +340,7 @@ function createBatcher({ db, runs, registry, credits = null, config = {}, clockconst job = await submitGroup(group.provider, group.model);if (job) sent.push(job);}-- return { sent: sent.length, jobs: sent };++ return { sent: sent.length, jobs: sent, ...(rerouted ? { rerouted } : {}) };} catch (err) {log.warn('[batch] the submitter stopped early: ' + String((err && err.message) || err));return { error: String((err && err.code) || 'error') };@@@ -265,7 +352,7 @@ function createBatcher({ db, runs, registry, credits = null, config = {}, clock/** Send one group to the provider's own batch API, as one file and one batch. */async function submitGroup(providerKey, modelKey) {const items = await db.prepare(`SELECT * FROM batch_queue WHERE provider = ? AND model = ? AND status = 'queued'-- ORDER BY created_at LIMIT ?`).all(providerKey, modelKey, Number(settings.maxItemsPerGroup));++ AND deadline_at > ?::timestamptz ORDER BY created_at LIMIT ?`).all(providerKey, modelKey, iso(), Number(settings.maxItemsPerGroup));if (!items || !items.length) return null;const adapter = await adapterFor(providerKey);const cfg = providerConfig(providerKey);@@@ -320,8 +407,10 @@ function createBatcher({ db, runs, registry, credits = null, config = {}, clockrunning = true;try {// An item that was being settled when the service stopped goes back into the queue: the-- // provider's answer is still there, and the run must not wait for ever.-- await db.prepare("UPDATE batch_queue SET status = 'submitted', updated_at = ? WHERE status = 'settling' AND updated_at <= ?")++ // provider's answer is still there, and the run must not wait for ever. An item with no++ // provider batch (a re-route that was interrupted) goes back to 'queued', so the deadline++ // is looked at again instead of waiting for a job that does not exist.++ await db.prepare("UPDATE batch_queue SET status = CASE WHEN job_id IS NULL THEN 'queued' ELSE 'submitted' END, updated_at = ? WHERE status = 'settling' AND updated_at <= ?").run(iso(), new Date(clock.now() - Math.max(1000, Number(settings.claimTtlMs) || 0)).toISOString());const job = await claimJob();if (!job) return { skipped: 'no batch' };@@@ -425,6 +514,9 @@ function createBatcher({ db, runs, registry, credits = null, config = {}, clockexplain: { objective: 'batch', reasons: [`answered by the provider's own batch API (${item.provider})`], selected: `batch:${item.provider}`, candidates: [], attempts: [] },serviceTier: 'batch',maxTokens: plan.maxTokens || 4096,++ // BA3: a run-level item resumes its own workflow step with this answer; a customer batch++ // item's run is the ai.complete run the batcher created, which the shortcut finishes.++ ...(plan.runLevel ? { resume: true, stepKind: plan.stepKind || null } : {}),}, { plan, costCheck: await checkFor(item, plan) });await db.prepare(`UPDATE batch_queue SET status = 'succeeded', answer = ?, usage = ?, cost_nano_usd = ?, updated_at = ? WHERE id = ?`).run(String((res.output && res.output.text) || '').slice(0, 20000), JSON.stringify(usage), String(nano), iso(), item.id);@@@ -440,19 +532,40 @@ function createBatcher({ db, runs, registry, credits = null, config = {}, clock/*** The deadline passed and the provider cannot meet it: the item goes back to the ordinary path. The-- * run answers through the pool as any other run does, and the item says it was re-routed.++ * run answers through the pool as any other run does, and the item says it was re-routed. A run-level++ * run (BA3) re-routes into the background class, exactly once: the item is then no longer queued, and++ * the run itself never asks for a batch again.*/++ async function rerouteItem(item, reason, from = 'submitted') {++ const claimed = await db.prepare(`UPDATE batch_queue SET status = 'settling', updated_at = ? WHERE id = ? AND status = ? RETURNING id`)++ .get(iso(), item.id, from).catch(() => null);++ if (!claimed) return false;++ const plan = item.plan ? JSON.parse(item.plan) : {};++ if (plan.runLevel && item.run_id) {++ const row = await db.prepare('SELECT options FROM runs WHERE id = ?').get(item.run_id).catch(() => null);++ const opts = row && row.options ? (JSON.parse(row.options) || {}) : {};++ opts.latency_clamped = { asked: 'batch', used: 'background' };++ await db.prepare("UPDATE runs SET latency_class = 'background', options = ? WHERE id = ?")++ .run(JSON.stringify(opts), item.run_id).catch(() => null);++ }++ await runs.completeBatch(item.run_id, { reroute: true }, { plan });++ await db.prepare(`UPDATE batch_queue SET status = 'rerouted', error_code = 'batch.rerouted', error_detail = ?, updated_at = ? WHERE id = ?`)++ .run(String(reason || '').slice(0, 300), iso(), item.id);++ await writeBack(item);++ return true;++ }++++ /** The items whose deadline passed before the provider ever took them. */++ async function reroutePending(reason) {++ const rows = await db.prepare(`SELECT * FROM batch_queue WHERE status = 'queued' AND deadline_at <= ?::timestamptz`).all(iso()).catch(() => []);++ let n = 0;++ for (const item of rows || []) if (await rerouteItem(item, reason, 'queued')) n++;++ return n;++ }++async function rerouteJob(job, reason) {const items = await db.prepare(`SELECT * FROM batch_queue WHERE job_id = ? AND status = 'submitted'`).all(job.id);-- for (const item of items || []) {-- const claimed = await db.prepare(`UPDATE batch_queue SET status = 'settling', updated_at = ? WHERE id = ? AND status = 'submitted' RETURNING id`)-- .get(iso(), item.id).catch(() => null);-- if (!claimed) continue;-- await runs.completeBatch(item.run_id, { reroute: true }, { plan: item.plan ? JSON.parse(item.plan) : {} });-- await db.prepare(`UPDATE batch_queue SET status = 'rerouted', error_code = 'batch.rerouted', error_detail = ?, updated_at = ? WHERE id = ?`)-- .run(String(reason || '').slice(0, 300), iso(), item.id);-- await writeBack(item);-- }++ for (const item of items || []) await rerouteItem(item, reason, 'submitted');await db.prepare(`UPDATE provider_batches SET status = 'expired', error_code = 'batch.rerouted', error_detail = ?, finished_at = ?, updated_at = ?, claimed_until = NULL, claimed_by = NULL WHERE id = ?`).run(String(reason || '').slice(0, 300), iso(), iso(), job.id);}@@@ -510,7 +623,7 @@ function createBatcher({ db, runs, registry, credits = null, config = {}, clock/** The batch service, so that a customer's batch is closed when its items are done. */function attach(theService) { service = theService; return api; }-- const api = { enqueue, submitOnce, pollOnce, runOnce, start, stop, settings, cardsFor, scaleCard, attach };++ const api = { enqueue, enqueueRun, planRun, submitOnce, pollOnce, runOnce, start, stop, settings, cardsFor, scaleCard, attach };return api;}@@@ -195,7 +195,9 @@ function createOpenAiCompatibleAdapter({ baseUrl, apiKey, fetchImpl = globalThisout.push({custom_id: row.custom_id,ok: true,-- output: { text },++ // The raw answer's choices, so a complete run (ai.complete) keeps its tool calls and finish++ // reason too; the text is what a chat/generate step shapes.++ output: { text, completion: { choices: body.choices || [], system_fingerprint: body.system_fingerprint || null } },usage: {input: Number(usage.prompt_tokens || 0),output: Number(usage.completion_tokens || 0),@@@ -144,7 +144,10 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now// a GPU key and a monthly cap AND Balance billing is on; the public status says which one is missing.const ownEndpoints = require('./gpu/endpoints').createOwnEndpoints({ db, gpus, credits: creditsRef, config, clock, fetchImpl, log });gpus.attachEndpoints(ownEndpoints);-- const runs = createRuns({ db, registry, engine, cache, quotas, config, clock, log, userModules, credentials, preferences, tiers, projectSecrets, pool, runBudgets, credits: creditsRef, community, presets, receipts, abuse, webhooks,++ // BA3: the runs and the batcher need each other (a batch-class run is created first, then queued), so++ // the runs hold this facade and the batcher fills it in below, before any request can arrive.++ const batchRef = {};++ const runs = createRuns({ db, registry, engine, cache, quotas, config, clock, log, userModules, credentials, preferences, tiers, projectSecrets, pool, runBudgets, credits: creditsRef, community, presets, receipts, abuse, webhooks, batch: batchRef,// BA2: a run that waits for a provider's batch answer is not interrupted by a restart: it stays// queued until the batcher finishes it (the provider is still working on it).batchWaiting: async (runId) => {@@@ -185,6 +188,9 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.nowconst refusal = require('./refusal/job').createRefusalJob({ db, registry, pool, config, env, clock, log });// The native batcher (BA2): the providers' own batch APIs, behind one queue.const batcher = require('./batch/batcher').createBatcher({ db, runs, registry, credits: creditsRef, config, clock, log, env, fetchImpl });++ // BA3: a batch-class run asks the batcher where it may wait, and hands its own run to the queue.++ batchRef.planRun = batcher.planRun;++ batchRef.enqueueRun = batcher.enqueueRun;// The half-price batches (server/batch/): many prompts, answered later, billed through Billing.const batches = require('./batch/service').createBatchService({ db, runs, registry, community, priceBook: offPeak, batcher, config, clock, log });batcher.attach(batches);@@@ -63,7 +63,7 @@ const runBody = (required = []) => ({workflow: str({ description: 'The workflow key, e.g. ai.generate' }), version: integer({ minimum: 1 }),input: { type: 'object' }, idempotency_key: str({ minLength: 8, maxLength: 128 }),target: { type: 'object' }, attribution: { type: 'object' }, on_behalf_of: ref('SubjectRef'),-- options: { type: 'object' }, route: { type: 'object', description: '{ provider, model } — one offer from GET /api/v1/routes?operation=…' },++ options: { type: 'object', description: 'cache, debug, and the latency class: latency (interactive | background | batch); a batch-class run also takes deadline_hours (1-72, the default is 24)' }, route: { type: 'object', description: '{ provider, model } — one offer from GET /api/v1/routes?operation=…' },},});const directBody = {@@@ -708,6 +708,9 @@ function openapi() {source_service: nullable('string'), target: nullable('object'), input: nullable('object'), output: nullable('object'),error: nullable('object', { properties: { code: str(), detail: str() } }), synthetic: { type: 'boolean' },provenance: { type: 'object' },++ latency_class: { type: 'string', enum: ['interactive', 'background', 'batch'] },++ service_tier: nullable('string', { description: 'the tier the answer really ran on: standard, flex, batch, offpeak or local' }),++ latency_clamped: nullable('object', { description: '{ asked, used }: the class the caller asked for and the class the run really took' }),usage: { type: 'object', properties: { tokens_in: nullable('integer'), tokens_out: nullable('integer'), cost_usd: nullable('number'), attempts: nullable('integer'), usage_sample_ids: nullable(arrayOf(str())) } },explain: nullable('object'), citations_count: nullable('integer'), grounding: nullable('object'),retry_of: nullable('string'), idempotency_key: nullable('string'), trace_id: nullable('string'),@@@ -57,6 +57,29 @@ function buildBody({ model, system, messages, image, json, maxTokens, temperaturreturn body;}++/**++ * The Chat Completions body of a complete operation (ai.complete), for a provider batch request too:++ * the caller's own request with the routed model, a reasoning model's allowance, and the Flex tier.++ */++function buildCompleteBody({ model, openai = {}, maxTokens, temperature, serviceTier } = {}) {++ const o = openai || {};++ const body = { model, messages: o.messages };++ for (const k of ['tools', 'tool_choice', 'parallel_tool_calls', 'response_format', 'stop', 'seed', 'top_p', 'presence_penalty', 'frequency_penalty']) {++ if (o[k] !== undefined && o[k] !== null) body[k] = o[k];++ }++ if (isReasoningModel(model)) {++ // max_tokens is the answer the caller wants; a reasoning model thinks first, out of the same allowance.++ if (maxTokens) body.max_completion_tokens = Math.max(maxTokens, 256) + 1024;++ body.reasoning_effort = 'low';++ for (const k of ['top_p', 'presence_penalty', 'frequency_penalty']) delete body[k];++ } else {++ if (maxTokens) body.max_tokens = maxTokens;++ if (temperature != null) body.temperature = temperature;++ }++ if (serviceTier) body.service_tier = String(serviceTier);++ return body;++}++function createOpenAiProvider(record, { apiKey = '', fetchImpl = globalThis.fetch } = {}) {const baseUrl = String(record.base_url || DEFAULT_BASE_URL).replace(/\/+$/, '');// A local llama.cpp process serves one model, so the embedding endpoint may live on another local@@@ -146,23 +169,7 @@ function createOpenAiProvider(record, { apiKey = '', fetchImpl = globalThis.fetc*/async function complete(req) {const o = req.openai || {};-- const body = { model: req.model, messages: o.messages };-- for (const k of ['tools', 'tool_choice', 'parallel_tool_calls', 'response_format', 'stop', 'seed', 'top_p', 'presence_penalty', 'frequency_penalty']) {-- if (o[k] !== undefined && o[k] !== null) body[k] = o[k];-- }-- if (isReasoningModel(req.model)) {-- // max_tokens is the answer the caller wants; a reasoning model thinks first, out of the same allowance. Without-- // room for that, and with its default (medium) effort, a long tool result could use it all up thinking and-- // answer nothing: Chat's first production inbox action (2026-10-25) ended "finish_reason: length", empty.-- if (req.maxTokens) body.max_completion_tokens = Math.max(req.maxTokens, 256) + 1024;-- body.reasoning_effort = 'low';-- for (const k of ['top_p', 'presence_penalty', 'frequency_penalty']) delete body[k];-- } else {-- 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);++ const body = buildCompleteBody({ model: req.model, openai: o, maxTokens: req.maxTokens, temperature: req.temperature, serviceTier: 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' });@@@ -250,4 +257,4 @@ function createOpenAiProvider(record, { apiKey = '', fetchImpl = globalThis.fetcreturn adapter;}--module.exports = { createOpenAiProvider, buildBody, DEFAULT_BASE_URL };++module.exports = { createOpenAiProvider, buildBody, buildCompleteBody, DEFAULT_BASE_URL };@@@ -35,6 +35,18 @@ const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'cached']);const QUEUE_RETRY_AFTER_S = 5;const newRunId = () => `run_${ids.ulid()}`;++/**++ * BA3: how long a batch-class run may wait for a provider's own batch API, in hours (1 to 72). The++ * caller names it in options.deadline_hours; the default is the batcher's own 24 hours.++ */++function batchDeadlineHours(value) {++ if (value == null || value === '') return 24;++ const n = Number(value);++ if (!Number.isFinite(n)) throw new AiError(422, 'input.invalid', 'deadline_hours must be a number of hours');++ if (n < 1 || n > 72) throw new AiError(422, 'input.invalid', 'deadline_hours is between 1 and 72');++ return Math.trunc(n);++}++function entityKey(ref) { return ref ? `${ref.service}:${ref.type}:${ref.id}` : null; }function subjectKey(ref) { return ref ? `${ref.type}:${ref.id}` : null; }@@@ -107,7 +119,7 @@ function routePinRequest(value, principal, wf) {return { provider, model };}--function createRuns({ db, registry, engine, cache, quotas, config, clock = { now: () => Date.now() }, log = console, userModules = null, credentials = null, preferences: personPreferences = null, tiers = null, projectSecrets = null, pool = null, runBudgets = null, credits = null, community = null, presets = null, receipts = null, abuse = null, webhooks = null, batchWaiting = null }) {++function createRuns({ db, registry, engine, cache, quotas, config, clock = { now: () => Date.now() }, log = console, userModules = null, credentials = null, preferences: personPreferences = null, tiers = null, projectSecrets = null, pool = null, runBudgets = null, credits = null, community = null, presets = null, receipts = null, abuse = null, webhooks = null, batchWaiting = null, batch = null }) {const inflight = new Map(); // run id -> { controller, promise, release, caller }// BA2: the runs that wait for a provider's own batch API: they are created at once and finish later.const deferred = new Map(); // run id -> { controller, scope, ctx, input, hold, resolveDone }@@@ -268,7 +280,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { nowconst 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;++ let latencyClass = resolvedLatency.class; // BA3 may move a batch-class run to the background 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@@@ -475,6 +487,51 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now}}++ // BA3: a run whose resolved class is 'batch' waits for a provider's own batch API whenever the++ // route has one whose real batch price fits the half-price card (the same money rule as capacity++ // route (d)). The run is created at once; the provider's answer finishes it on the ordinary path.++ // When no provider on the route can batch, the run runs in the background class at once and says so.++ let autoBatch = null;++ let batchDeadline = null;++ if (latencyClass === 'batch' && !(principal && principal.deferBatch)) {++ batchDeadline = batchDeadlineHours(options.deadline_hours);++ // A run paid by somebody else's key or by a free allowance is not put on the network's batch++ // provider; a free-only app run cannot be placed on paid capacity either.++ const appFreeOnly = Boolean(ctx.appRun && (ctx.sandbox || !(tiers && tiers.enabled)));++ let prepared = null;++ if (batch && typeof batch.planRun === 'function' && engine.prepareBatch++ && credentialSubject === null && !projectProvider && !selfKeys && !freeUse && !communityOptIn && !appFreeOnly) {++ try {++ prepared = await engine.prepareBatch(wf, { template_key: tpl ? tpl.key : null, template_version: tpl ? tpl.version : null }, input, { ...ctx, runId: id });++ } catch (err) {++ log.warn(`[runs] ${id}: the batch request could not be prepared: ${(err && err.message) || err}`);++ }++ }++ if (prepared && pool && typeof pool.offers === 'function') {++ let offers = await pool.offers(prepared.route, prepared.operation, ctx).catch(() => []);++ // A pinned run waits on the provider it pinned, never on another eligible one.++ if (pinned) offers = offers.filter((o) => o.provider === pinned.provider && (pinned.model == null || o.model === pinned.model));++ if (offers.length) autoBatch = await batch.planRun({ prepared, offers, principal, ctx, input, requestId: requestId || id, maxCostNano });++ }++ if (autoBatch) {++ ctx.discount = 'half';++ ctx.latencyClass = 'batch';++ options.deadline_hours = batchDeadline;++ options.batch = { provider: autoBatch.provider, model: autoBatch.model, deadline_hours: batchDeadline };++ } else {++ latencyClass = 'background';++ ctx.latencyClass = 'background';++ delete ctx.discount;++ options.latency_clamped = { asked: 'batch', used: 'background' };++ }++ base.latency_class = latencyClass;++ base.options = JSON.stringify({++ ...parseJson(base.options, {}),++ ...(options.latency_clamped ? { latency_clamped: options.latency_clamped } : {}),++ ...(autoBatch ? { deadline_hours: batchDeadline, batch: { provider: autoBatch.provider, model: autoBatch.model } } : {}),++ });++ }++// Queue caps, then quota: both before any provider call (a refused run is not counted).const caller = `${requester.type}:${requester.id}`;// Kudos bought with volunteer service start this run earlier in the queue (never cash, never a bypass of limits).@@@ -487,8 +544,8 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { nowconst hold = credentialSubject !== null || projectProvider || selfKeys || !tiers ? null : await tiers.admit(ctx, id);let reserved;try {-- // BA2: a batch run reserves its quota when the answer is here, not while it waits.-- reserved = credentialSubject !== null || selfKeys || (principal && principal.deferBatch) ? null : await quotas.reserve(ctx);++ // BA2/BA3: a batch run reserves its quota when the answer is here, not while it waits.++ reserved = credentialSubject !== null || selfKeys || (principal && principal.deferBatch) || autoBatch ? null : await quotas.reserve(ctx);} catch (err) { if (hold) await tiers.release(hold); throw err; }try {@@@ -511,6 +568,46 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now}}++ // BA3: the run waits for the provider's own batch API. It is created now (its class says 'batch')++ // and takes no execution slot and no quota until the answer is here; the batcher then finishes it++ // on the ordinary path, with the ordinary settle, receipt and events. A caller that waits (?wait=)++ // is given the queued run at once (202 from the API), never held for hours.++ if (autoBatch) {++ const controller = new AbortController();++ let resolveDone;++ const done = new Promise((r) => { resolveDone = r; });++ try {++ deferred.set(id, { controller, scope, ctx, input, hold, resolveDone, runLevel: true });++ inflight.set(id, { controller, promise: done, release: () => {}, caller, priority });++ await batch.enqueueRun({ runId: id, plan: autoBatch, deadlineHours: batchDeadline });++ return { run: await get(id), created: true, promise: null };++ } catch (err) {++ // The queue could not take it (the provider's batch table is unreachable, say): run it in++ // the background class now rather than leave it queued for ever.++ deferred.delete(id);++ inflight.delete(id);++ log.warn(`[runs] ${id}: the provider's batch queue did not take the run (${(err && err.message) || err}); it runs in the background class`);++ latencyClass = 'background';++ ctx.latencyClass = 'background';++ delete ctx.discount;++ const opts = { ...parseJson(base.options, {}), latency_clamped: { asked: 'batch', used: 'background' } };++ delete opts.deadline_hours;++ delete opts.batch;++ base.latency_class = 'background';++ base.options = JSON.stringify(opts);++ await db.prepare("UPDATE runs SET latency_class = 'background', options = ? WHERE id = ?").run(base.options, id);++ try {++ reserved = credentialSubject !== null || selfKeys ? null : await quotas.reserve(ctx);++ } catch (qerr) {++ await db.prepare("UPDATE runs SET status = 'failed', error_code = ?, error_detail = ?, finished_at = ? WHERE id = ?")++ .run(qerr.code || 'quota.refused', String(qerr.detail || qerr.message || 'quota refused').slice(0, 500), iso(clock.now()), id);++ await events.runChanged(await getRow(id));++ if (hold) await tiers.release(hold);++ throw qerr;++ }++ }++ }++// BA2: a batch run waits for the provider's own batch API. It is created now (the customer sees// it, and its latency class says 'batch'), but it takes no execution slot and no quota until the// answer is here; then it finishes on the ordinary path, with the ordinary settle and receipt.@@@ -839,7 +936,9 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { nowawait credit.holdRoute({ card: batchAnswer.holdRetail || null, provider: batchAnswer.provider || null, model: batchAnswer.model || null, access: batchAnswer.access || null, maxTokens: batchAnswer.maxTokens || 4096 });}let r = null;-- if (batchAnswer && batchAnswer.ok) {++ // BA3: a run-level answer resumes the workflow's own step (engine.execute reads it); a customer++ // batch item's run is the ai.complete run the batcher created, finished by this shortcut.++ if (batchAnswer && batchAnswer.ok && !batchAnswer.resume) {r = { output: batchAnswer.output || { text: '' }, usage: batchAnswer.usage || { input: 0, output: 0, cached: 0, cache_write: 0 }, cost: Number(batchAnswer.cost) || 0, provider: batchAnswer.provider || null, model: batchAnswer.model || null, citations: [], explain: batchAnswer.explain || null, grounding: null, synthetic: false, fallbackUsed: false };}if (!r) r = await engine.execute(wf, row, input, { signal: controller.signal, logRequest, debugRaw: Boolean(config.debugRawLog && opts.debug), inputHash: row.input_hash, preferences,@@@ -872,6 +971,8 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now...(credential ? { credential, role: input && typeof input.role === 'string' ? input.role : null } : {}),...(billingDelta ? { onDelta: billingDelta } : {}),...(onRoute ? { onRoute } : {}),++ // BA3: a run-level batch answer resumes the workflow's own step.++ ...(batchAnswer ? { batchAnswer } : {}),// 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 } : {}) });@@@ -1010,6 +1111,10 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { nowif (TERMINAL.has(row.status)) throw new AiError(409, 'run.terminal', `run is already ${row.status}`);const h = inflight.get(id);const r = await db.prepare("UPDATE runs SET status = 'cancelled', error_code = 'run.cancelled', error_detail = 'cancelled by caller', finished_at = ? WHERE id = ? AND status IN ('queued', 'running')").run(iso(clock.now()), id);++ // BA3: a run that has not been handed to a provider yet leaves its batch queue, so the provider is++ // never given work for a run nobody will read; a submitted one finishes and is charged as it must.++ await db.prepare("DELETE FROM batch_queue WHERE run_id = ? AND status = 'queued'").run(id).catch(() => null);++ deferred.delete(id);if (h) {h.controller.abort();const i = queue.indexOf(id);@@@ -1148,7 +1253,15 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { nowconst d = deferred.get(key);if (d) {deferred.delete(key);-- const ctx = { ...d.ctx, batchAnswer: Promise.resolve(answer), ...(costCheck ? { costCheck } : {}) };++ let baseCtx = d.ctx;++ if (answer && answer.reroute) {++ // The deadline passed: the batcher moved the run to the background class before this call.++ const row = await getRow(key);++ baseCtx = { ...baseCtx, latencyClass: (row && row.latency_class) || 'background' };++ // A run-level run re-routed to the background path is charged at the ordinary price, not half.++ if (d.runLevel) { const rest = { ...baseCtx }; delete rest.discount; baseCtx = rest; }++ }++ const ctx = { ...baseCtx, batchAnswer: Promise.resolve(answer), ...(costCheck ? { costCheck } : {}) };try {const reserved = await quotas.reserve(ctx).catch(() => null);await execute(key, { controller: d.controller, reserved, scope: d.scope, ctx, input: d.input, hold: d.hold });@@@ -1163,7 +1276,8 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { nowconst row = await getRow(key);if (!row) return null;if (TERMINAL.has(row.status)) return null; // it already finished, or it was cancelled-- const data = plan || {};++ // A run-level run re-routed to the background path is charged at the ordinary price, not half.++ const data = answer && answer.reroute && plan && plan.runLevel ? { ...plan, discount: null } : (plan || {});// The answer is the provider's own batch answer, exactly as in the fast path.const ctx = { ...(await rebuildCtx(row, data, costCheck)), batchAnswer: Promise.resolve(answer) };const input = data.input && typeof data.input === 'object' ? data.input : parseJson(row.input, {});@@@ -1192,6 +1306,9 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { nowconst attribution = parseJson(row.attribution, null);const onBehalfOf = parseJson(row.on_behalf_of, null);const principal = data.principal || {};++ // BA3: a run-level batch stores the scalars its own completion needs (the app, person and policy++ // flags); a customer batch item has none and is rebuilt from its principal as before.++ const saved = data.ctx && typeof data.ctx === 'object' ? data.ctx : {};// The person the run was made for: the row names them, and the options say whether they pay.const personSubject = opts.person_pays !== undefined ? (onBehalfOf && onBehalfOf.id ? String(onBehalfOf.id) : null) : null;const tokenBudget = runBudgets && principal.jti ? await runBudgets.spent(principal.jti).catch(() => null) : null;@@@ -1199,6 +1316,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now|| Boolean(tokenBudget && (tokenBudget.budget_usd != null || tokenBudget.budget_tokens != null));const isApp = principal.kind === 'app';return {++ ...saved,requesterType: row.requester_type,requesterId: row.requester_id,...(principal.jti ? { runJti: principal.jti } : {}),@@@ -1211,7 +1329,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { nowpersonSubject,personPays: Boolean(opts.person_pays),serviceBudgeted,-- freeUse: false, // a batch run is never the anonymous Try it++ freeUse: saved.freeUse === true, // a customer batch run is never the anonymous Try it...(data.discount ? { discount: String(data.discount) } : {}),...(data.maxCostNano != null ? { maxCostNano: String(data.maxCostNano) } : {}),...(costCheck ? { costCheck } : {}),@@@ -34,6 +34,16 @@ const { preferenceLines } = require('../user-modules');const schemas = require('../schemas');const { PREPARE, POSTPROCESS } = require('./hooks');const modelCatalog = require('../providers/model-catalog');++const { buildBody, buildCompleteBody } = require('../providers/openai');++++/** The last user message's text, for the queue's own record of what one item asks (never sent as-is). */++function lastUserText(messages) {++ for (let i = (messages || []).length - 1; i >= 0; i--) {++ const m = messages[i];++ if (m && m.role === 'user' && typeof m.content === 'string') return m.content.slice(0, 20000);++ }++ return '';++}const ROLE_TIMEOUT = { chat: 20000, vision: 30000, director: 25000, summary: 30000, legacy: 30000 };const INLINE_AUDIO_MAX = 1024 * 1024;@@@ -120,6 +130,28 @@ function normMessages(messages, user) {return out;}++/**++ * BA3: the answer of a provider's own batch API, when it is resuming the step that asked for it. The++ * batcher says `resume: true` and which kind of step the request was; the step then shapes the stored++ * answer exactly as it shapes a live one.++ */++function batchAnswerFor(ctx, stepKind) {++ const b = ctx && ctx.batchAnswer;++ return b && b.ok === true && b.resume === true && b.stepKind === stepKind ? b : null;++}++++/** The exec shape the steps use, built from a batch answer (no provider call, the batch cost). */++function batchExec(b, result) {++ const usage = b.usage || {};++ return {++ result,++ provider: b.provider || null, model: b.model || null,++ usage: { input: Number(usage.input || 0), output: Number(usage.output || 0), cached: Number(usage.cached || 0) },++ cost: Number(b.cost) || 0, serviceTier: 'batch', savedUsd: 0,++ explain: b.explain || null, synthetic: false, fallbackUsed: false,++ };++}++function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spawnImpl, log = console, research = null }) {// One loopback reader for everything that streams media (server/media-proxy.js): transcripts and media.analyze.let proxy = null;@@@ -134,7 +166,13 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spareturn r;}-- async function llmStep(step, input, wf, run, ctx) {++ /**++ * Everything an llm step needs before its provider call: the template it pinned, the rendered system and++ * user prompts, the route, the optional image and the request the pool would send. BA3 splits this out of++ * llmStep so the same request can be sent to a provider's own batch API (prepareBatch) and so a batch++ * answer can finish the step on the ordinary path (batchAnswer) without calling a provider again.++ */++ async function prepareLlm(step, input, wf, run, ctx) {// The run pinned its template version when it was created; later steps use the active one.const template = run.template_key === step.template && run.template_version? await registry.getTemplate(step.template, run.template_version)@@@ -174,7 +212,19 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spatimeoutMs: p.timeout_ms || route.timeout_ms || 30000,cacheKey: `${wf.key}:${wf.version}`,};-- const exec = await pool.execute(route, step.operation || 'chat', req, { ...ctx, routeKey: route.key, routeVersion: route.version, promptHash: sha256({ s: system, u: user, i: image ? image.base64.length : 0 }) });++ return { template, prep, vars, system, user, route, jsonSchema, image, req };++ }++++ async function llmStep(step, input, wf, run, ctx) {++ const p = await prepareLlm(step, input, wf, run, ctx);++ if (p.output !== undefined) return { output: p.output, noProvider: true, template: p.template };++ const { template, system, user, route, jsonSchema, image, req } = p;++ // BA3: the answer came from the provider's own batch API. The step shapes it as it shapes a live++ // answer (JSON parse, postprocess, citations); no provider is called again.++ const batch = batchAnswerFor(ctx, 'llm');++ const exec = batch++ ? batchExec(batch, { text: (batch.output && batch.output.text) || '' })++ : await pool.execute(route, step.operation || 'chat', req, { ...ctx, routeKey: route.key, routeVersion: route.version, promptHash: sha256({ s: system, u: user, i: image ? image.base64.length : 0 }) });// synthetic: the stub answered (no provider configured, or a test); a postprocess that checks an answer's// substance (a space needs a real index.html) lets a synthetic answer through in the right shape.const r = { text: exec.result.text || '', json: exec.result.json || null, synthetic: Boolean(exec.synthetic) };@@@ -210,7 +260,10 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spacacheKey: input.cache_key || null,prefer: ctx.prefer || null,};-- const exec = await pool.execute(route, 'chat', req, { ...ctx, routeKey: route.key, routeVersion: route.version, promptHash: sha256({ s: sys, m: messages }) });++ const batch = batchAnswerFor(ctx, 'passthrough');++ const exec = batch++ ? batchExec(batch, { text: (batch.output && batch.output.text) || '' })++ : await pool.execute(route, 'chat', req, { ...ctx, routeKey: route.key, routeVersion: route.version, promptHash: sha256({ s: sys, m: messages }) });let json = exec.result.json || null;if (input.json && !json) json = parseJsonLoose(exec.result.text);return { output: { text: exec.result.text || '', json }, exec, route };@@@ -234,6 +287,59 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa: { policy: selection.policy, aliasPolicy: selection.policy };}++ /**++ * BA3: the provider request of a run that may wait for a provider's own batch. Only a workflow with a++ * single provider step can be batched: a batch API answers one request, not a workflow's later steps.++ * The body is built for the model the run is placed on (the batcher knows it after the money check).++ *++ * → { stepKind, operation, route, maxTokens, prompt, bodyFor(model) } or null.++ */++ async function prepareBatch(wf, run, input, ctx = {}) {++ if (!wf || !Array.isArray(wf.steps) || wf.steps.length !== 1) return null;++ const step = wf.steps[0];++ if (step.kind === 'llm') {++ // An image cannot travel in a provider batch request here: the answer would need the media file.++ if (step.image && input && input[step.image]) return null;++ const p = await prepareLlm(step, input, wf, run, ctx);++ if (!p || p.output !== undefined || p.image) return null;++ const bodyFor = (model) => buildBody({++ model, system: p.req.system, messages: p.req.messages, image: null,++ json: p.req.json, maxTokens: p.req.maxTokens, temperature: p.req.temperature,++ cacheKey: null, baseUrl: '', serviceTier: null,++ }, 'schema');++ return { stepKind: 'llm', operation: step.operation || 'chat', route: p.route, maxTokens: p.req.maxTokens, bodyFor, prompt: lastUserText(p.req.messages) };++ }++ if (step.kind === 'complete') {++ const { route } = await selectedRoute(await routeFor(step.route || 'default.chat'), input);++ const openai = {};++ for (const k of ['messages', 'tools', 'tool_choice', 'parallel_tool_calls', 'response_format', 'stop', 'seed', 'top_p', 'presence_penalty', 'frequency_penalty']) {++ if (input[k] !== undefined) openai[k] = input[k];++ }++ if (!Array.isArray(openai.messages) || !openai.messages.length) return null;++ const maxTokens = Math.max(1, Math.round(input.max_tokens || route.max_output_tokens || 1024));++ const temperature = input.temperature == null ? null : input.temperature;++ return {++ stepKind: 'complete', operation: 'complete', route, maxTokens, prompt: lastUserText(openai.messages),++ bodyFor: (model) => buildCompleteBody({ model, openai, maxTokens, temperature }),++ };++ }++ if (step.kind === 'passthrough') {++ if (input.image) return null;++ const route = await routeFor(step.route || `${step.route_prefix || 'live'}.${input.role || 'legacy'}`);++ const sys = typeof input.system === 'string' ? (input.system.trim() ? [{ text: input.system, cache: false }] : [])++ : Array.isArray(input.system) ? input.system.filter(x => x && String(x.text || '').trim()).map(x => ({ text: String(x.text), cache: Boolean(x.cache) })) : [];++ const messages = normMessages(input.messages, input.user);++ const json = input.json ? { name: input.json.name || 'result', schema: input.json.schema, strict: input.json.strict !== false, description: input.json.description } : null;++ const maxTokens = Math.max(1, Math.round(input.max_tokens || 400));++ const temperature = input.temperature == null ? null : input.temperature;++ return {++ stepKind: 'passthrough', operation: 'chat', route, maxTokens, prompt: lastUserText(messages),++ bodyFor: (model) => buildBody({ model, system: sys, messages, image: null, json, maxTokens, temperature, cacheKey: null, baseUrl: '', serviceTier: null }, 'schema'),++ };++ }++ return null;++ }++/** complete: the OpenAI Chat Completions request as the caller wrote it (ai.complete), to an upstream that speaks it. */async function completeStep(step, input, wf, run, ctx) {const { route, selection } = await selectedRoute(await routeFor(step.route || 'default.chat'), input);@@@ -249,7 +355,10 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spaprefer: ctx.prefer || null,...(typeof ctx.onDelta === 'function' ? { onDelta: ctx.onDelta } : {}),};-- const exec = await pool.execute(route, 'complete', req, { ...ctx, ...selectionCtx(selection), routeKey: route.key, routeVersion: route.version, promptHash: sha256({ m: openai.messages, t: openai.tools || null }) });++ const batch = batchAnswerFor(ctx, 'complete');++ const exec = batch++ ? batchExec(batch, { completion: (batch.output && batch.output.completion) || null })++ : await pool.execute(route, 'complete', req, { ...ctx, ...selectionCtx(selection), routeKey: route.key, routeVersion: route.version, promptHash: sha256({ m: openai.messages, t: openai.tools || null }) });const c = exec.result.completion;if (!c || !Array.isArray(c.choices) || !c.choices.length) throw new AiError(502, 'output.empty', 'the provider answered, but with no choices');return { output: { choices: c.choices, system_fingerprint: c.system_fingerprint || null, model: exec.model }, exec, route };@@@ -396,7 +505,7 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa};}-- return { execute, narrowCitations, research: () => megasource() };++ return { execute, prepareBatch, narrowCitations, research: () => megasource() };}module.exports = { createEngine, narrowCitations, collectCited, collectGaps, groundingOf };nnew file mode 100644@@@ -0,0 +1,319 @@++'use strict';++/**++ * BA3: a run whose latency class is 'batch' waits for a provider's own batch API.++ *++ * - the run is created at once (202, status queued, latency_class 'batch') and takes no provider call;++ * - the provider's batch answer finishes the same run, at the provider's batch price;++ * - a restart while it waits leaves it queued, and the answer still finishes it;++ * - ?wait= is answered at once with the queued run, never held for hours;++ * - past deadline_hours the item re-routes to the background class once, and the run says it was clamped;++ * - a route with no batch provider runs in the background class at once and says it was clamped.++ */++const assert = require('assert');++const http = require('http');++const fs = require('fs');++const os = require('os');++const path = require('path');++const { boot, request, token, suite, ALL } = require('./helpers');++++const t = suite('batch-run');++const MODEL = 'gpt-4o-mini';++++/** A stub provider that speaks the OpenAI batch API and answers an ordinary chat call too. */++function stubProvider() {++ const state = { files: new Map(), batches: new Map(), seq: 0, calls: [] };++ const server = http.createServer((req, res) => {++ let raw = '';++ req.on('data', (c) => { raw += c; });++ req.on('end', () => {++ const p = new URL(req.url, 'http://x').pathname;++ let body = {};++ try { body = raw ? JSON.parse(raw) : {}; } catch { body = {}; }++ state.calls.push({ path: p, method: req.method, body });++ const send = (status, out, type) => {++ res.writeHead(status, { 'Content-Type': type || 'application/json' });++ res.end(typeof out === 'string' ? out : JSON.stringify(out));++ };++ if (p === '/v1/files' && req.method === 'POST') {++ const id = `file_${++state.seq}`;++ state.files.set(id, body.file || '');++ return send(200, { id, object: 'file', bytes: (body.file || '').length });++ }++ if (p === '/v1/batches' && req.method === 'POST') {++ const id = `batch_${++state.seq}`;++ state.batches.set(id, { input_file_id: body.input_file_id, status: 'in_progress', output_file_id: null });++ return send(200, { id, object: 'batch', status: 'in_progress' });++ }++ if (p.startsWith('/v1/batches/')) {++ const b = state.batches.get(p.split('/')[3]);++ if (!b) return send(404, { error: { message: 'no such batch' } });++ return send(200, { id: p.split('/')[3], object: 'batch', status: b.status, output_file_id: b.output_file_id, error_file_id: null });++ }++ if (p.startsWith('/v1/files/') && p.endsWith('/content')) {++ return send(200, state.files.get(p.split('/')[3]) || '', 'application/x-ndjson');++ }++ if (p === '/v1/chat/completions') {++ return send(200, { id: 'cmpl_1', object: 'chat.completion', model: body.model || MODEL,++ choices: [{ index: 0, message: { role: 'assistant', content: 'the ordinary path answered' }, finish_reason: 'stop' }],++ usage: { prompt_tokens: 5, completion_tokens: 5, total_tokens: 10 } });++ }++ return send(404, { error: { message: 'not found' } });++ });++ });++ return new Promise((resolve) => {++ server.listen(0, '127.0.0.1', () => {++ const url = `http://127.0.0.1:${server.address().port}`;++ resolve({++ url, state,++ close: () => new Promise((r) => { server.closeAllConnections?.(); server.close(() => r()); }),++ /** The provider answers every request in its file. */++ answer(id, { text = 'the batch answer' } = {}) {++ const b = state.batches.get(id);++ const lines = (state.files.get(b.input_file_id) || '').split('\n').filter(Boolean).map((l) => JSON.parse(l));++ const out = lines.map((line) => JSON.stringify({++ id: 'req_1', custom_id: line.custom_id,++ response: { status_code: 200, body: { id: 'cmpl_1', object: 'chat.completion', model: line.body.model,++ choices: [{ index: 0, message: { role: 'assistant', content: text }, finish_reason: 'stop' }],++ usage: { prompt_tokens: 100, completion_tokens: 50, total_tokens: 150 } } },++ error: null,++ }));++ const outId = `file_out_${++state.seq}`;++ state.files.set(outId, out.join('\n'));++ b.output_file_id = outId;++ b.status = 'completed';++ return id;++ },++ });++ });++ });++}++++/** A booted service whose shared provider is the stub, with a price card for the model. */++async function bootWith(stub, { env = {}, dir = undefined, priced = true } = {}) {++ const h = await boot({ dir, env: { AI_BATCH_NATIVE_MAX_ITEMS: '2', AI_BATCH_NATIVE_MAX_AGE_MS: '60000', ...env } });++ await h.registry.upsertProvider({ key: 'shared', base_url: `${stub.url}/v1`, auth_mode: 'none', provider_access: 'OWN_DEPLOYMENT' }, { actor: 'test' });++ if (priced) await h.registry.upsertModel({ provider_key: 'shared', model_key: MODEL, type: 'chat', cost: { in_per_mtok: 1, out_per_mtok: 2, source: 'urn:openvibe:ai:test', verified_at: '2026-01-01' } }, { actor: 'test' });++ return h;++}++++/** A small fake Billing: the hold and the settle, and what they were told. */++async function fakeBilling() {++ const state = { balance: 1000000000000n, held: 0n, holds: new Map(), settlements: [], seq: 0 };++ const server = http.createServer((req, res) => {++ let raw = '';++ req.on('data', (c) => { raw += c; });++ req.on('end', () => {++ const p = new URL(req.url, 'http://x').pathname;++ let body = {};++ try { body = raw ? JSON.parse(raw) : {}; } catch { body = {}; }++ const send = (status, out) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(out)); };++ if (p === '/internal/credits/holds') {++ const want = BigInt(String(body.max_nano_usd || '0'));++ if (want > state.balance - state.held) return send(402, { detail: 'not enough balance', title: 'Insufficient funds' });++ const id = `hld_${++state.seq}`;++ state.holds.set(body.idempotency_key, { hold_id: id, held_nano_usd: String(body.max_nano_usd) });++ state.held += want;++ return send(201, { hold_id: id, held_nano_usd: String(body.max_nano_usd), available_nano_usd: String(state.balance - state.held), lease_expires_at: new Date(Date.now() + 300000).toISOString() });++ }++ if (p.startsWith('/internal/credits/holds/') && p.endsWith('/settle')) {++ state.settlements.push({ path: p, body });++ const hold = [...state.holds.values()].find((x) => x.hold_id === decodeURIComponent(p.split('/')[4]));++ return send(201, { charge_nano_usd: '0', released_nano_usd: String(hold ? hold.held_nano_usd : '0'), journal_id: `txn_${state.settlements.length}`, shortfall_nano_usd: '0' });++ }++ if (p.startsWith('/internal/credits/holds/') && p.endsWith('/release')) return send(200, { released: true });++ if (p === '/oauth/token') return send(200, { access_token: 'test-token', token_type: 'Bearer', expires_in: 300, scope: '' });++ if (p === '/api/v1/usage') return send(201, { ok: true });++ return send(404, { error: 'not found' });++ });++ });++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const url = `http://127.0.0.1:${server.address().port}`;++ return { url, state, close: () => new Promise((r) => { server.closeAllConnections?.(); server.close(() => r()); }) };++}++++async function ageGroup(h) {++ await h.db.prepare("UPDATE batch_queue SET created_at = ? WHERE status = 'queued'").run(new Date(Date.now() - 60000).toISOString());++}++++const tok = () => token('all', ALL, { sub: 'svc:test' });++const body = (extra = {}) => ({ workflow: 'ai.generate', input: { prompt: 'take your time' }, options: { latency: 'batch', cache: false }, ...extra });++++t.test('a batch-class run is queued, answered by the provider\'s batch, and charged the batch price', async () => {++ const stub = await stubProvider();++ const h = await bootWith(stub);++ try {++ const started = Date.now();++ const res = await request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok: tok(), body: body() });++ const took = Date.now() - started;++ assert.strictEqual(res.status, 202, `the queued run is 202 (${res.text})`);++ assert.strictEqual(res.body.run.status, 'queued');++ assert.strictEqual(res.body.run.latency_class, 'batch');++ assert.strictEqual(res.body.run.latency_clamped, null);++ assert.ok(took < 3000, `?wait= did not hold the caller (${took} ms)`);++ // The run has no provider call and waits in the database.++ const requests = await h.db.prepare('SELECT COUNT(*) AS n FROM requests WHERE run_id = ?').get(res.body.run.id);++ assert.strictEqual(Number(requests.n), 0);++ const item = await h.db.prepare('SELECT * FROM batch_queue WHERE run_id = ?').get(res.body.run.id);++ assert.ok(item, 'the run waits in the batch queue');++ assert.strictEqual(item.kind, 'service');++ assert.strictEqual(JSON.parse(item.plan).runLevel, true, `the plan is a run-level plan: ${item.plan}`);++ assert.strictEqual(JSON.parse(item.plan).stepKind, 'llm');++ assert.strictEqual(stub.state.calls.filter((c) => c.path === '/v1/batches').length, 0);++++ await ageGroup(h);++ const submitted = await h.batcher.submitOnce();++ assert.strictEqual(submitted.sent, 1);++ const providerBatch = stub.state.batches.keys().next().value;++ stub.answer(providerBatch, { text: 'the batch answer' });++ await h.batcher.pollOnce();++++ const run = await h.db.prepare('SELECT * FROM runs WHERE id = ?').get(res.body.run.id);++ assert.strictEqual(run.status, 'succeeded');++ assert.strictEqual(run.latency_class, 'batch', 'the run it was created as');++ assert.strictEqual(run.service_tier, 'batch', 'the tier the provider really ran');++ assert.strictEqual(JSON.parse(run.output).text, 'the batch answer');++ // 100 input tokens at 1 USD/M and 50 output at 2 USD/M, both at the provider's half-price batch.++ const expected = Number((100n * 500000000n + 50n * 1000000000n) / 1000000n) / 1e9;++ assert.strictEqual(Number(run.cost_usd), expected, 'the run cost is the batch price');++ assert.ok(Number(run.cost_usd) > 0);++ } finally { await h.stop(); await stub.close(); }++});++++t.test('an ai.complete run is answered by the provider\'s batch with its choices intact', async () => {++ const stub = await stubProvider();++ const h = await bootWith(stub);++ try {++ const res = await request(h.base, 'POST', '/api/v1/runs?wait=5000', {++ tok: tok(),++ body: { workflow: 'ai.complete', input: { messages: [{ role: 'user', content: 'hello' }] }, options: { latency: 'batch', cache: false } },++ });++ assert.strictEqual(res.status, 202, res.text);++ await ageGroup(h);++ await h.batcher.submitOnce();++ stub.answer(stub.state.batches.keys().next().value, { text: 'the complete batch answer' });++ await h.batcher.pollOnce();++ const run = await h.db.prepare('SELECT * FROM runs WHERE id = ?').get(res.body.run.id);++ assert.strictEqual(run.status, 'succeeded');++ const out = JSON.parse(run.output);++ assert.strictEqual(out.choices[0].message.content, 'the complete batch answer');++ assert.strictEqual(Number(run.tokens_in), 100);++ assert.strictEqual(Number(run.tokens_out), 50);++ } finally { await h.stop(); await stub.close(); }++});++++t.test('a restart while the run waits leaves it queued, and the batch answer still finishes it', async () => {++ const stub = await stubProvider();++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-batch-run-restart-'));++ const h1 = await bootWith(stub, { dir });++ let runId;++ try {++ const res = await request(h1.base, 'POST', '/api/v1/runs?wait=5000', { tok: tok(), body: body() });++ assert.strictEqual(res.status, 202, res.text);++ runId = res.body.run.id;++ await h1.stop();++++ const h2 = await bootWith(stub, { dir });++ try {++ const left = await h2.db.prepare('SELECT status, latency_class FROM runs WHERE id = ?').get(runId);++ assert.strictEqual(left.status, 'queued', 'the restart did not fail the waiting run');++ assert.strictEqual(left.latency_class, 'batch');++ await ageGroup(h2);++ await h2.batcher.submitOnce();++ stub.answer(stub.state.batches.keys().next().value, { text: 'the answer after the restart' });++ await h2.batcher.pollOnce();++ const done = await h2.db.prepare('SELECT status, output, cost_usd FROM runs WHERE id = ?').get(runId);++ assert.strictEqual(done.status, 'succeeded');++ assert.strictEqual(JSON.parse(done.output).text, 'the answer after the restart');++ assert.ok(Number(done.cost_usd) > 0, 'the batch cost is on the run');++ } finally { await h2.stop(); }++ } finally { await stub.close(); }++});++++t.test('past deadline_hours the run re-routes to the background class once, and says it was clamped', async () => {++ const stub = await stubProvider();++ const h = await bootWith(stub);++ try {++ const res = await request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok: tok(), body: body({ options: { latency: 'batch', deadline_hours: 1, cache: false } }) });++ assert.strictEqual(res.status, 202, res.text);++ // The deadline passes while the item still waits for a group.++ await h.db.prepare('UPDATE batch_queue SET deadline_at = ? WHERE run_id = ?').run(new Date(Date.now() - 1000).toISOString(), res.body.run.id);++ await h.batcher.submitOnce();++ // It was never handed to the provider: it re-routed instead.++ assert.strictEqual(stub.state.calls.filter((c) => c.path === '/v1/batches').length, 0);++ const item = await h.db.prepare('SELECT status FROM batch_queue WHERE run_id = ?').get(res.body.run.id);++ assert.strictEqual(item.status, 'rerouted');++ const run = await h.db.prepare('SELECT * FROM runs WHERE id = ?').get(res.body.run.id);++ assert.strictEqual(run.status, 'succeeded', 'the background path answered it');++ assert.strictEqual(run.latency_class, 'background');++ assert.deepStrictEqual(JSON.parse(run.options).latency_clamped, { asked: 'batch', used: 'background' });++ assert.strictEqual(JSON.parse(run.output).text, 'the ordinary path answered');++ // Once: another pass does not send it or run it again.++ await h.batcher.submitOnce();++ await h.batcher.pollOnce();++ assert.strictEqual(stub.state.calls.filter((c) => c.path === '/v1/batches').length, 0);++ const after = await h.db.prepare('SELECT status FROM batch_queue WHERE run_id = ?').get(res.body.run.id);++ assert.strictEqual(after.status, 'rerouted');++ } finally { await h.stop(); await stub.close(); }++});++++t.test('a route with no batch provider runs in the background class at once and says it was clamped', async () => {++ const stub = await stubProvider();++ const h = await bootWith(stub, { priced: false });++ try {++ const res = await request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok: tok(), body: body() });++ assert.strictEqual(res.status, 201, `no provider can batch, so the run answers (${res.text})`);++ assert.strictEqual(res.body.run.latency_class, 'background');++ assert.deepStrictEqual(res.body.run.latency_clamped, { asked: 'batch', used: 'background' });++ assert.strictEqual((await h.db.prepare('SELECT COUNT(*) AS n FROM batch_queue').get()).n, 0, 'nothing waits for a batch');++ assert.strictEqual(JSON.parse(JSON.stringify(res.body.run.output)).text, 'the ordinary path answered');++ } finally { await h.stop(); await stub.close(); }++});++++t.test('the ordinary payer is charged the batch price, and the Balance is checked when the run is queued', async () => {++ const stub = await stubProvider();++ const billing = await fakeBilling();++ const h = await bootWith(stub, {++ env: {++ AI_CREDITS: '1', USAGE_SAMPLES: 'off',++ OV_BILLING_INTERNAL_URL: billing.url, OV_NETWORK_INTERNAL_URL: billing.url,++ OV_OAUTH_CLIENT_SECRET: 'test-secret', OV_OAUTH_CLIENT_ID: 'ai',++ },++ });++ try {++ const person = token('dana', ALL, { sub: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2B3', actorType: 'user' });++ const res = await request(h.base, 'POST', '/api/v1/runs?wait=5000', {++ tok: person,++ body: { workflow: 'ai.complete', input: { messages: [{ role: 'user', content: 'hello' }] }, options: { latency: 'batch', cache: false } },++ });++ assert.strictEqual(res.status, 202, res.text);++ // BA2's probe checked the person's Balance when the run was queued (under its own key).++ assert.ok([...billing.state.holds.keys()].some((k) => String(k).includes(':probe:')), 'the queued run probed the Balance');++ await ageGroup(h);++ await h.batcher.submitOnce();++ stub.answer(stub.state.batches.keys().next().value, { text: 'the paid batch answer' });++ await h.batcher.pollOnce();++++ const settle = billing.state.settlements[0];++ assert.ok(settle, 'the ordinary payer was charged');++ assert.strictEqual(settle.body.retail.nano_usd_per_m.input, '500000000', 'half the retail input price');++ assert.strictEqual(settle.body.retail.nano_usd_per_m.output, '1000000000', 'half the retail output price');++ const run = await h.db.prepare('SELECT status, cost_usd, service_tier FROM runs WHERE id = ?').get(res.body.run.id);++ assert.strictEqual(run.status, 'succeeded');++ assert.strictEqual(run.service_tier, 'batch');++ assert.strictEqual(String(settle.body.supply_cost_nano_usd), String(Math.round(Number(run.cost_usd) * 1e9)));++ } finally { await h.stop(); await stub.close(); await billing.close(); }++});++++t.test('deadline_hours is 1 to 72', async () => {++ const stub = await stubProvider();++ const h = await bootWith(stub);++ try {++ for (const hours of [0, 73, 'soon']) {++ const res = await request(h.base, 'POST', '/api/v1/runs', { tok: tok(), body: body({ options: { latency: 'batch', deadline_hours: hours } }) });++ assert.strictEqual(res.status, 422, `${hours}: ${res.text}`);++ assert.strictEqual(res.body.code, 'input.invalid');++ }++ } finally { await h.stop(); await stub.close(); }++});++++t.run();@@@ -244,11 +244,12 @@ t.test('a caller cannot make a background workflow interactive (it still runs, iassert.strictEqual(fast.body.run.latency_class, 'background', 'a background workflow stays in the background');// BA3: the run says that it was asked for more than it can give: the class asked for and the class used.assert.deepStrictEqual(fast.body.run.latency_clamped, { asked: 'interactive', used: 'background' }, 'the run says it was clamped');-- // A slower class is allowed.++ // A slower class is allowed. This route has no priced model, so no provider's batch price can be++ // checked: the run stays on the ordinary path, in the background class, and says it was clamped.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');-- assert.strictEqual(slower.body.run.latency_clamped, null, 'a slower ask is not clamped');++ assert.strictEqual(slower.status, 201, `a slower class is answered (${slower.text})`);++ assert.strictEqual(slower.body.run.latency_class, 'background', 'no batch provider on the route: the background class');++ assert.deepStrictEqual(slower.body.run.latency_clamped, { asked: 'batch', used: 'background' }, 'and the run says so');// 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);
@@ -84,7 +84,7 @@ function createBatcher({ db, runs, registry, credits = null, config = {}, clock
@@ -84,7 +84,7 @@ function createBatcher({ db, runs, registry, credits = null, config = {}, clock
// The batch service is attached after the batcher is built (the service needs the batcher too).
// The batch service is attached after the batcher is built (the service needs the batcher too).
const native = (config.batches && config.batches.native) || {};
const native = (config.batches && config.batches.native) || {};
const settings = {
const settings = {
- enabled: true,
+ enabled: !(config.batches && config.batches.enabled === false),
// How many requests one provider batch carries (the provider's own limit is the ceiling; this is
// How many requests one provider batch carries (the provider's own limit is the ceiling; this is
// the network's own, so a file is never bigger than it needs to be).
// the network's own, so a file is never bigger than it needs to be).
maxItemsPerGroup: 1000,
maxItemsPerGroup: 1000,
@@ -196,6 +196,90 @@ function createBatcher({ db, runs, registry, credits = null, config = {}, clock
@@ -196,6 +196,90 @@ function createBatcher({ db, runs, registry, credits = null, config = {}, clock
};
};
}
}
+ /** What a restarted service needs of the run's context: scalars only, never a function or a token. */
+ function ctxSnapshot(ctx) {
+ const out = {};
+ for (const k of ['appRun', 'projectId', 'sandbox', 'appId', 'onBehalfOf', 'personSubject', 'personPays', 'serviceBudgeted',
+ 'freeUse', 'byok', 'maxCostNano', 'communityOptIn', 'contentPolicy', 'presetPolicy', 'presetName']) {
+ if (ctx && ctx[k] !== undefined) out[k] = ctx[k];
+ }
+ return out;
+ }
+
+ /**
+ * BA3: where a batch-class run waits. The run's own workflow has one provider step (the engine built its
+ * request); the route's offers are tried in order, and the first provider whose own batch API is enabled
+ * and whose real batch price fits inside the half of the retail card the customer is charged takes it.
+ * A payer whose Balance cannot cover the run is refused here, before the provider is given the work.
+ *
+ * → { provider, model, body, prompt, stepKind, operation, maxTokens, holdCard, data } or null (no
+ * eligible provider: the run runs in the background class and says it was clamped).
+ */
+ async function planRun({ prepared = null, offers = [], principal = {}, ctx = {}, input = null, requestId = null, maxCostNano = null } = {}) {
+ if (!settings.enabled || !prepared || typeof prepared.bodyFor !== 'function' || !prepared.route) return null;
+ const capacity = require('./capacity'); // lazy: capacity requires this module for its cards
+ for (const offer of offers || []) {
+ if (!offer || !offer.provider || !offer.model) continue;
+ const cfg = providerConfig(offer.provider);
+ // Only a provider whose batch API is enabled and whose adapter speaks the OpenAI batch shape.
+ if (!cfg || !cfg.enabled || cfg.adapter !== 'openai-compatible') continue;
+ const provider = await registry.getProvider(offer.provider);
+ if (!provider || provider.status !== 'active') continue;
+ const cards = await cardsFor(offer.provider, offer.model);
+ if (!cards || !cards.batch) continue;
+ // The same money rule as capacity route (d): the provider's real batch price fits inside the
+ // retail card the run is charged at.
+ const charge = capacity.chargeOf(cards.row);
+ if (!charge || !capacity.within(cards.batch.nano_usd_per_m, charge)) continue;
+ let body;
+ try { body = prepared.bodyFor(offer.model); } catch { continue; }
+ if (!body || !Array.isArray(body.messages) || !body.messages.length) continue;
+ body.model = offer.model;
+ // BA2's probe: a real hold, released at once. A payer who cannot cover the run is refused now,
+ // so the network never owes a provider for an answer nobody can pay for.
+ if (credits && typeof credits.probe === 'function') {
+ const verdict = await credits.probe({
+ requestId: requestId || String(ctx.runId || ''),
+ ctx: payerCtx(principal, { discount: 'half', maxCostNano, requestId }),
+ input, card: cards.retail, provider: offer.provider, model: offer.model, access: cards.access,
+ maxTokens: prepared.maxTokens,
+ });
+ if (!verdict.ok) throw new AiError(402, String(verdict.reason || 'billing.insufficient_funds'), String(verdict.detail || 'Not enough Balance'));
+ }
+ return {
+ provider: offer.provider, model: offer.model, body, prompt: prepared.prompt || '',
+ stepKind: prepared.stepKind, operation: prepared.operation, maxTokens: prepared.maxTokens,
+ holdCard: cards.retail,
+ data: {
+ principal: planOf({ principal, input, card: cards.retail, maxTokens: prepared.maxTokens, discount: 'half', maxCostNano, requestId, servicePaid: Boolean(ctx.serviceBudgeted) }).principal,
+ input: input || null, card: cards.retail, maxTokens: Number(prepared.maxTokens) || 4096,
+ discount: 'half', maxCostNano: maxCostNano == null ? null : String(maxCostNano),
+ requestId: requestId || null, route: null, charge: null,
+ runLevel: true, stepKind: prepared.stepKind, ctx: ctxSnapshot(ctx),
+ },
+ };
+ }
+ return null;
+ }
+
+ /**
+ * BA3: put a run that already exists (created by runs.create) into the queue, with the deadline its
+ * caller asked for. The run itself is never created here: the API saw it at once, and the answer is
+ * written onto that same run when the provider answers.
+ */
+ async function enqueueRun({ runId, plan, deadlineHours = null } = {}) {
+ if (!runId || !plan || !plan.body) throw new AiError(500, 'batch.no_run', 'a batch-class run needs its own run');
+ const hours = Number(deadlineHours) > 0 ? Math.max(1, Math.min(72, Math.trunc(Number(deadlineHours)))) : Number(settings.deadlineHours) || 24;
+ const deadline = new Date(clock.now() + hours * 3600 * 1000).toISOString();
+ const id = `bni_${crypto.randomBytes(10).toString('hex')}`;
+ const data = { ...(plan.data || {}), deadlineHours: hours };
+ await db.prepare(`INSERT INTO batch_queue (id, run_id, kind, batch_id, idx, provider, model, prompt, request_body, hold_card, plan, status, deadline_at, created_at, updated_at)
+ VALUES (?, ?, 'service', NULL, NULL, ?, ?, ?, ?, ?, ?, 'queued', ?, ?, ?)`)
+ .run(id, runId, plan.provider, plan.model, plan.prompt || '', JSON.stringify(plan.body),
+ plan.holdCard ? JSON.stringify(plan.holdCard) : null, JSON.stringify(data), deadline, iso(), iso());
+ return { id, runId };
+ }
+
/**
/**
* Put one item in the queue. The run is created at once (so the customer sees it, and its latency
* Put one item in the queue. The run is created at once (so the customer sees it, and its latency
* class says 'batch'), but it waits for the provider instead of running.
* class says 'batch'), but it waits for the provider instead of running.
@@ -244,8 +328,11 @@ function createBatcher({ db, runs, registry, credits = null, config = {}, clock
@@ -244,8 +328,11 @@ function createBatcher({ db, runs, registry, credits = null, config = {}, clock
if (running) return { skipped: 'running' };
if (running) return { skipped: 'running' };
running = true;
running = true;
try {
try {
+ // BA3: an item whose deadline passed before the provider took it is never sent: it re-routes
+ // to the ordinary (background) path now, exactly once.
+ const rerouted = await reroutePending('the deadline passed before the provider took the batch');
const groups = await db.prepare(`SELECT provider, model, COUNT(*) AS n, MIN(created_at) AS oldest
const groups = await db.prepare(`SELECT provider, model, COUNT(*) AS n, MIN(created_at) AS oldest
- FROM batch_queue WHERE status = 'queued' GROUP BY provider, model`).all();
+ FROM batch_queue WHERE status = 'queued' AND deadline_at > ?::timestamptz GROUP BY provider, model`).all(iso());
const sent = [];
const sent = [];
for (const group of groups || []) {
for (const group of groups || []) {
const age = clock.now() - new Date(group.oldest).getTime();
const age = clock.now() - new Date(group.oldest).getTime();
@@ -253,7 +340,7 @@ function createBatcher({ db, runs, registry, credits = null, config = {}, clock
@@ -253,7 +340,7 @@ function createBatcher({ db, runs, registry, credits = null, config = {}, clock
const job = await submitGroup(group.provider, group.model);
const job = await submitGroup(group.provider, group.model);
if (job) sent.push(job);
if (job) sent.push(job);
}
}
- return { sent: sent.length, jobs: sent };
+ return { sent: sent.length, jobs: sent, ...(rerouted ? { rerouted } : {}) };
} catch (err) {
} catch (err) {
log.warn('[batch] the submitter stopped early: ' + String((err && err.message) || err));
log.warn('[batch] the submitter stopped early: ' + String((err && err.message) || err));
return { error: String((err && err.code) || 'error') };
return { error: String((err && err.code) || 'error') };
@@ -265,7 +352,7 @@ function createBatcher({ db, runs, registry, credits = null, config = {}, clock
@@ -265,7 +352,7 @@ function createBatcher({ db, runs, registry, credits = null, config = {}, clock
/** Send one group to the provider's own batch API, as one file and one batch. */
/** Send one group to the provider's own batch API, as one file and one batch. */
async function submitGroup(providerKey, modelKey) {
async function submitGroup(providerKey, modelKey) {
const items = await db.prepare(`SELECT * FROM batch_queue WHERE provider = ? AND model = ? AND status = 'queued'
const items = await db.prepare(`SELECT * FROM batch_queue WHERE provider = ? AND model = ? AND status = 'queued'
- ORDER BY created_at LIMIT ?`).all(providerKey, modelKey, Number(settings.maxItemsPerGroup));
+ AND deadline_at > ?::timestamptz ORDER BY created_at LIMIT ?`).all(providerKey, modelKey, iso(), Number(settings.maxItemsPerGroup));
if (!items || !items.length) return null;
if (!items || !items.length) return null;
const adapter = await adapterFor(providerKey);
const adapter = await adapterFor(providerKey);
const cfg = providerConfig(providerKey);
const cfg = providerConfig(providerKey);
@@ -320,8 +407,10 @@ function createBatcher({ db, runs, registry, credits = null, config = {}, clock
@@ -320,8 +407,10 @@ function createBatcher({ db, runs, registry, credits = null, config = {}, clock
running = true;
running = true;
try {
try {
// An item that was being settled when the service stopped goes back into the queue: the
// An item that was being settled when the service stopped goes back into the queue: the
- // provider's answer is still there, and the run must not wait for ever.
- await db.prepare("UPDATE batch_queue SET status = 'submitted', updated_at = ? WHERE status = 'settling' AND updated_at <= ?")
+ // provider's answer is still there, and the run must not wait for ever. An item with no
+ // provider batch (a re-route that was interrupted) goes back to 'queued', so the deadline
+ // is looked at again instead of waiting for a job that does not exist.
+ await db.prepare("UPDATE batch_queue SET status = CASE WHEN job_id IS NULL THEN 'queued' ELSE 'submitted' END, updated_at = ? WHERE status = 'settling' AND updated_at <= ?")
.run(iso(), new Date(clock.now() - Math.max(1000, Number(settings.claimTtlMs) || 0)).toISOString());
.run(iso(), new Date(clock.now() - Math.max(1000, Number(settings.claimTtlMs) || 0)).toISOString());
const job = await claimJob();
const job = await claimJob();
if (!job) return { skipped: 'no batch' };
if (!job) return { skipped: 'no batch' };
@@ -425,6 +514,9 @@ function createBatcher({ db, runs, registry, credits = null, config = {}, clock
@@ -425,6 +514,9 @@ function createBatcher({ db, runs, registry, credits = null, config = {}, clock
explain: { objective: 'batch', reasons: [`answered by the provider's own batch API (${item.provider})`], selected: `batch:${item.provider}`, candidates: [], attempts: [] },
explain: { objective: 'batch', reasons: [`answered by the provider's own batch API (${item.provider})`], selected: `batch:${item.provider}`, candidates: [], attempts: [] },
serviceTier: 'batch',
serviceTier: 'batch',
maxTokens: plan.maxTokens || 4096,
maxTokens: plan.maxTokens || 4096,
+ // BA3: a run-level item resumes its own workflow step with this answer; a customer batch
+ // item's run is the ai.complete run the batcher created, which the shortcut finishes.
+ ...(plan.runLevel ? { resume: true, stepKind: plan.stepKind || null } : {}),
}, { plan, costCheck: await checkFor(item, plan) });
}, { plan, costCheck: await checkFor(item, plan) });
await db.prepare(`UPDATE batch_queue SET status = 'succeeded', answer = ?, usage = ?, cost_nano_usd = ?, updated_at = ? WHERE id = ?`)
await db.prepare(`UPDATE batch_queue SET status = 'succeeded', answer = ?, usage = ?, cost_nano_usd = ?, updated_at = ? WHERE id = ?`)
.run(String((res.output && res.output.text) || '').slice(0, 20000), JSON.stringify(usage), String(nano), iso(), item.id);
.run(String((res.output && res.output.text) || '').slice(0, 20000), JSON.stringify(usage), String(nano), iso(), item.id);
@@ -440,19 +532,40 @@ function createBatcher({ db, runs, registry, credits = null, config = {}, clock
@@ -440,19 +532,40 @@ function createBatcher({ db, runs, registry, credits = null, config = {}, clock
/**
/**
* The deadline passed and the provider cannot meet it: the item goes back to the ordinary path. The
* The deadline passed and the provider cannot meet it: the item goes back to the ordinary path. The
- * run answers through the pool as any other run does, and the item says it was re-routed.
+ * run answers through the pool as any other run does, and the item says it was re-routed. A run-level
+ * run (BA3) re-routes into the background class, exactly once: the item is then no longer queued, and
+ * the run itself never asks for a batch again.
*/
*/
+ async function rerouteItem(item, reason, from = 'submitted') {
+ const claimed = await db.prepare(`UPDATE batch_queue SET status = 'settling', updated_at = ? WHERE id = ? AND status = ? RETURNING id`)
+ .get(iso(), item.id, from).catch(() => null);
+ if (!claimed) return false;
+ const plan = item.plan ? JSON.parse(item.plan) : {};
+ if (plan.runLevel && item.run_id) {
+ const row = await db.prepare('SELECT options FROM runs WHERE id = ?').get(item.run_id).catch(() => null);
+ const opts = row && row.options ? (JSON.parse(row.options) || {}) : {};
+ opts.latency_clamped = { asked: 'batch', used: 'background' };
+ await db.prepare("UPDATE runs SET latency_class = 'background', options = ? WHERE id = ?")
+ .run(JSON.stringify(opts), item.run_id).catch(() => null);
+ }
+ await runs.completeBatch(item.run_id, { reroute: true }, { plan });
+ await db.prepare(`UPDATE batch_queue SET status = 'rerouted', error_code = 'batch.rerouted', error_detail = ?, updated_at = ? WHERE id = ?`)
+ .run(String(reason || '').slice(0, 300), iso(), item.id);
+ await writeBack(item);
+ return true;
+ }
+
+ /** The items whose deadline passed before the provider ever took them. */
+ async function reroutePending(reason) {
+ const rows = await db.prepare(`SELECT * FROM batch_queue WHERE status = 'queued' AND deadline_at <= ?::timestamptz`).all(iso()).catch(() => []);
+ let n = 0;
+ for (const item of rows || []) if (await rerouteItem(item, reason, 'queued')) n++;
+ return n;
+ }
+
async function rerouteJob(job, reason) {
async function rerouteJob(job, reason) {
const items = await db.prepare(`SELECT * FROM batch_queue WHERE job_id = ? AND status = 'submitted'`).all(job.id);
const items = await db.prepare(`SELECT * FROM batch_queue WHERE job_id = ? AND status = 'submitted'`).all(job.id);
- for (const item of items || []) {
- const claimed = await db.prepare(`UPDATE batch_queue SET status = 'settling', updated_at = ? WHERE id = ? AND status = 'submitted' RETURNING id`)
- .get(iso(), item.id).catch(() => null);
- if (!claimed) continue;
- await runs.completeBatch(item.run_id, { reroute: true }, { plan: item.plan ? JSON.parse(item.plan) : {} });
- await db.prepare(`UPDATE batch_queue SET status = 'rerouted', error_code = 'batch.rerouted', error_detail = ?, updated_at = ? WHERE id = ?`)
- .run(String(reason || '').slice(0, 300), iso(), item.id);
- await writeBack(item);
- }
+ for (const item of items || []) await rerouteItem(item, reason, 'submitted');
await db.prepare(`UPDATE provider_batches SET status = 'expired', error_code = 'batch.rerouted', error_detail = ?, finished_at = ?, updated_at = ?, claimed_until = NULL, claimed_by = NULL WHERE id = ?`)
await db.prepare(`UPDATE provider_batches SET status = 'expired', error_code = 'batch.rerouted', error_detail = ?, finished_at = ?, updated_at = ?, claimed_until = NULL, claimed_by = NULL WHERE id = ?`)
.run(String(reason || '').slice(0, 300), iso(), iso(), job.id);
.run(String(reason || '').slice(0, 300), iso(), iso(), job.id);
}
}
@@ -510,7 +623,7 @@ function createBatcher({ db, runs, registry, credits = null, config = {}, clock
@@ -510,7 +623,7 @@ function createBatcher({ db, runs, registry, credits = null, config = {}, clock
/** The batch service, so that a customer's batch is closed when its items are done. */
/** The batch service, so that a customer's batch is closed when its items are done. */
function attach(theService) { service = theService; return api; }
function attach(theService) { service = theService; return api; }
- const api = { enqueue, submitOnce, pollOnce, runOnce, start, stop, settings, cardsFor, scaleCard, attach };
+ const api = { enqueue, enqueueRun, planRun, submitOnce, pollOnce, runOnce, start, stop, settings, cardsFor, scaleCard, attach };
return api;
return api;
}
}
@@ -195,7 +195,9 @@ function createOpenAiCompatibleAdapter({ baseUrl, apiKey, fetchImpl = globalThis
@@ -195,7 +195,9 @@ function createOpenAiCompatibleAdapter({ baseUrl, apiKey, fetchImpl = globalThis
out.push({
out.push({
custom_id: row.custom_id,
custom_id: row.custom_id,
ok: true,
ok: true,
- output: { text },
+ // The raw answer's choices, so a complete run (ai.complete) keeps its tool calls and finish
+ // reason too; the text is what a chat/generate step shapes.
+ output: { text, completion: { choices: body.choices || [], system_fingerprint: body.system_fingerprint || null } },
usage: {
usage: {
input: Number(usage.prompt_tokens || 0),
input: Number(usage.prompt_tokens || 0),
output: Number(usage.completion_tokens || 0),
output: Number(usage.completion_tokens || 0),
@@ -144,7 +144,10 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
@@ -144,7 +144,10 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
// a GPU key and a monthly cap AND Balance billing is on; the public status says which one is missing.
// a GPU key and a monthly cap AND Balance billing is on; the public status says which one is missing.
const ownEndpoints = require('./gpu/endpoints').createOwnEndpoints({ db, gpus, credits: creditsRef, config, clock, fetchImpl, log });
const ownEndpoints = require('./gpu/endpoints').createOwnEndpoints({ db, gpus, credits: creditsRef, config, clock, fetchImpl, log });
gpus.attachEndpoints(ownEndpoints);
gpus.attachEndpoints(ownEndpoints);
- const runs = createRuns({ db, registry, engine, cache, quotas, config, clock, log, userModules, credentials, preferences, tiers, projectSecrets, pool, runBudgets, credits: creditsRef, community, presets, receipts, abuse, webhooks,
+ // BA3: the runs and the batcher need each other (a batch-class run is created first, then queued), so
+ // the runs hold this facade and the batcher fills it in below, before any request can arrive.
+ const batchRef = {};
+ const runs = createRuns({ db, registry, engine, cache, quotas, config, clock, log, userModules, credentials, preferences, tiers, projectSecrets, pool, runBudgets, credits: creditsRef, community, presets, receipts, abuse, webhooks, batch: batchRef,
// BA2: a run that waits for a provider's batch answer is not interrupted by a restart: it stays
// BA2: a run that waits for a provider's batch answer is not interrupted by a restart: it stays
// queued until the batcher finishes it (the provider is still working on it).
// queued until the batcher finishes it (the provider is still working on it).
batchWaiting: async (runId) => {
batchWaiting: async (runId) => {
@@ -185,6 +188,9 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
@@ -185,6 +188,9 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
const refusal = require('./refusal/job').createRefusalJob({ db, registry, pool, config, env, clock, log });
const refusal = require('./refusal/job').createRefusalJob({ db, registry, pool, config, env, clock, log });
// The native batcher (BA2): the providers' own batch APIs, behind one queue.
// The native batcher (BA2): the providers' own batch APIs, behind one queue.
const batcher = require('./batch/batcher').createBatcher({ db, runs, registry, credits: creditsRef, config, clock, log, env, fetchImpl });
const batcher = require('./batch/batcher').createBatcher({ db, runs, registry, credits: creditsRef, config, clock, log, env, fetchImpl });
+ // BA3: a batch-class run asks the batcher where it may wait, and hands its own run to the queue.
+ batchRef.planRun = batcher.planRun;
+ batchRef.enqueueRun = batcher.enqueueRun;
// The half-price batches (server/batch/): many prompts, answered later, billed through Billing.
// The half-price batches (server/batch/): many prompts, answered later, billed through Billing.
const batches = require('./batch/service').createBatchService({ db, runs, registry, community, priceBook: offPeak, batcher, config, clock, log });
const batches = require('./batch/service').createBatchService({ db, runs, registry, community, priceBook: offPeak, batcher, config, clock, log });
batcher.attach(batches);
batcher.attach(batches);
@@ -63,7 +63,7 @@ const runBody = (required = []) => ({
@@ -63,7 +63,7 @@ const runBody = (required = []) => ({
workflow: str({ description: 'The workflow key, e.g. ai.generate' }), version: integer({ minimum: 1 }),
workflow: str({ description: 'The workflow key, e.g. ai.generate' }), version: integer({ minimum: 1 }),
input: { type: 'object' }, idempotency_key: str({ minLength: 8, maxLength: 128 }),
input: { type: 'object' }, idempotency_key: str({ minLength: 8, maxLength: 128 }),
target: { type: 'object' }, attribution: { type: 'object' }, on_behalf_of: ref('SubjectRef'),
target: { type: 'object' }, attribution: { type: 'object' }, on_behalf_of: ref('SubjectRef'),
- options: { type: 'object' }, route: { type: 'object', description: '{ provider, model } — one offer from GET /api/v1/routes?operation=…' },
+ options: { type: 'object', description: 'cache, debug, and the latency class: latency (interactive | background | batch); a batch-class run also takes deadline_hours (1-72, the default is 24)' }, route: { type: 'object', description: '{ provider, model } — one offer from GET /api/v1/routes?operation=…' },
},
},
});
});
const directBody = {
const directBody = {
@@ -708,6 +708,9 @@ function openapi() {
@@ -708,6 +708,9 @@ function openapi() {
source_service: nullable('string'), target: nullable('object'), input: nullable('object'), output: nullable('object'),
source_service: nullable('string'), target: nullable('object'), input: nullable('object'), output: nullable('object'),
error: nullable('object', { properties: { code: str(), detail: str() } }), synthetic: { type: 'boolean' },
error: nullable('object', { properties: { code: str(), detail: str() } }), synthetic: { type: 'boolean' },
provenance: { type: 'object' },
provenance: { type: 'object' },
+ latency_class: { type: 'string', enum: ['interactive', 'background', 'batch'] },
+ service_tier: nullable('string', { description: 'the tier the answer really ran on: standard, flex, batch, offpeak or local' }),
+ latency_clamped: nullable('object', { description: '{ asked, used }: the class the caller asked for and the class the run really took' }),
usage: { type: 'object', properties: { tokens_in: nullable('integer'), tokens_out: nullable('integer'), cost_usd: nullable('number'), attempts: nullable('integer'), usage_sample_ids: nullable(arrayOf(str())) } },
usage: { type: 'object', properties: { tokens_in: nullable('integer'), tokens_out: nullable('integer'), cost_usd: nullable('number'), attempts: nullable('integer'), usage_sample_ids: nullable(arrayOf(str())) } },
explain: nullable('object'), citations_count: nullable('integer'), grounding: nullable('object'),
explain: nullable('object'), citations_count: nullable('integer'), grounding: nullable('object'),
retry_of: nullable('string'), idempotency_key: nullable('string'), trace_id: nullable('string'),
retry_of: nullable('string'), idempotency_key: nullable('string'), trace_id: nullable('string'),
@@ -57,6 +57,29 @@ function buildBody({ model, system, messages, image, json, maxTokens, temperatur
@@ -57,6 +57,29 @@ function buildBody({ model, system, messages, image, json, maxTokens, temperatur
return body;
return body;
}
}
+/**
+ * The Chat Completions body of a complete operation (ai.complete), for a provider batch request too:
+ * the caller's own request with the routed model, a reasoning model's allowance, and the Flex tier.
+ */
+function buildCompleteBody({ model, openai = {}, maxTokens, temperature, serviceTier } = {}) {
+ const o = openai || {};
+ const body = { model, messages: o.messages };
+ for (const k of ['tools', 'tool_choice', 'parallel_tool_calls', 'response_format', 'stop', 'seed', 'top_p', 'presence_penalty', 'frequency_penalty']) {
+ if (o[k] !== undefined && o[k] !== null) body[k] = o[k];
+ }
+ if (isReasoningModel(model)) {
+ // max_tokens is the answer the caller wants; a reasoning model thinks first, out of the same allowance.
+ if (maxTokens) body.max_completion_tokens = Math.max(maxTokens, 256) + 1024;
+ body.reasoning_effort = 'low';
+ for (const k of ['top_p', 'presence_penalty', 'frequency_penalty']) delete body[k];
+ } else {
+ if (maxTokens) body.max_tokens = maxTokens;
+ if (temperature != null) body.temperature = temperature;
+ }
+ if (serviceTier) body.service_tier = String(serviceTier);
+ return body;
+}
+
function createOpenAiProvider(record, { apiKey = '', fetchImpl = globalThis.fetch } = {}) {
function createOpenAiProvider(record, { apiKey = '', fetchImpl = globalThis.fetch } = {}) {
const baseUrl = String(record.base_url || DEFAULT_BASE_URL).replace(/\/+$/, '');
const baseUrl = String(record.base_url || DEFAULT_BASE_URL).replace(/\/+$/, '');
// A local llama.cpp process serves one model, so the embedding endpoint may live on another local
// A local llama.cpp process serves one model, so the embedding endpoint may live on another local
@@ -146,23 +169,7 @@ function createOpenAiProvider(record, { apiKey = '', fetchImpl = globalThis.fetc
@@ -146,23 +169,7 @@ function createOpenAiProvider(record, { apiKey = '', fetchImpl = globalThis.fetc
*/
*/
async function complete(req) {
async function complete(req) {
const o = req.openai || {};
const o = req.openai || {};
- const body = { model: req.model, messages: o.messages };
- for (const k of ['tools', 'tool_choice', 'parallel_tool_calls', 'response_format', 'stop', 'seed', 'top_p', 'presence_penalty', 'frequency_penalty']) {
- if (o[k] !== undefined && o[k] !== null) body[k] = o[k];
- }
- if (isReasoningModel(req.model)) {
- // max_tokens is the answer the caller wants; a reasoning model thinks first, out of the same allowance. Without
- // room for that, and with its default (medium) effort, a long tool result could use it all up thinking and
- // answer nothing: Chat's first production inbox action (2026-10-25) ended "finish_reason: length", empty.
- if (req.maxTokens) body.max_completion_tokens = Math.max(req.maxTokens, 256) + 1024;
- body.reasoning_effort = 'low';
- for (const k of ['top_p', 'presence_penalty', 'frequency_penalty']) delete body[k];
- } else {
- 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);
+ const body = buildCompleteBody({ model: req.model, openai: o, maxTokens: req.maxTokens, temperature: req.temperature, serviceTier: req.serviceTier });
if (typeof req.onDelta === 'function') return await completeStreaming(body, req);
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 });
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' });
if (!Array.isArray(j.choices) || !j.choices.length) throw new ProviderError('completion has no choices', { code: 'provider.output' });
@@ -250,4 +257,4 @@ function createOpenAiProvider(record, { apiKey = '', fetchImpl = globalThis.fetc
@@ -250,4 +257,4 @@ function createOpenAiProvider(record, { apiKey = '', fetchImpl = globalThis.fetc
return adapter;
return adapter;
}
}
-module.exports = { createOpenAiProvider, buildBody, DEFAULT_BASE_URL };
+module.exports = { createOpenAiProvider, buildBody, buildCompleteBody, DEFAULT_BASE_URL };
@@ -35,6 +35,18 @@ const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'cached']);
@@ -35,6 +35,18 @@ const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'cached']);
const QUEUE_RETRY_AFTER_S = 5;
const QUEUE_RETRY_AFTER_S = 5;
const newRunId = () => `run_${ids.ulid()}`;
const newRunId = () => `run_${ids.ulid()}`;
+/**
+ * BA3: how long a batch-class run may wait for a provider's own batch API, in hours (1 to 72). The
+ * caller names it in options.deadline_hours; the default is the batcher's own 24 hours.
+ */
+function batchDeadlineHours(value) {
+ if (value == null || value === '') return 24;
+ const n = Number(value);
+ if (!Number.isFinite(n)) throw new AiError(422, 'input.invalid', 'deadline_hours must be a number of hours');
+ if (n < 1 || n > 72) throw new AiError(422, 'input.invalid', 'deadline_hours is between 1 and 72');
+ return Math.trunc(n);
+}
+
function entityKey(ref) { return ref ? `${ref.service}:${ref.type}:${ref.id}` : null; }
function entityKey(ref) { return ref ? `${ref.service}:${ref.type}:${ref.id}` : null; }
function subjectKey(ref) { return ref ? `${ref.type}:${ref.id}` : null; }
function subjectKey(ref) { return ref ? `${ref.type}:${ref.id}` : null; }
@@ -107,7 +119,7 @@ function routePinRequest(value, principal, wf) {
@@ -107,7 +119,7 @@ function routePinRequest(value, principal, wf) {
return { provider, model };
return { provider, model };
}
}
-function createRuns({ db, registry, engine, cache, quotas, config, clock = { now: () => Date.now() }, log = console, userModules = null, credentials = null, preferences: personPreferences = null, tiers = null, projectSecrets = null, pool = null, runBudgets = null, credits = null, community = null, presets = null, receipts = null, abuse = null, webhooks = null, batchWaiting = null }) {
+function createRuns({ db, registry, engine, cache, quotas, config, clock = { now: () => Date.now() }, log = console, userModules = null, credentials = null, preferences: personPreferences = null, tiers = null, projectSecrets = null, pool = null, runBudgets = null, credits = null, community = null, presets = null, receipts = null, abuse = null, webhooks = null, batchWaiting = null, batch = null }) {
const inflight = new Map(); // run id -> { controller, promise, release, caller }
const inflight = new Map(); // run id -> { controller, promise, release, caller }
// BA2: the runs that wait for a provider's own batch API: they are created at once and finish later.
// BA2: the runs that wait for a provider's own batch API: they are created at once and finish later.
const deferred = new Map(); // run id -> { controller, scope, ctx, input, hold, resolveDone }
const deferred = new Map(); // run id -> { controller, scope, ctx, input, hold, resolveDone }
@@ -268,7 +280,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -268,7 +280,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
const askedLatency = options.latency != null ? options.latency : null;
const askedLatency = options.latency != null ? options.latency : null;
const resolvedLatency = latency.resolve(workflowClass, askedLatency);
const resolvedLatency = latency.resolve(workflowClass, askedLatency);
if (resolvedLatency.error) throw new AiError(422, 'input.invalid', resolvedLatency.error, { latency_class: workflowClass });
if (resolvedLatency.error) throw new AiError(422, 'input.invalid', resolvedLatency.error, { latency_class: workflowClass });
- const latencyClass = resolvedLatency.class;
+ let latencyClass = resolvedLatency.class; // BA3 may move a batch-class run to the background class
// A faster class than the workflow allows was asked for: it runs in its own class, and the run's options say so.
// 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;
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
// Community capacity is opt-in per request: a person's machine sees the prompt, so only a caller that says so
@@ -475,6 +487,51 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -475,6 +487,51 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
}
}
}
}
+ // BA3: a run whose resolved class is 'batch' waits for a provider's own batch API whenever the
+ // route has one whose real batch price fits the half-price card (the same money rule as capacity
+ // route (d)). The run is created at once; the provider's answer finishes it on the ordinary path.
+ // When no provider on the route can batch, the run runs in the background class at once and says so.
+ let autoBatch = null;
+ let batchDeadline = null;
+ if (latencyClass === 'batch' && !(principal && principal.deferBatch)) {
+ batchDeadline = batchDeadlineHours(options.deadline_hours);
+ // A run paid by somebody else's key or by a free allowance is not put on the network's batch
+ // provider; a free-only app run cannot be placed on paid capacity either.
+ const appFreeOnly = Boolean(ctx.appRun && (ctx.sandbox || !(tiers && tiers.enabled)));
+ let prepared = null;
+ if (batch && typeof batch.planRun === 'function' && engine.prepareBatch
+ && credentialSubject === null && !projectProvider && !selfKeys && !freeUse && !communityOptIn && !appFreeOnly) {
+ try {
+ prepared = await engine.prepareBatch(wf, { template_key: tpl ? tpl.key : null, template_version: tpl ? tpl.version : null }, input, { ...ctx, runId: id });
+ } catch (err) {
+ log.warn(`[runs] ${id}: the batch request could not be prepared: ${(err && err.message) || err}`);
+ }
+ }
+ if (prepared && pool && typeof pool.offers === 'function') {
+ let offers = await pool.offers(prepared.route, prepared.operation, ctx).catch(() => []);
+ // A pinned run waits on the provider it pinned, never on another eligible one.
+ if (pinned) offers = offers.filter((o) => o.provider === pinned.provider && (pinned.model == null || o.model === pinned.model));
+ if (offers.length) autoBatch = await batch.planRun({ prepared, offers, principal, ctx, input, requestId: requestId || id, maxCostNano });
+ }
+ if (autoBatch) {
+ ctx.discount = 'half';
+ ctx.latencyClass = 'batch';
+ options.deadline_hours = batchDeadline;
+ options.batch = { provider: autoBatch.provider, model: autoBatch.model, deadline_hours: batchDeadline };
+ } else {
+ latencyClass = 'background';
+ ctx.latencyClass = 'background';
+ delete ctx.discount;
+ options.latency_clamped = { asked: 'batch', used: 'background' };
+ }
+ base.latency_class = latencyClass;
+ base.options = JSON.stringify({
+ ...parseJson(base.options, {}),
+ ...(options.latency_clamped ? { latency_clamped: options.latency_clamped } : {}),
+ ...(autoBatch ? { deadline_hours: batchDeadline, batch: { provider: autoBatch.provider, model: autoBatch.model } } : {}),
+ });
+ }
+
// Queue caps, then quota: both before any provider call (a refused run is not counted).
// Queue caps, then quota: both before any provider call (a refused run is not counted).
const caller = `${requester.type}:${requester.id}`;
const caller = `${requester.type}:${requester.id}`;
// Kudos bought with volunteer service start this run earlier in the queue (never cash, never a bypass of limits).
// Kudos bought with volunteer service start this run earlier in the queue (never cash, never a bypass of limits).
@@ -487,8 +544,8 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -487,8 +544,8 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
const hold = credentialSubject !== null || projectProvider || selfKeys || !tiers ? null : await tiers.admit(ctx, id);
const hold = credentialSubject !== null || projectProvider || selfKeys || !tiers ? null : await tiers.admit(ctx, id);
let reserved;
let reserved;
try {
try {
- // BA2: a batch run reserves its quota when the answer is here, not while it waits.
- reserved = credentialSubject !== null || selfKeys || (principal && principal.deferBatch) ? null : await quotas.reserve(ctx);
+ // BA2/BA3: a batch run reserves its quota when the answer is here, not while it waits.
+ reserved = credentialSubject !== null || selfKeys || (principal && principal.deferBatch) || autoBatch ? null : await quotas.reserve(ctx);
} catch (err) { if (hold) await tiers.release(hold); throw err; }
} catch (err) { if (hold) await tiers.release(hold); throw err; }
try {
try {
@@ -511,6 +568,46 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -511,6 +568,46 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
}
}
}
}
+ // BA3: the run waits for the provider's own batch API. It is created now (its class says 'batch')
+ // and takes no execution slot and no quota until the answer is here; the batcher then finishes it
+ // on the ordinary path, with the ordinary settle, receipt and events. A caller that waits (?wait=)
+ // is given the queued run at once (202 from the API), never held for hours.
+ if (autoBatch) {
+ const controller = new AbortController();
+ let resolveDone;
+ const done = new Promise((r) => { resolveDone = r; });
+ try {
+ deferred.set(id, { controller, scope, ctx, input, hold, resolveDone, runLevel: true });
+ inflight.set(id, { controller, promise: done, release: () => {}, caller, priority });
+ await batch.enqueueRun({ runId: id, plan: autoBatch, deadlineHours: batchDeadline });
+ return { run: await get(id), created: true, promise: null };
+ } catch (err) {
+ // The queue could not take it (the provider's batch table is unreachable, say): run it in
+ // the background class now rather than leave it queued for ever.
+ deferred.delete(id);
+ inflight.delete(id);
+ log.warn(`[runs] ${id}: the provider's batch queue did not take the run (${(err && err.message) || err}); it runs in the background class`);
+ latencyClass = 'background';
+ ctx.latencyClass = 'background';
+ delete ctx.discount;
+ const opts = { ...parseJson(base.options, {}), latency_clamped: { asked: 'batch', used: 'background' } };
+ delete opts.deadline_hours;
+ delete opts.batch;
+ base.latency_class = 'background';
+ base.options = JSON.stringify(opts);
+ await db.prepare("UPDATE runs SET latency_class = 'background', options = ? WHERE id = ?").run(base.options, id);
+ try {
+ reserved = credentialSubject !== null || selfKeys ? null : await quotas.reserve(ctx);
+ } catch (qerr) {
+ await db.prepare("UPDATE runs SET status = 'failed', error_code = ?, error_detail = ?, finished_at = ? WHERE id = ?")
+ .run(qerr.code || 'quota.refused', String(qerr.detail || qerr.message || 'quota refused').slice(0, 500), iso(clock.now()), id);
+ await events.runChanged(await getRow(id));
+ if (hold) await tiers.release(hold);
+ throw qerr;
+ }
+ }
+ }
+
// BA2: a batch run waits for the provider's own batch API. It is created now (the customer sees
// BA2: a batch run waits for the provider's own batch API. It is created now (the customer sees
// it, and its latency class says 'batch'), but it takes no execution slot and no quota until the
// it, and its latency class says 'batch'), but it takes no execution slot and no quota until the
// answer is here; then it finishes on the ordinary path, with the ordinary settle and receipt.
// answer is here; then it finishes on the ordinary path, with the ordinary settle and receipt.
@@ -839,7 +936,9 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -839,7 +936,9 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
await credit.holdRoute({ card: batchAnswer.holdRetail || null, provider: batchAnswer.provider || null, model: batchAnswer.model || null, access: batchAnswer.access || null, maxTokens: batchAnswer.maxTokens || 4096 });
await credit.holdRoute({ card: batchAnswer.holdRetail || null, provider: batchAnswer.provider || null, model: batchAnswer.model || null, access: batchAnswer.access || null, maxTokens: batchAnswer.maxTokens || 4096 });
}
}
let r = null;
let r = null;
- if (batchAnswer && batchAnswer.ok) {
+ // BA3: a run-level answer resumes the workflow's own step (engine.execute reads it); a customer
+ // batch item's run is the ai.complete run the batcher created, finished by this shortcut.
+ if (batchAnswer && batchAnswer.ok && !batchAnswer.resume) {
r = { output: batchAnswer.output || { text: '' }, usage: batchAnswer.usage || { input: 0, output: 0, cached: 0, cache_write: 0 }, cost: Number(batchAnswer.cost) || 0, provider: batchAnswer.provider || null, model: batchAnswer.model || null, citations: [], explain: batchAnswer.explain || null, grounding: null, synthetic: false, fallbackUsed: false };
r = { output: batchAnswer.output || { text: '' }, usage: batchAnswer.usage || { input: 0, output: 0, cached: 0, cache_write: 0 }, cost: Number(batchAnswer.cost) || 0, provider: batchAnswer.provider || null, model: batchAnswer.model || null, citations: [], explain: batchAnswer.explain || null, grounding: null, synthetic: false, fallbackUsed: false };
}
}
if (!r) r = await engine.execute(wf, row, input, { signal: controller.signal, logRequest, debugRaw: Boolean(config.debugRawLog && opts.debug), inputHash: row.input_hash, preferences,
if (!r) r = await engine.execute(wf, row, input, { signal: controller.signal, logRequest, debugRaw: Boolean(config.debugRawLog && opts.debug), inputHash: row.input_hash, preferences,
@@ -872,6 +971,8 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -872,6 +971,8 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
...(credential ? { credential, role: input && typeof input.role === 'string' ? input.role : null } : {}),
...(credential ? { credential, role: input && typeof input.role === 'string' ? input.role : null } : {}),
...(billingDelta ? { onDelta: billingDelta } : {}),
...(billingDelta ? { onDelta: billingDelta } : {}),
...(onRoute ? { onRoute } : {}),
...(onRoute ? { onRoute } : {}),
+ // BA3: a run-level batch answer resumes the workflow's own step.
+ ...(batchAnswer ? { batchAnswer } : {}),
// BA1: the class this run may wait in: the router picks the tier from it.
// BA1: the class this run may wait in: the router picks the tier from it.
latencyClass: ctx.latencyClass || latency.DEFAULT,
latencyClass: ctx.latencyClass || latency.DEFAULT,
...(ctx.flexBudgetMs ? { flexBudgetMs: ctx.flexBudgetMs } : {}) });
...(ctx.flexBudgetMs ? { flexBudgetMs: ctx.flexBudgetMs } : {}) });
@@ -1010,6 +1111,10 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -1010,6 +1111,10 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
if (TERMINAL.has(row.status)) throw new AiError(409, 'run.terminal', `run is already ${row.status}`);
if (TERMINAL.has(row.status)) throw new AiError(409, 'run.terminal', `run is already ${row.status}`);
const h = inflight.get(id);
const h = inflight.get(id);
const r = await db.prepare("UPDATE runs SET status = 'cancelled', error_code = 'run.cancelled', error_detail = 'cancelled by caller', finished_at = ? WHERE id = ? AND status IN ('queued', 'running')").run(iso(clock.now()), id);
const r = await db.prepare("UPDATE runs SET status = 'cancelled', error_code = 'run.cancelled', error_detail = 'cancelled by caller', finished_at = ? WHERE id = ? AND status IN ('queued', 'running')").run(iso(clock.now()), id);
+ // BA3: a run that has not been handed to a provider yet leaves its batch queue, so the provider is
+ // never given work for a run nobody will read; a submitted one finishes and is charged as it must.
+ await db.prepare("DELETE FROM batch_queue WHERE run_id = ? AND status = 'queued'").run(id).catch(() => null);
+ deferred.delete(id);
if (h) {
if (h) {
h.controller.abort();
h.controller.abort();
const i = queue.indexOf(id);
const i = queue.indexOf(id);
@@ -1148,7 +1253,15 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -1148,7 +1253,15 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
const d = deferred.get(key);
const d = deferred.get(key);
if (d) {
if (d) {
deferred.delete(key);
deferred.delete(key);
- const ctx = { ...d.ctx, batchAnswer: Promise.resolve(answer), ...(costCheck ? { costCheck } : {}) };
+ let baseCtx = d.ctx;
+ if (answer && answer.reroute) {
+ // The deadline passed: the batcher moved the run to the background class before this call.
+ const row = await getRow(key);
+ baseCtx = { ...baseCtx, latencyClass: (row && row.latency_class) || 'background' };
+ // A run-level run re-routed to the background path is charged at the ordinary price, not half.
+ if (d.runLevel) { const rest = { ...baseCtx }; delete rest.discount; baseCtx = rest; }
+ }
+ const ctx = { ...baseCtx, batchAnswer: Promise.resolve(answer), ...(costCheck ? { costCheck } : {}) };
try {
try {
const reserved = await quotas.reserve(ctx).catch(() => null);
const reserved = await quotas.reserve(ctx).catch(() => null);
await execute(key, { controller: d.controller, reserved, scope: d.scope, ctx, input: d.input, hold: d.hold });
await execute(key, { controller: d.controller, reserved, scope: d.scope, ctx, input: d.input, hold: d.hold });
@@ -1163,7 +1276,8 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -1163,7 +1276,8 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
const row = await getRow(key);
const row = await getRow(key);
if (!row) return null;
if (!row) return null;
if (TERMINAL.has(row.status)) return null; // it already finished, or it was cancelled
if (TERMINAL.has(row.status)) return null; // it already finished, or it was cancelled
- const data = plan || {};
+ // A run-level run re-routed to the background path is charged at the ordinary price, not half.
+ const data = answer && answer.reroute && plan && plan.runLevel ? { ...plan, discount: null } : (plan || {});
// The answer is the provider's own batch answer, exactly as in the fast path.
// The answer is the provider's own batch answer, exactly as in the fast path.
const ctx = { ...(await rebuildCtx(row, data, costCheck)), batchAnswer: Promise.resolve(answer) };
const ctx = { ...(await rebuildCtx(row, data, costCheck)), batchAnswer: Promise.resolve(answer) };
const input = data.input && typeof data.input === 'object' ? data.input : parseJson(row.input, {});
const input = data.input && typeof data.input === 'object' ? data.input : parseJson(row.input, {});
@@ -1192,6 +1306,9 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -1192,6 +1306,9 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
const attribution = parseJson(row.attribution, null);
const attribution = parseJson(row.attribution, null);
const onBehalfOf = parseJson(row.on_behalf_of, null);
const onBehalfOf = parseJson(row.on_behalf_of, null);
const principal = data.principal || {};
const principal = data.principal || {};
+ // BA3: a run-level batch stores the scalars its own completion needs (the app, person and policy
+ // flags); a customer batch item has none and is rebuilt from its principal as before.
+ const saved = data.ctx && typeof data.ctx === 'object' ? data.ctx : {};
// The person the run was made for: the row names them, and the options say whether they pay.
// The person the run was made for: the row names them, and the options say whether they pay.
const personSubject = opts.person_pays !== undefined ? (onBehalfOf && onBehalfOf.id ? String(onBehalfOf.id) : null) : null;
const personSubject = opts.person_pays !== undefined ? (onBehalfOf && onBehalfOf.id ? String(onBehalfOf.id) : null) : null;
const tokenBudget = runBudgets && principal.jti ? await runBudgets.spent(principal.jti).catch(() => null) : null;
const tokenBudget = runBudgets && principal.jti ? await runBudgets.spent(principal.jti).catch(() => null) : null;
@@ -1199,6 +1316,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -1199,6 +1316,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
|| Boolean(tokenBudget && (tokenBudget.budget_usd != null || tokenBudget.budget_tokens != null));
|| Boolean(tokenBudget && (tokenBudget.budget_usd != null || tokenBudget.budget_tokens != null));
const isApp = principal.kind === 'app';
const isApp = principal.kind === 'app';
return {
return {
+ ...saved,
requesterType: row.requester_type,
requesterType: row.requester_type,
requesterId: row.requester_id,
requesterId: row.requester_id,
...(principal.jti ? { runJti: principal.jti } : {}),
...(principal.jti ? { runJti: principal.jti } : {}),
@@ -1211,7 +1329,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -1211,7 +1329,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
personSubject,
personSubject,
personPays: Boolean(opts.person_pays),
personPays: Boolean(opts.person_pays),
serviceBudgeted,
serviceBudgeted,
- freeUse: false, // a batch run is never the anonymous Try it
+ freeUse: saved.freeUse === true, // a customer batch run is never the anonymous Try it
...(data.discount ? { discount: String(data.discount) } : {}),
...(data.discount ? { discount: String(data.discount) } : {}),
...(data.maxCostNano != null ? { maxCostNano: String(data.maxCostNano) } : {}),
...(data.maxCostNano != null ? { maxCostNano: String(data.maxCostNano) } : {}),
...(costCheck ? { costCheck } : {}),
...(costCheck ? { costCheck } : {}),
@@ -34,6 +34,16 @@ const { preferenceLines } = require('../user-modules');
@@ -34,6 +34,16 @@ const { preferenceLines } = require('../user-modules');
const schemas = require('../schemas');
const schemas = require('../schemas');
const { PREPARE, POSTPROCESS } = require('./hooks');
const { PREPARE, POSTPROCESS } = require('./hooks');
const modelCatalog = require('../providers/model-catalog');
const modelCatalog = require('../providers/model-catalog');
+const { buildBody, buildCompleteBody } = require('../providers/openai');
+
+/** The last user message's text, for the queue's own record of what one item asks (never sent as-is). */
+function lastUserText(messages) {
+ for (let i = (messages || []).length - 1; i >= 0; i--) {
+ const m = messages[i];
+ if (m && m.role === 'user' && typeof m.content === 'string') return m.content.slice(0, 20000);
+ }
+ return '';
+}
const ROLE_TIMEOUT = { chat: 20000, vision: 30000, director: 25000, summary: 30000, legacy: 30000 };
const ROLE_TIMEOUT = { chat: 20000, vision: 30000, director: 25000, summary: 30000, legacy: 30000 };
const INLINE_AUDIO_MAX = 1024 * 1024;
const INLINE_AUDIO_MAX = 1024 * 1024;
@@ -120,6 +130,28 @@ function normMessages(messages, user) {
@@ -120,6 +130,28 @@ function normMessages(messages, user) {
return out;
return out;
}
}
+/**
+ * BA3: the answer of a provider's own batch API, when it is resuming the step that asked for it. The
+ * batcher says `resume: true` and which kind of step the request was; the step then shapes the stored
+ * answer exactly as it shapes a live one.
+ */
+function batchAnswerFor(ctx, stepKind) {
+ const b = ctx && ctx.batchAnswer;
+ return b && b.ok === true && b.resume === true && b.stepKind === stepKind ? b : null;
+}
+
+/** The exec shape the steps use, built from a batch answer (no provider call, the batch cost). */
+function batchExec(b, result) {
+ const usage = b.usage || {};
+ return {
+ result,
+ provider: b.provider || null, model: b.model || null,
+ usage: { input: Number(usage.input || 0), output: Number(usage.output || 0), cached: Number(usage.cached || 0) },
+ cost: Number(b.cost) || 0, serviceTier: 'batch', savedUsd: 0,
+ explain: b.explain || null, synthetic: false, fallbackUsed: false,
+ };
+}
+
function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spawnImpl, log = console, research = null }) {
function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spawnImpl, log = console, research = null }) {
// One loopback reader for everything that streams media (server/media-proxy.js): transcripts and media.analyze.
// One loopback reader for everything that streams media (server/media-proxy.js): transcripts and media.analyze.
let proxy = null;
let proxy = null;
@@ -134,7 +166,13 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
@@ -134,7 +166,13 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
return r;
return r;
}
}
- async function llmStep(step, input, wf, run, ctx) {
+ /**
+ * Everything an llm step needs before its provider call: the template it pinned, the rendered system and
+ * user prompts, the route, the optional image and the request the pool would send. BA3 splits this out of
+ * llmStep so the same request can be sent to a provider's own batch API (prepareBatch) and so a batch
+ * answer can finish the step on the ordinary path (batchAnswer) without calling a provider again.
+ */
+ async function prepareLlm(step, input, wf, run, ctx) {
// The run pinned its template version when it was created; later steps use the active one.
// The run pinned its template version when it was created; later steps use the active one.
const template = run.template_key === step.template && run.template_version
const template = run.template_key === step.template && run.template_version
? await registry.getTemplate(step.template, run.template_version)
? await registry.getTemplate(step.template, run.template_version)
@@ -174,7 +212,19 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
@@ -174,7 +212,19 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
timeoutMs: p.timeout_ms || route.timeout_ms || 30000,
timeoutMs: p.timeout_ms || route.timeout_ms || 30000,
cacheKey: `${wf.key}:${wf.version}`,
cacheKey: `${wf.key}:${wf.version}`,
};
};
- const exec = await pool.execute(route, step.operation || 'chat', req, { ...ctx, routeKey: route.key, routeVersion: route.version, promptHash: sha256({ s: system, u: user, i: image ? image.base64.length : 0 }) });
+ return { template, prep, vars, system, user, route, jsonSchema, image, req };
+ }
+
+ async function llmStep(step, input, wf, run, ctx) {
+ const p = await prepareLlm(step, input, wf, run, ctx);
+ if (p.output !== undefined) return { output: p.output, noProvider: true, template: p.template };
+ const { template, system, user, route, jsonSchema, image, req } = p;
+ // BA3: the answer came from the provider's own batch API. The step shapes it as it shapes a live
+ // answer (JSON parse, postprocess, citations); no provider is called again.
+ const batch = batchAnswerFor(ctx, 'llm');
+ const exec = batch
+ ? batchExec(batch, { text: (batch.output && batch.output.text) || '' })
+ : await pool.execute(route, step.operation || 'chat', req, { ...ctx, routeKey: route.key, routeVersion: route.version, promptHash: sha256({ s: system, u: user, i: image ? image.base64.length : 0 }) });
// synthetic: the stub answered (no provider configured, or a test); a postprocess that checks an answer's
// synthetic: the stub answered (no provider configured, or a test); a postprocess that checks an answer's
// substance (a space needs a real index.html) lets a synthetic answer through in the right shape.
// substance (a space needs a real index.html) lets a synthetic answer through in the right shape.
const r = { text: exec.result.text || '', json: exec.result.json || null, synthetic: Boolean(exec.synthetic) };
const r = { text: exec.result.text || '', json: exec.result.json || null, synthetic: Boolean(exec.synthetic) };
@@ -210,7 +260,10 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
@@ -210,7 +260,10 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
cacheKey: input.cache_key || null,
cacheKey: input.cache_key || null,
prefer: ctx.prefer || null,
prefer: ctx.prefer || null,
};
};
- const exec = await pool.execute(route, 'chat', req, { ...ctx, routeKey: route.key, routeVersion: route.version, promptHash: sha256({ s: sys, m: messages }) });
+ const batch = batchAnswerFor(ctx, 'passthrough');
+ const exec = batch
+ ? batchExec(batch, { text: (batch.output && batch.output.text) || '' })
+ : await pool.execute(route, 'chat', req, { ...ctx, routeKey: route.key, routeVersion: route.version, promptHash: sha256({ s: sys, m: messages }) });
let json = exec.result.json || null;
let json = exec.result.json || null;
if (input.json && !json) json = parseJsonLoose(exec.result.text);
if (input.json && !json) json = parseJsonLoose(exec.result.text);
return { output: { text: exec.result.text || '', json }, exec, route };
return { output: { text: exec.result.text || '', json }, exec, route };
@@ -234,6 +287,59 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
@@ -234,6 +287,59 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
: { policy: selection.policy, aliasPolicy: selection.policy };
: { policy: selection.policy, aliasPolicy: selection.policy };
}
}
+ /**
+ * BA3: the provider request of a run that may wait for a provider's own batch. Only a workflow with a
+ * single provider step can be batched: a batch API answers one request, not a workflow's later steps.
+ * The body is built for the model the run is placed on (the batcher knows it after the money check).
+ *
+ * → { stepKind, operation, route, maxTokens, prompt, bodyFor(model) } or null.
+ */
+ async function prepareBatch(wf, run, input, ctx = {}) {
+ if (!wf || !Array.isArray(wf.steps) || wf.steps.length !== 1) return null;
+ const step = wf.steps[0];
+ if (step.kind === 'llm') {
+ // An image cannot travel in a provider batch request here: the answer would need the media file.
+ if (step.image && input && input[step.image]) return null;
+ const p = await prepareLlm(step, input, wf, run, ctx);
+ if (!p || p.output !== undefined || p.image) return null;
+ const bodyFor = (model) => buildBody({
+ model, system: p.req.system, messages: p.req.messages, image: null,
+ json: p.req.json, maxTokens: p.req.maxTokens, temperature: p.req.temperature,
+ cacheKey: null, baseUrl: '', serviceTier: null,
+ }, 'schema');
+ return { stepKind: 'llm', operation: step.operation || 'chat', route: p.route, maxTokens: p.req.maxTokens, bodyFor, prompt: lastUserText(p.req.messages) };
+ }
+ if (step.kind === 'complete') {
+ const { route } = await selectedRoute(await routeFor(step.route || 'default.chat'), input);
+ const openai = {};
+ for (const k of ['messages', 'tools', 'tool_choice', 'parallel_tool_calls', 'response_format', 'stop', 'seed', 'top_p', 'presence_penalty', 'frequency_penalty']) {
+ if (input[k] !== undefined) openai[k] = input[k];
+ }
+ if (!Array.isArray(openai.messages) || !openai.messages.length) return null;
+ const maxTokens = Math.max(1, Math.round(input.max_tokens || route.max_output_tokens || 1024));
+ const temperature = input.temperature == null ? null : input.temperature;
+ return {
+ stepKind: 'complete', operation: 'complete', route, maxTokens, prompt: lastUserText(openai.messages),
+ bodyFor: (model) => buildCompleteBody({ model, openai, maxTokens, temperature }),
+ };
+ }
+ if (step.kind === 'passthrough') {
+ if (input.image) return null;
+ const route = await routeFor(step.route || `${step.route_prefix || 'live'}.${input.role || 'legacy'}`);
+ const sys = typeof input.system === 'string' ? (input.system.trim() ? [{ text: input.system, cache: false }] : [])
+ : Array.isArray(input.system) ? input.system.filter(x => x && String(x.text || '').trim()).map(x => ({ text: String(x.text), cache: Boolean(x.cache) })) : [];
+ const messages = normMessages(input.messages, input.user);
+ const json = input.json ? { name: input.json.name || 'result', schema: input.json.schema, strict: input.json.strict !== false, description: input.json.description } : null;
+ const maxTokens = Math.max(1, Math.round(input.max_tokens || 400));
+ const temperature = input.temperature == null ? null : input.temperature;
+ return {
+ stepKind: 'passthrough', operation: 'chat', route, maxTokens, prompt: lastUserText(messages),
+ bodyFor: (model) => buildBody({ model, system: sys, messages, image: null, json, maxTokens, temperature, cacheKey: null, baseUrl: '', serviceTier: null }, 'schema'),
+ };
+ }
+ return null;
+ }
+
/** complete: the OpenAI Chat Completions request as the caller wrote it (ai.complete), to an upstream that speaks it. */
/** complete: the OpenAI Chat Completions request as the caller wrote it (ai.complete), to an upstream that speaks it. */
async function completeStep(step, input, wf, run, ctx) {
async function completeStep(step, input, wf, run, ctx) {
const { route, selection } = await selectedRoute(await routeFor(step.route || 'default.chat'), input);
const { route, selection } = await selectedRoute(await routeFor(step.route || 'default.chat'), input);
@@ -249,7 +355,10 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
@@ -249,7 +355,10 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
prefer: ctx.prefer || null,
prefer: ctx.prefer || null,
...(typeof ctx.onDelta === 'function' ? { onDelta: ctx.onDelta } : {}),
...(typeof ctx.onDelta === 'function' ? { onDelta: ctx.onDelta } : {}),
};
};
- const exec = await pool.execute(route, 'complete', req, { ...ctx, ...selectionCtx(selection), routeKey: route.key, routeVersion: route.version, promptHash: sha256({ m: openai.messages, t: openai.tools || null }) });
+ const batch = batchAnswerFor(ctx, 'complete');
+ const exec = batch
+ ? batchExec(batch, { completion: (batch.output && batch.output.completion) || null })
+ : await pool.execute(route, 'complete', req, { ...ctx, ...selectionCtx(selection), routeKey: route.key, routeVersion: route.version, promptHash: sha256({ m: openai.messages, t: openai.tools || null }) });
const c = exec.result.completion;
const c = exec.result.completion;
if (!c || !Array.isArray(c.choices) || !c.choices.length) throw new AiError(502, 'output.empty', 'the provider answered, but with no choices');
if (!c || !Array.isArray(c.choices) || !c.choices.length) throw new AiError(502, 'output.empty', 'the provider answered, but with no choices');
return { output: { choices: c.choices, system_fingerprint: c.system_fingerprint || null, model: exec.model }, exec, route };
return { output: { choices: c.choices, system_fingerprint: c.system_fingerprint || null, model: exec.model }, exec, route };
@@ -396,7 +505,7 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
@@ -396,7 +505,7 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
};
};
}
}
- return { execute, narrowCitations, research: () => megasource() };
+ return { execute, prepareBatch, narrowCitations, research: () => megasource() };
}
}
module.exports = { createEngine, narrowCitations, collectCited, collectGaps, groundingOf };
module.exports = { createEngine, narrowCitations, collectCited, collectGaps, groundingOf };
new file mode 100644
new file mode 100644
@@ -0,0 +1,319 @@
@@ -0,0 +1,319 @@
+'use strict';
+/**
+ * BA3: a run whose latency class is 'batch' waits for a provider's own batch API.
+ *
+ * - the run is created at once (202, status queued, latency_class 'batch') and takes no provider call;
+ * - the provider's batch answer finishes the same run, at the provider's batch price;
+ * - a restart while it waits leaves it queued, and the answer still finishes it;
+ * - ?wait= is answered at once with the queued run, never held for hours;
+ * - past deadline_hours the item re-routes to the background class once, and the run says it was clamped;
+ * - a route with no batch provider runs in the background class at once and says it was clamped.
+ */
+const assert = require('assert');
+const http = require('http');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { boot, request, token, suite, ALL } = require('./helpers');
+
+const t = suite('batch-run');
+const MODEL = 'gpt-4o-mini';
+
+/** A stub provider that speaks the OpenAI batch API and answers an ordinary chat call too. */
+function stubProvider() {
+ const state = { files: new Map(), batches: new Map(), seq: 0, calls: [] };
+ const server = http.createServer((req, res) => {
+ let raw = '';
+ req.on('data', (c) => { raw += c; });
+ req.on('end', () => {
+ const p = new URL(req.url, 'http://x').pathname;
+ let body = {};
+ try { body = raw ? JSON.parse(raw) : {}; } catch { body = {}; }
+ state.calls.push({ path: p, method: req.method, body });
+ const send = (status, out, type) => {
+ res.writeHead(status, { 'Content-Type': type || 'application/json' });
+ res.end(typeof out === 'string' ? out : JSON.stringify(out));
+ };
+ if (p === '/v1/files' && req.method === 'POST') {
+ const id = `file_${++state.seq}`;
+ state.files.set(id, body.file || '');
+ return send(200, { id, object: 'file', bytes: (body.file || '').length });
+ }
+ if (p === '/v1/batches' && req.method === 'POST') {
+ const id = `batch_${++state.seq}`;
+ state.batches.set(id, { input_file_id: body.input_file_id, status: 'in_progress', output_file_id: null });
+ return send(200, { id, object: 'batch', status: 'in_progress' });
+ }
+ if (p.startsWith('/v1/batches/')) {
+ const b = state.batches.get(p.split('/')[3]);
+ if (!b) return send(404, { error: { message: 'no such batch' } });
+ return send(200, { id: p.split('/')[3], object: 'batch', status: b.status, output_file_id: b.output_file_id, error_file_id: null });
+ }
+ if (p.startsWith('/v1/files/') && p.endsWith('/content')) {
+ return send(200, state.files.get(p.split('/')[3]) || '', 'application/x-ndjson');
+ }
+ if (p === '/v1/chat/completions') {
+ return send(200, { id: 'cmpl_1', object: 'chat.completion', model: body.model || MODEL,
+ choices: [{ index: 0, message: { role: 'assistant', content: 'the ordinary path answered' }, finish_reason: 'stop' }],
+ usage: { prompt_tokens: 5, completion_tokens: 5, total_tokens: 10 } });
+ }
+ return send(404, { error: { message: 'not found' } });
+ });
+ });
+ return new Promise((resolve) => {
+ server.listen(0, '127.0.0.1', () => {
+ const url = `http://127.0.0.1:${server.address().port}`;
+ resolve({
+ url, state,
+ close: () => new Promise((r) => { server.closeAllConnections?.(); server.close(() => r()); }),
+ /** The provider answers every request in its file. */
+ answer(id, { text = 'the batch answer' } = {}) {
+ const b = state.batches.get(id);
+ const lines = (state.files.get(b.input_file_id) || '').split('\n').filter(Boolean).map((l) => JSON.parse(l));
+ const out = lines.map((line) => JSON.stringify({
+ id: 'req_1', custom_id: line.custom_id,
+ response: { status_code: 200, body: { id: 'cmpl_1', object: 'chat.completion', model: line.body.model,
+ choices: [{ index: 0, message: { role: 'assistant', content: text }, finish_reason: 'stop' }],
+ usage: { prompt_tokens: 100, completion_tokens: 50, total_tokens: 150 } } },
+ error: null,
+ }));
+ const outId = `file_out_${++state.seq}`;
+ state.files.set(outId, out.join('\n'));
+ b.output_file_id = outId;
+ b.status = 'completed';
+ return id;
+ },
+ });
+ });
+ });
+}
+
+/** A booted service whose shared provider is the stub, with a price card for the model. */
+async function bootWith(stub, { env = {}, dir = undefined, priced = true } = {}) {
+ const h = await boot({ dir, env: { AI_BATCH_NATIVE_MAX_ITEMS: '2', AI_BATCH_NATIVE_MAX_AGE_MS: '60000', ...env } });
+ await h.registry.upsertProvider({ key: 'shared', base_url: `${stub.url}/v1`, auth_mode: 'none', provider_access: 'OWN_DEPLOYMENT' }, { actor: 'test' });
+ if (priced) await h.registry.upsertModel({ provider_key: 'shared', model_key: MODEL, type: 'chat', cost: { in_per_mtok: 1, out_per_mtok: 2, source: 'urn:openvibe:ai:test', verified_at: '2026-01-01' } }, { actor: 'test' });
+ return h;
+}
+
+/** A small fake Billing: the hold and the settle, and what they were told. */
+async function fakeBilling() {
+ const state = { balance: 1000000000000n, held: 0n, holds: new Map(), settlements: [], seq: 0 };
+ const server = http.createServer((req, res) => {
+ let raw = '';
+ req.on('data', (c) => { raw += c; });
+ req.on('end', () => {
+ const p = new URL(req.url, 'http://x').pathname;
+ let body = {};
+ try { body = raw ? JSON.parse(raw) : {}; } catch { body = {}; }
+ const send = (status, out) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(out)); };
+ if (p === '/internal/credits/holds') {
+ const want = BigInt(String(body.max_nano_usd || '0'));
+ if (want > state.balance - state.held) return send(402, { detail: 'not enough balance', title: 'Insufficient funds' });
+ const id = `hld_${++state.seq}`;
+ state.holds.set(body.idempotency_key, { hold_id: id, held_nano_usd: String(body.max_nano_usd) });
+ state.held += want;
+ return send(201, { hold_id: id, held_nano_usd: String(body.max_nano_usd), available_nano_usd: String(state.balance - state.held), lease_expires_at: new Date(Date.now() + 300000).toISOString() });
+ }
+ if (p.startsWith('/internal/credits/holds/') && p.endsWith('/settle')) {
+ state.settlements.push({ path: p, body });
+ const hold = [...state.holds.values()].find((x) => x.hold_id === decodeURIComponent(p.split('/')[4]));
+ return send(201, { charge_nano_usd: '0', released_nano_usd: String(hold ? hold.held_nano_usd : '0'), journal_id: `txn_${state.settlements.length}`, shortfall_nano_usd: '0' });
+ }
+ if (p.startsWith('/internal/credits/holds/') && p.endsWith('/release')) return send(200, { released: true });
+ if (p === '/oauth/token') return send(200, { access_token: 'test-token', token_type: 'Bearer', expires_in: 300, scope: '' });
+ if (p === '/api/v1/usage') return send(201, { ok: true });
+ return send(404, { error: 'not found' });
+ });
+ });
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const url = `http://127.0.0.1:${server.address().port}`;
+ return { url, state, close: () => new Promise((r) => { server.closeAllConnections?.(); server.close(() => r()); }) };
+}
+
+async function ageGroup(h) {
+ await h.db.prepare("UPDATE batch_queue SET created_at = ? WHERE status = 'queued'").run(new Date(Date.now() - 60000).toISOString());
+}
+
+const tok = () => token('all', ALL, { sub: 'svc:test' });
+const body = (extra = {}) => ({ workflow: 'ai.generate', input: { prompt: 'take your time' }, options: { latency: 'batch', cache: false }, ...extra });
+
+t.test('a batch-class run is queued, answered by the provider\'s batch, and charged the batch price', async () => {
+ const stub = await stubProvider();
+ const h = await bootWith(stub);
+ try {
+ const started = Date.now();
+ const res = await request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok: tok(), body: body() });
+ const took = Date.now() - started;
+ assert.strictEqual(res.status, 202, `the queued run is 202 (${res.text})`);
+ assert.strictEqual(res.body.run.status, 'queued');
+ assert.strictEqual(res.body.run.latency_class, 'batch');
+ assert.strictEqual(res.body.run.latency_clamped, null);
+ assert.ok(took < 3000, `?wait= did not hold the caller (${took} ms)`);
+ // The run has no provider call and waits in the database.
+ const requests = await h.db.prepare('SELECT COUNT(*) AS n FROM requests WHERE run_id = ?').get(res.body.run.id);
+ assert.strictEqual(Number(requests.n), 0);
+ const item = await h.db.prepare('SELECT * FROM batch_queue WHERE run_id = ?').get(res.body.run.id);
+ assert.ok(item, 'the run waits in the batch queue');
+ assert.strictEqual(item.kind, 'service');
+ assert.strictEqual(JSON.parse(item.plan).runLevel, true, `the plan is a run-level plan: ${item.plan}`);
+ assert.strictEqual(JSON.parse(item.plan).stepKind, 'llm');
+ assert.strictEqual(stub.state.calls.filter((c) => c.path === '/v1/batches').length, 0);
+
+ await ageGroup(h);
+ const submitted = await h.batcher.submitOnce();
+ assert.strictEqual(submitted.sent, 1);
+ const providerBatch = stub.state.batches.keys().next().value;
+ stub.answer(providerBatch, { text: 'the batch answer' });
+ await h.batcher.pollOnce();
+
+ const run = await h.db.prepare('SELECT * FROM runs WHERE id = ?').get(res.body.run.id);
+ assert.strictEqual(run.status, 'succeeded');
+ assert.strictEqual(run.latency_class, 'batch', 'the run it was created as');
+ assert.strictEqual(run.service_tier, 'batch', 'the tier the provider really ran');
+ assert.strictEqual(JSON.parse(run.output).text, 'the batch answer');
+ // 100 input tokens at 1 USD/M and 50 output at 2 USD/M, both at the provider's half-price batch.
+ const expected = Number((100n * 500000000n + 50n * 1000000000n) / 1000000n) / 1e9;
+ assert.strictEqual(Number(run.cost_usd), expected, 'the run cost is the batch price');
+ assert.ok(Number(run.cost_usd) > 0);
+ } finally { await h.stop(); await stub.close(); }
+});
+
+t.test('an ai.complete run is answered by the provider\'s batch with its choices intact', async () => {
+ const stub = await stubProvider();
+ const h = await bootWith(stub);
+ try {
+ const res = await request(h.base, 'POST', '/api/v1/runs?wait=5000', {
+ tok: tok(),
+ body: { workflow: 'ai.complete', input: { messages: [{ role: 'user', content: 'hello' }] }, options: { latency: 'batch', cache: false } },
+ });
+ assert.strictEqual(res.status, 202, res.text);
+ await ageGroup(h);
+ await h.batcher.submitOnce();
+ stub.answer(stub.state.batches.keys().next().value, { text: 'the complete batch answer' });
+ await h.batcher.pollOnce();
+ const run = await h.db.prepare('SELECT * FROM runs WHERE id = ?').get(res.body.run.id);
+ assert.strictEqual(run.status, 'succeeded');
+ const out = JSON.parse(run.output);
+ assert.strictEqual(out.choices[0].message.content, 'the complete batch answer');
+ assert.strictEqual(Number(run.tokens_in), 100);
+ assert.strictEqual(Number(run.tokens_out), 50);
+ } finally { await h.stop(); await stub.close(); }
+});
+
+t.test('a restart while the run waits leaves it queued, and the batch answer still finishes it', async () => {
+ const stub = await stubProvider();
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-batch-run-restart-'));
+ const h1 = await bootWith(stub, { dir });
+ let runId;
+ try {
+ const res = await request(h1.base, 'POST', '/api/v1/runs?wait=5000', { tok: tok(), body: body() });
+ assert.strictEqual(res.status, 202, res.text);
+ runId = res.body.run.id;
+ await h1.stop();
+
+ const h2 = await bootWith(stub, { dir });
+ try {
+ const left = await h2.db.prepare('SELECT status, latency_class FROM runs WHERE id = ?').get(runId);
+ assert.strictEqual(left.status, 'queued', 'the restart did not fail the waiting run');
+ assert.strictEqual(left.latency_class, 'batch');
+ await ageGroup(h2);
+ await h2.batcher.submitOnce();
+ stub.answer(stub.state.batches.keys().next().value, { text: 'the answer after the restart' });
+ await h2.batcher.pollOnce();
+ const done = await h2.db.prepare('SELECT status, output, cost_usd FROM runs WHERE id = ?').get(runId);
+ assert.strictEqual(done.status, 'succeeded');
+ assert.strictEqual(JSON.parse(done.output).text, 'the answer after the restart');
+ assert.ok(Number(done.cost_usd) > 0, 'the batch cost is on the run');
+ } finally { await h2.stop(); }
+ } finally { await stub.close(); }
+});
+
+t.test('past deadline_hours the run re-routes to the background class once, and says it was clamped', async () => {
+ const stub = await stubProvider();
+ const h = await bootWith(stub);
+ try {
+ const res = await request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok: tok(), body: body({ options: { latency: 'batch', deadline_hours: 1, cache: false } }) });
+ assert.strictEqual(res.status, 202, res.text);
+ // The deadline passes while the item still waits for a group.
+ await h.db.prepare('UPDATE batch_queue SET deadline_at = ? WHERE run_id = ?').run(new Date(Date.now() - 1000).toISOString(), res.body.run.id);
+ await h.batcher.submitOnce();
+ // It was never handed to the provider: it re-routed instead.
+ assert.strictEqual(stub.state.calls.filter((c) => c.path === '/v1/batches').length, 0);
+ const item = await h.db.prepare('SELECT status FROM batch_queue WHERE run_id = ?').get(res.body.run.id);
+ assert.strictEqual(item.status, 'rerouted');
+ const run = await h.db.prepare('SELECT * FROM runs WHERE id = ?').get(res.body.run.id);
+ assert.strictEqual(run.status, 'succeeded', 'the background path answered it');
+ assert.strictEqual(run.latency_class, 'background');
+ assert.deepStrictEqual(JSON.parse(run.options).latency_clamped, { asked: 'batch', used: 'background' });
+ assert.strictEqual(JSON.parse(run.output).text, 'the ordinary path answered');
+ // Once: another pass does not send it or run it again.
+ await h.batcher.submitOnce();
+ await h.batcher.pollOnce();
+ assert.strictEqual(stub.state.calls.filter((c) => c.path === '/v1/batches').length, 0);
+ const after = await h.db.prepare('SELECT status FROM batch_queue WHERE run_id = ?').get(res.body.run.id);
+ assert.strictEqual(after.status, 'rerouted');
+ } finally { await h.stop(); await stub.close(); }
+});
+
+t.test('a route with no batch provider runs in the background class at once and says it was clamped', async () => {
+ const stub = await stubProvider();
+ const h = await bootWith(stub, { priced: false });
+ try {
+ const res = await request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok: tok(), body: body() });
+ assert.strictEqual(res.status, 201, `no provider can batch, so the run answers (${res.text})`);
+ assert.strictEqual(res.body.run.latency_class, 'background');
+ assert.deepStrictEqual(res.body.run.latency_clamped, { asked: 'batch', used: 'background' });
+ assert.strictEqual((await h.db.prepare('SELECT COUNT(*) AS n FROM batch_queue').get()).n, 0, 'nothing waits for a batch');
+ assert.strictEqual(JSON.parse(JSON.stringify(res.body.run.output)).text, 'the ordinary path answered');
+ } finally { await h.stop(); await stub.close(); }
+});
+
+t.test('the ordinary payer is charged the batch price, and the Balance is checked when the run is queued', async () => {
+ const stub = await stubProvider();
+ const billing = await fakeBilling();
+ const h = await bootWith(stub, {
+ env: {
+ AI_CREDITS: '1', USAGE_SAMPLES: 'off',
+ OV_BILLING_INTERNAL_URL: billing.url, OV_NETWORK_INTERNAL_URL: billing.url,
+ OV_OAUTH_CLIENT_SECRET: 'test-secret', OV_OAUTH_CLIENT_ID: 'ai',
+ },
+ });
+ try {
+ const person = token('dana', ALL, { sub: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2B3', actorType: 'user' });
+ const res = await request(h.base, 'POST', '/api/v1/runs?wait=5000', {
+ tok: person,
+ body: { workflow: 'ai.complete', input: { messages: [{ role: 'user', content: 'hello' }] }, options: { latency: 'batch', cache: false } },
+ });
+ assert.strictEqual(res.status, 202, res.text);
+ // BA2's probe checked the person's Balance when the run was queued (under its own key).
+ assert.ok([...billing.state.holds.keys()].some((k) => String(k).includes(':probe:')), 'the queued run probed the Balance');
+ await ageGroup(h);
+ await h.batcher.submitOnce();
+ stub.answer(stub.state.batches.keys().next().value, { text: 'the paid batch answer' });
+ await h.batcher.pollOnce();
+
+ const settle = billing.state.settlements[0];
+ assert.ok(settle, 'the ordinary payer was charged');
+ assert.strictEqual(settle.body.retail.nano_usd_per_m.input, '500000000', 'half the retail input price');
+ assert.strictEqual(settle.body.retail.nano_usd_per_m.output, '1000000000', 'half the retail output price');
+ const run = await h.db.prepare('SELECT status, cost_usd, service_tier FROM runs WHERE id = ?').get(res.body.run.id);
+ assert.strictEqual(run.status, 'succeeded');
+ assert.strictEqual(run.service_tier, 'batch');
+ assert.strictEqual(String(settle.body.supply_cost_nano_usd), String(Math.round(Number(run.cost_usd) * 1e9)));
+ } finally { await h.stop(); await stub.close(); await billing.close(); }
+});
+
+t.test('deadline_hours is 1 to 72', async () => {
+ const stub = await stubProvider();
+ const h = await bootWith(stub);
+ try {
+ for (const hours of [0, 73, 'soon']) {
+ const res = await request(h.base, 'POST', '/api/v1/runs', { tok: tok(), body: body({ options: { latency: 'batch', deadline_hours: hours } }) });
+ assert.strictEqual(res.status, 422, `${hours}: ${res.text}`);
+ assert.strictEqual(res.body.code, 'input.invalid');
+ }
+ } finally { await h.stop(); await stub.close(); }
+});
+
+t.run();
@@ -244,11 +244,12 @@ t.test('a caller cannot make a background workflow interactive (it still runs, i
@@ -244,11 +244,12 @@ t.test('a caller cannot make a background workflow interactive (it still runs, i
assert.strictEqual(fast.body.run.latency_class, 'background', 'a background workflow stays in the background');
assert.strictEqual(fast.body.run.latency_class, 'background', 'a background workflow stays in the background');
// BA3: the run says that it was asked for more than it can give: the class asked for and the class used.
// BA3: the run says that it was asked for more than it can give: the class asked for and the class used.
assert.deepStrictEqual(fast.body.run.latency_clamped, { asked: 'interactive', used: 'background' }, 'the run says it was clamped');
assert.deepStrictEqual(fast.body.run.latency_clamped, { asked: 'interactive', used: 'background' }, 'the run says it was clamped');
- // A slower class is allowed.
+ // A slower class is allowed. This route has no priced model, so no provider's batch price can be
+ // checked: the run stays on the ordinary path, in the background class, and says it was clamped.
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 } } });
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');
- assert.strictEqual(slower.body.run.latency_clamped, null, 'a slower ask is not clamped');
+ assert.strictEqual(slower.status, 201, `a slower class is answered (${slower.text})`);
+ assert.strictEqual(slower.body.run.latency_class, 'background', 'no batch provider on the route: the background class');
+ assert.deepStrictEqual(slower.body.run.latency_clamped, { asked: 'batch', used: 'background' }, 'and the run says so');
// The plain run is background, on the Flex tier, and says so.
// 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 } } });
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.status, 201, run.text);