openvibers/openvibe.host Commits
D11 owner settings: host.inference_planner (#284)
Changes
@@@ -38,3 +38,15 @@ OV_WATCH_URL=https://openvibe.watch#HOST_CONSOLE_MAX_ACCOUNTS=10# OpenVibe.Events → Host (account export and deletion): signs deliveries to /internal/events (32+ characters).#HOST_EVENTS_SECRET=++++# Inference placement planner (staff-only, recommendations only). Demand is the planning JSON by default;++# SOURCE=ai reads OpenVibe.AI's aggregate usage series with Host's service token (Network grant:++# ai.usage.read on openvibe.ai) and caches it ten minutes. A feed failure falls back only to a configured INPUT.++#HOST_INFERENCE_PLANNER=off++#HOST_INFERENCE_PLANNER_INPUT=++#HOST_INFERENCE_PLANNER_SOURCE=json++#OV_AI_INTERNAL_URL=http://127.0.0.1:4700++#HOST_INFERENCE_PLANNER_DAYS=28++#HOST_INFERENCE_PLANNER_CACHE_MS=600000++#HOST_INFERENCE_PLANNER_TIMEOUT_MS=5000++#HOST_INFERENCE_BUDGET_NANO_USD=@@@ -380,9 +380,9 @@ the owner's gates (go-ahead, a DMCA agent, a Public Suffix List entry for openviPart of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).<!-- versions:start -->--- openvibe-contracts: v0.272.0++- openvibe-contracts: v0.288.0- openvibe-sdk: v0.82.0--- openvibe-shared: v4.109.2++- openvibe-shared: v4.110.0- openvibe-publishing: v1.4.0<!-- versions:end -->---@@@ -13,13 +13,13 @@"runtime": "node 22; Express 4 + PostgreSQL 18 (openvibe-sdk/db, async) + optional Valkey","port": 4910,"domain": "openvibe.host",-- "contracts": "openvibe-contracts v0.272.0",++ "contracts": "openvibe-contracts v0.288.0","sdk": "openvibe-sdk v0.82.0",-- "shared": "openvibe-shared v4.109.2",++ "shared": "openvibe-shared v4.110.0","packages": [-- "openvibe-contracts v0.272.0",++ "openvibe-contracts v0.288.0","openvibe-sdk v0.82.0",-- "openvibe-shared v4.109.2"++ "openvibe-shared v4.110.0"],"capabilities": ["host.catalog.manage"nnew file mode 100644@@@ -0,0 +1,28 @@++-- phase: expand++-- D11 owner settings: openvibe-shared/config (config_snapshots, config_keys). Applied migrations are immutable.++CREATE TABLE IF NOT EXISTS config_snapshots (++ namespace text COLLATE "C" NOT NULL,++ revision bigint NOT NULL,++ service text COLLATE "C" NOT NULL,++ previous_revision bigint,++ state text COLLATE "C" NOT NULL CHECK (state IN ('proposed', 'active', 'superseded', 'rejected', 'rolled_back')),++ values_json text COLLATE "C" NOT NULL, -- the full values; secret-class values are envelopes (never their plaintext)++ classification_json text COLLATE "C" NOT NULL,++ values_checksum text COLLATE "C" NOT NULL, -- sha256 of the values as served (secrets as markers)++ created_at text COLLATE "C" NOT NULL,++ created_by text COLLATE "C" NOT NULL,++ activated_at text COLLATE "C",++ activated_by text COLLATE "C",++ reason text COLLATE "C",++ error text COLLATE "C",++ copied_from bigint,++ good bigint NOT NULL DEFAULT 0, -- 1 once it activated successfully (last-known-good)++ PRIMARY KEY (namespace, revision)++);++CREATE UNIQUE INDEX IF NOT EXISTS config_snapshots_one_active ON config_snapshots (namespace) WHERE state = 'active';++-- The fingerprint key of each namespace. Never served, never logged.++CREATE TABLE IF NOT EXISTS config_keys (++ namespace text COLLATE "C" PRIMARY KEY,++ hmac_key bytea NOT NULL, -- the 32-byte fingerprint key, or that key wrapped under the current KEK++ created_at text COLLATE "C" NOT NULL++);@@@ -15,10 +15,10 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.272.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.288.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.110.0","pg": "^8.23.0"},"bin": {@@@ -1534,9 +1534,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.272.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.272.0",-- "integrity": "sha512-qZc5WDgPcEQinxHPi1/pe4vvI3hoSgrq5rSio9bjeTKj9BTFIIgy33KzOEL9vsW7baGnAlxusnaGuHQUnh9xeg==",++ "version": "0.288.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.288.0",++ "integrity": "sha512-AKRP1yXACYNu7okpf4kCDq4ds0PhsoNvD92VlvBWBCjcSlNimSTQhLWIvSa3D31/kQYz91fodo06ZnwuTanOqg==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -1596,9 +1596,9 @@}},"node_modules/openvibe-shared": {-- "version": "4.109.2",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",-- "integrity": "sha512-t9K1yHr6GOJsfamqmgERRTTtiWYWAVmjWBEm4ykTcYjEenbTiqQTwX6CJsZW4lYnqS1IlqEvn+0jG4vXmHSJ3Q==",++ "version": "4.110.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.110.0",++ "integrity": "sha512-ZNOSraSgE89jhLTI//lFJALvKCiBJIBwf+V0aCkiIk0VCrPPAytsfsgOgCINU8kXDVdCEnuWWfcA9hZUc54QVA==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0",@@@ -31,10 +31,10 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.272.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.288.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.110.0","pg": "^8.23.0"},"engines": {nnew file mode 100644@@@ -0,0 +1,287 @@++{++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/schemas/inference-planner.v1.json",++ "title": "InferencePlannerInput",++ "description": "Inputs for OpenVibe.Host's inference placement planner (PLAN.md §D, §E and §G-P7). It forecasts a daily request series and compares the monthly cost of a warm rented GPU, scale-to-zero and wholesale for every model × GPU-class candidate. Every price is an integer nano-USD (D1). There is no action: this document can recommend a rental but never starts, stops or rents anything.",++ "type": "object",++ "required": ["forecast", "request", "models", "gpu_classes"],++ "additionalProperties": false,++ "properties": {++ "$schema": {++ "type": "string",++ "description": "Optional editor association with this schema file."++ },++ "note": {++ "type": "string",++ "description": "Provenance, for example 'example, not a quote' or the quote ids and measurement dates."++ },++ "forecast": {++ "type": "object",++ "required": ["series"],++ "additionalProperties": false,++ "properties": {++ "horizon_days": {++ "type": "integer",++ "minimum": 1,++ "maximum": 365,++ "default": 30,++ "description": "Days to forecast after the last series point."++ },++ "window_days": {++ "type": "integer",++ "minimum": 7,++ "maximum": 365,++ "default": 28,++ "description": "Trailing days used for the moving average and the day-of-week factors; not more than the series length."++ },++ "series": {++ "type": "array",++ "minItems": 14,++ "description": "Consecutive daily request counts, oldest first. Dates must be calendar days and every point must be the next day.",++ "items": {++ "type": "object",++ "required": ["date", "requests"],++ "additionalProperties": false,++ "properties": {++ "date": {++ "type": "string",++ "format": "date",++ "description": "UTC calendar day (YYYY-MM-DD)."++ },++ "requests": {++ "type": "integer",++ "minimum": 0,++ "maximum": 1000000000,++ "description": "Requests counted on that day."++ }++ }++ }++ }++ }++ },++ "request": {++ "type": "object",++ "required": ["input_tokens", "cached_read_tokens", "cache_write_tokens", "output_tokens"],++ "additionalProperties": false,++ "properties": {++ "input_tokens": {++ "type": "integer",++ "minimum": 0,++ "maximum": 1000000000,++ "description": "I: fresh input tokens per request."++ },++ "cached_read_tokens": {++ "type": "integer",++ "minimum": 0,++ "maximum": 1000000000,++ "description": "R: input tokens read from a prompt cache per request."++ },++ "cache_write_tokens": {++ "type": "integer",++ "minimum": 0,++ "maximum": 1000000000,++ "description": "W: input tokens written to a prompt cache per request."++ },++ "output_tokens": {++ "type": "integer",++ "minimum": 0,++ "maximum": 1000000000,++ "description": "O: generated output tokens per request."++ }++ }++ },++ "models": {++ "type": "array",++ "minItems": 1,++ "description": "Model candidates. Every model is paired with every GPU class.",++ "items": {++ "type": "object",++ "required": ["id", "throughput", "success_bps", "wholesale"],++ "additionalProperties": false,++ "properties": {++ "id": {++ "type": "string",++ "minLength": 1,++ "description": "Exact model id, for example qwen/qwen3-9b:q4_k_m@3f2a1c9."++ },++ "label": {++ "type": "string",++ "description": "Display name; defaults to id."++ },++ "throughput": {++ "type": "object",++ "required": ["prefill_tokens_per_sec", "decode_tokens_per_sec"],++ "additionalProperties": false,++ "properties": {++ "prefill_tokens_per_sec": {++ "type": "number",++ "exclusiveMinimum": 0,++ "description": "Measured prompt-processing throughput at the stated concurrency."++ },++ "decode_tokens_per_sec": {++ "type": "number",++ "exclusiveMinimum": 0,++ "description": "Measured output throughput at the stated concurrency."++ },++ "cached_read_tokens_per_sec": {++ "type": "number",++ "exclusiveMinimum": 0,++ "description": "Optional measured cached-read throughput; omitted means prefill throughput (conservative)."++ },++ "concurrency": {++ "type": "integer",++ "minimum": 1,++ "maximum": 1024,++ "default": 1,++ "description": "Concurrency at which the throughput numbers were measured; recorded, not multiplied."++ }++ }++ },++ "success_bps": {++ "type": "integer",++ "minimum": 1,++ "maximum": 10000,++ "description": "Successful tasks per 10,000 requests (basis points), used for cost per successful task."++ },++ "wholesale": {++ "type": "object",++ "required": ["input_nano_usd_per_million", "cached_read_nano_usd_per_million", "cache_write_nano_usd_per_million", "output_nano_usd_per_million", "per_request_nano_usd"],++ "additionalProperties": false,++ "properties": {++ "input_nano_usd_per_million": {++ "$ref": "#/$defs/nanoUsd",++ "description": "Wholesale input rate, nano-USD per million tokens."++ },++ "cached_read_nano_usd_per_million": {++ "$ref": "#/$defs/nanoUsd",++ "description": "Wholesale cached-read rate, nano-USD per million tokens."++ },++ "cache_write_nano_usd_per_million": {++ "$ref": "#/$defs/nanoUsd",++ "description": "Wholesale cache-write rate, nano-USD per million tokens."++ },++ "output_nano_usd_per_million": {++ "$ref": "#/$defs/nanoUsd",++ "description": "Wholesale output rate, nano-USD per million tokens."++ },++ "per_request_nano_usd": {++ "$ref": "#/$defs/nanoUsd",++ "description": "Wholesale per-request fee, nano-USD."++ },++ "success_bps": {++ "type": "integer",++ "minimum": 1,++ "maximum": 10000,++ "description": "Optional wholesale success rate in basis points; defaults to the model's success_bps."++ }++ }++ }++ }++ }++ },++ "gpu_classes": {++ "type": "array",++ "minItems": 1,++ "description": "GPU classes. Every GPU class is paired with every model.",++ "items": {++ "type": "object",++ "required": ["id", "warm", "scale_to_zero"],++ "additionalProperties": false,++ "properties": {++ "id": {++ "type": "string",++ "minLength": 1,++ "description": "Class id, for example rtx-4070-12gb."++ },++ "label": {++ "type": "string",++ "description": "Display name; defaults to id."++ },++ "warm": {++ "type": "object",++ "required": ["hourly_nano_usd", "storage_gb", "storage_nano_usd_per_gb_month", "egress_bytes_per_request", "egress_nano_usd_per_gb"],++ "additionalProperties": false,++ "properties": {++ "hourly_nano_usd": {++ "$ref": "#/$defs/nanoUsd",++ "description": "Rented warm GPU rate, nano-USD per GPU-hour."++ },++ "storage_gb": {++ "type": "integer",++ "minimum": 0,++ "description": "Model storage held for the warm deployment, in GB (counted once per candidate)."++ },++ "storage_nano_usd_per_gb_month": {++ "$ref": "#/$defs/nanoUsd",++ "description": "Storage rate, nano-USD per GB-month."++ },++ "egress_bytes_per_request": {++ "type": "integer",++ "minimum": 0,++ "description": "Response bytes sent per request (1 GB = 1,000,000,000 bytes)."++ },++ "egress_nano_usd_per_gb": {++ "$ref": "#/$defs/nanoUsd",++ "description": "Egress rate, nano-USD per GB."++ }++ }++ },++ "scale_to_zero": {++ "type": "object",++ "required": ["hourly_nano_usd", "startup_seconds", "requests_per_cold_start", "storage_gb", "storage_nano_usd_per_gb_month", "egress_bytes_per_request", "egress_nano_usd_per_gb"],++ "additionalProperties": false,++ "properties": {++ "hourly_nano_usd": {++ "$ref": "#/$defs/nanoUsd",++ "description": "Serverless execution rate, nano-USD per GPU-hour of busy time."++ },++ "startup_seconds": {++ "type": "number",++ "minimum": 0,++ "description": "Cold-start seconds paid at the serverless hourly rate."++ },++ "requests_per_cold_start": {++ "type": "integer",++ "minimum": 1,++ "description": "Requests served per cold start; one cold start is paid per this many requests."++ },++ "storage_gb": {++ "type": "integer",++ "minimum": 0,++ "description": "Storage held for the scale-to-zero deployment, in GB."++ },++ "storage_nano_usd_per_gb_month": {++ "$ref": "#/$defs/nanoUsd",++ "description": "Storage rate, nano-USD per GB-month."++ },++ "egress_bytes_per_request": {++ "type": "integer",++ "minimum": 0,++ "description": "Response bytes sent per request."++ },++ "egress_nano_usd_per_gb": {++ "$ref": "#/$defs/nanoUsd",++ "description": "Egress rate, nano-USD per GB."++ }++ }++ }++ }++ }++ }++ },++ "$defs": {++ "nanoUsd": {++ "description": "Integer nano-USD (D1). JSON numbers above 2^53 must be written as decimal strings to survive parsing; the planner accepts both.",++ "oneOf": [++ {++ "type": "integer",++ "minimum": 0++ },++ {++ "type": "string",++ "pattern": "^[0-9]+$"++ }++ ]++ }++ }++}@@@ -17,6 +17,7 @@ const { openStore } = require('./db');const { createHostOutbox } = require('./events/outbox');const { createSsoClient } = require('openvibe-sdk/sso');const { createViewerResolver } = require('./auth/viewer');++const createInferencePlannerConfig = require('./inference-planner-config').create;const { createHostSession } = require('./auth/sso');const { createHostReadiness } = require('./observability');const { createWorker } = require('./worker');@@@ -51,6 +52,11 @@ async function createApp(opts = {}) {const config = opts.config || configLib.load();const log = opts.log || console;const store = opts.store || await openStore(config, { now: opts.now, log });++ // D11 owner settings (host.inference_planner): the planner's switches read through the store, env pins winning.++ const plannerConfig = await createInferencePlannerConfig({ db: store.db, config, env: opts.env || process.env, log });++ Object.defineProperty(config.inferencePlanner, 'enabled', { enumerable: true, configurable: true, get: () => plannerConfig.get('enabled') });++ Object.defineProperty(config.inferencePlanner, 'source', { enumerable: true, configurable: true, get: () => plannerConfig.get('source') });++ Object.defineProperty(config.inferencePlanner, 'budgetNanoUsdRaw', { enumerable: true, configurable: true, get: () => { const v = plannerConfig.get('budget_nano_usd'); return v == null ? '' : String(v); } });require('./http/errors').setLogger(log);const outbox = createHostOutbox({ db: store.db, config, fetchImpl: opts.fetchImpl, now: store.now, log });const auth = opts.auth || createSsoClient({@@@ -81,9 +87,10 @@ async function createApp(opts = {}) {const views = require('./http/catalog-views').createCatalogViews({ catalog, benchmarks, community, config, log });catalog.onChange(() => { if (config.worker.enabled) catalogSearch.sweepSoon(); });const worker = createWorker({ config, outbox, catalogIngest, catalogSearch, log });-- const ctx = { config, store, outbox, catalog, domainCheck, benchmarks, community, catalogIngest, catalogSearch, auth, viewers, worker, log, views };++ const ctx = { config, store, plannerConfig, outbox, catalog, domainCheck, benchmarks, community, catalogIngest, catalogSearch, auth, viewers, worker, log, views };ctx.console = require('./console/console').createConsole({ store, config, catalog, fetchImpl: opts.fetchImpl, log });const app = express();++ plannerConfig.mount(app);// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts// (openvibe-contracts); the operations they mark internal-only are left out.app.get('/openapi.json', (_req, res) => {@@@ -244,6 +251,16 @@ async function createApp(opts = {}) {return agentLayer(req, res, next);});app.use('/console', rateLimit({ windowMs: 60_000, max: 120, standardHeaders: true, legacyHeaders: false }), require('./console/pages').createConsolePages(ctx));++ // Inference planner (PLAN.md §G-P7): a staff-only recommendation page and JSON API. It reads a planning JSON++ // and has no action endpoint: nothing here starts, stops or rents a GPU. D11 owner settings: the source and++ // routes always exist, and the switch is read live (off, each router answers the ordinary 404).++ {++ const { createInferencePlannerSource, createInferencePlannerPages, createInferencePlannerApi } = require('./http/inference-planner');++ ctx.inferencePlanner = createInferencePlannerSource({ config, log, now: store.now });++ const plannerCtx = { ...ctx, planner: ctx.inferencePlanner };++ app.use('/catalog/inference', rateLimit({ windowMs: 60_000, max: 60, standardHeaders: true, legacyHeaders: false }), createInferencePlannerPages(plannerCtx));++ app.use('/api/v1/catalog/inference-planner', rateLimit({ windowMs: 60_000, max: 60, standardHeaders: true, legacyHeaders: false }), createInferencePlannerApi(plannerCtx));++ }app.use('/api/v1/catalog', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createCatalogApi(ctx));const movedApi = (req, res) => res.status(410).type('application/problem+json').json({ code: 'host.moved_to_space', title: 'Static hosting moved', detail: MOVED, url: SPACE_URL });const movedPage = (req, res) => res.status(410).type('html').send(renderPage({ title: 'Static hosting moved', body: `<h1>Static hosting moved</h1><p>${MOVED} <a href="${SPACE_URL}">Visit OpenVibe.Space</a>.</p>`, config, path: req.originalUrl }));@@@ -1,6 +1,7 @@'use strict';/** OpenVibe.Host catalog service configuration. */++const path = require('path');require('dotenv').config();const trim = (s) => String(s || '').replace(/\/+$/, '');@@@ -62,6 +63,27 @@ function load(env = process.env) {// The console (server/console/): a person's own hosting accounts, their tokens sealed with HOST_CREDENTIALS_KEY// (32 bytes, hex or base64). Without it the console shows servers of no account and connects none.console: { credentialsKey: env.HOST_CREDENTIALS_KEY || '', maxAccounts: int(env.HOST_CONSOLE_MAX_ACCOUNTS, 10) },++ // Inference placement planner (PLAN.md §G-P7): a staff-only forecast and break-even, off unless++ // HOST_INFERENCE_PLANNER=on. It never starts, stops or rents a GPU. Its demand comes from the++ // planning JSON (default) or, with HOST_INFERENCE_PLANNER_SOURCE=ai, from OpenVibe.AI's aggregate++ // usage series (GET /api/v1/usage/series, Host's service token, capability ai.usage.read).++ // The budget is integer nano-USD (D1); empty means 0, and an invalid value is treated as 0 by the++ // planner (fail safe) and reported as invalid on the page.++ inferencePlanner: {++ enabled: String(env.HOST_INFERENCE_PLANNER || 'off').trim().toLowerCase() === 'on',++ source: String(env.HOST_INFERENCE_PLANNER_SOURCE || 'json').trim().toLowerCase() === 'ai' ? 'ai' : 'json',++ inputPath: env.HOST_INFERENCE_PLANNER_INPUT || path.join(__dirname, '..', 'workers', 'llm-inference', 'examples', 'inference-planner.example.json'),++ // A feed failure may only fall back to a JSON input the operator configured, never to the++ // bundled example that happens to be the default path.++ inputConfigured: env.HOST_INFERENCE_PLANNER_INPUT != null && String(env.HOST_INFERENCE_PLANNER_INPUT).trim() !== '',++ aiUrl: trim(env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700'),++ aiAudience: 'openvibe.ai',++ aiCapability: 'ai.usage.read',++ days: Math.max(14, Math.min(90, int(env.HOST_INFERENCE_PLANNER_DAYS, 28))),++ cacheMs: Math.max(0, int(env.HOST_INFERENCE_PLANNER_CACHE_MS, 10 * 60 * 1000)),++ timeoutMs: Math.max(100, int(env.HOST_INFERENCE_PLANNER_TIMEOUT_MS, 5000)),++ budgetNanoUsdRaw: env.HOST_INFERENCE_BUDGET_NANO_USD === undefined ? '' : String(env.HOST_INFERENCE_BUDGET_NANO_USD),++ },worker: {enabled: env.HOST_WORKER !== 'off',id: env.HOST_WORKER_ID || `host-${process.pid}`,nnew file mode 100644@@@ -0,0 +1,131 @@++'use strict';++/**++ * The staff-only inference planner: GET /catalog/inference (HTML) and++ * GET /api/v1/catalog/inference-planner (JSON). Both are mounted only when++ * HOST_INFERENCE_PLANNER=on, so flag off adds no route at all.++ *++ * The planner itself (server/inference-planner.js) is pure. This module is the only I/O: it reads the++ * planning JSON from HOST_INFERENCE_PLANNER_INPUT (mtime-cached), and with++ * HOST_INFERENCE_PLANNER_SOURCE=ai (default: json) it asks server/inference-planner-feed.js for the++ * daily request series from OpenVibe.AI (cached ten minutes). A feed failure falls back to the JSON++ * series when HOST_INFERENCE_PLANNER_INPUT is configured, and is reported as "demand feed unavailable"++ * either way. There is no action endpoint: nothing can start, stop or rent a GPU from here.++ */++const express = require('express');++const fs = require('fs');++const path = require('path');++const { ApiError, run, privateNoStore, sendError } = require('./errors');++const { renderPage } = require('../render/layout');++const { guard } = require('../auth/viewer');++const { plan } = require('../inference-planner');++const { createDemandFeed } = require('../inference-planner-feed');++const render = require('../render/inference-planner');++++function createInferencePlannerSource({ config, log = console, feed = null, now = Date.now }) {++ let cached = null;++ // D11 owner settings: source may change live; the feed is created the first time the source is ai.++ let demandFeed = feed || null;++ function feedNow() {++ if (feed) return feed;++ if (config.inferencePlanner.source !== 'ai') return null;++ if (!demandFeed) demandFeed = createDemandFeed({ config, log, clock: { now } });++ return demandFeed;++ }++++ async function readDocument() {++ const file = path.resolve(config.inferencePlanner.inputPath);++ let stat;++ try {++ stat = await fs.promises.stat(file);++ } catch {++ throw new ApiError(503, 'host.inference_planner.unavailable', 'the planning JSON is missing or unreadable (HOST_INFERENCE_PLANNER_INPUT)');++ }++ if (!stat.isFile()) throw new ApiError(503, 'host.inference_planner.unavailable', 'HOST_INFERENCE_PLANNER_INPUT is not a file');++ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) return cached.document;++ let document;++ try {++ document = JSON.parse(await fs.promises.readFile(file, 'utf8'));++ } catch (err) {++ throw new ApiError(503, 'host.inference_planner.invalid', `the planning JSON could not be read as JSON: ${err.message}`);++ }++ cached = { mtimeMs: stat.mtimeMs, size: stat.size, document };++ return document;++ }++++ function planDocument(document) {++ try {++ return plan(document, { budgetNanoUsd: config.inferencePlanner.budgetNanoUsdRaw });++ } catch (err) {++ throw new ApiError(503, 'host.inference_planner.invalid', `the planning JSON did not validate: ${err.message}`);++ }++ }++++ const withDemand = (result, demand) => ({ ...result, demand });++++ async function current() {++ const document = await readDocument();++ const demand = feedNow();++ if (!demand) return withDemand(planDocument(document), { source: 'json' });++ try {++ const series = await demand.current();++ const forecast = document && typeof document.forecast === 'object' && !Array.isArray(document.forecast) ? document.forecast : {};++ const result = planDocument({ ...document, forecast: { ...forecast, series: series.series } });++ return withDemand(result, { source: 'ai', from: series.from, to: series.to, days: series.days, fetched_at: series.fetched_at });++ } catch (err) {++ const warning = `demand feed unavailable: ${err.message}`;++ if (!config.inferencePlanner.inputConfigured) {++ throw new ApiError(503, 'host.inference_planner.unavailable', warning);++ }++ log.warn(`[Host inference planner] ${warning}; using the configured planning JSON`);++ try {++ return withDemand(planDocument(document), { source: 'json', fallback: true, warning });++ } catch (fallbackErr) {++ throw new ApiError(fallbackErr.status || 503, fallbackErr.code || 'host.inference_planner.invalid', `${warning}; ${fallbackErr.message}`);++ }++ }++ }++ return { current };++}++++function createInferencePlannerPages(ctx) {++ const { config, viewers, planner, log = console } = ctx;++ const router = express.Router();++ // D11 owner settings: off, the ordinary 404 and no session work.++ router.use((_req, _res, next) => (ctx.config.inferencePlanner.enabled ? next() : next('router')));++ router.use(viewers.middleware('dashboard'));++ if (ctx.actorLimits) router.use(ctx.actorLimits);++ router.use((_req, res, next) => { res.set('Cache-Control', 'private, no-store'); res.vary('Cookie'); res.vary('Authorization'); next(); });++ const send = (req, res, status, body) => res.status(status).type('html').send(renderPage({++ title: 'Inference placement planner', body, viewer: req.viewer, config, path: req.originalUrl, indexable: false, css: ['css/catalog.css'],++ }));++ router.get('/', async (req, res, next) => {++ try {++ if (!(req.viewer.kind === 'user' && req.viewer.staff)) return send(req, res, 403, render.errorPage('Staff access required.'));++ let result;++ try {++ result = await planner.current();++ } catch (err) {++ if (!err.status) log.error('[Host inference planner]', err && err.stack ? err.stack : err);++ return send(req, res, err.status || 503, render.errorPage(err.status ? err.message : 'the planner could not build a plan', { input: Boolean(err.status) }));++ }++ return send(req, res, 200, render.page(result));++ } catch (err) { next(err); }++ });++ return router;++}++++function createInferencePlannerApi(ctx) {++ const { viewers, planner, actorLimits, log = console } = ctx;++ const router = express.Router();++ // D11 owner settings: off, the ordinary 404 and no token work.++ router.use((_req, _res, next) => (ctx.config.inferencePlanner.enabled ? next() : next('router')));++ router.use(viewers.middleware('api'));++ if (actorLimits) router.use(actorLimits);++ router.use((_req, res, next) => { privateNoStore(res); next(); });++ const staff = (req, _res, next) => req.viewer.kind === 'user' && req.viewer.staff ? next() : next(new ApiError(403, 'host.catalog.forbidden', 'Staff access required'));++ router.get('/', guard('host.catalog.manage'), staff, run(async () => ({ planner: await planner.current() })));++ router.use((err, req, res, next) => { if (res.headersSent) return next(err); sendError(res, req, err, log); });++ return router;++}++++module.exports = { createInferencePlannerSource, createInferencePlannerPages, createInferencePlannerApi };nnew file mode 100644@@@ -0,0 +1,108 @@++'use strict';++/**++ * The `host.inference_planner` namespace (D11 owner settings): the staff planner's switch, its demand source and++ * the monthly budget it may recommend within. The env variables stay the pins (`x-env`): while one is set it wins,++ * is read-only in the summary and a write answers 409 config.pinned_in_env.++ *++ * /internal/config is openvibe-shared/config's adminRoutes, guarded by host.config.manage (Network's service token++ * only). actor is the owner Network names in X-OV-Subject. budget_nano_usd is x-danger (a recommendation above it++ * needs the owner's budget), so changing it needs X-OV-Fresh: 1. Every activation writes one audit line with the++ * actor and the keys, never a value.++ */++const express = require('express');++const contracts = require('openvibe-contracts');++const shared = require('openvibe-shared/config');++const { verifyServiceToken } = require('openvibe-sdk/auth');++const { http, ids } = contracts;++++const SERVICE = 'host';++const NAMESPACE = 'host.inference_planner';++const CAPABILITY = 'host.config.manage';++const AUDIENCE = 'openvibe.host';++++const SCHEMA = {++ $schema: 'https://json-schema.org/draft/2020-12/schema',++ title: 'Inference placement planner',++ description: 'The staff-only forecast and break-even page and JSON API. The planner recommends; it never starts, stops or rents a GPU.',++ type: 'object',++ additionalProperties: false,++ required: ['enabled', 'budget_nano_usd', 'source'],++ properties: {++ enabled: {++ type: 'boolean', title: 'Planner page and API', 'x-group': 'Inference planner', 'x-env': 'HOST_INFERENCE_PLANNER',++ description: 'Serve /catalog/inference and /api/v1/catalog/inference-planner to staff. Off adds no route.',++ },++ budget_nano_usd: {++ type: ['integer', 'null'], minimum: 0, title: 'Monthly budget', 'x-group': 'Inference planner',++ 'x-env': 'HOST_INFERENCE_BUDGET_NANO_USD', 'x-unit': 'nano_usd', 'x-danger': true,++ description: 'Integer nano-USD a month. A recommendation at the forecast or the high band above it is marked "needs the owner\'s budget"; null means unset (0, nothing pre-approved).',++ },++ source: {++ type: 'string', enum: ['json', 'ai'], title: 'Demand source', 'x-group': 'Inference planner', 'x-env': 'HOST_INFERENCE_PLANNER_SOURCE',++ description: 'json reads the configured planning document; ai reads OpenVibe.AI\'s aggregate usage series and falls back only to a configured JSON input.',++ },++ },++};++const DEFAULTS = Object.freeze({ enabled: false, budget_nano_usd: null, source: 'json' });++++function bearer(req) {++ const h = String((req.headers && req.headers.authorization) || '');++ return h.startsWith('Bearer ') ? h.slice(7).trim() : null;++}++++function capabilityGuard({ config, log }) {++ return async function guard(req, res, next) {++ try {++ const token = bearer(req);++ if (!token) return http.sendProblem(res, 401, 'token.missing', { detail: 'a Network service token is required', ctx: req.ov });++ const r = await verifyServiceToken(token, {++ jwks: `${config.networkInternalUrl || config.networkUrl}/api/.well-known/jwks`,++ issuer: config.networkUrl, audience: AUDIENCE, contracts, log,++ });++ if (!r.ok) return http.sendProblem(res, r.code === 'token.unavailable' ? 503 : 401, r.code || 'token.invalid', { detail: r.reason || 'the token does not verify', ctx: req.ov });++ const sub = String((r.claims && r.claims.sub) || '');++ if (!sub.startsWith('svc:')) return http.sendProblem(res, 403, 'capability.denied', { detail: `${CAPABILITY} is first-party; an app may not change Host configuration`, ctx: req.ov });++ const decision = contracts.capabilities.check(r.claims, CAPABILITY);++ if (!decision.allowed) return http.sendProblem(res, 403, decision.code || 'capability.denied', { detail: decision.reason || `${CAPABILITY} not granted`, ctx: req.ov });++ req.configClaims = r.claims;++ return next();++ } catch (err) {++ log.error(`[config] host.inference_planner guard failed: ${(err && err.message) || err}`);++ return http.sendProblem(res, 500, 'internal.error', { detail: 'the configuration request failed', ctx: req.ov });++ }++ };++}++++function actor(req) {++ const named = String((req.headers && req.headers['x-ov-subject']) || '').trim();++ return ids.isSubjectId('user', named) ? { type: 'user', id: named } : null;++}++++const requireFresh = (req) => String((req.headers && req.headers['x-ov-fresh']) || '') === '1';++++async function create({ db, config, env = process.env, log = console, onActivate = null }) {++ // The shared store's boolean pin reads true/1; Host's env has always said "on". Normalise the pin value, not the++ // name: the summary still reports HOST_INFERENCE_PLANNER.++ const storeEnv = { ...env };++ if (/^(on|yes|true|1)$/i.test(String(storeEnv.HOST_INFERENCE_PLANNER || ''))) storeEnv.HOST_INFERENCE_PLANNER = '1';++ const store = await shared.createConfigStore({++ db, service: SERVICE, namespace: NAMESPACE, schema: SCHEMA, defaults: DEFAULTS, env: storeEnv, log,++ onActivate: async (values) => { if (onActivate) await onActivate(values); },++ onChange: ({ namespace, keys, actor: who, revision }) => {++ log.info(`[config] ${SERVICE} ${namespace} revision ${revision} by ${who && who.id ? `${who.type}:${who.id}` : 'unknown'}: ${keys.join(', ')}`);++ },++ });++ if (env.OV_CONFIG_KEKS) {++ try { await store.reseal(); } catch (err) { log.warn(`[config] ${NAMESPACE}: reseal failed: ${(err && err.message) || err}`); }++ }++ const router = shared.adminRoutes([store], {++ basePath: '/internal/config', requireAdmin: capabilityGuard({ config, log }), actor, requireFresh, log,++ });++ return {++ store, router,++ get: (key) => store.get(key),++ mount(app) { app.use('/internal/config', express.json({ limit: '128kb' })); router.mount(app); },++ };++}++++module.exports = { create, SCHEMA, NAMESPACE, CAPABILITY };nnew file mode 100644@@@ -0,0 +1,118 @@++'use strict';++/**++ * The demand half of the inference planner: OpenVibe.AI's aggregate usage series++ * (`GET /api/v1/usage/series`, capability ai.usage.read) turned into the planner's daily request++ * series. The planner itself stays pure (server/inference-planner.js): this module is the only I/O++ * on the AI path, and the planning JSON remains the only I/O on the default JSON path.++ *++ * The feed is cached for ten minutes: a successful series is reused without another call, and a++ * failed refresh is reported as unavailable (the caller may then fall back to a configured JSON++ * input). No caller id, prompt or answer is read or kept: the endpoint returns aggregates only.++ */++const { serviceAuth } = require('openvibe-contracts');++++const DAY_MS = 86_400_000;++const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;++++class DemandFeedError extends Error {++ constructor(message) { super(message); this.name = 'DemandFeedError'; }++}++++function dayOf(value) {++ if (typeof value !== 'string') return null;++ const m = DATE_RE.exec(value);++ if (!m) return null;++ const ms = Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]));++ const d = new Date(ms);++ if (d.getUTCFullYear() !== Number(m[1]) || d.getUTCMonth() !== Number(m[2]) - 1 || d.getUTCDate() !== Number(m[3])) return null;++ return value;++}++++const isoDay = (ms) => new Date(ms).toISOString().slice(0, 10);++++/**++ * `body` is the AI endpoint's answer ({ days, from, to, series: [{ day, requests, … }] }). Rows are++ * summed across models: the planner forecasts one demand curve, not a model per candidate. Missing++ * days in the window are zero-demand days (AI sends only rows that have usage).++ */++function seriesOf(body, { days, now }) {++ if (!body || !Array.isArray(body.series)) throw new DemandFeedError('the usage feed did not return a series');++ const totals = new Map();++ for (const row of body.series) {++ if (!row || typeof row !== 'object') throw new DemandFeedError('a usage feed row is not an object');++ const day = dayOf(row.day);++ if (!day) throw new DemandFeedError('a usage feed row has no UTC day');++ const requests = row.requests === undefined ? 0 : Number(row.requests);++ if (!Number.isSafeInteger(requests) || requests < 0) throw new DemandFeedError(`the usage feed's requests on ${day} is not a non-negative integer`);++ totals.set(day, (totals.get(day) || 0) + requests);++ }++ const to = dayOf(body.to) || isoDay(now);++ const requestedFrom = dayOf(body.from);++ if (!requestedFrom) throw new DemandFeedError('the usage feed did not return its window');++ // Never trust a window longer than configured: keep its last `days` days. It must still cover the++ // planner's 14-day minimum, or there is no honest forecast to make.++ const from = isoDay(Math.max(Date.parse(`${requestedFrom}T00:00:00Z`), Date.parse(`${to}T00:00:00Z`) - (days - 1) * DAY_MS));++ const span = Math.round((Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`)) / DAY_MS) + 1;++ if (span < 14) throw new DemandFeedError(`the usage feed covers ${span} day${span === 1 ? '' : 's'}; the planner needs at least 14`);++ if (span > 90) throw new DemandFeedError('the usage feed covers more than 90 days');++ const series = [];++ for (let i = 0; i < span; i++) {++ const date = isoDay(Date.parse(`${from}T00:00:00Z`) + i * DAY_MS);++ series.push({ date, requests: totals.get(date) || 0 });++ }++ return { days: span, from, to, series };++}++++function createDemandFeed({ config, fetchImpl = globalThis.fetch, clock = { now: () => Date.now() }, log = console }) {++ const planner = config.inferencePlanner;++ const cacheMs = planner.cacheMs;++ let tokens = null;++ let cached = null; // { at, value }++ let inflight = null;++++ function tokenClient() {++ if (tokens) return tokens;++ if (!config.oauth.clientSecret) throw new DemandFeedError('HOST_INFERENCE_PLANNER_SOURCE=ai needs OV_OAUTH_CLIENT_SECRET for Host\'s service token');++ tokens = serviceAuth.createTokenClient({++ tokenUrl: `${config.networkInternalUrl}/oauth/token`,++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ audience: planner.aiAudience,++ scope: planner.aiCapability,++ fetchImpl,++ timeoutMs: planner.timeoutMs,++ });++ return tokens;++ }++++ async function fetchSeries() {++ const url = `${planner.aiUrl}/api/v1/usage/series?days=${planner.days}`;++ let res;++ try {++ res = await fetchImpl(url, {++ headers: { Accept: 'application/json', ...(await tokenClient().authHeaders()) },++ signal: AbortSignal.timeout(planner.timeoutMs),++ });++ } catch (err) {++ throw new DemandFeedError(`the usage feed is unreachable: ${err.message}`);++ }++ const body = await res.json().catch(() => null);++ if (!res.ok) throw new DemandFeedError(`the usage feed answered ${res.status}`);++ const value = seriesOf(body, { days: planner.days, now: clock.now() });++ return { ...value, fetched_at: new Date(clock.now()).toISOString() };++ }++++ async function current() {++ if (cached && clock.now() - cached.at < cacheMs) return cached.value;++ if (!inflight) {++ inflight = fetchSeries()++ .then((value) => { cached = { at: clock.now(), value }; return value; })++ .finally(() => { inflight = null; });++ }++ return inflight;++ }++++ return { current };++}++++module.exports = { createDemandFeed, seriesOf, DemandFeedError, DAY_MS };nnew file mode 100644@@@ -0,0 +1,613 @@++'use strict';++/**++ * Inference placement planner (PLAN.md §D ObservedPerformance, §E cost model and §G-P7).++ *++ * Pure functions only: this module reads no file, opens no socket, starts no process and has no++ * clock. The caller hands it a parsed planning document; it returns a forecast, a break-even and a++ * recommendation with its arithmetic — never an action. There is no code path here that starts,++ * stops or rents a GPU.++ *++ * D1: every price is integer nano-USD and every money expression is BigInt. Rates in the input may++ * be JSON integers or decimal strings; token counts, bytes and request counts become BigInt before++ * the multiplication. Durations (milliseconds) are planning estimates and are rounded up before++ * any money is computed.++ */++++const HOURS_PER_MONTH = 730n;++const SECONDS_PER_HOUR = 3600n;++const DAY_MS = 86_400_000;++const NANO_PER_MILLION = 1_000_000n;++const NANO_PER_USD = 1_000_000_000n;++const BYTES_PER_GB = 1_000_000_000n;++const BPS = 10_000n;++const Z_95 = 1.96;++const SCHEMA = 'openvibe.inference-planner-result@1';++const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];++const MODES = ['warm', 'scale_to_zero', 'wholesale'];++const LABELS = { warm: 'Warm rented GPU', scale_to_zero: 'Scale to zero', wholesale: 'Wholesale API' };++// A tie never recommends renting: wholesale first, then scale-to-zero, warm last.++const TIE_ORDER = ['wholesale', 'scale_to_zero', 'warm'];++++function fail(where, message) {++ throw new Error(`${where}: ${message}`);++}++++function isRecord(value) {++ return value !== null && typeof value === 'object' && !Array.isArray(value);++}++++function record(value, where) {++ if (value === undefined) fail(where, 'is required');++ if (!isRecord(value)) fail(where, 'must be an object');++ return value;++}++++function list(value, where, { min = 1 } = {}) {++ if (value === undefined) fail(where, 'is required');++ if (!Array.isArray(value)) fail(where, 'must be an array');++ if (value.length < min) fail(where, `needs at least ${min} item${min === 1 ? '' : 's'}`);++ return value;++}++++function text(value, where, { required = true, fallback = null } = {}) {++ if (value === undefined || value === null) {++ if (required) fail(where, 'is required');++ return fallback;++ }++ if (typeof value !== 'string') fail(where, 'must be a string');++ return value;++}++++function integer(value, where, { min = 0n, max = null, required = true, fallback = null } = {}) {++ if (value === undefined || value === null) {++ if (required) fail(where, 'is required');++ return fallback;++ }++ let n;++ if (typeof value === 'bigint') n = value;++ else if (typeof value === 'number' && Number.isSafeInteger(value)) n = BigInt(value);++ else if (typeof value === 'string' && /^-?\d+$/.test(value.trim())) n = BigInt(value.trim());++ else fail(where, 'must be an integer');++ if (n < min) fail(where, `must be >= ${min}`);++ if (max !== null && n > max) fail(where, `must be <= ${max}`);++ return n;++}++++function count(value, where, opts = {}) {++ const n = Number(integer(value, where, opts));++ if (!Number.isSafeInteger(n)) fail(where, 'is too large');++ return n;++}++++function positive(value, where, { required = true, fallback = null } = {}) {++ if (value === undefined || value === null) {++ if (required) fail(where, 'is required');++ return fallback;++ }++ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) fail(where, 'must be a positive number');++ return value;++}++++function nonNegative(value, where, { required = true, fallback = null } = {}) {++ if (value === undefined || value === null) {++ if (required) fail(where, 'is required');++ return fallback;++ }++ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) fail(where, 'must be a non-negative number');++ return value;++}++++const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;++function parseDate(value, where) {++ const m = DATE_RE.exec(value);++ if (!m) fail(where, 'must be a calendar date (YYYY-MM-DD)');++ const year = Number(m[1]);++ const month = Number(m[2]);++ const day = Number(m[3]);++ const ms = Date.UTC(year, month - 1, day);++ const d = new Date(ms);++ if (d.getUTCFullYear() !== year || d.getUTCMonth() !== month - 1 || d.getUTCDate() !== day) fail(where, 'is not a real calendar date');++ return ms;++}++++function isoDate(ms) {++ return new Date(ms).toISOString().slice(0, 10);++}++++function round(value, digits = 0) {++ const factor = 10 ** digits;++ return Math.round(value * factor) / factor;++}++++function validateForecast(value) {++ const f = record(value, 'forecast');++ if (f.series === undefined) fail('forecast.series', 'is required');++ const rows = list(f.series, 'forecast.series', { min: 14 });++ const points = rows.map((row, i) => {++ const where = `forecast.series[${i}]`;++ const p = record(row, where);++ const date = text(p.date, `${where}.date`);++ const ms = parseDate(date, `${where}.date`);++ const requests = count(p.requests, `${where}.requests`, { min: 0n, max: 1_000_000_000n });++ return { date, ms, requests, dow: new Date(ms).getUTCDay() };++ });++ points.sort((a, b) => a.ms - b.ms);++ for (let i = 1; i < points.length; i += 1) {++ if (points[i].ms === points[i - 1].ms) fail(`forecast.series[${i}].date`, 'duplicates an earlier date');++ if (points[i].ms !== points[i - 1].ms + DAY_MS) fail(`forecast.series[${i}].date`, 'every point must be the next calendar day');++ }++ const horizonDays = count(f.horizon_days, 'forecast.horizon_days', { min: 1n, max: 365n, required: false, fallback: 30n });++ const windowDays = count(f.window_days, 'forecast.window_days', { min: 7n, max: BigInt(points.length), required: false, fallback: 28n });++ return { horizonDays, windowDays, points };++}++++function validateRequest(value) {++ const r = record(value, 'request');++ const token = (key) => integer(r[key], `request.${key}`, { min: 0n, max: 1_000_000_000n });++ return {++ inputTokens: token('input_tokens'),++ cachedReadTokens: token('cached_read_tokens'),++ cacheWriteTokens: token('cache_write_tokens'),++ outputTokens: token('output_tokens'),++ };++}++++function validateModel(value, i) {++ const where = `models[${i}]`;++ const m = record(value, where);++ const id = text(m.id, `${where}.id`).trim();++ if (!id) fail(`${where}.id`, 'must not be empty');++ const label = text(m.label, `${where}.label`, { required: false, fallback: id });++ const t = record(m.throughput, `${where}.throughput`);++ const prefill = positive(t.prefill_tokens_per_sec, `${where}.throughput.prefill_tokens_per_sec`);++ const decode = positive(t.decode_tokens_per_sec, `${where}.throughput.decode_tokens_per_sec`);++ const cached = positive(t.cached_read_tokens_per_sec, `${where}.throughput.cached_read_tokens_per_sec`, { required: false, fallback: prefill });++ const concurrency = count(t.concurrency, `${where}.throughput.concurrency`, { min: 1n, max: 1024n, required: false, fallback: 1n });++ const successBps = count(m.success_bps, `${where}.success_bps`, { min: 1n, max: 10000n });++ const w = record(m.wholesale, `${where}.wholesale`);++ const rate = (key) => integer(w[key], `${where}.wholesale.${key}`, { min: 0n });++ return {++ id,++ label,++ throughput: { prefillTokensPerSec: prefill, decodeTokensPerSec: decode, cachedReadTokensPerSec: cached, concurrency },++ successBps,++ wholesale: {++ inputNanoPerMillion: rate('input_nano_usd_per_million'),++ cachedReadNanoPerMillion: rate('cached_read_nano_usd_per_million'),++ cacheWriteNanoPerMillion: rate('cache_write_nano_usd_per_million'),++ outputNanoPerMillion: rate('output_nano_usd_per_million'),++ perRequestNano: rate('per_request_nano_usd'),++ successBps: count(w.success_bps, `${where}.wholesale.success_bps`, { min: 1n, max: 10000n, required: false, fallback: BigInt(successBps) }),++ },++ };++}++++function validateGpuSection(value, where) {++ const s = record(value, where);++ return {++ storageGb: integer(s.storage_gb, `${where}.storage_gb`, { min: 0n }),++ storageNanoPerGbMonth: integer(s.storage_nano_usd_per_gb_month, `${where}.storage_nano_usd_per_gb_month`, { min: 0n }),++ egressBytesPerRequest: integer(s.egress_bytes_per_request, `${where}.egress_bytes_per_request`, { min: 0n }),++ egressNanoPerGb: integer(s.egress_nano_usd_per_gb, `${where}.egress_nano_usd_per_gb`, { min: 0n }),++ };++}++++function validateGpuClass(value, i) {++ const where = `gpu_classes[${i}]`;++ const g = record(value, where);++ const id = text(g.id, `${where}.id`).trim();++ if (!id) fail(`${where}.id`, 'must not be empty');++ const label = text(g.label, `${where}.label`, { required: false, fallback: id });++ const warm = validateGpuSection(g.warm, `${where}.warm`);++ warm.hourlyNanoUsd = integer(g.warm.hourly_nano_usd, `${where}.warm.hourly_nano_usd`, { min: 0n });++ const scale = validateGpuSection(g.scale_to_zero, `${where}.scale_to_zero`);++ scale.hourlyNanoUsd = integer(g.scale_to_zero.hourly_nano_usd, `${where}.scale_to_zero.hourly_nano_usd`, { min: 0n });++ scale.startupSeconds = nonNegative(g.scale_to_zero.startup_seconds, `${where}.scale_to_zero.startup_seconds`);++ scale.requestsPerColdStart = integer(g.scale_to_zero.requests_per_cold_start, `${where}.scale_to_zero.requests_per_cold_start`, { min: 1n });++ return { id, label, warm, scaleToZero: scale };++}++++function unique(rows, kind) {++ const seen = new Set();++ for (const row of rows) {++ if (seen.has(row.id)) fail(`${kind}.id`, `'${row.id}' is listed twice`);++ seen.add(row.id);++ }++ return rows;++}++++/** Validate and normalise a planning document. Throws on the first problem, naming its field. */++function validate(input) {++ if (!isRecord(input)) fail('input', 'must be a JSON object');++ const note = text(input.note, 'note', { required: false, fallback: null });++ return {++ note,++ forecast: validateForecast(input.forecast),++ request: validateRequest(input.request),++ models: unique(list(input.models, 'models').map(validateModel), 'models'),++ gpuClasses: unique(list(input.gpu_classes, 'gpu_classes').map(validateGpuClass), 'gpu_classes'),++ };++}++++/**++ * Forecast the next `horizon_days` daily request counts.++ *++ * Level = the mean of the trailing `window_days` counts. Day-of-week factor = that weekday's mean++ * in the same window / the level. A future day is level × its weekday factor, rounded to a whole++ * request. The band is ±1.96 × the pooled residual standard deviation × √horizon (daily residuals++ * are assumed independent; the band is a planning aid, not a guarantee).++ */++function forecast(input) {++ const { horizonDays, windowDays, points } = input;++ const window = points.slice(-windowDays);++ const level = window.reduce((sum, p) => sum + p.requests, 0) / window.length;++ const sums = Array.from({ length: 7 }, () => ({ sum: 0, count: 0 }));++ for (const p of window) {++ sums[p.dow].sum += p.requests;++ sums[p.dow].count += 1;++ }++ const means = sums.map((s) => (s.count ? s.sum / s.count : level));++ const factors = means.map((m) => (level > 0 ? m / level : 1));++ const last = points[points.length - 1];++ const days = [];++ let total = 0;++ for (let i = 1; i <= horizonDays; i += 1) {++ const ms = last.ms + i * DAY_MS;++ const dow = new Date(ms).getUTCDay();++ const requests = Math.round(level * factors[dow]);++ total += requests;++ days.push({ date: isoDate(ms), weekday: WEEKDAYS[dow], requests, factor: round(factors[dow], 4) });++ }++ let sumSquares = 0;++ for (const p of window) {++ const residual = p.requests - means[p.dow];++ sumSquares += residual * residual;++ }++ const degrees = windowDays - 7;++ // With a 7-day window each weekday has one observation, so there is no residual degree of++ // freedom; fall back to the window's spread around its level instead of claiming zero error.++ const spread = degrees > 0++ ? sumSquares / degrees++ : window.reduce((sum, p) => sum + (p.requests - level) ** 2, 0) / Math.max(1, window.length - 1);++ const sd = Math.sqrt(spread);++ const band = Math.ceil(Z_95 * sd * Math.sqrt(horizonDays));++ return {++ method: 'moving average with day-of-week seasonality',++ explanation: 'Level is the mean of the trailing window; each weekday factor is that weekday\'s mean in the window divided by the level; a future day is level × its factor, rounded to a whole request. The band is ±1.96 × the pooled residual standard deviation × √horizon, assuming independent daily residuals; a 7-day window has no residual degree of freedom and uses its spread around the level instead.',++ source: { points: points.length, first_date: points[0].date, last_date: last.date },++ window: {++ start_date: window[0].date,++ end_date: window[window.length - 1].date,++ days: window.length,++ level_requests_per_day: round(level, 4),++ residual_sd_requests: round(sd, 4),++ weekday_factors: Object.fromEntries(WEEKDAYS.map((name, dow) => [name, round(factors[dow], 4)])),++ },++ horizon: { days: horizonDays, start_date: days[0].date, end_date: days[days.length - 1].date },++ monthly: {++ requests: total,++ band,++ low: Math.max(0, total - band),++ high: total + band,++ band_bps: total > 0 ? Math.round((band / total) * 10000) : 0,++ },++ daily: days,++ };++}++++function parseBudget(value) {++ const raw = value === undefined || value === null ? '' : String(value).trim();++ if (raw === '') return { nano: 0n, raw, configured: false, valid: true };++ if (!/^\d+$/.test(raw)) return { nano: 0n, raw, configured: true, valid: false };++ return { nano: BigInt(raw), raw, configured: true, valid: true };++}++++function ceilDiv(a, b) {++ if (b <= 0n) throw new Error('internal: division by a non-positive BigInt');++ return (a + b - 1n) / b;++}++++function storageMonthly(section) {++ return section.storageGb * section.storageNanoPerGbMonth;++}++++function egressPerRequest(section) {++ if (section.egressBytesPerRequest === 0n || section.egressNanoPerGb === 0n) return 0n;++ return ceilDiv(section.egressBytesPerRequest * section.egressNanoPerGb, BYTES_PER_GB);++}++++/** Prompt and decode time per request, rounded up to whole milliseconds. */++function busyMillis(model, request) {++ const t = model.throughput;++ const prefillTokens = request.inputTokens + request.cacheWriteTokens;++ const prefill = prefillTokens === 0n ? 0 : Math.ceil(Number(prefillTokens) * 1000 / t.prefillTokensPerSec);++ const cachedRead = request.cachedReadTokens === 0n ? 0 : Math.ceil(Number(request.cachedReadTokens) * 1000 / t.cachedReadTokensPerSec);++ const decode = request.outputTokens === 0n ? 0 : Math.ceil(Number(request.outputTokens) * 1000 / t.decodeTokensPerSec);++ return { prefill, cached_read: cachedRead, decode, total: prefill + cachedRead + decode, concurrency: t.concurrency };++}++++/**++ * Warm: one GPU-month (730 h) at the class's hourly rate for each GPU the busy time needs, plus++ * storage and egress. The idle burn is the rental minus the value of the busy GPU time at the same++ * rate; it is informational and already inside `gpu_rental`.++ */++function warmCost(requests, gpuClass, busy) {++ const n = BigInt(requests);++ const busyMs = BigInt(busy.total);++ const gpuMs = n * busyMs;++ const monthMs = HOURS_PER_MONTH * SECONDS_PER_HOUR * 1000n;++ const neededGpus = ceilDiv(gpuMs, monthMs);++ const gpuCount = neededGpus > 0n ? neededGpus : 1n;++ const gpuRental = gpuCount * HOURS_PER_MONTH * gpuClass.warm.hourlyNanoUsd;++ const busyValue = ceilDiv(gpuMs * gpuClass.warm.hourlyNanoUsd, 1000n * SECONDS_PER_HOUR);++ const storage = storageMonthly(gpuClass.warm);++ const egress = n * egressPerRequest(gpuClass.warm);++ return {++ mode: 'warm',++ total: gpuRental + storage + egress,++ parts: { gpu_rental: gpuRental, storage, egress },++ idleBurn: gpuRental - busyValue,++ gpuCount,++ occupancyBps: gpuMs > 0n ? Number((gpuMs * BPS) / (gpuCount * monthMs)) : 0,++ };++}++++/**++ * Scale to zero: busy milliseconds at the serverless hourly rate, a cold start every++ * `requests_per_cold_start` requests at `startup_seconds`, storage and egress. Idle costs nothing.++ */++function scaleToZeroCost(requests, gpuClass, busy) {++ const n = BigInt(requests);++ const section = gpuClass.scaleToZero;++ const busyPerRequest = ceilDiv(BigInt(busy.total) * section.hourlyNanoUsd, 1000n * SECONDS_PER_HOUR);++ const busyCost = n * busyPerRequest;++ const coldStarts = ceilDiv(n, section.requestsPerColdStart);++ const startupMs = BigInt(Math.round(section.startupSeconds * 1000));++ const coldStartCost = ceilDiv(coldStarts * startupMs * section.hourlyNanoUsd, 1000n * SECONDS_PER_HOUR);++ const storage = storageMonthly(section);++ const egress = n * egressPerRequest(section);++ return {++ mode: 'scale_to_zero',++ total: busyCost + coldStartCost + storage + egress,++ parts: { busy: busyCost, cold_start: coldStartCost, storage, egress },++ idleBurn: 0n,++ coldStarts,++ perRequestBusy: busyPerRequest,++ };++}++++/** Wholesale: input, cached read, cache write and output at per-million rates, plus the fee. */++function wholesaleCost(requests, model, request) {++ const n = BigInt(requests);++ const w = model.wholesale;++ const parts = {++ input: ceilDiv(request.inputTokens * w.inputNanoPerMillion, NANO_PER_MILLION),++ cached_read: ceilDiv(request.cachedReadTokens * w.cachedReadNanoPerMillion, NANO_PER_MILLION),++ cache_write: ceilDiv(request.cacheWriteTokens * w.cacheWriteNanoPerMillion, NANO_PER_MILLION),++ output: ceilDiv(request.outputTokens * w.outputNanoPerMillion, NANO_PER_MILLION),++ per_request: w.perRequestNano,++ };++ const perRequest = Object.values(parts).reduce((sum, value) => sum + value, 0n);++ return { mode: 'wholesale', total: n * perRequest, parts, idleBurn: 0n, perRequest };++}++++function successOf(total, requests, successBps) {++ const tasks = (BigInt(requests) * BigInt(successBps)) / BPS;++ return { tasks, cost: tasks > 0n ? ceilDiv(total, tasks) : null };++}++++function serializeCost(cost, requests, successBps) {++ const n = BigInt(requests);++ const perRequest = cost.mode === 'wholesale' ? cost.perRequest : (n > 0n ? ceilDiv(cost.total, n) : 0n);++ const success = successOf(cost.total, n, successBps);++ const out = {++ total_nano_usd: cost.total.toString(),++ per_request_nano_usd: perRequest.toString(),++ parts_nano_usd: Object.fromEntries(Object.entries(cost.parts).map(([key, value]) => [key, value.toString()])),++ idle_burn_nano_usd: cost.idleBurn.toString(),++ expected_successful_tasks: success.tasks.toString(),++ cost_per_successful_task_nano_usd: success.cost === null ? null : success.cost.toString(),++ };++ if (cost.gpuCount !== undefined) {++ out.gpu_count = cost.gpuCount.toString();++ out.occupancy_bps = cost.occupancyBps;++ }++ if (cost.coldStarts !== undefined) out.cold_starts = cost.coldStarts.toString();++ return out;++}++++function costsAt(requests, model, gpuClass, request, busy) {++ return {++ warm: warmCost(requests, gpuClass, busy),++ scale_to_zero: scaleToZeroCost(requests, gpuClass, busy),++ wholesale: wholesaleCost(requests, model, request),++ };++}++++function serializeCosts(costs, requests, model) {++ return {++ requests_per_month: requests,++ warm: serializeCost(costs.warm, requests, model.successBps),++ scale_to_zero: serializeCost(costs.scale_to_zero, requests, model.successBps),++ wholesale: serializeCost(costs.wholesale, requests, model.wholesale.successBps),++ };++}++++function chooseMode(costs) {++ let best = TIE_ORDER[0];++ for (const mode of TIE_ORDER.slice(1)) {++ if (costs[mode].total < costs[best].total) best = mode;++ }++ return best;++}++++function formatUsd(nano, digits = 2) {++ const n = BigInt(nano);++ const negative = n < 0n;++ const abs = negative ? -n : n;++ const scale = 10n ** BigInt(9 - digits);++ const rounded = (abs + scale / 2n) / scale;++ const unit = 10n ** BigInt(digits);++ const whole = rounded / unit;++ const fraction = (rounded % unit).toString().padStart(digits, '0');++ return `${negative ? '-' : ''}$${whole}.${fraction}`;++}++++function moneyText(nano) {++ const abs = BigInt(nano) < 0n ? -BigInt(nano) : BigInt(nano);++ return formatUsd(nano, abs > 0n && abs < 1_000_000n ? 6 : 2);++}++++function oneGpuCapacity(busy) {++ if (busy.total <= 0) return 1_000_000_000n;++ const monthMs = HOURS_PER_MONTH * SECONDS_PER_HOUR * 1000n;++ const capacity = monthMs / BigInt(busy.total);++ return capacity > 0n ? capacity : 1n;++}++++/**++ * The smallest whole request count per month at which `a` costs no more than `b`, within one warm++ * GPU's monthly busy time. Both cost curves are non-decreasing and the gap only widens after the++ * threshold, so a binary search is exact. `null` means there is no break-even in that range.++ */++function firstAtMost(a, b, max) {++ if (a(0n) <= b(0n)) return 0n;++ if (!(a(max) <= b(max))) return null;++ let low = 0n;++ let high = max;++ while (low + 1n < high) {++ const mid = (low + high) / 2n;++ if (a(mid) <= b(mid)) high = mid;++ else low = mid;++ }++ return high;++}++++function breakEven(model, gpuClass, request, busy) {++ const max = oneGpuCapacity(busy);++ const warm = (n) => warmCost(n, gpuClass, busy).total;++ const scale = (n) => scaleToZeroCost(n, gpuClass, busy).total;++ const wholesale = (n) => wholesaleCost(n, model, request).total;++ const pair = (fn) => {++ const at = fn();++ return at === null ? null : { requests_per_month: at.toString(), cheaper_above: 'warm' };++ };++ return {++ basis: 'within one warm GPU\'s monthly busy time',++ capacity_requests_per_month: max.toString(),++ warm_vs_scale_to_zero: pair(() => firstAtMost(warm, scale, max)),++ warm_vs_wholesale: pair(() => firstAtMost(warm, wholesale, max)),++ };++}++++function reasonFor(mode, otherMode, atForecast, atHigh, stable, budget, needsOwnerBudget, recommendedHigh) {++ const parts = [];++ if (stable) {++ parts.push(`${LABELS[mode]} is cheapest at the forecast and at the high band (${moneyText(atForecast[mode].total)}/month).`);++ } else {++ parts.push(`${LABELS[mode]} is cheapest at the forecast (${moneyText(atForecast[mode].total)}/month), but ${LABELS[otherMode]} is cheapest at the high band (${moneyText(atHigh[otherMode].total)}/month); the choice depends on demand.`);++ }++ if (mode === 'warm') parts.push(`Its idle burn is ${moneyText(atForecast.warm.idleBurn)}/month at the forecast.`);++ else if (mode === 'scale_to_zero') parts.push(`Its cold-start cost is ${moneyText(atForecast.scale_to_zero.parts.cold_start)}/month at the forecast; idle time is free.`);++ else parts.push('No GPU is rented and no idle burn is paid.');++ if (needsOwnerBudget) {++ parts.push(`Needs the owner's budget: ${moneyText(recommendedHigh)}/month at the high band is above the configured ${moneyText(budget.nano)}/month.`);++ } else {++ parts.push(`Within the configured ${moneyText(budget.nano)}/month budget.`);++ }++ if (!budget.valid) parts.push('HOST_INFERENCE_BUDGET_NANO_USD is not a non-negative integer; it is treated as 0.');++ return parts.join(' ');++}++++function planCandidate(model, gpuClass, request, forecastResult, budget) {++ const busy = busyMillis(model, request);++ const forecastRequests = forecastResult.monthly.requests;++ const highRequests = forecastResult.monthly.high;++ const atForecast = costsAt(forecastRequests, model, gpuClass, request, busy);++ const atHigh = costsAt(highRequests, model, gpuClass, request, busy);++ const mode = chooseMode(atForecast);++ const highMode = chooseMode(atHigh);++ const recommendedForecast = atForecast[mode].total;++ const recommendedHigh = atHigh[mode].total;++ const exceedsForecast = recommendedForecast > budget.nano;++ const exceedsHighBand = recommendedHigh > budget.nano;++ const needsOwnerBudget = exceedsForecast || exceedsHighBand;++ const stable = mode === highMode;++ return {++ model: { id: model.id, label: model.label },++ gpu_class: { id: gpuClass.id, label: gpuClass.label },++ busy_per_request: { prefill_ms: busy.prefill, cached_read_ms: busy.cached_read, decode_ms: busy.decode, total_ms: busy.total, concurrency: busy.concurrency },++ costs: {++ forecast: serializeCosts(atForecast, forecastRequests, model),++ high_band: serializeCosts(atHigh, highRequests, model),++ },++ break_even: breakEven(model, gpuClass, request, busy),++ recommendation: {++ mode,++ label: LABELS[mode],++ reason: reasonFor(mode, highMode, atForecast, atHigh, stable, budget, needsOwnerBudget, recommendedHigh),++ stable,++ forecast_nano_usd: recommendedForecast.toString(),++ high_band_nano_usd: recommendedHigh.toString(),++ budget: {++ status: needsOwnerBudget ? 'needs_owner_budget' : 'within_budget',++ budget_nano_usd: budget.nano.toString(),++ exceeds_forecast: exceedsForecast,++ exceeds_high_band: exceedsHighBand,++ },++ },++ };++}++++/**++ * Build the whole plan. `options.budgetNanoUsd` is read from HOST_INFERENCE_BUDGET_NANO_USD by the++ * caller; an unset value is 0, an invalid value is also 0 (fail safe) and is reported as invalid.++ * Nothing here acts on the recommendation.++ */++function plan(input, options = {}) {++ const normalized = validate(input);++ const budget = parseBudget(options.budgetNanoUsd);++ const forecastResult = forecast(normalized.forecast);++ const candidates = [];++ for (const model of normalized.models) {++ for (const gpuClass of normalized.gpuClasses) {++ candidates.push(planCandidate(model, gpuClass, normalized.request, forecastResult, budget));++ }++ }++ return {++ schema: SCHEMA,++ executes: false,++ action: 'none',++ note: normalized.note,++ forecast: forecastResult,++ budget: {++ env: 'HOST_INFERENCE_BUDGET_NANO_USD',++ configured_nano_usd: budget.nano.toString(),++ configured: budget.configured,++ valid: budget.valid,++ unset_means_zero: true,++ },++ candidates,++ summary: {++ candidates: candidates.length,++ needs_owner_budget: candidates.filter((c) => c.recommendation.budget.status === 'needs_owner_budget').length,++ stable_recommendations: candidates.filter((c) => c.recommendation.stable).length,++ modes: MODES,++ actions: [],++ },++ };++}++++module.exports = {++ plan,++ validate,++ forecast,++ parseBudget,++ formatUsd,++ busyMillis,++ warmCost,++ scaleToZeroCost,++ wholesaleCost,++ HOURS_PER_MONTH,++ SCHEMA,++};nnew file mode 100644@@@ -0,0 +1,122 @@++'use strict';++/**++ * The staff-only inference planner page (server/http/inference-planner.js). Plain server-rendered++ * HTML in Host's catalog style: no form, no button and no action — the page reads a plan and says++ * what it recommends. Every amount is integer nano-USD in the JSON; it is formatted as USD here.++ */++const { esc } = require('./layout');++const { formatUsd } = require('../inference-planner');++++const MODE_LABELS = { warm: 'Warm rented GPU', scale_to_zero: 'Scale to zero', wholesale: 'Wholesale API' };++++function commas(value) {++ return String(value).replace(/\B(?=(\d{3})+(?!\d))/g, ',');++}++++function money(nano) {++ const n = BigInt(nano);++ return formatUsd(n, n > 0n && n < 1_000_000n ? 6 : 2);++}++++function budgetText(budget) {++ if (!budget.valid) return 'HOST_INFERENCE_BUDGET_NANO_USD is not a non-negative integer; it is treated as 0, so every positive recommendation needs the owner\'s budget.';++ if (!budget.configured) return 'HOST_INFERENCE_BUDGET_NANO_USD is unset = 0, so every positive recommendation needs the owner\'s budget.';++ return 'from HOST_INFERENCE_BUDGET_NANO_USD. A recommendation at the forecast or at the high band above it needs the owner\'s budget.';++}++++function costCell(cost, high, mode) {++ let extra;++ if (mode === 'warm') {++ extra = `${commas(cost.gpu_count)} GPU${cost.gpu_count === '1' ? '' : 's'} · idle burn ${money(cost.idle_burn_nano_usd)}`;++ } else if (mode === 'scale_to_zero') {++ extra = `${commas(cost.cold_starts)} cold start${cost.cold_starts === '1' ? '' : 's'} · ${money(cost.parts_nano_usd.cold_start)}`;++ } else {++ extra = 'all-in · no idle burn';++ }++ return `<td data-label="${esc(MODE_LABELS[mode])}"><span class="hc-price">${money(cost.total_nano_usd)}</span><span class="hc-term">high band ${money(high.total_nano_usd)}</span><span class="hc-dim">${esc(extra)}</span></td>`;++}++++function breakEvenText(be) {++ const bits = [];++ if (be.warm_vs_scale_to_zero) bits.push(`warm beats scale-to-zero above ${commas(be.warm_vs_scale_to_zero.requests_per_month)} requests/month`);++ if (be.warm_vs_wholesale) bits.push(`warm beats wholesale above ${commas(be.warm_vs_wholesale.requests_per_month)} requests/month`);++ if (!bits.length) return `No warm break-even within ${commas(be.capacity_requests_per_month)} requests/month (one GPU's monthly busy time).`;++ return bits.join('; ') + `. (One warm GPU covers ${commas(be.capacity_requests_per_month)} requests/month.)`;++}++++function row(candidate) {++ const f = candidate.costs.forecast;++ const h = candidate.costs.high_band;++ const rec = candidate.recommendation;++ const badge = rec.budget.status === 'needs_owner_budget'++ ? '<span class="hc-chip">needs the owner\'s budget</span>'++ : '<span class="hc-dim">within budget</span>';++ return `<tr>++ <th scope="row">${esc(candidate.model.label)}<span class="hc-kind">${esc(candidate.model.id)}</span></th>++ <td data-label="GPU class">${esc(candidate.gpu_class.label)}<span class="hc-kind">${esc(candidate.gpu_class.id)}</span></td>++ ${costCell(f.warm, h.warm, 'warm')}++ ${costCell(f.scale_to_zero, h.scale_to_zero, 'scale_to_zero')}++ ${costCell(f.wholesale, h.wholesale, 'wholesale')}++ <td data-label="Recommendation" style="grid-column: 1 / -1"><strong>${esc(rec.label)}</strong> ${badge}<p class="hc-dim">${esc(rec.reason)}</p><p class="hc-dim">${esc(breakEvenText(candidate.break_even))}</p></td>++</tr>`;++}++++function method() {++ return `<section class="hc-method" aria-labelledby="hp-method-h">++<h2 id="hp-method-h">How the forecast is made</h2>++<ul>++<li>The level is the mean of the trailing window of daily request counts. Each weekday's factor is that weekday's mean in the window divided by the level; a future day is the level times its weekday factor, rounded to a whole request.</li>++<li>The band is ±1.96 × the pooled standard deviation of the window's residuals from their weekday means × √horizon. Daily residuals are assumed independent. It is a planning band, not a guarantee.</li>++<li>The input is a planning JSON (<code>HOST_INFERENCE_PLANNER_INPUT</code>); with <code>HOST_INFERENCE_PLANNER_SOURCE=ai</code> the daily request series comes from OpenVibe.AI's aggregate usage feed and is cached ten minutes. No provider API is read and no benchmark is invented.</li>++</ul>++</section>++<section class="hc-method" aria-labelledby="hp-cost-h">++<h2 id="hp-cost-h">How the costs are compared</h2>++<ul>++<li><strong>Warm rented GPU:</strong> one GPU-month (730 hours) at the class's hourly rate for each GPU the busy time needs, plus storage and egress. The idle burn is the part of the rental not spent on busy requests.</li>++<li><strong>Scale to zero:</strong> busy milliseconds at the serverless hourly rate, a cold start every <code>requests_per_cold_start</code> requests at the startup seconds, plus storage and egress. Idle time is free.</li>++<li><strong>Wholesale API:</strong> input, cached read, cache write and output tokens at the per-million rate, plus the per-request fee. All-in: no owned storage or egress.</li>++<li>Every amount is integer nano-USD (D1); the page formats it as USD for reading and the JSON keeps the exact integer. A tie prefers wholesale, then scale-to-zero — never a rental.</li>++</ul>++</section>++<section class="hc-method" aria-labelledby="hp-action-h">++<h2 id="hp-action-h">Budget and authority</h2>++<p>Any recommendation whose monthly cost at the forecast or at the high band exceeds <code>HOST_INFERENCE_BUDGET_NANO_USD</code> is marked <strong>needs the owner's budget</strong>. Unset means 0, so nothing is pre-approved. The planner returns a recommendation and an empty action list; there is no code path in it that starts, stops or rents a GPU. The owner approves every rental separately.</p>++</section>`;++}++++/** Where the demand came from: the AI feed, the JSON input, or the JSON after a failed feed. */++function demandText(demand) {++ if (!demand) return null;++ if (demand.source === 'ai') return `Demand: OpenVibe.AI's aggregate usage series, ${commas(demand.days)} days (${demand.from} to ${demand.to}), cached up to 10 minutes.`;++ if (demand.fallback) return `${demand.warning}; using the configured planning JSON instead.`;++ return 'Demand: the configured planning JSON.';++}++++/** The planner page body. `result` is the output of server/inference-planner.js. */++function page(result) {++ const monthly = result.forecast.monthly;++ const candidates = result.candidates;++ const needs = result.summary.needs_owner_budget;++ const demand = demandText(result.demand);++ return `<header class="hc-head">++<p class="hc-eyebrow">OpenVibe.Host · staff · inference</p>++<h1>Inference placement planner</h1>++<p class="hc-lede">A forecast and a monthly break-even for a warm rented GPU, scale-to-zero and wholesale. This page recommends; it never starts, stops or rents anything. The owner approves every rental.</p>++</header>++${demand ? `<p class="hc-facts">${esc(demand)}</p>` : ''}++<p class="hc-facts">Forecast: <strong>${commas(monthly.requests)} requests</strong> in the next ${result.forecast.horizon.days} days, uncertainty band ${commas(monthly.low)}–${commas(monthly.high)}. ${needs} of ${candidates.length} recommendation${candidates.length === 1 ? '' : 's'} need${needs === 1 ? 's' : ''} the owner's budget.</p>++<p class="hc-facts">Budget: <strong>${money(result.budget.configured_nano_usd)}/month</strong> (<code>${commas(result.budget.configured_nano_usd)} nano-USD</code>). ${esc(budgetText(result.budget))}</p>++${result.note ? `<p class="hc-disclosure">${esc(result.note)}</p>` : ''}++<div class="hc-table-wrap"><table class="hc-table"><caption>Monthly cost at the forecast and at the high band. Exact nano-USD is in <code>/api/v1/catalog/inference-planner</code>.</caption><thead><tr><th scope="col">Model</th><th scope="col">GPU class</th><th scope="col">Warm rented GPU</th><th scope="col">Scale to zero</th><th scope="col">Wholesale API</th><th scope="col">Recommendation</th></tr></thead><tbody>${candidates.map(row).join('')}</tbody></table></div>++${method()}`;++}++++/** A staff-only error page: no result is shown and nothing is acted on. */++function errorPage(message, { input = false } = {}) {++ return `<header class="hc-head"><p class="hc-eyebrow">OpenVibe.Host · staff · inference</p><h1>Inference placement planner</h1>++<p class="hc-lede">The planner could not show a plan. Nothing was started, stopped or rented.</p></header>++<div class="hc-empty"><p><strong>${input ? 'The planning input is unavailable.' : 'The planner has no plan to show.'}</strong></p><p>${esc(message)}</p>${input ? '<p>Set <code>HOST_INFERENCE_PLANNER_INPUT</code> to a readable planning JSON and reload.</p>' : ''}</div>`;++}++++module.exports = { page, errorPage, money, commas, MODE_LABELS };@@@ -17,6 +17,7 @@ const { testDb } = require('./helpers/db');exempt: {account_data_events: 'the ledger of requests itself: which export and deletion host applied',host_index_revisions: "owner is the search-index document's owner (type/id), not a person; the row holds a revision and hash",++ config_snapshots: 'D11 configuration revisions: created_by names the owner who changed a setting, not a person\'s account data',},});assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);@@@ -39,7 +39,7 @@ async function boot(opts = {}) {async function start() {const config = configLib.load(env);-- built = await createApp({ config, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: quiet, fetchImpl: opts.fetchImpl, rdap: opts.rdap, accountSend: opts.accountSend });++ built = await createApp({ config, env, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: quiet, fetchImpl: opts.fetchImpl, rdap: opts.rdap, accountSend: opts.accountSend });server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });t.port = server.address().port;t.app = built.app;nnew file mode 100644@@@ -0,0 +1,203 @@++'use strict';++/**++ * The inference planner's AI demand feed (HOST_INFERENCE_PLANNER_SOURCE=ai): Host asks a fake++ * OpenVibe.AI for its aggregate series with Host's own service token, caches it ten minutes, and on a++ * failure says "demand feed unavailable" and falls back to the configured planning JSON. The source++ * flag unset keeps today's JSON behaviour and never calls AI. The planner module stays pure: every++ * fetch lives in server/inference-planner-feed.js.++ */++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const { boot, check, done, makeClock } = require('./helpers/boot');++++const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-inference-feed-'));++const aiPlan = path.join(root, 'ai-plan.json');++const jsonPlan = path.join(root, 'json-plan.json');++const DAY = 86_400_000;++const CLOCK = Date.parse('2026-09-21T12:00:00Z'); // Monday++const WEEK_START = Date.parse('2026-08-24T00:00:00Z'); // Monday, 28 days before the forecast day++const WEEK_END = '2026-09-20';++++function iso(ms) { return new Date(ms).toISOString().slice(0, 10); }++++function document(series, windowDays) {++ return {++ note: 'test fixture, not a quote',++ forecast: { horizon_days: 30, window_days: windowDays, series },++ request: { input_tokens: 1000, cached_read_tokens: 0, cache_write_tokens: 0, output_tokens: 100 },++ models: [{++ id: 'qwen/qwen3-9b:q4_k_m@3f2a1c9',++ label: 'Qwen3 9B Q4',++ throughput: { prefill_tokens_per_sec: 1000, decode_tokens_per_sec: 50 },++ success_bps: 8000,++ wholesale: {++ input_nano_usd_per_million: 100000000, cached_read_nano_usd_per_million: 0,++ cache_write_nano_usd_per_million: 0, output_nano_usd_per_million: 400000000,++ per_request_nano_usd: 0,++ },++ }],++ gpu_classes: [{++ id: 'rtx-4070-12gb',++ label: 'RTX 4070 12 GB',++ warm: { hourly_nano_usd: 100000000, storage_gb: 0, storage_nano_usd_per_gb_month: 0, egress_bytes_per_request: 0, egress_nano_usd_per_gb: 0 },++ scale_to_zero: { hourly_nano_usd: 200000000, startup_seconds: 0, requests_per_cold_start: 1, storage_gb: 0, storage_nano_usd_per_gb_month: 0, egress_bytes_per_request: 0, egress_nano_usd_per_gb: 0 },++ }],++ };++}++++// The same four-week shape the pure test uses: Mon-Fri 100, Saturday 40, Sunday 20. The 30 forecast++// days after 2026-09-20 start on Monday 2026-09-21 and total 2,440 requests.++const aiSeries = Array.from({ length: 28 }, (_, i) => {++ const day = iso(WEEK_START + i * DAY);++ const dow = new Date(`${day}T00:00:00Z`).getUTCDay(); // 0 Sunday, 6 Saturday++ return { date: day, requests: dow === 6 ? 40 : dow === 0 ? 20 : 100 };++});++fs.writeFileSync(aiPlan, JSON.stringify(document(aiSeries, 28), null, 2));++++// A deliberately different JSON demand: 14 days of 10 requests, so a fallback (or a flag-unset run) is++// 300 requests a month, never the 2,440 the AI series would make.++const jsonSeries = Array.from({ length: 14 }, (_, i) => ({ date: iso(Date.parse('2026-09-07T00:00:00Z') + i * DAY), requests: 10 }));++fs.writeFileSync(jsonPlan, JSON.stringify(document(jsonSeries, 14), null, 2));++++function startAi(handler) {++ return new Promise((resolve) => {++ const tokens = [];++ const urls = [];++ const server = http.createServer((req, res) => {++ tokens.push(String(req.headers.authorization || ''));++ urls.push(req.url);++ const out = handler(req);++ res.writeHead(out.status || 200, { 'content-type': 'application/json' });++ res.end(JSON.stringify(out.body === undefined ? out : out.body));++ });++ server.listen(0, '127.0.0.1', () => resolve({++ url: `http://127.0.0.1:${server.address().port}`,++ tokens,++ urls,++ calls: () => tokens.length,++ close: () => new Promise((r) => server.close(r)),++ }));++ });++}++++/** The AI endpoint's aggregate answer: two models a day, summing to the shape above. */++function aiBody() {++ const series = [];++ for (const row of aiSeries) {++ const half = Math.floor(row.requests / 2);++ for (const model of ['model-a', 'model-b']) {++ series.push({++ day: row.date, model, provider: 'fake', model_key: model,++ requests: model === 'model-a' ? half : row.requests - half, successes: 0,++ tokens: { input: 0, cached_read: 0, cache_write: 0, output: 0 },++ });++ }++ }++ return { days: 28, from: iso(WEEK_START), to: WEEK_END, series };++}++++function bearerClaims(authorization) {++ return JSON.parse(Buffer.from(String(authorization).replace(/^Bearer /, '').split('.')[1], 'base64url'));++}++++(async () => {++ // 1. A fake AI: Host's service token, the aggregate series, and the ten-minute cache.++ const ai = await startAi(() => ({ body: aiBody() }));++ const clock = makeClock(CLOCK);++ const on = await boot({ clock, env: {++ HOST_INFERENCE_PLANNER: 'on', HOST_INFERENCE_PLANNER_SOURCE: 'ai', HOST_INFERENCE_PLANNER_INPUT: aiPlan,++ HOST_INFERENCE_BUDGET_NANO_USD: '0', OV_AI_INTERNAL_URL: ai.url,++ } });++ const staff = on.user('planner-editor', { role: 'admin' });++ try {++ await check('the AI feed builds the series with Host\'s ai.usage.read token', async () => {++ const r = await on.api('GET', '/catalog/inference', { session: staff });++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));++ assert.match(r.text, /Demand: OpenVibe\.AI/);++ assert.match(r.text, /aggregate usage series, 28 days \(2026-08-24 to 2026-09-20\)/);++ assert.match(r.text, /2,440 requests/);++ assert.strictEqual(ai.calls(), 1, 'one feed call');++ assert.strictEqual(ai.urls[0], '/api/v1/usage/series?days=28', 'the aggregate endpoint and the configured window');++ const claims = bearerClaims(ai.tokens[0]);++ assert.ok(claims.aud.includes('openvibe.ai'), 'audience openvibe.ai');++ assert.ok(claims.cap.includes('ai.usage.read'), 'capability ai.usage.read');++ assert.strictEqual(claims.sub, 'svc:host', 'Host\'s own service token');++ const json = await on.api('GET', '/api/v1/catalog/inference-planner', { as: staff });++ assert.strictEqual(json.status, 200);++ assert.deepStrictEqual(json.json().planner.demand, { source: 'ai', from: '2026-08-24', to: '2026-09-20', days: 28, fetched_at: new Date(CLOCK).toISOString() });++ });++++ await check('the series is cached for ten minutes', async () => {++ const before = ai.calls();++ const again = await on.api('GET', '/api/v1/catalog/inference-planner', { as: staff });++ assert.strictEqual(again.status, 200);++ assert.strictEqual(ai.calls(), before, 'a second read inside ten minutes makes no call');++ clock.advance(10 * 60 * 1000 + 1000);++ const refreshed = await on.api('GET', '/api/v1/catalog/inference-planner', { as: staff });++ assert.strictEqual(refreshed.status, 200);++ assert.strictEqual(ai.calls(), before + 1, 'an expired cache asks again');++ });++ } finally { await on.close(); await ai.close(); }++++ // 2. The feed fails and a configured JSON input is the fallback.++ const down = await startAi(() => ({ status: 503, body: { code: 'ai.not_found' } }));++ const fallback = await boot({ env: {++ HOST_INFERENCE_PLANNER: 'on', HOST_INFERENCE_PLANNER_SOURCE: 'ai', HOST_INFERENCE_PLANNER_INPUT: jsonPlan,++ HOST_INFERENCE_BUDGET_NANO_USD: '0', OV_AI_INTERNAL_URL: down.url,++ } });++ const fallbackStaff = fallback.user('planner-editor', { role: 'admin' });++ try {++ await check('a failure says demand feed unavailable and falls back to the configured JSON', async () => {++ const r = await fallback.api('GET', '/catalog/inference', { session: fallbackStaff });++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));++ assert.match(r.text, /demand feed unavailable/);++ assert.match(r.text, /using the configured planning JSON instead/);++ assert.match(r.text, /300 requests/, 'the JSON series, not the AI series');++ const json = await fallback.api('GET', '/api/v1/catalog/inference-planner', { as: fallbackStaff });++ assert.strictEqual(json.status, 200);++ assert.strictEqual(json.json().planner.demand.source, 'json');++ assert.strictEqual(json.json().planner.demand.fallback, true);++ assert.match(json.json().planner.demand.warning, /demand feed unavailable/);++ });++ } finally { await fallback.close(); await down.close(); }++++ // 3. The feed fails and no JSON input is configured: no silent example fallback, just unavailable.++ const down2 = await startAi(() => ({ status: 503, body: { code: 'ai.not_found' } }));++ const noFallback = await boot({ env: {++ HOST_INFERENCE_PLANNER: 'on', HOST_INFERENCE_PLANNER_SOURCE: 'ai',++ HOST_INFERENCE_BUDGET_NANO_USD: '0', OV_AI_INTERNAL_URL: down2.url,++ } });++ try {++ await check('a failure with no configured JSON input is unavailable, never the bundled example', async () => {++ const r = await noFallback.api('GET', '/catalog/inference', { session: noFallback.user('planner-editor', { role: 'admin' }) });++ assert.strictEqual(r.status, 503);++ assert.match(r.text, /demand feed unavailable/);++ const json = await noFallback.api('GET', '/api/v1/catalog/inference-planner', { as: noFallback.user('planner-editor', { role: 'admin' }) });++ assert.strictEqual(json.status, 503);++ assert.strictEqual(json.json().code, 'host.inference_planner.unavailable');++ });++ } finally { await noFallback.close(); await down2.close(); }++++ // 4. The source flag unset keeps today's JSON behaviour, and AI is never called.++ const never = await startAi(() => ({ body: aiBody() }));++ const off = await boot({ env: {++ HOST_INFERENCE_PLANNER: 'on', HOST_INFERENCE_PLANNER_INPUT: jsonPlan,++ HOST_INFERENCE_BUDGET_NANO_USD: '0', OV_AI_INTERNAL_URL: never.url,++ } });++ try {++ await check('source unset: the JSON series, no demand feed call', async () => {++ assert.strictEqual(off.config.inferencePlanner.source, 'json');++ const r = await off.api('GET', '/catalog/inference', { session: off.user('planner-editor', { role: 'admin' }) });++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /Demand: the configured planning JSON/);++ assert.match(r.text, /300 requests/);++ assert.strictEqual(never.calls(), 0, 'no AI call on the default path');++ });++ } finally { await off.close(); await never.close(); }++++ fs.rmSync(root, { recursive: true, force: true });++ done();++})();nnew file mode 100644@@@ -0,0 +1,131 @@++'use strict';++/**++ * The inference planner's staff surface: with HOST_INFERENCE_PLANNER off there is no page and no++ * endpoint; on, only staff see them, the page has no form or button, and the JSON says the planner++ * has no action. The planning JSON is a temp file; no provider, GPU or process is touched.++ */++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const { boot, check, done } = require('./helpers/boot');++++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-inference-planner-'));++const fixturePath = path.join(tmp, 'plan.json');++++const start = Date.parse('2026-10-05T00:00:00Z');++const series = Array.from({ length: 28 }, (_, i) => {++ const dow = i % 7; // 0 Monday ... 5 Saturday, 6 Sunday++ return {++ date: new Date(start + i * 86_400_000).toISOString().slice(0, 10),++ requests: dow === 5 ? 40 : dow === 6 ? 20 : 100,++ };++});++fs.writeFileSync(fixturePath, JSON.stringify({++ note: 'test fixture, not a quote',++ forecast: { horizon_days: 30, window_days: 28, series },++ request: { input_tokens: 1000, cached_read_tokens: 0, cache_write_tokens: 0, output_tokens: 100 },++ models: [{++ id: 'qwen/qwen3-9b:q4_k_m@3f2a1c9',++ label: 'Qwen3 9B Q4',++ throughput: { prefill_tokens_per_sec: 1000, decode_tokens_per_sec: 50 },++ success_bps: 8000,++ wholesale: {++ input_nano_usd_per_million: 100000000, cached_read_nano_usd_per_million: 0,++ cache_write_nano_usd_per_million: 0, output_nano_usd_per_million: 400000000,++ per_request_nano_usd: 0,++ },++ }],++ gpu_classes: [{++ id: 'rtx-4070-12gb',++ label: 'RTX 4070 12 GB',++ warm: { hourly_nano_usd: 100000000, storage_gb: 0, storage_nano_usd_per_gb_month: 0, egress_bytes_per_request: 0, egress_nano_usd_per_gb: 0 },++ scale_to_zero: { hourly_nano_usd: 200000000, startup_seconds: 0, requests_per_cold_start: 1, storage_gb: 0, storage_nano_usd_per_gb_month: 0, egress_bytes_per_request: 0, egress_nano_usd_per_gb: 0 },++ }],++}, null, 2));++++(async () => {++ let off;++ try {++ off = await boot({ env: { HOST_INFERENCE_PLANNER: 'off', HOST_INFERENCE_PLANNER_INPUT: fixturePath } });++ const staff = off.user('planner-editor', { role: 'admin' });++ await check('flag off: no page and no JSON endpoint', async () => {++ const page = await off.api('GET', '/catalog/inference', { session: staff });++ assert.strictEqual(page.status, 404);++ const api = await off.api('GET', '/api/v1/catalog/inference-planner', { as: staff });++ assert.strictEqual(api.status, 404);++ assert.strictEqual(api.json().code, 'route.not_found');++ assert.strictEqual(off.config.inferencePlanner.enabled, false, 'the source is constructed so the owner switch can apply live; off, it serves nothing');++ });++ } finally { await off.close(); }++++ const on = await boot({ env: { HOST_INFERENCE_PLANNER: 'on', HOST_INFERENCE_PLANNER_INPUT: fixturePath, HOST_INFERENCE_BUDGET_NANO_USD: '0' } });++ const staff = on.user('planner-editor', { role: 'admin' });++ const reader = on.user('planner-reader');++ try {++ await check('staff only: a signed-in reader and an anonymous caller are refused', async () => {++ const page = await on.api('GET', '/catalog/inference', { session: reader });++ assert.strictEqual(page.status, 403);++ assert.match(page.text, /Staff access required/);++ assert.doesNotMatch(page.text, /Warm rented GPU/);++ const api = await on.api('GET', '/api/v1/catalog/inference-planner', { as: reader });++ assert.strictEqual(api.status, 403);++ assert.strictEqual(api.json().code, 'host.catalog.forbidden');++ const anon = await on.api('GET', '/api/v1/catalog/inference-planner');++ assert.strictEqual(anon.status, 403);++ assert.strictEqual(anon.json().code, 'host.catalog.forbidden');++ });++++ await check('the staff page shows the forecast, the three costs, the budget lock and no action', async () => {++ const r = await on.api('GET', '/catalog/inference', { session: staff });++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));++ assert.match(r.text, /Inference placement planner/);++ assert.match(r.text, /2,440 requests/);++ assert.match(r.text, /uncertainty band 2,440–2,440/);++ assert.match(r.text, /Warm rented GPU/);++ assert.match(r.text, /Scale to zero/);++ assert.match(r.text, /Wholesale API/);++ assert.match(r.text, /needs the owner's budget/);++ assert.match(r.text, /never starts, stops or rents anything/);++ assert.match(r.text, /no code path/);++ assert.match(r.text, /A tie prefers wholesale/);++ assert.doesNotMatch(r.text, /<form/i);++ assert.doesNotMatch(r.text, /<button/i);++ assert.strictEqual(r.headers['cache-control'], 'private, no-store');++ });++++ await check('the JSON endpoint returns exact integer nano-USD and an empty action list', async () => {++ const r = await on.api('GET', '/api/v1/catalog/inference-planner', { as: staff });++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));++ assert.match(r.headers['content-type'], /application\/json/);++ const p = r.json().planner;++ assert.strictEqual(p.schema, 'openvibe.inference-planner-result@1');++ assert.strictEqual(p.executes, false);++ assert.strictEqual(p.action, 'none');++ assert.deepStrictEqual(p.summary.actions, []);++ assert.strictEqual(p.forecast.monthly.requests, 2440);++ assert.strictEqual(p.candidates.length, 1);++ const c = p.candidates[0];++ assert.strictEqual(c.costs.forecast.warm.total_nano_usd, '73000000000');++ assert.strictEqual(c.costs.forecast.scale_to_zero.total_nano_usd, '406667480');++ assert.strictEqual(c.costs.forecast.wholesale.total_nano_usd, '341600000');++ assert.strictEqual(c.recommendation.mode, 'wholesale');++ assert.strictEqual(c.recommendation.budget.status, 'needs_owner_budget');++ assert.strictEqual(p.summary.needs_owner_budget, 1);++ });++++ await check('a missing planning file is a 503 page and problem, not a crash', async () => {++ fs.renameSync(fixturePath, `${fixturePath}.gone`);++ const page = await on.api('GET', '/catalog/inference', { session: staff });++ assert.strictEqual(page.status, 503);++ assert.match(page.text, /planning input is unavailable/i);++ const api = await on.api('GET', '/api/v1/catalog/inference-planner', { as: staff });++ assert.strictEqual(api.status, 503);++ assert.strictEqual(api.json().code, 'host.inference_planner.unavailable');++ });++ } finally {++ await on.close();++ fs.rmSync(tmp, { recursive: true, force: true });++ }++ done();++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,261 @@++'use strict';++// server/inference-planner.js: the forecast, the integer nano-USD cost comparison, the hand-computed++// break-evens and the budget lock. Every expected number below is written out by hand; the test does++// not re-derive it with the implementation. It also asserts that the module has no network or spawn.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const {++ plan, validate, parseBudget, formatUsd,++ busyMillis, warmCost, scaleToZeroCost, wholesaleCost,++} = require('../server/inference-planner');++++const START = Date.parse('2026-10-05T00:00:00Z'); // Monday++const DAY = 86_400_000;++++// Four identical weeks: Mon-Fri 100, Saturday 40, Sunday 20. The next 30 days start on Monday++// 2026-11-02: 5 Mon, 5 Tue, 4 Wed, 4 Thu, 4 Fri, 4 Sat, 4 Sun = 22*100 + 4*40 + 4*20 = 2,440.++function series4() {++ return Array.from({ length: 28 }, (_, i) => {++ const dow = i % 7; // 0 Monday ... 5 Saturday, 6 Sunday++ return {++ date: new Date(START + i * DAY).toISOString().slice(0, 10),++ requests: dow === 5 ? 40 : dow === 6 ? 20 : 100,++ };++ });++}++++function model() {++ // 1,000 prefill tokens at 1,000 tok/s + 100 output tokens at 50 tok/s = 1 s + 2 s = 3 s busy.++ // Wholesale: 1,000 * $0.10/M = 100,000 nano + 100 * $0.40/M = 40,000 nano = 140,000 nano/request.++ return {++ id: 'qwen/qwen3-9b:q4_k_m@3f2a1c9',++ label: 'Qwen3 9B Q4',++ throughput: { prefill_tokens_per_sec: 1000, decode_tokens_per_sec: 50 },++ success_bps: 8000,++ wholesale: {++ input_nano_usd_per_million: 100000000,++ cached_read_nano_usd_per_million: 0,++ cache_write_nano_usd_per_million: 0,++ output_nano_usd_per_million: 400000000,++ per_request_nano_usd: 0,++ },++ };++}++++function gpu() {++ // Warm $0.10/h => 730 h * $0.10 = $73 = 73,000,000,000 nano a month.++ // Scale-to-zero $0.20/h => 3 s = 3 * 200,000,000 / 3,600,000 = 166,666.66 -> 166,667 nano/request.++ return {++ id: 'rtx-4070-12gb',++ label: 'RTX 4070 12 GB',++ warm: { hourly_nano_usd: 100000000, storage_gb: 0, storage_nano_usd_per_gb_month: 0, egress_bytes_per_request: 0, egress_nano_usd_per_gb: 0 },++ scale_to_zero: { hourly_nano_usd: 200000000, startup_seconds: 0, requests_per_cold_start: 1, storage_gb: 0, storage_nano_usd_per_gb_month: 0, egress_bytes_per_request: 0, egress_nano_usd_per_gb: 0 },++ };++}++++function input(points, overrides = {}) {++ return {++ note: 'test fixture',++ forecast: { horizon_days: 30, window_days: 28, series: points, ...(overrides.forecast || {}) },++ request: {++ input_tokens: 1000, cached_read_tokens: 0, cache_write_tokens: 0, output_tokens: 100,++ ...(overrides.request || {}),++ },++ models: overrides.models || [model()],++ gpu_classes: overrides.gpu_classes || [gpu()],++ };++}++++// ── Forecast ─────────────────────────────────────────────────────────────────────────────────────++const steady = plan(input(series4()));++assert.strictEqual(steady.forecast.method, 'moving average with day-of-week seasonality');++assert.strictEqual(steady.forecast.source.points, 28);++assert.strictEqual(steady.forecast.window.level_requests_per_day, 80);++assert.strictEqual(steady.forecast.horizon.days, 30);++assert.strictEqual(steady.forecast.horizon.start_date, '2026-11-02');++assert.strictEqual(steady.forecast.horizon.end_date, '2026-12-01');++assert.deepStrictEqual(steady.forecast.daily.slice(0, 7).map((d) => d.requests), [100, 100, 100, 100, 100, 40, 20]);++assert.strictEqual(steady.forecast.monthly.requests, 2440);++assert.strictEqual(steady.forecast.monthly.band, 0, 'an identical weekly pattern has no residual spread');++assert.strictEqual(steady.forecast.monthly.high, 2440);++assert.strictEqual(steady.forecast.monthly.low, 2440);++++// One Monday is 120 instead of 100. Monday's mean is 105, so Monday forecasts 105 while the other++// weekdays stay 100: 5*105 + 5*100 + 12*100 + 4*40 + 4*20 = 2,465. The residuals are +15 for that++// Monday and -5 for the other three Mondays: (225 + 3*25) / (28 - 7) = 300/21 = 14.2857;++// sqrt = 3.77964; the 30-day band = ceil(1.96 * 3.77964 * sqrt(30)) = ceil(40.55) = 41.++// So the band is 2,424–2,506.++const oneOutlier = series4();++oneOutlier[0].requests = 120;++const banded = plan(input(oneOutlier));++assert.strictEqual(banded.forecast.monthly.requests, 2465);++assert.ok(Math.abs(banded.forecast.window.residual_sd_requests - 3.7796) < 1e-4);++assert.strictEqual(banded.forecast.monthly.band, 41);++assert.strictEqual(banded.forecast.monthly.low, 2424);++assert.strictEqual(banded.forecast.monthly.high, 2506);++assert.strictEqual(banded.forecast.daily[0].requests, 105, 'the next Monday follows its weekday factor');++assert.strictEqual(banded.forecast.daily[1].requests, 100);++++// A 7-day window has one sample per weekday, so there is no residual degree of freedom; the band++// falls back to the window's spread around its level instead of claiming zero uncertainty.++// Spread: (5*20^2 + 40^2 + 60^2) / 6 = 7,200/6 = 1,200; sqrt = 34.641; band = ceil(1.96 *++// 34.641 * sqrt(30)) = ceil(371.9) = 372.++const sevenDayWindow = plan(input(series4(), { forecast: { window_days: 7 } }));++assert.strictEqual(sevenDayWindow.forecast.monthly.requests, 2440);++assert.strictEqual(sevenDayWindow.forecast.monthly.band, 372);++++// ── Monthly costs at the forecast (2,440 requests) ───────────────────────────────────────────────++const planned = plan(input(series4()), { budgetNanoUsd: '1000000000' }); // $1/month++const candidate = planned.candidates[0];++assert.strictEqual(candidate.busy_per_request.total_ms, 3000);++assert.strictEqual(candidate.costs.forecast.requests_per_month, 2440);++assert.strictEqual(candidate.costs.forecast.warm.total_nano_usd, '73000000000');++assert.strictEqual(candidate.costs.forecast.warm.idle_burn_nano_usd, '72796666666');++assert.strictEqual(candidate.costs.forecast.scale_to_zero.total_nano_usd, '406667480');++assert.strictEqual(candidate.costs.forecast.scale_to_zero.parts_nano_usd.busy, '406667480');++assert.strictEqual(candidate.costs.forecast.wholesale.total_nano_usd, '341600000');++assert.strictEqual(candidate.costs.forecast.wholesale.parts_nano_usd.input, '100000');++assert.strictEqual(candidate.costs.forecast.wholesale.parts_nano_usd.output, '40000');++// 2,440 * 0.8 = 1,952 expected successes.++// Warm: 73,000,000,000 / 1,952 = 37,397,540.98 -> 37,397,541.++// Scale: 406,667,480 / 1,952 = 208,333.75 -> 208,334.++// Wholesale: 341,600,000 / 1,952 = 175,000 exactly.++assert.strictEqual(candidate.costs.forecast.warm.cost_per_successful_task_nano_usd, '37397541');++assert.strictEqual(candidate.costs.forecast.scale_to_zero.cost_per_successful_task_nano_usd, '208334');++assert.strictEqual(candidate.costs.forecast.wholesale.cost_per_successful_task_nano_usd, '175000');++assert.strictEqual(candidate.recommendation.mode, 'wholesale');++assert.strictEqual(candidate.recommendation.stable, true);++assert.strictEqual(candidate.recommendation.budget.status, 'within_budget');++assert.match(candidate.recommendation.reason, /Wholesale API is cheapest/);++++// ── Break-even at the hand-computed points ───────────────────────────────────────────────────────++// Warm vs scale-to-zero: 73,000,000,000 / 166,667 = 437,999.12...; at 437,999 scale is still++// cheaper (72,999,979,333), at 438,000 it is 73,000,146,000, so 438,000 is the first warm month.++// Warm vs wholesale: 73,000,000,000 / 140,000 = 521,428.57; at 521,428 wholesale is 72,999,920,000,++// at 521,429 it is 73,000,060,000, so 521,429 is the first warm month.++assert.deepStrictEqual(candidate.break_even.warm_vs_scale_to_zero, { requests_per_month: '438000', cheaper_above: 'warm' });++assert.deepStrictEqual(candidate.break_even.warm_vs_wholesale, { requests_per_month: '521429', cheaper_above: 'warm' });++assert.strictEqual(candidate.break_even.capacity_requests_per_month, '876000');++const normalized = validate(input(series4()));++const busy = busyMillis(normalized.models[0], normalized.request);++const warm = (n) => warmCost(n, normalized.gpuClasses[0], busy).total;++const scale = (n) => scaleToZeroCost(n, normalized.gpuClasses[0], busy).total;++const wholesale = (n) => wholesaleCost(n, normalized.models[0], normalized.request).total;++assert.ok(warm(437999n) > scale(437999n), 'below the scale crossover warm still costs more');++assert.ok(warm(438000n) <= scale(438000n), 'at the scale crossover warm costs no more');++assert.ok(warm(521428n) > wholesale(521428n), 'below the wholesale crossover warm still costs more');++assert.ok(warm(521429n) <= wholesale(521429n), 'at the wholesale crossover warm costs no more');++++// A third of the way is still wholesale; 300× the series is 732,000 requests, above both++// crossovers, so warm wins: 73,000,000,000 vs 102,480,000,000 wholesale.++const high = plan(input(series4().map((p) => ({ ...p, requests: p.requests * 300 }))), { budgetNanoUsd: '100000000000' });++assert.strictEqual(high.forecast.monthly.requests, 732000);++assert.strictEqual(high.candidates[0].recommendation.mode, 'warm');++assert.strictEqual(high.candidates[0].costs.forecast.warm.total_nano_usd, '73000000000');++assert.strictEqual(high.candidates[0].costs.forecast.wholesale.total_nano_usd, '102480000000');++++// ── Integer nano-USD, beyond Number.MAX_SAFE_INTEGER ─────────────────────────────────────────────++// 730 h * 1,000,000,000,000,000 nano/h = 730,000,000,000,000,000 nano = $730,000,000 a month.++// A float would lose the units; BigInt keeps the exact integer and the output is a string.++const hugeGpu = gpu();++hugeGpu.warm.hourly_nano_usd = 1000000000000000;++const huge = plan(input(series4(), { gpu_classes: [hugeGpu] }));++assert.strictEqual(huge.candidates[0].costs.forecast.warm.total_nano_usd, '730000000000000000');++assert.ok(BigInt(huge.candidates[0].costs.forecast.warm.total_nano_usd) > BigInt(Number.MAX_SAFE_INTEGER));++++// ── Budget lock ──────────────────────────────────────────────────────────────────────────────────++const unset = plan(input(series4()));++assert.strictEqual(unset.budget.configured, false);++assert.strictEqual(unset.budget.valid, true);++assert.strictEqual(unset.budget.configured_nano_usd, '0');++assert.strictEqual(unset.candidates[0].recommendation.budget.status, 'needs_owner_budget');++assert.match(unset.candidates[0].recommendation.reason, /Needs the owner's budget/);++++const invalid = plan(input(series4()), { budgetNanoUsd: '1.5' });++assert.strictEqual(invalid.budget.valid, false);++assert.strictEqual(invalid.budget.configured, true);++assert.strictEqual(invalid.budget.configured_nano_usd, '0');++assert.strictEqual(invalid.candidates[0].recommendation.budget.status, 'needs_owner_budget');++assert.match(invalid.candidates[0].recommendation.reason, /not a non-negative integer/);++++// The high band can cross the budget even when the forecast does not: 2,465 * 140,000 =++// 345,100,000 wholesale at the forecast and 2,506 * 140,000 = 350,840,000 at the high band. A++// $0.348 budget passes the forecast and fails the high band, so it needs the owner's budget.++const highBudget = plan(input(oneOutlier), { budgetNanoUsd: '348000000' });++const highRecommendation = highBudget.candidates[0].recommendation;++assert.strictEqual(highRecommendation.mode, 'wholesale');++assert.strictEqual(highRecommendation.budget.exceeds_forecast, false);++assert.strictEqual(highRecommendation.budget.exceeds_high_band, true);++assert.strictEqual(highRecommendation.budget.status, 'needs_owner_budget');++++// A tie never recommends a rental: with zero-price modes the tie order is wholesale, scale, warm.++const freeModel = model();++freeModel.wholesale = { input_nano_usd_per_million: 0, cached_read_nano_usd_per_million: 0, cache_write_nano_usd_per_million: 0, output_nano_usd_per_million: 0, per_request_nano_usd: 0 };++const freeGpu = gpu();++freeGpu.warm.hourly_nano_usd = 0;++freeGpu.scale_to_zero.hourly_nano_usd = 0;++const free = plan(input(series4(), { models: [freeModel], gpu_classes: [freeGpu] }));++assert.strictEqual(free.candidates[0].recommendation.mode, 'wholesale');++++// ── Validation names the field ──────────────────────────────────────────────────────────────────++for (const [label, bad, pattern] of [++ ['non-consecutive dates', (() => { const s = series4(); s.splice(10, 1); return input(s); })(), /forecast\.series\[10\]\.date: every point must be the next calendar day/],++ ['window longer than series', input(series4(), { forecast: { window_days: 29 } }), /forecast\.window_days: must be <= 28/],++ ['success rate zero', input(series4(), { models: [{ ...model(), success_bps: 0 }] }), /models\[0\]\.success_bps: must be >= 1/],++ ['negative tokens', input(series4(), { request: { output_tokens: -1 } }), /request\.output_tokens: must be >= 0/],++ ['zero throughput', input(series4(), { models: [{ ...model(), throughput: { prefill_tokens_per_sec: 0, decode_tokens_per_sec: 50 } }] }), /models\[0\]\.throughput\.prefill_tokens_per_sec: must be a positive number/],++ ['duplicate model id', input(series4(), { models: [model(), model()] }), /models\.id: 'qwen\/qwen3-9b:q4_k_m@3f2a1c9' is listed twice/],++ ['missing gpu class', (() => { const x = input(series4()); delete x.gpu_classes; return x; })(), /gpu_classes: is required/],++]) {++ assert.throws(() => plan(bad), pattern, label);++}++++// ── parseBudget and formatUsd ────────────────────────────────────────────────────────────────────++assert.deepStrictEqual(parseBudget(''), { nano: 0n, raw: '', configured: false, valid: true });++assert.deepStrictEqual(parseBudget('42'), { nano: 42n, raw: '42', configured: true, valid: true });++assert.deepStrictEqual(parseBudget('-1'), { nano: 0n, raw: '-1', configured: true, valid: false });++assert.strictEqual(formatUsd('73000000000'), '$73.00');++assert.strictEqual(formatUsd('341600000'), '$0.34');++assert.strictEqual(formatUsd('999999', 6), '$0.001000');++assert.strictEqual(formatUsd('0'), '$0.00');++++// ── The checked-in example and its schema ────────────────────────────────────────────────────────++const examplePath = path.join(__dirname, '..', 'workers', 'llm-inference', 'examples', 'inference-planner.example.json');++const example = JSON.parse(fs.readFileSync(examplePath, 'utf8'));++assert.match(example.note, /example, not a quote/);++const examplePlan = plan(example);++assert.strictEqual(examplePlan.candidates.length, example.models.length * example.gpu_classes.length);++assert.strictEqual(examplePlan.summary.actions.length, 0);++assert.strictEqual(examplePlan.action, 'none');++assert.strictEqual(examplePlan.executes, false);++for (const c of examplePlan.candidates) {++ assert.match(c.costs.forecast.warm.total_nano_usd, /^\d+$/);++ assert.match(c.costs.high_band.wholesale.total_nano_usd, /^\d+$/);++ assert.ok(c.recommendation.reason.length > 0);++}++const schema = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'scripts', 'inference-planner.schema.json'), 'utf8'));++for (const key of ['forecast', 'request', 'models', 'gpu_classes']) assert.ok(schema.properties[key], `schema property ${key}`);++assert.ok(schema.$defs.nanoUsd, 'schema defines integer nano-USD');++for (const key of Object.keys(example)) {++ if (key === '$schema') continue;++ assert.ok(schema.properties[key], `schema property ${key}`);++}++++// ── No network, no process spawn, no clock ──────────────────────────────────────────────────────++const source = fs.readFileSync(path.join(__dirname, '..', 'server', 'inference-planner.js'), 'utf8');++assert.doesNotMatch(source, /require\(\s*['"](?:fs|child_process|http|https|net|tls|dns|dgram|cluster|worker_threads)['"]\s*\)/);++assert.doesNotMatch(source, /child_process/);++assert.doesNotMatch(source, /\b(?:spawn|spawnSync|execFile|execSync|fork)\s*\(/);++assert.doesNotMatch(source, /\bfetch\s*\(/);++assert.doesNotMatch(source, /\b(?:setTimeout|setInterval|setImmediate)\s*\(/);++assert.doesNotMatch(source, /process\.(?:spawn|exec|fork)\b/);++const originalFetch = globalThis.fetch;++globalThis.fetch = () => { throw new Error('the planner must not call fetch'); };++try {++ plan(input(series4()));++} finally {++ globalThis.fetch = originalFetch;++}++++console.log('inference planner: forecast, integer costs, break-evens and budget checks passed');nnew file mode 100644@@@ -0,0 +1,96 @@++'use strict';++/**++ * D11 owner settings: the host.inference_planner namespace and /internal/config.++ * Env pins win; host.config.manage is required; an owner change applies live; budget_nano_usd is x-danger and++ * needs X-OV-Fresh; no KEK still works; a secret-class value never leaves a summary, snapshot or history.++ */++const assert = require('assert');++const { boot, check, done, DASHBOARD } = require('./helpers/boot');++++const CAP = ['host.config.manage'];++const OWNER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';++const SECRET = 'sk_host_d11_toppsecret_123456';++++(async () => {++ const t = await boot({ env: { HOST_INFERENCE_PLANNER: 'on', HOST_INFERENCE_BUDGET_NANO_USD: '0' } });++ const admin = () => t.network.serviceToken('network', CAP);++ try {++ await check('the summary: schema, x-danger/x-unit, the env pins, sealed:false without a KEK', async () => {++ const r = await t.api('GET', '/internal/config/host.inference_planner', { as: admin() });++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));++ assert.strictEqual(r.json().namespace, 'host.inference_planner');++ assert.strictEqual(r.json().sealed, false);++ assert.strictEqual(r.json().values.enabled, true);++ assert.deepStrictEqual(r.json().pins.enabled, { env: 'HOST_INFERENCE_PLANNER', pinned: true });++ assert.strictEqual(r.json().schema.properties.budget_nano_usd['x-danger'], true);++ assert.strictEqual(r.json().schema.properties.budget_nano_usd['x-unit'], 'nano_usd');++ assert.strictEqual(r.json().schema.properties.source.enum.join(','), 'json,ai');++ });++++ await check('a service token without host.config.manage is refused', async () => {++ const token = t.network.serviceToken('other', ['host.catalog.read']);++ const r = await t.api('GET', '/internal/config/host.inference_planner', { as: token });++ assert.strictEqual(r.status, 403);++ assert.strictEqual(r.json().code, 'capability.denied');++ });++++ await check('an env-pinned key is read-only', async () => {++ const r = await t.api('POST', '/internal/config/host.inference_planner', {++ as: admin(), headers: { 'x-ov-subject': OWNER }, json: { values: { enabled: false }, reason: 'off' },++ });++ assert.strictEqual(r.status, 409, r.text.slice(0, 300));++ assert.strictEqual(r.json().code, 'config.pinned_in_env');++ });++ } finally { await t.close(); }++++ const live = await boot({ env: { HOST_INFERENCE_PLANNER_INPUT: require('path').join(__dirname, '..', 'workers', 'llm-inference', 'examples', 'inference-planner.example.json') } });++ const liveAdmin = () => live.network.serviceToken('network', [...CAP, 'host.catalog.manage']);++ const staff = live.user('planner-owner', { role: 'admin' });++ try {++ await check('a danger key without X-OV-Fresh is refused, with it applies live', async () => {++ const stale = await live.api('POST', '/internal/config/host.inference_planner', {++ as: liveAdmin(), headers: { 'x-ov-subject': OWNER }, json: { values: { budget_nano_usd: 500 } , reason: 'raise it' },++ });++ assert.strictEqual(stale.status, 403, stale.text.slice(0, 300));++ assert.strictEqual(stale.json().code, 'config.fresh_required');++ const fresh = await live.api('POST', '/internal/config/host.inference_planner', {++ as: liveAdmin(), headers: { 'x-ov-subject': OWNER, 'x-ov-fresh': '1' }, json: { values: { budget_nano_usd: 500, enabled: true, source: 'json' }, reason: 'owner raised the budget' },++ });++ assert.strictEqual(fresh.status, 200, fresh.text.slice(0, 300));++ assert.strictEqual(fresh.json().revision, 2);++ assert.strictEqual(live.config.inferencePlanner.enabled, true);++ assert.strictEqual(live.config.inferencePlanner.budgetNanoUsdRaw, '500');++ const page = await live.api('GET', '/api/v1/catalog/inference-planner', { as: live.network.userToken(staff) });++ assert.strictEqual(page.status, 200, page.text.slice(0, 300));++ });++++ await check('with a KEK the store is sealed and boot reseals', async () => {++ const sealed = await boot({ env: { OV_CONFIG_KEKS: 'k1:MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=', OV_CONFIG_KEK_CURRENT: 'k1' } });++ try {++ const token = sealed.network.serviceToken('network', CAP);++ const r = await sealed.api('GET', '/internal/config/host.inference_planner', { as: token });++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));++ assert.strictEqual(r.json().sealed, true);++ assert.strictEqual(r.json().kek, 'k1');++ } finally { await sealed.close(); }++ });++++ await check('a secret is write-only and never appears in a summary, snapshot or history', async () => {++ const shared = require('openvibe-shared/config');++ const extra = await shared.createConfigStore({++ db: live.ctx.store.db, service: 'host', namespace: 'host.test_secrets',++ schema: { type: 'object', additionalProperties: false, properties: { api_key: { type: 'string' } } },++ classify: () => 'secret',++ });++ const marker = await extra.setSecret('api_key', SECRET, { actor: { type: 'user', id: OWNER }, reason: 'test' });++ assert.strictEqual(marker.redacted, true);++ assert.ok(!JSON.stringify(marker).includes(SECRET));++ const summary = await extra.summary();++ assert.strictEqual(summary.values.api_key.redacted, true);++ assert.ok(!JSON.stringify(summary).includes(SECRET));++ assert.ok(!JSON.stringify(await extra.history()).includes(SECRET));++ assert.ok(!JSON.stringify(await extra.snapshot(1)).includes(SECRET));++ });++ } finally { await live.close(); }++ done();++})().catch((e) => { console.error(e); process.exit(1); });@@@ -25,6 +25,7 @@ of long-running worker: it can run under a `workerUnits` unit or any GPU orchest| `Caddyfile` | `/health`, `/ready`, `/metrics` and API passthrough; blocks LoRA paths || `examples/qwen3-4b-abliterated.manifest.example.json` | one compact abliterated Qwen 4B artifact, revision and hash **TODO** || `examples/inference-cost-model.example.json` | clearly labelled placeholder inputs for the cost model |++| `examples/inference-planner.example.json` | clearly labelled placeholder inputs for the placement planner (daily series, models, GPU classes) |## Build@@@ -206,11 +207,49 @@ built-in benchmarks. Replace them with dated complete quotes and measured tok/sThe script uses floats for forecasting. Customer liability is Billing's integer nano-USD ledger(D1); this is not a settlement or quoting authority.++## Placement planner (staff, flag off)++++`server/inference-planner.js` (schema: `scripts/inference-planner.schema.json`) turns a daily++request series into a 30-day forecast and, for every model × GPU-class candidate, compares the++expected monthly cost of a **warm rented GPU**, **scale-to-zero** and **wholesale** at the forecast++and at its high band. It is a pure module: no file, socket, clock or child process, and every++amount is integer nano-USD (BigInt).++++- **Forecast:** level = mean of the trailing window; each weekday factor = that weekday's mean /++ the level; a future day = level × factor. The band is ±1.96 × the pooled residual standard++ deviation × √horizon, assuming independent daily residuals.++- **Warm:** 730 h × the GPU hourly rate for each GPU the busy time needs, plus storage and egress.++ The idle burn is the part of the rental not spent on busy requests.++- **Scale-to-zero:** busy milliseconds at the serverless rate, one cold start every++ `requests_per_cold_start`, plus storage and egress; idle is free.++- **Wholesale:** per-million token rates plus the per-request fee, all-in.++- **Recommendation:** the cheapest monthly cost, with a tie preferring wholesale, then++ scale-to-zero — never a rental. If the forecast or high-band cost is above++ `HOST_INFERENCE_BUDGET_NANO_USD` the row is marked **needs the owner's budget**; unset means 0.++++The staff page is `GET /catalog/inference` and the JSON is++`GET /api/v1/catalog/inference-planner`. Both are mounted only when `HOST_INFERENCE_PLANNER=on`++(default off), and both use Host's existing staff gate (`staff.site.configure`/role) and catalog++page style. There is no action endpoint: nothing here starts, stops or rents a GPU. The owner++approves every rental separately.++++```sh++HOST_INFERENCE_PLANNER=on \++HOST_INFERENCE_PLANNER_INPUT=workers/llm-inference/examples/inference-planner.example.json \++HOST_INFERENCE_BUDGET_NANO_USD=0 \++node server/index.js++```++++The example file is **example, not a quote**: every price and throughput is a placeholder. The++input may be JSON now; the AI usage feed is not connected.++## Tests```shnode test/inference-artifact-verify.test.jsnode test/inference-cost-model.test.js++node test/inference-planner.test.js++node test/inference-planner-http.test.js# or, through the repository runner:npm test -- inference```@@@ -218,7 +257,11 @@ npm test -- inferenceThe artifact test covers a correct download, a wrong hash (refused, no file left), a `TODO`placeholder, a traversal filename, a local URL without the smoke-test opt-in, a cached file, andthe entrypoint's bounded-env refusals. The cost-model test checks the arithmetic against numbers--worked out by hand, invalid inputs and the checked-in example.++worked out by hand, invalid inputs and the checked-in example. The planner test covers the++forecast and its band on fixtures, integer nano-USD costs, hand-computed warm/scale/wholesale++break-evens, the budget lock (including a high band that crosses it), validation, and asserts that++the module has no network call, process spawn or clock. The HTTP test covers flag off (no route),++the staff gate, the page and JSON, and a missing input.## Not done herennew file mode 100644@@@ -0,0 +1,210 @@++{++ "$schema": "../../../scripts/inference-planner.schema.json",++ "note": "example, not a quote — every price and throughput below is a placeholder for arithmetic only. Replace each one with a dated complete quote and a measured tok/s run before using this plan for a decision. The planner recommends; it never starts, stops or rents a GPU.",++ "forecast": {++ "horizon_days": 30,++ "window_days": 28,++ "series": [++ {++ "date": "2026-10-05",++ "requests": 120++ },++ {++ "date": "2026-10-06",++ "requests": 120++ },++ {++ "date": "2026-10-07",++ "requests": 120++ },++ {++ "date": "2026-10-08",++ "requests": 120++ },++ {++ "date": "2026-10-09",++ "requests": 120++ },++ {++ "date": "2026-10-10",++ "requests": 45++ },++ {++ "date": "2026-10-11",++ "requests": 30++ },++ {++ "date": "2026-10-12",++ "requests": 130++ },++ {++ "date": "2026-10-13",++ "requests": 130++ },++ {++ "date": "2026-10-14",++ "requests": 130++ },++ {++ "date": "2026-10-15",++ "requests": 130++ },++ {++ "date": "2026-10-16",++ "requests": 130++ },++ {++ "date": "2026-10-17",++ "requests": 50++ },++ {++ "date": "2026-10-18",++ "requests": 35++ },++ {++ "date": "2026-10-19",++ "requests": 125++ },++ {++ "date": "2026-10-20",++ "requests": 125++ },++ {++ "date": "2026-10-21",++ "requests": 125++ },++ {++ "date": "2026-10-22",++ "requests": 125++ },++ {++ "date": "2026-10-23",++ "requests": 125++ },++ {++ "date": "2026-10-24",++ "requests": 48++ },++ {++ "date": "2026-10-25",++ "requests": 32++ },++ {++ "date": "2026-10-26",++ "requests": 140++ },++ {++ "date": "2026-10-27",++ "requests": 140++ },++ {++ "date": "2026-10-28",++ "requests": 140++ },++ {++ "date": "2026-10-29",++ "requests": 140++ },++ {++ "date": "2026-10-30",++ "requests": 140++ },++ {++ "date": "2026-10-31",++ "requests": 55++ },++ {++ "date": "2026-11-01",++ "requests": 38++ }++ ]++ },++ "request": {++ "input_tokens": 1000,++ "cached_read_tokens": 250,++ "cache_write_tokens": 100,++ "output_tokens": 500++ },++ "models": [++ {++ "id": "qwen/qwen3-9b:q4_k_m@3f2a1c9",++ "label": "Qwen3 9B Q4 (example)",++ "throughput": {++ "prefill_tokens_per_sec": 800,++ "decode_tokens_per_sec": 40,++ "cached_read_tokens_per_sec": 800,++ "concurrency": 1++ },++ "success_bps": 9500,++ "wholesale": {++ "input_nano_usd_per_million": 100000000,++ "cached_read_nano_usd_per_million": 20000000,++ "cache_write_nano_usd_per_million": 100000000,++ "output_nano_usd_per_million": 400000000,++ "per_request_nano_usd": 5000,++ "success_bps": 9800++ }++ },++ {++ "id": "qwen/qwen3-4b:q4_k_m@c9e9066",++ "label": "Qwen3 4B Q4 (example)",++ "throughput": {++ "prefill_tokens_per_sec": 1200,++ "decode_tokens_per_sec": 60,++ "cached_read_tokens_per_sec": 1200,++ "concurrency": 1++ },++ "success_bps": 9700,++ "wholesale": {++ "input_nano_usd_per_million": 50000000,++ "cached_read_nano_usd_per_million": 10000000,++ "cache_write_nano_usd_per_million": 50000000,++ "output_nano_usd_per_million": 200000000,++ "per_request_nano_usd": 2000,++ "success_bps": 9900++ }++ }++ ],++ "gpu_classes": [++ {++ "id": "rtx-4070-12gb",++ "label": "RTX 4070 12 GB (example)",++ "warm": {++ "hourly_nano_usd": 70000000,++ "storage_gb": 4,++ "storage_nano_usd_per_gb_month": 100000000,++ "egress_bytes_per_request": 200000,++ "egress_nano_usd_per_gb": 90000000++ },++ "scale_to_zero": {++ "hourly_nano_usd": 750000000,++ "startup_seconds": 45,++ "requests_per_cold_start": 1,++ "storage_gb": 4,++ "storage_nano_usd_per_gb_month": 100000000,++ "egress_bytes_per_request": 200000,++ "egress_nano_usd_per_gb": 90000000++ }++ },++ {++ "id": "rtx-3090-24gb",++ "label": "RTX 3090 24 GB (example)",++ "warm": {++ "hourly_nano_usd": 90000000,++ "storage_gb": 6,++ "storage_nano_usd_per_gb_month": 100000000,++ "egress_bytes_per_request": 200000,++ "egress_nano_usd_per_gb": 90000000++ },++ "scale_to_zero": {++ "hourly_nano_usd": 1200000000,++ "startup_seconds": 60,++ "requests_per_cold_start": 2,++ "storage_gb": 6,++ "storage_nano_usd_per_gb_month": 100000000,++ "egress_bytes_per_request": 200000,++ "egress_nano_usd_per_gb": 90000000++ }++ }++ ]++}
@@ -38,3 +38,15 @@ OV_WATCH_URL=https://openvibe.watch
@@ -38,3 +38,15 @@ OV_WATCH_URL=https://openvibe.watch
#HOST_CONSOLE_MAX_ACCOUNTS=10
#HOST_CONSOLE_MAX_ACCOUNTS=10
# OpenVibe.Events → Host (account export and deletion): signs deliveries to /internal/events (32+ characters).
# OpenVibe.Events → Host (account export and deletion): signs deliveries to /internal/events (32+ characters).
#HOST_EVENTS_SECRET=
#HOST_EVENTS_SECRET=
+
+# Inference placement planner (staff-only, recommendations only). Demand is the planning JSON by default;
+# SOURCE=ai reads OpenVibe.AI's aggregate usage series with Host's service token (Network grant:
+# ai.usage.read on openvibe.ai) and caches it ten minutes. A feed failure falls back only to a configured INPUT.
+#HOST_INFERENCE_PLANNER=off
+#HOST_INFERENCE_PLANNER_INPUT=
+#HOST_INFERENCE_PLANNER_SOURCE=json
+#OV_AI_INTERNAL_URL=http://127.0.0.1:4700
+#HOST_INFERENCE_PLANNER_DAYS=28
+#HOST_INFERENCE_PLANNER_CACHE_MS=600000
+#HOST_INFERENCE_PLANNER_TIMEOUT_MS=5000
+#HOST_INFERENCE_BUDGET_NANO_USD=
@@ -380,9 +380,9 @@ the owner's gates (go-ahead, a DMCA agent, a Public Suffix List entry for openvi
@@ -380,9 +380,9 @@ the owner's gates (go-ahead, a DMCA agent, a Public Suffix List entry for openvi
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.272.0
+- openvibe-contracts: v0.288.0
- openvibe-sdk: v0.82.0
- openvibe-sdk: v0.82.0
-- openvibe-shared: v4.109.2
+- openvibe-shared: v4.110.0
- openvibe-publishing: v1.4.0
- openvibe-publishing: v1.4.0
<!-- versions:end -->
<!-- versions:end -->
---
---
@@ -13,13 +13,13 @@
@@ -13,13 +13,13 @@
"runtime": "node 22; Express 4 + PostgreSQL 18 (openvibe-sdk/db, async) + optional Valkey",
"runtime": "node 22; Express 4 + PostgreSQL 18 (openvibe-sdk/db, async) + optional Valkey",
"port": 4910,
"port": 4910,
"domain": "openvibe.host",
"domain": "openvibe.host",
- "contracts": "openvibe-contracts v0.272.0",
+ "contracts": "openvibe-contracts v0.288.0",
"sdk": "openvibe-sdk v0.82.0",
"sdk": "openvibe-sdk v0.82.0",
- "shared": "openvibe-shared v4.109.2",
+ "shared": "openvibe-shared v4.110.0",
"packages": [
"packages": [
- "openvibe-contracts v0.272.0",
+ "openvibe-contracts v0.288.0",
"openvibe-sdk v0.82.0",
"openvibe-sdk v0.82.0",
- "openvibe-shared v4.109.2"
+ "openvibe-shared v4.110.0"
],
],
"capabilities": [
"capabilities": [
"host.catalog.manage"
"host.catalog.manage"
new file mode 100644
new file mode 100644
@@ -0,0 +1,28 @@
@@ -0,0 +1,28 @@
+-- phase: expand
+-- D11 owner settings: openvibe-shared/config (config_snapshots, config_keys). Applied migrations are immutable.
+CREATE TABLE IF NOT EXISTS config_snapshots (
+ namespace text COLLATE "C" NOT NULL,
+ revision bigint NOT NULL,
+ service text COLLATE "C" NOT NULL,
+ previous_revision bigint,
+ state text COLLATE "C" NOT NULL CHECK (state IN ('proposed', 'active', 'superseded', 'rejected', 'rolled_back')),
+ values_json text COLLATE "C" NOT NULL, -- the full values; secret-class values are envelopes (never their plaintext)
+ classification_json text COLLATE "C" NOT NULL,
+ values_checksum text COLLATE "C" NOT NULL, -- sha256 of the values as served (secrets as markers)
+ created_at text COLLATE "C" NOT NULL,
+ created_by text COLLATE "C" NOT NULL,
+ activated_at text COLLATE "C",
+ activated_by text COLLATE "C",
+ reason text COLLATE "C",
+ error text COLLATE "C",
+ copied_from bigint,
+ good bigint NOT NULL DEFAULT 0, -- 1 once it activated successfully (last-known-good)
+ PRIMARY KEY (namespace, revision)
+);
+CREATE UNIQUE INDEX IF NOT EXISTS config_snapshots_one_active ON config_snapshots (namespace) WHERE state = 'active';
+-- The fingerprint key of each namespace. Never served, never logged.
+CREATE TABLE IF NOT EXISTS config_keys (
+ namespace text COLLATE "C" PRIMARY KEY,
+ hmac_key bytea NOT NULL, -- the 32-byte fingerprint key, or that key wrapped under the current KEK
+ created_at text COLLATE "C" NOT NULL
+);
@@ -15,10 +15,10 @@
@@ -15,10 +15,10 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.272.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.288.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.110.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"bin": {
"bin": {
@@ -1534,9 +1534,9 @@
@@ -1534,9 +1534,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.272.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.272.0",
- "integrity": "sha512-qZc5WDgPcEQinxHPi1/pe4vvI3hoSgrq5rSio9bjeTKj9BTFIIgy33KzOEL9vsW7baGnAlxusnaGuHQUnh9xeg==",
+ "version": "0.288.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.288.0",
+ "integrity": "sha512-AKRP1yXACYNu7okpf4kCDq4ds0PhsoNvD92VlvBWBCjcSlNimSTQhLWIvSa3D31/kQYz91fodo06ZnwuTanOqg==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -1596,9 +1596,9 @@
@@ -1596,9 +1596,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "4.109.2",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",
- "integrity": "sha512-t9K1yHr6GOJsfamqmgERRTTtiWYWAVmjWBEm4ykTcYjEenbTiqQTwX6CJsZW4lYnqS1IlqEvn+0jG4vXmHSJ3Q==",
+ "version": "4.110.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.110.0",
+ "integrity": "sha512-ZNOSraSgE89jhLTI//lFJALvKCiBJIBwf+V0aCkiIk0VCrPPAytsfsgOgCINU8kXDVdCEnuWWfcA9hZUc54QVA==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -31,10 +31,10 @@
@@ -31,10 +31,10 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.272.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.288.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.110.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"engines": {
"engines": {
new file mode 100644
new file mode 100644
@@ -0,0 +1,287 @@
@@ -0,0 +1,287 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/schemas/inference-planner.v1.json",
+ "title": "InferencePlannerInput",
+ "description": "Inputs for OpenVibe.Host's inference placement planner (PLAN.md §D, §E and §G-P7). It forecasts a daily request series and compares the monthly cost of a warm rented GPU, scale-to-zero and wholesale for every model × GPU-class candidate. Every price is an integer nano-USD (D1). There is no action: this document can recommend a rental but never starts, stops or rents anything.",
+ "type": "object",
+ "required": ["forecast", "request", "models", "gpu_classes"],
+ "additionalProperties": false,
+ "properties": {
+ "$schema": {
+ "type": "string",
+ "description": "Optional editor association with this schema file."
+ },
+ "note": {
+ "type": "string",
+ "description": "Provenance, for example 'example, not a quote' or the quote ids and measurement dates."
+ },
+ "forecast": {
+ "type": "object",
+ "required": ["series"],
+ "additionalProperties": false,
+ "properties": {
+ "horizon_days": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 365,
+ "default": 30,
+ "description": "Days to forecast after the last series point."
+ },
+ "window_days": {
+ "type": "integer",
+ "minimum": 7,
+ "maximum": 365,
+ "default": 28,
+ "description": "Trailing days used for the moving average and the day-of-week factors; not more than the series length."
+ },
+ "series": {
+ "type": "array",
+ "minItems": 14,
+ "description": "Consecutive daily request counts, oldest first. Dates must be calendar days and every point must be the next day.",
+ "items": {
+ "type": "object",
+ "required": ["date", "requests"],
+ "additionalProperties": false,
+ "properties": {
+ "date": {
+ "type": "string",
+ "format": "date",
+ "description": "UTC calendar day (YYYY-MM-DD)."
+ },
+ "requests": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 1000000000,
+ "description": "Requests counted on that day."
+ }
+ }
+ }
+ }
+ }
+ },
+ "request": {
+ "type": "object",
+ "required": ["input_tokens", "cached_read_tokens", "cache_write_tokens", "output_tokens"],
+ "additionalProperties": false,
+ "properties": {
+ "input_tokens": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 1000000000,
+ "description": "I: fresh input tokens per request."
+ },
+ "cached_read_tokens": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 1000000000,
+ "description": "R: input tokens read from a prompt cache per request."
+ },
+ "cache_write_tokens": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 1000000000,
+ "description": "W: input tokens written to a prompt cache per request."
+ },
+ "output_tokens": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 1000000000,
+ "description": "O: generated output tokens per request."
+ }
+ }
+ },
+ "models": {
+ "type": "array",
+ "minItems": 1,
+ "description": "Model candidates. Every model is paired with every GPU class.",
+ "items": {
+ "type": "object",
+ "required": ["id", "throughput", "success_bps", "wholesale"],
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Exact model id, for example qwen/qwen3-9b:q4_k_m@3f2a1c9."
+ },
+ "label": {
+ "type": "string",
+ "description": "Display name; defaults to id."
+ },
+ "throughput": {
+ "type": "object",
+ "required": ["prefill_tokens_per_sec", "decode_tokens_per_sec"],
+ "additionalProperties": false,
+ "properties": {
+ "prefill_tokens_per_sec": {
+ "type": "number",
+ "exclusiveMinimum": 0,
+ "description": "Measured prompt-processing throughput at the stated concurrency."
+ },
+ "decode_tokens_per_sec": {
+ "type": "number",
+ "exclusiveMinimum": 0,
+ "description": "Measured output throughput at the stated concurrency."
+ },
+ "cached_read_tokens_per_sec": {
+ "type": "number",
+ "exclusiveMinimum": 0,
+ "description": "Optional measured cached-read throughput; omitted means prefill throughput (conservative)."
+ },
+ "concurrency": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 1024,
+ "default": 1,
+ "description": "Concurrency at which the throughput numbers were measured; recorded, not multiplied."
+ }
+ }
+ },
+ "success_bps": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 10000,
+ "description": "Successful tasks per 10,000 requests (basis points), used for cost per successful task."
+ },
+ "wholesale": {
+ "type": "object",
+ "required": ["input_nano_usd_per_million", "cached_read_nano_usd_per_million", "cache_write_nano_usd_per_million", "output_nano_usd_per_million", "per_request_nano_usd"],
+ "additionalProperties": false,
+ "properties": {
+ "input_nano_usd_per_million": {
+ "$ref": "#/$defs/nanoUsd",
+ "description": "Wholesale input rate, nano-USD per million tokens."
+ },
+ "cached_read_nano_usd_per_million": {
+ "$ref": "#/$defs/nanoUsd",
+ "description": "Wholesale cached-read rate, nano-USD per million tokens."
+ },
+ "cache_write_nano_usd_per_million": {
+ "$ref": "#/$defs/nanoUsd",
+ "description": "Wholesale cache-write rate, nano-USD per million tokens."
+ },
+ "output_nano_usd_per_million": {
+ "$ref": "#/$defs/nanoUsd",
+ "description": "Wholesale output rate, nano-USD per million tokens."
+ },
+ "per_request_nano_usd": {
+ "$ref": "#/$defs/nanoUsd",
+ "description": "Wholesale per-request fee, nano-USD."
+ },
+ "success_bps": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 10000,
+ "description": "Optional wholesale success rate in basis points; defaults to the model's success_bps."
+ }
+ }
+ }
+ }
+ }
+ },
+ "gpu_classes": {
+ "type": "array",
+ "minItems": 1,
+ "description": "GPU classes. Every GPU class is paired with every model.",
+ "items": {
+ "type": "object",
+ "required": ["id", "warm", "scale_to_zero"],
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Class id, for example rtx-4070-12gb."
+ },
+ "label": {
+ "type": "string",
+ "description": "Display name; defaults to id."
+ },
+ "warm": {
+ "type": "object",
+ "required": ["hourly_nano_usd", "storage_gb", "storage_nano_usd_per_gb_month", "egress_bytes_per_request", "egress_nano_usd_per_gb"],
+ "additionalProperties": false,
+ "properties": {
+ "hourly_nano_usd": {
+ "$ref": "#/$defs/nanoUsd",
+ "description": "Rented warm GPU rate, nano-USD per GPU-hour."
+ },
+ "storage_gb": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "Model storage held for the warm deployment, in GB (counted once per candidate)."
+ },
+ "storage_nano_usd_per_gb_month": {
+ "$ref": "#/$defs/nanoUsd",
+ "description": "Storage rate, nano-USD per GB-month."
+ },
+ "egress_bytes_per_request": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "Response bytes sent per request (1 GB = 1,000,000,000 bytes)."
+ },
+ "egress_nano_usd_per_gb": {
+ "$ref": "#/$defs/nanoUsd",
+ "description": "Egress rate, nano-USD per GB."
+ }
+ }
+ },
+ "scale_to_zero": {
+ "type": "object",
+ "required": ["hourly_nano_usd", "startup_seconds", "requests_per_cold_start", "storage_gb", "storage_nano_usd_per_gb_month", "egress_bytes_per_request", "egress_nano_usd_per_gb"],
+ "additionalProperties": false,
+ "properties": {
+ "hourly_nano_usd": {
+ "$ref": "#/$defs/nanoUsd",
+ "description": "Serverless execution rate, nano-USD per GPU-hour of busy time."
+ },
+ "startup_seconds": {
+ "type": "number",
+ "minimum": 0,
+ "description": "Cold-start seconds paid at the serverless hourly rate."
+ },
+ "requests_per_cold_start": {
+ "type": "integer",
+ "minimum": 1,
+ "description": "Requests served per cold start; one cold start is paid per this many requests."
+ },
+ "storage_gb": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "Storage held for the scale-to-zero deployment, in GB."
+ },
+ "storage_nano_usd_per_gb_month": {
+ "$ref": "#/$defs/nanoUsd",
+ "description": "Storage rate, nano-USD per GB-month."
+ },
+ "egress_bytes_per_request": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "Response bytes sent per request."
+ },
+ "egress_nano_usd_per_gb": {
+ "$ref": "#/$defs/nanoUsd",
+ "description": "Egress rate, nano-USD per GB."
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "$defs": {
+ "nanoUsd": {
+ "description": "Integer nano-USD (D1). JSON numbers above 2^53 must be written as decimal strings to survive parsing; the planner accepts both.",
+ "oneOf": [
+ {
+ "type": "integer",
+ "minimum": 0
+ },
+ {
+ "type": "string",
+ "pattern": "^[0-9]+$"
+ }
+ ]
+ }
+ }
+}
@@ -17,6 +17,7 @@ const { openStore } = require('./db');
@@ -17,6 +17,7 @@ const { openStore } = require('./db');
const { createHostOutbox } = require('./events/outbox');
const { createHostOutbox } = require('./events/outbox');
const { createSsoClient } = require('openvibe-sdk/sso');
const { createSsoClient } = require('openvibe-sdk/sso');
const { createViewerResolver } = require('./auth/viewer');
const { createViewerResolver } = require('./auth/viewer');
+const createInferencePlannerConfig = require('./inference-planner-config').create;
const { createHostSession } = require('./auth/sso');
const { createHostSession } = require('./auth/sso');
const { createHostReadiness } = require('./observability');
const { createHostReadiness } = require('./observability');
const { createWorker } = require('./worker');
const { createWorker } = require('./worker');
@@ -51,6 +52,11 @@ async function createApp(opts = {}) {
@@ -51,6 +52,11 @@ async function createApp(opts = {}) {
const config = opts.config || configLib.load();
const config = opts.config || configLib.load();
const log = opts.log || console;
const log = opts.log || console;
const store = opts.store || await openStore(config, { now: opts.now, log });
const store = opts.store || await openStore(config, { now: opts.now, log });
+ // D11 owner settings (host.inference_planner): the planner's switches read through the store, env pins winning.
+ const plannerConfig = await createInferencePlannerConfig({ db: store.db, config, env: opts.env || process.env, log });
+ Object.defineProperty(config.inferencePlanner, 'enabled', { enumerable: true, configurable: true, get: () => plannerConfig.get('enabled') });
+ Object.defineProperty(config.inferencePlanner, 'source', { enumerable: true, configurable: true, get: () => plannerConfig.get('source') });
+ Object.defineProperty(config.inferencePlanner, 'budgetNanoUsdRaw', { enumerable: true, configurable: true, get: () => { const v = plannerConfig.get('budget_nano_usd'); return v == null ? '' : String(v); } });
require('./http/errors').setLogger(log);
require('./http/errors').setLogger(log);
const outbox = createHostOutbox({ db: store.db, config, fetchImpl: opts.fetchImpl, now: store.now, log });
const outbox = createHostOutbox({ db: store.db, config, fetchImpl: opts.fetchImpl, now: store.now, log });
const auth = opts.auth || createSsoClient({
const auth = opts.auth || createSsoClient({
@@ -81,9 +87,10 @@ async function createApp(opts = {}) {
@@ -81,9 +87,10 @@ async function createApp(opts = {}) {
const views = require('./http/catalog-views').createCatalogViews({ catalog, benchmarks, community, config, log });
const views = require('./http/catalog-views').createCatalogViews({ catalog, benchmarks, community, config, log });
catalog.onChange(() => { if (config.worker.enabled) catalogSearch.sweepSoon(); });
catalog.onChange(() => { if (config.worker.enabled) catalogSearch.sweepSoon(); });
const worker = createWorker({ config, outbox, catalogIngest, catalogSearch, log });
const worker = createWorker({ config, outbox, catalogIngest, catalogSearch, log });
- const ctx = { config, store, outbox, catalog, domainCheck, benchmarks, community, catalogIngest, catalogSearch, auth, viewers, worker, log, views };
+ const ctx = { config, store, plannerConfig, outbox, catalog, domainCheck, benchmarks, community, catalogIngest, catalogSearch, auth, viewers, worker, log, views };
ctx.console = require('./console/console').createConsole({ store, config, catalog, fetchImpl: opts.fetchImpl, log });
ctx.console = require('./console/console').createConsole({ store, config, catalog, fetchImpl: opts.fetchImpl, log });
const app = express();
const app = express();
+ plannerConfig.mount(app);
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
// (openvibe-contracts); the operations they mark internal-only are left out.
// (openvibe-contracts); the operations they mark internal-only are left out.
app.get('/openapi.json', (_req, res) => {
app.get('/openapi.json', (_req, res) => {
@@ -244,6 +251,16 @@ async function createApp(opts = {}) {
@@ -244,6 +251,16 @@ async function createApp(opts = {}) {
return agentLayer(req, res, next);
return agentLayer(req, res, next);
});
});
app.use('/console', rateLimit({ windowMs: 60_000, max: 120, standardHeaders: true, legacyHeaders: false }), require('./console/pages').createConsolePages(ctx));
app.use('/console', rateLimit({ windowMs: 60_000, max: 120, standardHeaders: true, legacyHeaders: false }), require('./console/pages').createConsolePages(ctx));
+ // Inference planner (PLAN.md §G-P7): a staff-only recommendation page and JSON API. It reads a planning JSON
+ // and has no action endpoint: nothing here starts, stops or rents a GPU. D11 owner settings: the source and
+ // routes always exist, and the switch is read live (off, each router answers the ordinary 404).
+ {
+ const { createInferencePlannerSource, createInferencePlannerPages, createInferencePlannerApi } = require('./http/inference-planner');
+ ctx.inferencePlanner = createInferencePlannerSource({ config, log, now: store.now });
+ const plannerCtx = { ...ctx, planner: ctx.inferencePlanner };
+ app.use('/catalog/inference', rateLimit({ windowMs: 60_000, max: 60, standardHeaders: true, legacyHeaders: false }), createInferencePlannerPages(plannerCtx));
+ app.use('/api/v1/catalog/inference-planner', rateLimit({ windowMs: 60_000, max: 60, standardHeaders: true, legacyHeaders: false }), createInferencePlannerApi(plannerCtx));
+ }
app.use('/api/v1/catalog', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createCatalogApi(ctx));
app.use('/api/v1/catalog', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createCatalogApi(ctx));
const movedApi = (req, res) => res.status(410).type('application/problem+json').json({ code: 'host.moved_to_space', title: 'Static hosting moved', detail: MOVED, url: SPACE_URL });
const movedApi = (req, res) => res.status(410).type('application/problem+json').json({ code: 'host.moved_to_space', title: 'Static hosting moved', detail: MOVED, url: SPACE_URL });
const movedPage = (req, res) => res.status(410).type('html').send(renderPage({ title: 'Static hosting moved', body: `<h1>Static hosting moved</h1><p>${MOVED} <a href="${SPACE_URL}">Visit OpenVibe.Space</a>.</p>`, config, path: req.originalUrl }));
const movedPage = (req, res) => res.status(410).type('html').send(renderPage({ title: 'Static hosting moved', body: `<h1>Static hosting moved</h1><p>${MOVED} <a href="${SPACE_URL}">Visit OpenVibe.Space</a>.</p>`, config, path: req.originalUrl }));
@@ -1,6 +1,7 @@
@@ -1,6 +1,7 @@
'use strict';
'use strict';
/** OpenVibe.Host catalog service configuration. */
/** OpenVibe.Host catalog service configuration. */
+const path = require('path');
require('dotenv').config();
require('dotenv').config();
const trim = (s) => String(s || '').replace(/\/+$/, '');
const trim = (s) => String(s || '').replace(/\/+$/, '');
@@ -62,6 +63,27 @@ function load(env = process.env) {
@@ -62,6 +63,27 @@ function load(env = process.env) {
// The console (server/console/): a person's own hosting accounts, their tokens sealed with HOST_CREDENTIALS_KEY
// The console (server/console/): a person's own hosting accounts, their tokens sealed with HOST_CREDENTIALS_KEY
// (32 bytes, hex or base64). Without it the console shows servers of no account and connects none.
// (32 bytes, hex or base64). Without it the console shows servers of no account and connects none.
console: { credentialsKey: env.HOST_CREDENTIALS_KEY || '', maxAccounts: int(env.HOST_CONSOLE_MAX_ACCOUNTS, 10) },
console: { credentialsKey: env.HOST_CREDENTIALS_KEY || '', maxAccounts: int(env.HOST_CONSOLE_MAX_ACCOUNTS, 10) },
+ // Inference placement planner (PLAN.md §G-P7): a staff-only forecast and break-even, off unless
+ // HOST_INFERENCE_PLANNER=on. It never starts, stops or rents a GPU. Its demand comes from the
+ // planning JSON (default) or, with HOST_INFERENCE_PLANNER_SOURCE=ai, from OpenVibe.AI's aggregate
+ // usage series (GET /api/v1/usage/series, Host's service token, capability ai.usage.read).
+ // The budget is integer nano-USD (D1); empty means 0, and an invalid value is treated as 0 by the
+ // planner (fail safe) and reported as invalid on the page.
+ inferencePlanner: {
+ enabled: String(env.HOST_INFERENCE_PLANNER || 'off').trim().toLowerCase() === 'on',
+ source: String(env.HOST_INFERENCE_PLANNER_SOURCE || 'json').trim().toLowerCase() === 'ai' ? 'ai' : 'json',
+ inputPath: env.HOST_INFERENCE_PLANNER_INPUT || path.join(__dirname, '..', 'workers', 'llm-inference', 'examples', 'inference-planner.example.json'),
+ // A feed failure may only fall back to a JSON input the operator configured, never to the
+ // bundled example that happens to be the default path.
+ inputConfigured: env.HOST_INFERENCE_PLANNER_INPUT != null && String(env.HOST_INFERENCE_PLANNER_INPUT).trim() !== '',
+ aiUrl: trim(env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700'),
+ aiAudience: 'openvibe.ai',
+ aiCapability: 'ai.usage.read',
+ days: Math.max(14, Math.min(90, int(env.HOST_INFERENCE_PLANNER_DAYS, 28))),
+ cacheMs: Math.max(0, int(env.HOST_INFERENCE_PLANNER_CACHE_MS, 10 * 60 * 1000)),
+ timeoutMs: Math.max(100, int(env.HOST_INFERENCE_PLANNER_TIMEOUT_MS, 5000)),
+ budgetNanoUsdRaw: env.HOST_INFERENCE_BUDGET_NANO_USD === undefined ? '' : String(env.HOST_INFERENCE_BUDGET_NANO_USD),
+ },
worker: {
worker: {
enabled: env.HOST_WORKER !== 'off',
enabled: env.HOST_WORKER !== 'off',
id: env.HOST_WORKER_ID || `host-${process.pid}`,
id: env.HOST_WORKER_ID || `host-${process.pid}`,
new file mode 100644
new file mode 100644
@@ -0,0 +1,131 @@
@@ -0,0 +1,131 @@
+'use strict';
+/**
+ * The staff-only inference planner: GET /catalog/inference (HTML) and
+ * GET /api/v1/catalog/inference-planner (JSON). Both are mounted only when
+ * HOST_INFERENCE_PLANNER=on, so flag off adds no route at all.
+ *
+ * The planner itself (server/inference-planner.js) is pure. This module is the only I/O: it reads the
+ * planning JSON from HOST_INFERENCE_PLANNER_INPUT (mtime-cached), and with
+ * HOST_INFERENCE_PLANNER_SOURCE=ai (default: json) it asks server/inference-planner-feed.js for the
+ * daily request series from OpenVibe.AI (cached ten minutes). A feed failure falls back to the JSON
+ * series when HOST_INFERENCE_PLANNER_INPUT is configured, and is reported as "demand feed unavailable"
+ * either way. There is no action endpoint: nothing can start, stop or rent a GPU from here.
+ */
+const express = require('express');
+const fs = require('fs');
+const path = require('path');
+const { ApiError, run, privateNoStore, sendError } = require('./errors');
+const { renderPage } = require('../render/layout');
+const { guard } = require('../auth/viewer');
+const { plan } = require('../inference-planner');
+const { createDemandFeed } = require('../inference-planner-feed');
+const render = require('../render/inference-planner');
+
+function createInferencePlannerSource({ config, log = console, feed = null, now = Date.now }) {
+ let cached = null;
+ // D11 owner settings: source may change live; the feed is created the first time the source is ai.
+ let demandFeed = feed || null;
+ function feedNow() {
+ if (feed) return feed;
+ if (config.inferencePlanner.source !== 'ai') return null;
+ if (!demandFeed) demandFeed = createDemandFeed({ config, log, clock: { now } });
+ return demandFeed;
+ }
+
+ async function readDocument() {
+ const file = path.resolve(config.inferencePlanner.inputPath);
+ let stat;
+ try {
+ stat = await fs.promises.stat(file);
+ } catch {
+ throw new ApiError(503, 'host.inference_planner.unavailable', 'the planning JSON is missing or unreadable (HOST_INFERENCE_PLANNER_INPUT)');
+ }
+ if (!stat.isFile()) throw new ApiError(503, 'host.inference_planner.unavailable', 'HOST_INFERENCE_PLANNER_INPUT is not a file');
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) return cached.document;
+ let document;
+ try {
+ document = JSON.parse(await fs.promises.readFile(file, 'utf8'));
+ } catch (err) {
+ throw new ApiError(503, 'host.inference_planner.invalid', `the planning JSON could not be read as JSON: ${err.message}`);
+ }
+ cached = { mtimeMs: stat.mtimeMs, size: stat.size, document };
+ return document;
+ }
+
+ function planDocument(document) {
+ try {
+ return plan(document, { budgetNanoUsd: config.inferencePlanner.budgetNanoUsdRaw });
+ } catch (err) {
+ throw new ApiError(503, 'host.inference_planner.invalid', `the planning JSON did not validate: ${err.message}`);
+ }
+ }
+
+ const withDemand = (result, demand) => ({ ...result, demand });
+
+ async function current() {
+ const document = await readDocument();
+ const demand = feedNow();
+ if (!demand) return withDemand(planDocument(document), { source: 'json' });
+ try {
+ const series = await demand.current();
+ const forecast = document && typeof document.forecast === 'object' && !Array.isArray(document.forecast) ? document.forecast : {};
+ const result = planDocument({ ...document, forecast: { ...forecast, series: series.series } });
+ return withDemand(result, { source: 'ai', from: series.from, to: series.to, days: series.days, fetched_at: series.fetched_at });
+ } catch (err) {
+ const warning = `demand feed unavailable: ${err.message}`;
+ if (!config.inferencePlanner.inputConfigured) {
+ throw new ApiError(503, 'host.inference_planner.unavailable', warning);
+ }
+ log.warn(`[Host inference planner] ${warning}; using the configured planning JSON`);
+ try {
+ return withDemand(planDocument(document), { source: 'json', fallback: true, warning });
+ } catch (fallbackErr) {
+ throw new ApiError(fallbackErr.status || 503, fallbackErr.code || 'host.inference_planner.invalid', `${warning}; ${fallbackErr.message}`);
+ }
+ }
+ }
+ return { current };
+}
+
+function createInferencePlannerPages(ctx) {
+ const { config, viewers, planner, log = console } = ctx;
+ const router = express.Router();
+ // D11 owner settings: off, the ordinary 404 and no session work.
+ router.use((_req, _res, next) => (ctx.config.inferencePlanner.enabled ? next() : next('router')));
+ router.use(viewers.middleware('dashboard'));
+ if (ctx.actorLimits) router.use(ctx.actorLimits);
+ router.use((_req, res, next) => { res.set('Cache-Control', 'private, no-store'); res.vary('Cookie'); res.vary('Authorization'); next(); });
+ const send = (req, res, status, body) => res.status(status).type('html').send(renderPage({
+ title: 'Inference placement planner', body, viewer: req.viewer, config, path: req.originalUrl, indexable: false, css: ['css/catalog.css'],
+ }));
+ router.get('/', async (req, res, next) => {
+ try {
+ if (!(req.viewer.kind === 'user' && req.viewer.staff)) return send(req, res, 403, render.errorPage('Staff access required.'));
+ let result;
+ try {
+ result = await planner.current();
+ } catch (err) {
+ if (!err.status) log.error('[Host inference planner]', err && err.stack ? err.stack : err);
+ return send(req, res, err.status || 503, render.errorPage(err.status ? err.message : 'the planner could not build a plan', { input: Boolean(err.status) }));
+ }
+ return send(req, res, 200, render.page(result));
+ } catch (err) { next(err); }
+ });
+ return router;
+}
+
+function createInferencePlannerApi(ctx) {
+ const { viewers, planner, actorLimits, log = console } = ctx;
+ const router = express.Router();
+ // D11 owner settings: off, the ordinary 404 and no token work.
+ router.use((_req, _res, next) => (ctx.config.inferencePlanner.enabled ? next() : next('router')));
+ router.use(viewers.middleware('api'));
+ if (actorLimits) router.use(actorLimits);
+ router.use((_req, res, next) => { privateNoStore(res); next(); });
+ const staff = (req, _res, next) => req.viewer.kind === 'user' && req.viewer.staff ? next() : next(new ApiError(403, 'host.catalog.forbidden', 'Staff access required'));
+ router.get('/', guard('host.catalog.manage'), staff, run(async () => ({ planner: await planner.current() })));
+ router.use((err, req, res, next) => { if (res.headersSent) return next(err); sendError(res, req, err, log); });
+ return router;
+}
+
+module.exports = { createInferencePlannerSource, createInferencePlannerPages, createInferencePlannerApi };
new file mode 100644
new file mode 100644
@@ -0,0 +1,108 @@
@@ -0,0 +1,108 @@
+'use strict';
+/**
+ * The `host.inference_planner` namespace (D11 owner settings): the staff planner's switch, its demand source and
+ * the monthly budget it may recommend within. The env variables stay the pins (`x-env`): while one is set it wins,
+ * is read-only in the summary and a write answers 409 config.pinned_in_env.
+ *
+ * /internal/config is openvibe-shared/config's adminRoutes, guarded by host.config.manage (Network's service token
+ * only). actor is the owner Network names in X-OV-Subject. budget_nano_usd is x-danger (a recommendation above it
+ * needs the owner's budget), so changing it needs X-OV-Fresh: 1. Every activation writes one audit line with the
+ * actor and the keys, never a value.
+ */
+const express = require('express');
+const contracts = require('openvibe-contracts');
+const shared = require('openvibe-shared/config');
+const { verifyServiceToken } = require('openvibe-sdk/auth');
+const { http, ids } = contracts;
+
+const SERVICE = 'host';
+const NAMESPACE = 'host.inference_planner';
+const CAPABILITY = 'host.config.manage';
+const AUDIENCE = 'openvibe.host';
+
+const SCHEMA = {
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
+ title: 'Inference placement planner',
+ description: 'The staff-only forecast and break-even page and JSON API. The planner recommends; it never starts, stops or rents a GPU.',
+ type: 'object',
+ additionalProperties: false,
+ required: ['enabled', 'budget_nano_usd', 'source'],
+ properties: {
+ enabled: {
+ type: 'boolean', title: 'Planner page and API', 'x-group': 'Inference planner', 'x-env': 'HOST_INFERENCE_PLANNER',
+ description: 'Serve /catalog/inference and /api/v1/catalog/inference-planner to staff. Off adds no route.',
+ },
+ budget_nano_usd: {
+ type: ['integer', 'null'], minimum: 0, title: 'Monthly budget', 'x-group': 'Inference planner',
+ 'x-env': 'HOST_INFERENCE_BUDGET_NANO_USD', 'x-unit': 'nano_usd', 'x-danger': true,
+ description: 'Integer nano-USD a month. A recommendation at the forecast or the high band above it is marked "needs the owner\'s budget"; null means unset (0, nothing pre-approved).',
+ },
+ source: {
+ type: 'string', enum: ['json', 'ai'], title: 'Demand source', 'x-group': 'Inference planner', 'x-env': 'HOST_INFERENCE_PLANNER_SOURCE',
+ description: 'json reads the configured planning document; ai reads OpenVibe.AI\'s aggregate usage series and falls back only to a configured JSON input.',
+ },
+ },
+};
+const DEFAULTS = Object.freeze({ enabled: false, budget_nano_usd: null, source: 'json' });
+
+function bearer(req) {
+ const h = String((req.headers && req.headers.authorization) || '');
+ return h.startsWith('Bearer ') ? h.slice(7).trim() : null;
+}
+
+function capabilityGuard({ config, log }) {
+ return async function guard(req, res, next) {
+ try {
+ const token = bearer(req);
+ if (!token) return http.sendProblem(res, 401, 'token.missing', { detail: 'a Network service token is required', ctx: req.ov });
+ const r = await verifyServiceToken(token, {
+ jwks: `${config.networkInternalUrl || config.networkUrl}/api/.well-known/jwks`,
+ issuer: config.networkUrl, audience: AUDIENCE, contracts, log,
+ });
+ if (!r.ok) return http.sendProblem(res, r.code === 'token.unavailable' ? 503 : 401, r.code || 'token.invalid', { detail: r.reason || 'the token does not verify', ctx: req.ov });
+ const sub = String((r.claims && r.claims.sub) || '');
+ if (!sub.startsWith('svc:')) return http.sendProblem(res, 403, 'capability.denied', { detail: `${CAPABILITY} is first-party; an app may not change Host configuration`, ctx: req.ov });
+ const decision = contracts.capabilities.check(r.claims, CAPABILITY);
+ if (!decision.allowed) return http.sendProblem(res, 403, decision.code || 'capability.denied', { detail: decision.reason || `${CAPABILITY} not granted`, ctx: req.ov });
+ req.configClaims = r.claims;
+ return next();
+ } catch (err) {
+ log.error(`[config] host.inference_planner guard failed: ${(err && err.message) || err}`);
+ return http.sendProblem(res, 500, 'internal.error', { detail: 'the configuration request failed', ctx: req.ov });
+ }
+ };
+}
+
+function actor(req) {
+ const named = String((req.headers && req.headers['x-ov-subject']) || '').trim();
+ return ids.isSubjectId('user', named) ? { type: 'user', id: named } : null;
+}
+
+const requireFresh = (req) => String((req.headers && req.headers['x-ov-fresh']) || '') === '1';
+
+async function create({ db, config, env = process.env, log = console, onActivate = null }) {
+ // The shared store's boolean pin reads true/1; Host's env has always said "on". Normalise the pin value, not the
+ // name: the summary still reports HOST_INFERENCE_PLANNER.
+ const storeEnv = { ...env };
+ if (/^(on|yes|true|1)$/i.test(String(storeEnv.HOST_INFERENCE_PLANNER || ''))) storeEnv.HOST_INFERENCE_PLANNER = '1';
+ const store = await shared.createConfigStore({
+ db, service: SERVICE, namespace: NAMESPACE, schema: SCHEMA, defaults: DEFAULTS, env: storeEnv, log,
+ onActivate: async (values) => { if (onActivate) await onActivate(values); },
+ onChange: ({ namespace, keys, actor: who, revision }) => {
+ log.info(`[config] ${SERVICE} ${namespace} revision ${revision} by ${who && who.id ? `${who.type}:${who.id}` : 'unknown'}: ${keys.join(', ')}`);
+ },
+ });
+ if (env.OV_CONFIG_KEKS) {
+ try { await store.reseal(); } catch (err) { log.warn(`[config] ${NAMESPACE}: reseal failed: ${(err && err.message) || err}`); }
+ }
+ const router = shared.adminRoutes([store], {
+ basePath: '/internal/config', requireAdmin: capabilityGuard({ config, log }), actor, requireFresh, log,
+ });
+ return {
+ store, router,
+ get: (key) => store.get(key),
+ mount(app) { app.use('/internal/config', express.json({ limit: '128kb' })); router.mount(app); },
+ };
+}
+
+module.exports = { create, SCHEMA, NAMESPACE, CAPABILITY };
new file mode 100644
new file mode 100644
@@ -0,0 +1,118 @@
@@ -0,0 +1,118 @@
+'use strict';
+/**
+ * The demand half of the inference planner: OpenVibe.AI's aggregate usage series
+ * (`GET /api/v1/usage/series`, capability ai.usage.read) turned into the planner's daily request
+ * series. The planner itself stays pure (server/inference-planner.js): this module is the only I/O
+ * on the AI path, and the planning JSON remains the only I/O on the default JSON path.
+ *
+ * The feed is cached for ten minutes: a successful series is reused without another call, and a
+ * failed refresh is reported as unavailable (the caller may then fall back to a configured JSON
+ * input). No caller id, prompt or answer is read or kept: the endpoint returns aggregates only.
+ */
+const { serviceAuth } = require('openvibe-contracts');
+
+const DAY_MS = 86_400_000;
+const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
+
+class DemandFeedError extends Error {
+ constructor(message) { super(message); this.name = 'DemandFeedError'; }
+}
+
+function dayOf(value) {
+ if (typeof value !== 'string') return null;
+ const m = DATE_RE.exec(value);
+ if (!m) return null;
+ const ms = Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
+ const d = new Date(ms);
+ if (d.getUTCFullYear() !== Number(m[1]) || d.getUTCMonth() !== Number(m[2]) - 1 || d.getUTCDate() !== Number(m[3])) return null;
+ return value;
+}
+
+const isoDay = (ms) => new Date(ms).toISOString().slice(0, 10);
+
+/**
+ * `body` is the AI endpoint's answer ({ days, from, to, series: [{ day, requests, … }] }). Rows are
+ * summed across models: the planner forecasts one demand curve, not a model per candidate. Missing
+ * days in the window are zero-demand days (AI sends only rows that have usage).
+ */
+function seriesOf(body, { days, now }) {
+ if (!body || !Array.isArray(body.series)) throw new DemandFeedError('the usage feed did not return a series');
+ const totals = new Map();
+ for (const row of body.series) {
+ if (!row || typeof row !== 'object') throw new DemandFeedError('a usage feed row is not an object');
+ const day = dayOf(row.day);
+ if (!day) throw new DemandFeedError('a usage feed row has no UTC day');
+ const requests = row.requests === undefined ? 0 : Number(row.requests);
+ if (!Number.isSafeInteger(requests) || requests < 0) throw new DemandFeedError(`the usage feed's requests on ${day} is not a non-negative integer`);
+ totals.set(day, (totals.get(day) || 0) + requests);
+ }
+ const to = dayOf(body.to) || isoDay(now);
+ const requestedFrom = dayOf(body.from);
+ if (!requestedFrom) throw new DemandFeedError('the usage feed did not return its window');
+ // Never trust a window longer than configured: keep its last `days` days. It must still cover the
+ // planner's 14-day minimum, or there is no honest forecast to make.
+ const from = isoDay(Math.max(Date.parse(`${requestedFrom}T00:00:00Z`), Date.parse(`${to}T00:00:00Z`) - (days - 1) * DAY_MS));
+ const span = Math.round((Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`)) / DAY_MS) + 1;
+ if (span < 14) throw new DemandFeedError(`the usage feed covers ${span} day${span === 1 ? '' : 's'}; the planner needs at least 14`);
+ if (span > 90) throw new DemandFeedError('the usage feed covers more than 90 days');
+ const series = [];
+ for (let i = 0; i < span; i++) {
+ const date = isoDay(Date.parse(`${from}T00:00:00Z`) + i * DAY_MS);
+ series.push({ date, requests: totals.get(date) || 0 });
+ }
+ return { days: span, from, to, series };
+}
+
+function createDemandFeed({ config, fetchImpl = globalThis.fetch, clock = { now: () => Date.now() }, log = console }) {
+ const planner = config.inferencePlanner;
+ const cacheMs = planner.cacheMs;
+ let tokens = null;
+ let cached = null; // { at, value }
+ let inflight = null;
+
+ function tokenClient() {
+ if (tokens) return tokens;
+ if (!config.oauth.clientSecret) throw new DemandFeedError('HOST_INFERENCE_PLANNER_SOURCE=ai needs OV_OAUTH_CLIENT_SECRET for Host\'s service token');
+ tokens = serviceAuth.createTokenClient({
+ tokenUrl: `${config.networkInternalUrl}/oauth/token`,
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ audience: planner.aiAudience,
+ scope: planner.aiCapability,
+ fetchImpl,
+ timeoutMs: planner.timeoutMs,
+ });
+ return tokens;
+ }
+
+ async function fetchSeries() {
+ const url = `${planner.aiUrl}/api/v1/usage/series?days=${planner.days}`;
+ let res;
+ try {
+ res = await fetchImpl(url, {
+ headers: { Accept: 'application/json', ...(await tokenClient().authHeaders()) },
+ signal: AbortSignal.timeout(planner.timeoutMs),
+ });
+ } catch (err) {
+ throw new DemandFeedError(`the usage feed is unreachable: ${err.message}`);
+ }
+ const body = await res.json().catch(() => null);
+ if (!res.ok) throw new DemandFeedError(`the usage feed answered ${res.status}`);
+ const value = seriesOf(body, { days: planner.days, now: clock.now() });
+ return { ...value, fetched_at: new Date(clock.now()).toISOString() };
+ }
+
+ async function current() {
+ if (cached && clock.now() - cached.at < cacheMs) return cached.value;
+ if (!inflight) {
+ inflight = fetchSeries()
+ .then((value) => { cached = { at: clock.now(), value }; return value; })
+ .finally(() => { inflight = null; });
+ }
+ return inflight;
+ }
+
+ return { current };
+}
+
+module.exports = { createDemandFeed, seriesOf, DemandFeedError, DAY_MS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,613 @@
@@ -0,0 +1,613 @@
+'use strict';
+/**
+ * Inference placement planner (PLAN.md §D ObservedPerformance, §E cost model and §G-P7).
+ *
+ * Pure functions only: this module reads no file, opens no socket, starts no process and has no
+ * clock. The caller hands it a parsed planning document; it returns a forecast, a break-even and a
+ * recommendation with its arithmetic — never an action. There is no code path here that starts,
+ * stops or rents a GPU.
+ *
+ * D1: every price is integer nano-USD and every money expression is BigInt. Rates in the input may
+ * be JSON integers or decimal strings; token counts, bytes and request counts become BigInt before
+ * the multiplication. Durations (milliseconds) are planning estimates and are rounded up before
+ * any money is computed.
+ */
+
+const HOURS_PER_MONTH = 730n;
+const SECONDS_PER_HOUR = 3600n;
+const DAY_MS = 86_400_000;
+const NANO_PER_MILLION = 1_000_000n;
+const NANO_PER_USD = 1_000_000_000n;
+const BYTES_PER_GB = 1_000_000_000n;
+const BPS = 10_000n;
+const Z_95 = 1.96;
+const SCHEMA = 'openvibe.inference-planner-result@1';
+const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
+const MODES = ['warm', 'scale_to_zero', 'wholesale'];
+const LABELS = { warm: 'Warm rented GPU', scale_to_zero: 'Scale to zero', wholesale: 'Wholesale API' };
+// A tie never recommends renting: wholesale first, then scale-to-zero, warm last.
+const TIE_ORDER = ['wholesale', 'scale_to_zero', 'warm'];
+
+function fail(where, message) {
+ throw new Error(`${where}: ${message}`);
+}
+
+function isRecord(value) {
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
+}
+
+function record(value, where) {
+ if (value === undefined) fail(where, 'is required');
+ if (!isRecord(value)) fail(where, 'must be an object');
+ return value;
+}
+
+function list(value, where, { min = 1 } = {}) {
+ if (value === undefined) fail(where, 'is required');
+ if (!Array.isArray(value)) fail(where, 'must be an array');
+ if (value.length < min) fail(where, `needs at least ${min} item${min === 1 ? '' : 's'}`);
+ return value;
+}
+
+function text(value, where, { required = true, fallback = null } = {}) {
+ if (value === undefined || value === null) {
+ if (required) fail(where, 'is required');
+ return fallback;
+ }
+ if (typeof value !== 'string') fail(where, 'must be a string');
+ return value;
+}
+
+function integer(value, where, { min = 0n, max = null, required = true, fallback = null } = {}) {
+ if (value === undefined || value === null) {
+ if (required) fail(where, 'is required');
+ return fallback;
+ }
+ let n;
+ if (typeof value === 'bigint') n = value;
+ else if (typeof value === 'number' && Number.isSafeInteger(value)) n = BigInt(value);
+ else if (typeof value === 'string' && /^-?\d+$/.test(value.trim())) n = BigInt(value.trim());
+ else fail(where, 'must be an integer');
+ if (n < min) fail(where, `must be >= ${min}`);
+ if (max !== null && n > max) fail(where, `must be <= ${max}`);
+ return n;
+}
+
+function count(value, where, opts = {}) {
+ const n = Number(integer(value, where, opts));
+ if (!Number.isSafeInteger(n)) fail(where, 'is too large');
+ return n;
+}
+
+function positive(value, where, { required = true, fallback = null } = {}) {
+ if (value === undefined || value === null) {
+ if (required) fail(where, 'is required');
+ return fallback;
+ }
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) fail(where, 'must be a positive number');
+ return value;
+}
+
+function nonNegative(value, where, { required = true, fallback = null } = {}) {
+ if (value === undefined || value === null) {
+ if (required) fail(where, 'is required');
+ return fallback;
+ }
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) fail(where, 'must be a non-negative number');
+ return value;
+}
+
+const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
+function parseDate(value, where) {
+ const m = DATE_RE.exec(value);
+ if (!m) fail(where, 'must be a calendar date (YYYY-MM-DD)');
+ const year = Number(m[1]);
+ const month = Number(m[2]);
+ const day = Number(m[3]);
+ const ms = Date.UTC(year, month - 1, day);
+ const d = new Date(ms);
+ if (d.getUTCFullYear() !== year || d.getUTCMonth() !== month - 1 || d.getUTCDate() !== day) fail(where, 'is not a real calendar date');
+ return ms;
+}
+
+function isoDate(ms) {
+ return new Date(ms).toISOString().slice(0, 10);
+}
+
+function round(value, digits = 0) {
+ const factor = 10 ** digits;
+ return Math.round(value * factor) / factor;
+}
+
+function validateForecast(value) {
+ const f = record(value, 'forecast');
+ if (f.series === undefined) fail('forecast.series', 'is required');
+ const rows = list(f.series, 'forecast.series', { min: 14 });
+ const points = rows.map((row, i) => {
+ const where = `forecast.series[${i}]`;
+ const p = record(row, where);
+ const date = text(p.date, `${where}.date`);
+ const ms = parseDate(date, `${where}.date`);
+ const requests = count(p.requests, `${where}.requests`, { min: 0n, max: 1_000_000_000n });
+ return { date, ms, requests, dow: new Date(ms).getUTCDay() };
+ });
+ points.sort((a, b) => a.ms - b.ms);
+ for (let i = 1; i < points.length; i += 1) {
+ if (points[i].ms === points[i - 1].ms) fail(`forecast.series[${i}].date`, 'duplicates an earlier date');
+ if (points[i].ms !== points[i - 1].ms + DAY_MS) fail(`forecast.series[${i}].date`, 'every point must be the next calendar day');
+ }
+ const horizonDays = count(f.horizon_days, 'forecast.horizon_days', { min: 1n, max: 365n, required: false, fallback: 30n });
+ const windowDays = count(f.window_days, 'forecast.window_days', { min: 7n, max: BigInt(points.length), required: false, fallback: 28n });
+ return { horizonDays, windowDays, points };
+}
+
+function validateRequest(value) {
+ const r = record(value, 'request');
+ const token = (key) => integer(r[key], `request.${key}`, { min: 0n, max: 1_000_000_000n });
+ return {
+ inputTokens: token('input_tokens'),
+ cachedReadTokens: token('cached_read_tokens'),
+ cacheWriteTokens: token('cache_write_tokens'),
+ outputTokens: token('output_tokens'),
+ };
+}
+
+function validateModel(value, i) {
+ const where = `models[${i}]`;
+ const m = record(value, where);
+ const id = text(m.id, `${where}.id`).trim();
+ if (!id) fail(`${where}.id`, 'must not be empty');
+ const label = text(m.label, `${where}.label`, { required: false, fallback: id });
+ const t = record(m.throughput, `${where}.throughput`);
+ const prefill = positive(t.prefill_tokens_per_sec, `${where}.throughput.prefill_tokens_per_sec`);
+ const decode = positive(t.decode_tokens_per_sec, `${where}.throughput.decode_tokens_per_sec`);
+ const cached = positive(t.cached_read_tokens_per_sec, `${where}.throughput.cached_read_tokens_per_sec`, { required: false, fallback: prefill });
+ const concurrency = count(t.concurrency, `${where}.throughput.concurrency`, { min: 1n, max: 1024n, required: false, fallback: 1n });
+ const successBps = count(m.success_bps, `${where}.success_bps`, { min: 1n, max: 10000n });
+ const w = record(m.wholesale, `${where}.wholesale`);
+ const rate = (key) => integer(w[key], `${where}.wholesale.${key}`, { min: 0n });
+ return {
+ id,
+ label,
+ throughput: { prefillTokensPerSec: prefill, decodeTokensPerSec: decode, cachedReadTokensPerSec: cached, concurrency },
+ successBps,
+ wholesale: {
+ inputNanoPerMillion: rate('input_nano_usd_per_million'),
+ cachedReadNanoPerMillion: rate('cached_read_nano_usd_per_million'),
+ cacheWriteNanoPerMillion: rate('cache_write_nano_usd_per_million'),
+ outputNanoPerMillion: rate('output_nano_usd_per_million'),
+ perRequestNano: rate('per_request_nano_usd'),
+ successBps: count(w.success_bps, `${where}.wholesale.success_bps`, { min: 1n, max: 10000n, required: false, fallback: BigInt(successBps) }),
+ },
+ };
+}
+
+function validateGpuSection(value, where) {
+ const s = record(value, where);
+ return {
+ storageGb: integer(s.storage_gb, `${where}.storage_gb`, { min: 0n }),
+ storageNanoPerGbMonth: integer(s.storage_nano_usd_per_gb_month, `${where}.storage_nano_usd_per_gb_month`, { min: 0n }),
+ egressBytesPerRequest: integer(s.egress_bytes_per_request, `${where}.egress_bytes_per_request`, { min: 0n }),
+ egressNanoPerGb: integer(s.egress_nano_usd_per_gb, `${where}.egress_nano_usd_per_gb`, { min: 0n }),
+ };
+}
+
+function validateGpuClass(value, i) {
+ const where = `gpu_classes[${i}]`;
+ const g = record(value, where);
+ const id = text(g.id, `${where}.id`).trim();
+ if (!id) fail(`${where}.id`, 'must not be empty');
+ const label = text(g.label, `${where}.label`, { required: false, fallback: id });
+ const warm = validateGpuSection(g.warm, `${where}.warm`);
+ warm.hourlyNanoUsd = integer(g.warm.hourly_nano_usd, `${where}.warm.hourly_nano_usd`, { min: 0n });
+ const scale = validateGpuSection(g.scale_to_zero, `${where}.scale_to_zero`);
+ scale.hourlyNanoUsd = integer(g.scale_to_zero.hourly_nano_usd, `${where}.scale_to_zero.hourly_nano_usd`, { min: 0n });
+ scale.startupSeconds = nonNegative(g.scale_to_zero.startup_seconds, `${where}.scale_to_zero.startup_seconds`);
+ scale.requestsPerColdStart = integer(g.scale_to_zero.requests_per_cold_start, `${where}.scale_to_zero.requests_per_cold_start`, { min: 1n });
+ return { id, label, warm, scaleToZero: scale };
+}
+
+function unique(rows, kind) {
+ const seen = new Set();
+ for (const row of rows) {
+ if (seen.has(row.id)) fail(`${kind}.id`, `'${row.id}' is listed twice`);
+ seen.add(row.id);
+ }
+ return rows;
+}
+
+/** Validate and normalise a planning document. Throws on the first problem, naming its field. */
+function validate(input) {
+ if (!isRecord(input)) fail('input', 'must be a JSON object');
+ const note = text(input.note, 'note', { required: false, fallback: null });
+ return {
+ note,
+ forecast: validateForecast(input.forecast),
+ request: validateRequest(input.request),
+ models: unique(list(input.models, 'models').map(validateModel), 'models'),
+ gpuClasses: unique(list(input.gpu_classes, 'gpu_classes').map(validateGpuClass), 'gpu_classes'),
+ };
+}
+
+/**
+ * Forecast the next `horizon_days` daily request counts.
+ *
+ * Level = the mean of the trailing `window_days` counts. Day-of-week factor = that weekday's mean
+ * in the same window / the level. A future day is level × its weekday factor, rounded to a whole
+ * request. The band is ±1.96 × the pooled residual standard deviation × √horizon (daily residuals
+ * are assumed independent; the band is a planning aid, not a guarantee).
+ */
+function forecast(input) {
+ const { horizonDays, windowDays, points } = input;
+ const window = points.slice(-windowDays);
+ const level = window.reduce((sum, p) => sum + p.requests, 0) / window.length;
+ const sums = Array.from({ length: 7 }, () => ({ sum: 0, count: 0 }));
+ for (const p of window) {
+ sums[p.dow].sum += p.requests;
+ sums[p.dow].count += 1;
+ }
+ const means = sums.map((s) => (s.count ? s.sum / s.count : level));
+ const factors = means.map((m) => (level > 0 ? m / level : 1));
+ const last = points[points.length - 1];
+ const days = [];
+ let total = 0;
+ for (let i = 1; i <= horizonDays; i += 1) {
+ const ms = last.ms + i * DAY_MS;
+ const dow = new Date(ms).getUTCDay();
+ const requests = Math.round(level * factors[dow]);
+ total += requests;
+ days.push({ date: isoDate(ms), weekday: WEEKDAYS[dow], requests, factor: round(factors[dow], 4) });
+ }
+ let sumSquares = 0;
+ for (const p of window) {
+ const residual = p.requests - means[p.dow];
+ sumSquares += residual * residual;
+ }
+ const degrees = windowDays - 7;
+ // With a 7-day window each weekday has one observation, so there is no residual degree of
+ // freedom; fall back to the window's spread around its level instead of claiming zero error.
+ const spread = degrees > 0
+ ? sumSquares / degrees
+ : window.reduce((sum, p) => sum + (p.requests - level) ** 2, 0) / Math.max(1, window.length - 1);
+ const sd = Math.sqrt(spread);
+ const band = Math.ceil(Z_95 * sd * Math.sqrt(horizonDays));
+ return {
+ method: 'moving average with day-of-week seasonality',
+ explanation: 'Level is the mean of the trailing window; each weekday factor is that weekday\'s mean in the window divided by the level; a future day is level × its factor, rounded to a whole request. The band is ±1.96 × the pooled residual standard deviation × √horizon, assuming independent daily residuals; a 7-day window has no residual degree of freedom and uses its spread around the level instead.',
+ source: { points: points.length, first_date: points[0].date, last_date: last.date },
+ window: {
+ start_date: window[0].date,
+ end_date: window[window.length - 1].date,
+ days: window.length,
+ level_requests_per_day: round(level, 4),
+ residual_sd_requests: round(sd, 4),
+ weekday_factors: Object.fromEntries(WEEKDAYS.map((name, dow) => [name, round(factors[dow], 4)])),
+ },
+ horizon: { days: horizonDays, start_date: days[0].date, end_date: days[days.length - 1].date },
+ monthly: {
+ requests: total,
+ band,
+ low: Math.max(0, total - band),
+ high: total + band,
+ band_bps: total > 0 ? Math.round((band / total) * 10000) : 0,
+ },
+ daily: days,
+ };
+}
+
+function parseBudget(value) {
+ const raw = value === undefined || value === null ? '' : String(value).trim();
+ if (raw === '') return { nano: 0n, raw, configured: false, valid: true };
+ if (!/^\d+$/.test(raw)) return { nano: 0n, raw, configured: true, valid: false };
+ return { nano: BigInt(raw), raw, configured: true, valid: true };
+}
+
+function ceilDiv(a, b) {
+ if (b <= 0n) throw new Error('internal: division by a non-positive BigInt');
+ return (a + b - 1n) / b;
+}
+
+function storageMonthly(section) {
+ return section.storageGb * section.storageNanoPerGbMonth;
+}
+
+function egressPerRequest(section) {
+ if (section.egressBytesPerRequest === 0n || section.egressNanoPerGb === 0n) return 0n;
+ return ceilDiv(section.egressBytesPerRequest * section.egressNanoPerGb, BYTES_PER_GB);
+}
+
+/** Prompt and decode time per request, rounded up to whole milliseconds. */
+function busyMillis(model, request) {
+ const t = model.throughput;
+ const prefillTokens = request.inputTokens + request.cacheWriteTokens;
+ const prefill = prefillTokens === 0n ? 0 : Math.ceil(Number(prefillTokens) * 1000 / t.prefillTokensPerSec);
+ const cachedRead = request.cachedReadTokens === 0n ? 0 : Math.ceil(Number(request.cachedReadTokens) * 1000 / t.cachedReadTokensPerSec);
+ const decode = request.outputTokens === 0n ? 0 : Math.ceil(Number(request.outputTokens) * 1000 / t.decodeTokensPerSec);
+ return { prefill, cached_read: cachedRead, decode, total: prefill + cachedRead + decode, concurrency: t.concurrency };
+}
+
+/**
+ * Warm: one GPU-month (730 h) at the class's hourly rate for each GPU the busy time needs, plus
+ * storage and egress. The idle burn is the rental minus the value of the busy GPU time at the same
+ * rate; it is informational and already inside `gpu_rental`.
+ */
+function warmCost(requests, gpuClass, busy) {
+ const n = BigInt(requests);
+ const busyMs = BigInt(busy.total);
+ const gpuMs = n * busyMs;
+ const monthMs = HOURS_PER_MONTH * SECONDS_PER_HOUR * 1000n;
+ const neededGpus = ceilDiv(gpuMs, monthMs);
+ const gpuCount = neededGpus > 0n ? neededGpus : 1n;
+ const gpuRental = gpuCount * HOURS_PER_MONTH * gpuClass.warm.hourlyNanoUsd;
+ const busyValue = ceilDiv(gpuMs * gpuClass.warm.hourlyNanoUsd, 1000n * SECONDS_PER_HOUR);
+ const storage = storageMonthly(gpuClass.warm);
+ const egress = n * egressPerRequest(gpuClass.warm);
+ return {
+ mode: 'warm',
+ total: gpuRental + storage + egress,
+ parts: { gpu_rental: gpuRental, storage, egress },
+ idleBurn: gpuRental - busyValue,
+ gpuCount,
+ occupancyBps: gpuMs > 0n ? Number((gpuMs * BPS) / (gpuCount * monthMs)) : 0,
+ };
+}
+
+/**
+ * Scale to zero: busy milliseconds at the serverless hourly rate, a cold start every
+ * `requests_per_cold_start` requests at `startup_seconds`, storage and egress. Idle costs nothing.
+ */
+function scaleToZeroCost(requests, gpuClass, busy) {
+ const n = BigInt(requests);
+ const section = gpuClass.scaleToZero;
+ const busyPerRequest = ceilDiv(BigInt(busy.total) * section.hourlyNanoUsd, 1000n * SECONDS_PER_HOUR);
+ const busyCost = n * busyPerRequest;
+ const coldStarts = ceilDiv(n, section.requestsPerColdStart);
+ const startupMs = BigInt(Math.round(section.startupSeconds * 1000));
+ const coldStartCost = ceilDiv(coldStarts * startupMs * section.hourlyNanoUsd, 1000n * SECONDS_PER_HOUR);
+ const storage = storageMonthly(section);
+ const egress = n * egressPerRequest(section);
+ return {
+ mode: 'scale_to_zero',
+ total: busyCost + coldStartCost + storage + egress,
+ parts: { busy: busyCost, cold_start: coldStartCost, storage, egress },
+ idleBurn: 0n,
+ coldStarts,
+ perRequestBusy: busyPerRequest,
+ };
+}
+
+/** Wholesale: input, cached read, cache write and output at per-million rates, plus the fee. */
+function wholesaleCost(requests, model, request) {
+ const n = BigInt(requests);
+ const w = model.wholesale;
+ const parts = {
+ input: ceilDiv(request.inputTokens * w.inputNanoPerMillion, NANO_PER_MILLION),
+ cached_read: ceilDiv(request.cachedReadTokens * w.cachedReadNanoPerMillion, NANO_PER_MILLION),
+ cache_write: ceilDiv(request.cacheWriteTokens * w.cacheWriteNanoPerMillion, NANO_PER_MILLION),
+ output: ceilDiv(request.outputTokens * w.outputNanoPerMillion, NANO_PER_MILLION),
+ per_request: w.perRequestNano,
+ };
+ const perRequest = Object.values(parts).reduce((sum, value) => sum + value, 0n);
+ return { mode: 'wholesale', total: n * perRequest, parts, idleBurn: 0n, perRequest };
+}
+
+function successOf(total, requests, successBps) {
+ const tasks = (BigInt(requests) * BigInt(successBps)) / BPS;
+ return { tasks, cost: tasks > 0n ? ceilDiv(total, tasks) : null };
+}
+
+function serializeCost(cost, requests, successBps) {
+ const n = BigInt(requests);
+ const perRequest = cost.mode === 'wholesale' ? cost.perRequest : (n > 0n ? ceilDiv(cost.total, n) : 0n);
+ const success = successOf(cost.total, n, successBps);
+ const out = {
+ total_nano_usd: cost.total.toString(),
+ per_request_nano_usd: perRequest.toString(),
+ parts_nano_usd: Object.fromEntries(Object.entries(cost.parts).map(([key, value]) => [key, value.toString()])),
+ idle_burn_nano_usd: cost.idleBurn.toString(),
+ expected_successful_tasks: success.tasks.toString(),
+ cost_per_successful_task_nano_usd: success.cost === null ? null : success.cost.toString(),
+ };
+ if (cost.gpuCount !== undefined) {
+ out.gpu_count = cost.gpuCount.toString();
+ out.occupancy_bps = cost.occupancyBps;
+ }
+ if (cost.coldStarts !== undefined) out.cold_starts = cost.coldStarts.toString();
+ return out;
+}
+
+function costsAt(requests, model, gpuClass, request, busy) {
+ return {
+ warm: warmCost(requests, gpuClass, busy),
+ scale_to_zero: scaleToZeroCost(requests, gpuClass, busy),
+ wholesale: wholesaleCost(requests, model, request),
+ };
+}
+
+function serializeCosts(costs, requests, model) {
+ return {
+ requests_per_month: requests,
+ warm: serializeCost(costs.warm, requests, model.successBps),
+ scale_to_zero: serializeCost(costs.scale_to_zero, requests, model.successBps),
+ wholesale: serializeCost(costs.wholesale, requests, model.wholesale.successBps),
+ };
+}
+
+function chooseMode(costs) {
+ let best = TIE_ORDER[0];
+ for (const mode of TIE_ORDER.slice(1)) {
+ if (costs[mode].total < costs[best].total) best = mode;
+ }
+ return best;
+}
+
+function formatUsd(nano, digits = 2) {
+ const n = BigInt(nano);
+ const negative = n < 0n;
+ const abs = negative ? -n : n;
+ const scale = 10n ** BigInt(9 - digits);
+ const rounded = (abs + scale / 2n) / scale;
+ const unit = 10n ** BigInt(digits);
+ const whole = rounded / unit;
+ const fraction = (rounded % unit).toString().padStart(digits, '0');
+ return `${negative ? '-' : ''}$${whole}.${fraction}`;
+}
+
+function moneyText(nano) {
+ const abs = BigInt(nano) < 0n ? -BigInt(nano) : BigInt(nano);
+ return formatUsd(nano, abs > 0n && abs < 1_000_000n ? 6 : 2);
+}
+
+function oneGpuCapacity(busy) {
+ if (busy.total <= 0) return 1_000_000_000n;
+ const monthMs = HOURS_PER_MONTH * SECONDS_PER_HOUR * 1000n;
+ const capacity = monthMs / BigInt(busy.total);
+ return capacity > 0n ? capacity : 1n;
+}
+
+/**
+ * The smallest whole request count per month at which `a` costs no more than `b`, within one warm
+ * GPU's monthly busy time. Both cost curves are non-decreasing and the gap only widens after the
+ * threshold, so a binary search is exact. `null` means there is no break-even in that range.
+ */
+function firstAtMost(a, b, max) {
+ if (a(0n) <= b(0n)) return 0n;
+ if (!(a(max) <= b(max))) return null;
+ let low = 0n;
+ let high = max;
+ while (low + 1n < high) {
+ const mid = (low + high) / 2n;
+ if (a(mid) <= b(mid)) high = mid;
+ else low = mid;
+ }
+ return high;
+}
+
+function breakEven(model, gpuClass, request, busy) {
+ const max = oneGpuCapacity(busy);
+ const warm = (n) => warmCost(n, gpuClass, busy).total;
+ const scale = (n) => scaleToZeroCost(n, gpuClass, busy).total;
+ const wholesale = (n) => wholesaleCost(n, model, request).total;
+ const pair = (fn) => {
+ const at = fn();
+ return at === null ? null : { requests_per_month: at.toString(), cheaper_above: 'warm' };
+ };
+ return {
+ basis: 'within one warm GPU\'s monthly busy time',
+ capacity_requests_per_month: max.toString(),
+ warm_vs_scale_to_zero: pair(() => firstAtMost(warm, scale, max)),
+ warm_vs_wholesale: pair(() => firstAtMost(warm, wholesale, max)),
+ };
+}
+
+function reasonFor(mode, otherMode, atForecast, atHigh, stable, budget, needsOwnerBudget, recommendedHigh) {
+ const parts = [];
+ if (stable) {
+ parts.push(`${LABELS[mode]} is cheapest at the forecast and at the high band (${moneyText(atForecast[mode].total)}/month).`);
+ } else {
+ parts.push(`${LABELS[mode]} is cheapest at the forecast (${moneyText(atForecast[mode].total)}/month), but ${LABELS[otherMode]} is cheapest at the high band (${moneyText(atHigh[otherMode].total)}/month); the choice depends on demand.`);
+ }
+ if (mode === 'warm') parts.push(`Its idle burn is ${moneyText(atForecast.warm.idleBurn)}/month at the forecast.`);
+ else if (mode === 'scale_to_zero') parts.push(`Its cold-start cost is ${moneyText(atForecast.scale_to_zero.parts.cold_start)}/month at the forecast; idle time is free.`);
+ else parts.push('No GPU is rented and no idle burn is paid.');
+ if (needsOwnerBudget) {
+ parts.push(`Needs the owner's budget: ${moneyText(recommendedHigh)}/month at the high band is above the configured ${moneyText(budget.nano)}/month.`);
+ } else {
+ parts.push(`Within the configured ${moneyText(budget.nano)}/month budget.`);
+ }
+ if (!budget.valid) parts.push('HOST_INFERENCE_BUDGET_NANO_USD is not a non-negative integer; it is treated as 0.');
+ return parts.join(' ');
+}
+
+function planCandidate(model, gpuClass, request, forecastResult, budget) {
+ const busy = busyMillis(model, request);
+ const forecastRequests = forecastResult.monthly.requests;
+ const highRequests = forecastResult.monthly.high;
+ const atForecast = costsAt(forecastRequests, model, gpuClass, request, busy);
+ const atHigh = costsAt(highRequests, model, gpuClass, request, busy);
+ const mode = chooseMode(atForecast);
+ const highMode = chooseMode(atHigh);
+ const recommendedForecast = atForecast[mode].total;
+ const recommendedHigh = atHigh[mode].total;
+ const exceedsForecast = recommendedForecast > budget.nano;
+ const exceedsHighBand = recommendedHigh > budget.nano;
+ const needsOwnerBudget = exceedsForecast || exceedsHighBand;
+ const stable = mode === highMode;
+ return {
+ model: { id: model.id, label: model.label },
+ gpu_class: { id: gpuClass.id, label: gpuClass.label },
+ busy_per_request: { prefill_ms: busy.prefill, cached_read_ms: busy.cached_read, decode_ms: busy.decode, total_ms: busy.total, concurrency: busy.concurrency },
+ costs: {
+ forecast: serializeCosts(atForecast, forecastRequests, model),
+ high_band: serializeCosts(atHigh, highRequests, model),
+ },
+ break_even: breakEven(model, gpuClass, request, busy),
+ recommendation: {
+ mode,
+ label: LABELS[mode],
+ reason: reasonFor(mode, highMode, atForecast, atHigh, stable, budget, needsOwnerBudget, recommendedHigh),
+ stable,
+ forecast_nano_usd: recommendedForecast.toString(),
+ high_band_nano_usd: recommendedHigh.toString(),
+ budget: {
+ status: needsOwnerBudget ? 'needs_owner_budget' : 'within_budget',
+ budget_nano_usd: budget.nano.toString(),
+ exceeds_forecast: exceedsForecast,
+ exceeds_high_band: exceedsHighBand,
+ },
+ },
+ };
+}
+
+/**
+ * Build the whole plan. `options.budgetNanoUsd` is read from HOST_INFERENCE_BUDGET_NANO_USD by the
+ * caller; an unset value is 0, an invalid value is also 0 (fail safe) and is reported as invalid.
+ * Nothing here acts on the recommendation.
+ */
+function plan(input, options = {}) {
+ const normalized = validate(input);
+ const budget = parseBudget(options.budgetNanoUsd);
+ const forecastResult = forecast(normalized.forecast);
+ const candidates = [];
+ for (const model of normalized.models) {
+ for (const gpuClass of normalized.gpuClasses) {
+ candidates.push(planCandidate(model, gpuClass, normalized.request, forecastResult, budget));
+ }
+ }
+ return {
+ schema: SCHEMA,
+ executes: false,
+ action: 'none',
+ note: normalized.note,
+ forecast: forecastResult,
+ budget: {
+ env: 'HOST_INFERENCE_BUDGET_NANO_USD',
+ configured_nano_usd: budget.nano.toString(),
+ configured: budget.configured,
+ valid: budget.valid,
+ unset_means_zero: true,
+ },
+ candidates,
+ summary: {
+ candidates: candidates.length,
+ needs_owner_budget: candidates.filter((c) => c.recommendation.budget.status === 'needs_owner_budget').length,
+ stable_recommendations: candidates.filter((c) => c.recommendation.stable).length,
+ modes: MODES,
+ actions: [],
+ },
+ };
+}
+
+module.exports = {
+ plan,
+ validate,
+ forecast,
+ parseBudget,
+ formatUsd,
+ busyMillis,
+ warmCost,
+ scaleToZeroCost,
+ wholesaleCost,
+ HOURS_PER_MONTH,
+ SCHEMA,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,122 @@
@@ -0,0 +1,122 @@
+'use strict';
+/**
+ * The staff-only inference planner page (server/http/inference-planner.js). Plain server-rendered
+ * HTML in Host's catalog style: no form, no button and no action — the page reads a plan and says
+ * what it recommends. Every amount is integer nano-USD in the JSON; it is formatted as USD here.
+ */
+const { esc } = require('./layout');
+const { formatUsd } = require('../inference-planner');
+
+const MODE_LABELS = { warm: 'Warm rented GPU', scale_to_zero: 'Scale to zero', wholesale: 'Wholesale API' };
+
+function commas(value) {
+ return String(value).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
+}
+
+function money(nano) {
+ const n = BigInt(nano);
+ return formatUsd(n, n > 0n && n < 1_000_000n ? 6 : 2);
+}
+
+function budgetText(budget) {
+ if (!budget.valid) return 'HOST_INFERENCE_BUDGET_NANO_USD is not a non-negative integer; it is treated as 0, so every positive recommendation needs the owner\'s budget.';
+ if (!budget.configured) return 'HOST_INFERENCE_BUDGET_NANO_USD is unset = 0, so every positive recommendation needs the owner\'s budget.';
+ return 'from HOST_INFERENCE_BUDGET_NANO_USD. A recommendation at the forecast or at the high band above it needs the owner\'s budget.';
+}
+
+function costCell(cost, high, mode) {
+ let extra;
+ if (mode === 'warm') {
+ extra = `${commas(cost.gpu_count)} GPU${cost.gpu_count === '1' ? '' : 's'} · idle burn ${money(cost.idle_burn_nano_usd)}`;
+ } else if (mode === 'scale_to_zero') {
+ extra = `${commas(cost.cold_starts)} cold start${cost.cold_starts === '1' ? '' : 's'} · ${money(cost.parts_nano_usd.cold_start)}`;
+ } else {
+ extra = 'all-in · no idle burn';
+ }
+ return `<td data-label="${esc(MODE_LABELS[mode])}"><span class="hc-price">${money(cost.total_nano_usd)}</span><span class="hc-term">high band ${money(high.total_nano_usd)}</span><span class="hc-dim">${esc(extra)}</span></td>`;
+}
+
+function breakEvenText(be) {
+ const bits = [];
+ if (be.warm_vs_scale_to_zero) bits.push(`warm beats scale-to-zero above ${commas(be.warm_vs_scale_to_zero.requests_per_month)} requests/month`);
+ if (be.warm_vs_wholesale) bits.push(`warm beats wholesale above ${commas(be.warm_vs_wholesale.requests_per_month)} requests/month`);
+ if (!bits.length) return `No warm break-even within ${commas(be.capacity_requests_per_month)} requests/month (one GPU's monthly busy time).`;
+ return bits.join('; ') + `. (One warm GPU covers ${commas(be.capacity_requests_per_month)} requests/month.)`;
+}
+
+function row(candidate) {
+ const f = candidate.costs.forecast;
+ const h = candidate.costs.high_band;
+ const rec = candidate.recommendation;
+ const badge = rec.budget.status === 'needs_owner_budget'
+ ? '<span class="hc-chip">needs the owner\'s budget</span>'
+ : '<span class="hc-dim">within budget</span>';
+ return `<tr>
+ <th scope="row">${esc(candidate.model.label)}<span class="hc-kind">${esc(candidate.model.id)}</span></th>
+ <td data-label="GPU class">${esc(candidate.gpu_class.label)}<span class="hc-kind">${esc(candidate.gpu_class.id)}</span></td>
+ ${costCell(f.warm, h.warm, 'warm')}
+ ${costCell(f.scale_to_zero, h.scale_to_zero, 'scale_to_zero')}
+ ${costCell(f.wholesale, h.wholesale, 'wholesale')}
+ <td data-label="Recommendation" style="grid-column: 1 / -1"><strong>${esc(rec.label)}</strong> ${badge}<p class="hc-dim">${esc(rec.reason)}</p><p class="hc-dim">${esc(breakEvenText(candidate.break_even))}</p></td>
+</tr>`;
+}
+
+function method() {
+ return `<section class="hc-method" aria-labelledby="hp-method-h">
+<h2 id="hp-method-h">How the forecast is made</h2>
+<ul>
+<li>The level is the mean of the trailing window of daily request counts. Each weekday's factor is that weekday's mean in the window divided by the level; a future day is the level times its weekday factor, rounded to a whole request.</li>
+<li>The band is ±1.96 × the pooled standard deviation of the window's residuals from their weekday means × √horizon. Daily residuals are assumed independent. It is a planning band, not a guarantee.</li>
+<li>The input is a planning JSON (<code>HOST_INFERENCE_PLANNER_INPUT</code>); with <code>HOST_INFERENCE_PLANNER_SOURCE=ai</code> the daily request series comes from OpenVibe.AI's aggregate usage feed and is cached ten minutes. No provider API is read and no benchmark is invented.</li>
+</ul>
+</section>
+<section class="hc-method" aria-labelledby="hp-cost-h">
+<h2 id="hp-cost-h">How the costs are compared</h2>
+<ul>
+<li><strong>Warm rented GPU:</strong> one GPU-month (730 hours) at the class's hourly rate for each GPU the busy time needs, plus storage and egress. The idle burn is the part of the rental not spent on busy requests.</li>
+<li><strong>Scale to zero:</strong> busy milliseconds at the serverless hourly rate, a cold start every <code>requests_per_cold_start</code> requests at the startup seconds, plus storage and egress. Idle time is free.</li>
+<li><strong>Wholesale API:</strong> input, cached read, cache write and output tokens at the per-million rate, plus the per-request fee. All-in: no owned storage or egress.</li>
+<li>Every amount is integer nano-USD (D1); the page formats it as USD for reading and the JSON keeps the exact integer. A tie prefers wholesale, then scale-to-zero — never a rental.</li>
+</ul>
+</section>
+<section class="hc-method" aria-labelledby="hp-action-h">
+<h2 id="hp-action-h">Budget and authority</h2>
+<p>Any recommendation whose monthly cost at the forecast or at the high band exceeds <code>HOST_INFERENCE_BUDGET_NANO_USD</code> is marked <strong>needs the owner's budget</strong>. Unset means 0, so nothing is pre-approved. The planner returns a recommendation and an empty action list; there is no code path in it that starts, stops or rents a GPU. The owner approves every rental separately.</p>
+</section>`;
+}
+
+/** Where the demand came from: the AI feed, the JSON input, or the JSON after a failed feed. */
+function demandText(demand) {
+ if (!demand) return null;
+ if (demand.source === 'ai') return `Demand: OpenVibe.AI's aggregate usage series, ${commas(demand.days)} days (${demand.from} to ${demand.to}), cached up to 10 minutes.`;
+ if (demand.fallback) return `${demand.warning}; using the configured planning JSON instead.`;
+ return 'Demand: the configured planning JSON.';
+}
+
+/** The planner page body. `result` is the output of server/inference-planner.js. */
+function page(result) {
+ const monthly = result.forecast.monthly;
+ const candidates = result.candidates;
+ const needs = result.summary.needs_owner_budget;
+ const demand = demandText(result.demand);
+ return `<header class="hc-head">
+<p class="hc-eyebrow">OpenVibe.Host · staff · inference</p>
+<h1>Inference placement planner</h1>
+<p class="hc-lede">A forecast and a monthly break-even for a warm rented GPU, scale-to-zero and wholesale. This page recommends; it never starts, stops or rents anything. The owner approves every rental.</p>
+</header>
+${demand ? `<p class="hc-facts">${esc(demand)}</p>` : ''}
+<p class="hc-facts">Forecast: <strong>${commas(monthly.requests)} requests</strong> in the next ${result.forecast.horizon.days} days, uncertainty band ${commas(monthly.low)}–${commas(monthly.high)}. ${needs} of ${candidates.length} recommendation${candidates.length === 1 ? '' : 's'} need${needs === 1 ? 's' : ''} the owner's budget.</p>
+<p class="hc-facts">Budget: <strong>${money(result.budget.configured_nano_usd)}/month</strong> (<code>${commas(result.budget.configured_nano_usd)} nano-USD</code>). ${esc(budgetText(result.budget))}</p>
+${result.note ? `<p class="hc-disclosure">${esc(result.note)}</p>` : ''}
+<div class="hc-table-wrap"><table class="hc-table"><caption>Monthly cost at the forecast and at the high band. Exact nano-USD is in <code>/api/v1/catalog/inference-planner</code>.</caption><thead><tr><th scope="col">Model</th><th scope="col">GPU class</th><th scope="col">Warm rented GPU</th><th scope="col">Scale to zero</th><th scope="col">Wholesale API</th><th scope="col">Recommendation</th></tr></thead><tbody>${candidates.map(row).join('')}</tbody></table></div>
+${method()}`;
+}
+
+/** A staff-only error page: no result is shown and nothing is acted on. */
+function errorPage(message, { input = false } = {}) {
+ return `<header class="hc-head"><p class="hc-eyebrow">OpenVibe.Host · staff · inference</p><h1>Inference placement planner</h1>
+<p class="hc-lede">The planner could not show a plan. Nothing was started, stopped or rented.</p></header>
+<div class="hc-empty"><p><strong>${input ? 'The planning input is unavailable.' : 'The planner has no plan to show.'}</strong></p><p>${esc(message)}</p>${input ? '<p>Set <code>HOST_INFERENCE_PLANNER_INPUT</code> to a readable planning JSON and reload.</p>' : ''}</div>`;
+}
+
+module.exports = { page, errorPage, money, commas, MODE_LABELS };
@@ -17,6 +17,7 @@ const { testDb } = require('./helpers/db');
@@ -17,6 +17,7 @@ const { testDb } = require('./helpers/db');
exempt: {
exempt: {
account_data_events: 'the ledger of requests itself: which export and deletion host applied',
account_data_events: 'the ledger of requests itself: which export and deletion host applied',
host_index_revisions: "owner is the search-index document's owner (type/id), not a person; the row holds a revision and hash",
host_index_revisions: "owner is the search-index document's owner (type/id), not a person; the row holds a revision and hash",
+ config_snapshots: 'D11 configuration revisions: created_by names the owner who changed a setting, not a person\'s account data',
},
},
});
});
assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
@@ -39,7 +39,7 @@ async function boot(opts = {}) {
@@ -39,7 +39,7 @@ async function boot(opts = {}) {
async function start() {
async function start() {
const config = configLib.load(env);
const config = configLib.load(env);
- built = await createApp({ config, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: quiet, fetchImpl: opts.fetchImpl, rdap: opts.rdap, accountSend: opts.accountSend });
+ built = await createApp({ config, env, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: quiet, fetchImpl: opts.fetchImpl, rdap: opts.rdap, accountSend: opts.accountSend });
server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
t.port = server.address().port;
t.port = server.address().port;
t.app = built.app;
t.app = built.app;
new file mode 100644
new file mode 100644
@@ -0,0 +1,203 @@
@@ -0,0 +1,203 @@
+'use strict';
+/**
+ * The inference planner's AI demand feed (HOST_INFERENCE_PLANNER_SOURCE=ai): Host asks a fake
+ * OpenVibe.AI for its aggregate series with Host's own service token, caches it ten minutes, and on a
+ * failure says "demand feed unavailable" and falls back to the configured planning JSON. The source
+ * flag unset keeps today's JSON behaviour and never calls AI. The planner module stays pure: every
+ * fetch lives in server/inference-planner-feed.js.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const { boot, check, done, makeClock } = require('./helpers/boot');
+
+const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-inference-feed-'));
+const aiPlan = path.join(root, 'ai-plan.json');
+const jsonPlan = path.join(root, 'json-plan.json');
+const DAY = 86_400_000;
+const CLOCK = Date.parse('2026-09-21T12:00:00Z'); // Monday
+const WEEK_START = Date.parse('2026-08-24T00:00:00Z'); // Monday, 28 days before the forecast day
+const WEEK_END = '2026-09-20';
+
+function iso(ms) { return new Date(ms).toISOString().slice(0, 10); }
+
+function document(series, windowDays) {
+ return {
+ note: 'test fixture, not a quote',
+ forecast: { horizon_days: 30, window_days: windowDays, series },
+ request: { input_tokens: 1000, cached_read_tokens: 0, cache_write_tokens: 0, output_tokens: 100 },
+ models: [{
+ id: 'qwen/qwen3-9b:q4_k_m@3f2a1c9',
+ label: 'Qwen3 9B Q4',
+ throughput: { prefill_tokens_per_sec: 1000, decode_tokens_per_sec: 50 },
+ success_bps: 8000,
+ wholesale: {
+ input_nano_usd_per_million: 100000000, cached_read_nano_usd_per_million: 0,
+ cache_write_nano_usd_per_million: 0, output_nano_usd_per_million: 400000000,
+ per_request_nano_usd: 0,
+ },
+ }],
+ gpu_classes: [{
+ id: 'rtx-4070-12gb',
+ label: 'RTX 4070 12 GB',
+ warm: { hourly_nano_usd: 100000000, storage_gb: 0, storage_nano_usd_per_gb_month: 0, egress_bytes_per_request: 0, egress_nano_usd_per_gb: 0 },
+ scale_to_zero: { hourly_nano_usd: 200000000, startup_seconds: 0, requests_per_cold_start: 1, storage_gb: 0, storage_nano_usd_per_gb_month: 0, egress_bytes_per_request: 0, egress_nano_usd_per_gb: 0 },
+ }],
+ };
+}
+
+// The same four-week shape the pure test uses: Mon-Fri 100, Saturday 40, Sunday 20. The 30 forecast
+// days after 2026-09-20 start on Monday 2026-09-21 and total 2,440 requests.
+const aiSeries = Array.from({ length: 28 }, (_, i) => {
+ const day = iso(WEEK_START + i * DAY);
+ const dow = new Date(`${day}T00:00:00Z`).getUTCDay(); // 0 Sunday, 6 Saturday
+ return { date: day, requests: dow === 6 ? 40 : dow === 0 ? 20 : 100 };
+});
+fs.writeFileSync(aiPlan, JSON.stringify(document(aiSeries, 28), null, 2));
+
+// A deliberately different JSON demand: 14 days of 10 requests, so a fallback (or a flag-unset run) is
+// 300 requests a month, never the 2,440 the AI series would make.
+const jsonSeries = Array.from({ length: 14 }, (_, i) => ({ date: iso(Date.parse('2026-09-07T00:00:00Z') + i * DAY), requests: 10 }));
+fs.writeFileSync(jsonPlan, JSON.stringify(document(jsonSeries, 14), null, 2));
+
+function startAi(handler) {
+ return new Promise((resolve) => {
+ const tokens = [];
+ const urls = [];
+ const server = http.createServer((req, res) => {
+ tokens.push(String(req.headers.authorization || ''));
+ urls.push(req.url);
+ const out = handler(req);
+ res.writeHead(out.status || 200, { 'content-type': 'application/json' });
+ res.end(JSON.stringify(out.body === undefined ? out : out.body));
+ });
+ server.listen(0, '127.0.0.1', () => resolve({
+ url: `http://127.0.0.1:${server.address().port}`,
+ tokens,
+ urls,
+ calls: () => tokens.length,
+ close: () => new Promise((r) => server.close(r)),
+ }));
+ });
+}
+
+/** The AI endpoint's aggregate answer: two models a day, summing to the shape above. */
+function aiBody() {
+ const series = [];
+ for (const row of aiSeries) {
+ const half = Math.floor(row.requests / 2);
+ for (const model of ['model-a', 'model-b']) {
+ series.push({
+ day: row.date, model, provider: 'fake', model_key: model,
+ requests: model === 'model-a' ? half : row.requests - half, successes: 0,
+ tokens: { input: 0, cached_read: 0, cache_write: 0, output: 0 },
+ });
+ }
+ }
+ return { days: 28, from: iso(WEEK_START), to: WEEK_END, series };
+}
+
+function bearerClaims(authorization) {
+ return JSON.parse(Buffer.from(String(authorization).replace(/^Bearer /, '').split('.')[1], 'base64url'));
+}
+
+(async () => {
+ // 1. A fake AI: Host's service token, the aggregate series, and the ten-minute cache.
+ const ai = await startAi(() => ({ body: aiBody() }));
+ const clock = makeClock(CLOCK);
+ const on = await boot({ clock, env: {
+ HOST_INFERENCE_PLANNER: 'on', HOST_INFERENCE_PLANNER_SOURCE: 'ai', HOST_INFERENCE_PLANNER_INPUT: aiPlan,
+ HOST_INFERENCE_BUDGET_NANO_USD: '0', OV_AI_INTERNAL_URL: ai.url,
+ } });
+ const staff = on.user('planner-editor', { role: 'admin' });
+ try {
+ await check('the AI feed builds the series with Host\'s ai.usage.read token', async () => {
+ const r = await on.api('GET', '/catalog/inference', { session: staff });
+ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
+ assert.match(r.text, /Demand: OpenVibe\.AI/);
+ assert.match(r.text, /aggregate usage series, 28 days \(2026-08-24 to 2026-09-20\)/);
+ assert.match(r.text, /2,440 requests/);
+ assert.strictEqual(ai.calls(), 1, 'one feed call');
+ assert.strictEqual(ai.urls[0], '/api/v1/usage/series?days=28', 'the aggregate endpoint and the configured window');
+ const claims = bearerClaims(ai.tokens[0]);
+ assert.ok(claims.aud.includes('openvibe.ai'), 'audience openvibe.ai');
+ assert.ok(claims.cap.includes('ai.usage.read'), 'capability ai.usage.read');
+ assert.strictEqual(claims.sub, 'svc:host', 'Host\'s own service token');
+ const json = await on.api('GET', '/api/v1/catalog/inference-planner', { as: staff });
+ assert.strictEqual(json.status, 200);
+ assert.deepStrictEqual(json.json().planner.demand, { source: 'ai', from: '2026-08-24', to: '2026-09-20', days: 28, fetched_at: new Date(CLOCK).toISOString() });
+ });
+
+ await check('the series is cached for ten minutes', async () => {
+ const before = ai.calls();
+ const again = await on.api('GET', '/api/v1/catalog/inference-planner', { as: staff });
+ assert.strictEqual(again.status, 200);
+ assert.strictEqual(ai.calls(), before, 'a second read inside ten minutes makes no call');
+ clock.advance(10 * 60 * 1000 + 1000);
+ const refreshed = await on.api('GET', '/api/v1/catalog/inference-planner', { as: staff });
+ assert.strictEqual(refreshed.status, 200);
+ assert.strictEqual(ai.calls(), before + 1, 'an expired cache asks again');
+ });
+ } finally { await on.close(); await ai.close(); }
+
+ // 2. The feed fails and a configured JSON input is the fallback.
+ const down = await startAi(() => ({ status: 503, body: { code: 'ai.not_found' } }));
+ const fallback = await boot({ env: {
+ HOST_INFERENCE_PLANNER: 'on', HOST_INFERENCE_PLANNER_SOURCE: 'ai', HOST_INFERENCE_PLANNER_INPUT: jsonPlan,
+ HOST_INFERENCE_BUDGET_NANO_USD: '0', OV_AI_INTERNAL_URL: down.url,
+ } });
+ const fallbackStaff = fallback.user('planner-editor', { role: 'admin' });
+ try {
+ await check('a failure says demand feed unavailable and falls back to the configured JSON', async () => {
+ const r = await fallback.api('GET', '/catalog/inference', { session: fallbackStaff });
+ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
+ assert.match(r.text, /demand feed unavailable/);
+ assert.match(r.text, /using the configured planning JSON instead/);
+ assert.match(r.text, /300 requests/, 'the JSON series, not the AI series');
+ const json = await fallback.api('GET', '/api/v1/catalog/inference-planner', { as: fallbackStaff });
+ assert.strictEqual(json.status, 200);
+ assert.strictEqual(json.json().planner.demand.source, 'json');
+ assert.strictEqual(json.json().planner.demand.fallback, true);
+ assert.match(json.json().planner.demand.warning, /demand feed unavailable/);
+ });
+ } finally { await fallback.close(); await down.close(); }
+
+ // 3. The feed fails and no JSON input is configured: no silent example fallback, just unavailable.
+ const down2 = await startAi(() => ({ status: 503, body: { code: 'ai.not_found' } }));
+ const noFallback = await boot({ env: {
+ HOST_INFERENCE_PLANNER: 'on', HOST_INFERENCE_PLANNER_SOURCE: 'ai',
+ HOST_INFERENCE_BUDGET_NANO_USD: '0', OV_AI_INTERNAL_URL: down2.url,
+ } });
+ try {
+ await check('a failure with no configured JSON input is unavailable, never the bundled example', async () => {
+ const r = await noFallback.api('GET', '/catalog/inference', { session: noFallback.user('planner-editor', { role: 'admin' }) });
+ assert.strictEqual(r.status, 503);
+ assert.match(r.text, /demand feed unavailable/);
+ const json = await noFallback.api('GET', '/api/v1/catalog/inference-planner', { as: noFallback.user('planner-editor', { role: 'admin' }) });
+ assert.strictEqual(json.status, 503);
+ assert.strictEqual(json.json().code, 'host.inference_planner.unavailable');
+ });
+ } finally { await noFallback.close(); await down2.close(); }
+
+ // 4. The source flag unset keeps today's JSON behaviour, and AI is never called.
+ const never = await startAi(() => ({ body: aiBody() }));
+ const off = await boot({ env: {
+ HOST_INFERENCE_PLANNER: 'on', HOST_INFERENCE_PLANNER_INPUT: jsonPlan,
+ HOST_INFERENCE_BUDGET_NANO_USD: '0', OV_AI_INTERNAL_URL: never.url,
+ } });
+ try {
+ await check('source unset: the JSON series, no demand feed call', async () => {
+ assert.strictEqual(off.config.inferencePlanner.source, 'json');
+ const r = await off.api('GET', '/catalog/inference', { session: off.user('planner-editor', { role: 'admin' }) });
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /Demand: the configured planning JSON/);
+ assert.match(r.text, /300 requests/);
+ assert.strictEqual(never.calls(), 0, 'no AI call on the default path');
+ });
+ } finally { await off.close(); await never.close(); }
+
+ fs.rmSync(root, { recursive: true, force: true });
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,131 @@
@@ -0,0 +1,131 @@
+'use strict';
+/**
+ * The inference planner's staff surface: with HOST_INFERENCE_PLANNER off there is no page and no
+ * endpoint; on, only staff see them, the page has no form or button, and the JSON says the planner
+ * has no action. The planning JSON is a temp file; no provider, GPU or process is touched.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { boot, check, done } = require('./helpers/boot');
+
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-inference-planner-'));
+const fixturePath = path.join(tmp, 'plan.json');
+
+const start = Date.parse('2026-10-05T00:00:00Z');
+const series = Array.from({ length: 28 }, (_, i) => {
+ const dow = i % 7; // 0 Monday ... 5 Saturday, 6 Sunday
+ return {
+ date: new Date(start + i * 86_400_000).toISOString().slice(0, 10),
+ requests: dow === 5 ? 40 : dow === 6 ? 20 : 100,
+ };
+});
+fs.writeFileSync(fixturePath, JSON.stringify({
+ note: 'test fixture, not a quote',
+ forecast: { horizon_days: 30, window_days: 28, series },
+ request: { input_tokens: 1000, cached_read_tokens: 0, cache_write_tokens: 0, output_tokens: 100 },
+ models: [{
+ id: 'qwen/qwen3-9b:q4_k_m@3f2a1c9',
+ label: 'Qwen3 9B Q4',
+ throughput: { prefill_tokens_per_sec: 1000, decode_tokens_per_sec: 50 },
+ success_bps: 8000,
+ wholesale: {
+ input_nano_usd_per_million: 100000000, cached_read_nano_usd_per_million: 0,
+ cache_write_nano_usd_per_million: 0, output_nano_usd_per_million: 400000000,
+ per_request_nano_usd: 0,
+ },
+ }],
+ gpu_classes: [{
+ id: 'rtx-4070-12gb',
+ label: 'RTX 4070 12 GB',
+ warm: { hourly_nano_usd: 100000000, storage_gb: 0, storage_nano_usd_per_gb_month: 0, egress_bytes_per_request: 0, egress_nano_usd_per_gb: 0 },
+ scale_to_zero: { hourly_nano_usd: 200000000, startup_seconds: 0, requests_per_cold_start: 1, storage_gb: 0, storage_nano_usd_per_gb_month: 0, egress_bytes_per_request: 0, egress_nano_usd_per_gb: 0 },
+ }],
+}, null, 2));
+
+(async () => {
+ let off;
+ try {
+ off = await boot({ env: { HOST_INFERENCE_PLANNER: 'off', HOST_INFERENCE_PLANNER_INPUT: fixturePath } });
+ const staff = off.user('planner-editor', { role: 'admin' });
+ await check('flag off: no page and no JSON endpoint', async () => {
+ const page = await off.api('GET', '/catalog/inference', { session: staff });
+ assert.strictEqual(page.status, 404);
+ const api = await off.api('GET', '/api/v1/catalog/inference-planner', { as: staff });
+ assert.strictEqual(api.status, 404);
+ assert.strictEqual(api.json().code, 'route.not_found');
+ assert.strictEqual(off.config.inferencePlanner.enabled, false, 'the source is constructed so the owner switch can apply live; off, it serves nothing');
+ });
+ } finally { await off.close(); }
+
+ const on = await boot({ env: { HOST_INFERENCE_PLANNER: 'on', HOST_INFERENCE_PLANNER_INPUT: fixturePath, HOST_INFERENCE_BUDGET_NANO_USD: '0' } });
+ const staff = on.user('planner-editor', { role: 'admin' });
+ const reader = on.user('planner-reader');
+ try {
+ await check('staff only: a signed-in reader and an anonymous caller are refused', async () => {
+ const page = await on.api('GET', '/catalog/inference', { session: reader });
+ assert.strictEqual(page.status, 403);
+ assert.match(page.text, /Staff access required/);
+ assert.doesNotMatch(page.text, /Warm rented GPU/);
+ const api = await on.api('GET', '/api/v1/catalog/inference-planner', { as: reader });
+ assert.strictEqual(api.status, 403);
+ assert.strictEqual(api.json().code, 'host.catalog.forbidden');
+ const anon = await on.api('GET', '/api/v1/catalog/inference-planner');
+ assert.strictEqual(anon.status, 403);
+ assert.strictEqual(anon.json().code, 'host.catalog.forbidden');
+ });
+
+ await check('the staff page shows the forecast, the three costs, the budget lock and no action', async () => {
+ const r = await on.api('GET', '/catalog/inference', { session: staff });
+ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
+ assert.match(r.text, /Inference placement planner/);
+ assert.match(r.text, /2,440 requests/);
+ assert.match(r.text, /uncertainty band 2,440–2,440/);
+ assert.match(r.text, /Warm rented GPU/);
+ assert.match(r.text, /Scale to zero/);
+ assert.match(r.text, /Wholesale API/);
+ assert.match(r.text, /needs the owner's budget/);
+ assert.match(r.text, /never starts, stops or rents anything/);
+ assert.match(r.text, /no code path/);
+ assert.match(r.text, /A tie prefers wholesale/);
+ assert.doesNotMatch(r.text, /<form/i);
+ assert.doesNotMatch(r.text, /<button/i);
+ assert.strictEqual(r.headers['cache-control'], 'private, no-store');
+ });
+
+ await check('the JSON endpoint returns exact integer nano-USD and an empty action list', async () => {
+ const r = await on.api('GET', '/api/v1/catalog/inference-planner', { as: staff });
+ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
+ assert.match(r.headers['content-type'], /application\/json/);
+ const p = r.json().planner;
+ assert.strictEqual(p.schema, 'openvibe.inference-planner-result@1');
+ assert.strictEqual(p.executes, false);
+ assert.strictEqual(p.action, 'none');
+ assert.deepStrictEqual(p.summary.actions, []);
+ assert.strictEqual(p.forecast.monthly.requests, 2440);
+ assert.strictEqual(p.candidates.length, 1);
+ const c = p.candidates[0];
+ assert.strictEqual(c.costs.forecast.warm.total_nano_usd, '73000000000');
+ assert.strictEqual(c.costs.forecast.scale_to_zero.total_nano_usd, '406667480');
+ assert.strictEqual(c.costs.forecast.wholesale.total_nano_usd, '341600000');
+ assert.strictEqual(c.recommendation.mode, 'wholesale');
+ assert.strictEqual(c.recommendation.budget.status, 'needs_owner_budget');
+ assert.strictEqual(p.summary.needs_owner_budget, 1);
+ });
+
+ await check('a missing planning file is a 503 page and problem, not a crash', async () => {
+ fs.renameSync(fixturePath, `${fixturePath}.gone`);
+ const page = await on.api('GET', '/catalog/inference', { session: staff });
+ assert.strictEqual(page.status, 503);
+ assert.match(page.text, /planning input is unavailable/i);
+ const api = await on.api('GET', '/api/v1/catalog/inference-planner', { as: staff });
+ assert.strictEqual(api.status, 503);
+ assert.strictEqual(api.json().code, 'host.inference_planner.unavailable');
+ });
+ } finally {
+ await on.close();
+ fs.rmSync(tmp, { recursive: true, force: true });
+ }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,261 @@
@@ -0,0 +1,261 @@
+'use strict';
+// server/inference-planner.js: the forecast, the integer nano-USD cost comparison, the hand-computed
+// break-evens and the budget lock. Every expected number below is written out by hand; the test does
+// not re-derive it with the implementation. It also asserts that the module has no network or spawn.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const {
+ plan, validate, parseBudget, formatUsd,
+ busyMillis, warmCost, scaleToZeroCost, wholesaleCost,
+} = require('../server/inference-planner');
+
+const START = Date.parse('2026-10-05T00:00:00Z'); // Monday
+const DAY = 86_400_000;
+
+// Four identical weeks: Mon-Fri 100, Saturday 40, Sunday 20. The next 30 days start on Monday
+// 2026-11-02: 5 Mon, 5 Tue, 4 Wed, 4 Thu, 4 Fri, 4 Sat, 4 Sun = 22*100 + 4*40 + 4*20 = 2,440.
+function series4() {
+ return Array.from({ length: 28 }, (_, i) => {
+ const dow = i % 7; // 0 Monday ... 5 Saturday, 6 Sunday
+ return {
+ date: new Date(START + i * DAY).toISOString().slice(0, 10),
+ requests: dow === 5 ? 40 : dow === 6 ? 20 : 100,
+ };
+ });
+}
+
+function model() {
+ // 1,000 prefill tokens at 1,000 tok/s + 100 output tokens at 50 tok/s = 1 s + 2 s = 3 s busy.
+ // Wholesale: 1,000 * $0.10/M = 100,000 nano + 100 * $0.40/M = 40,000 nano = 140,000 nano/request.
+ return {
+ id: 'qwen/qwen3-9b:q4_k_m@3f2a1c9',
+ label: 'Qwen3 9B Q4',
+ throughput: { prefill_tokens_per_sec: 1000, decode_tokens_per_sec: 50 },
+ success_bps: 8000,
+ wholesale: {
+ input_nano_usd_per_million: 100000000,
+ cached_read_nano_usd_per_million: 0,
+ cache_write_nano_usd_per_million: 0,
+ output_nano_usd_per_million: 400000000,
+ per_request_nano_usd: 0,
+ },
+ };
+}
+
+function gpu() {
+ // Warm $0.10/h => 730 h * $0.10 = $73 = 73,000,000,000 nano a month.
+ // Scale-to-zero $0.20/h => 3 s = 3 * 200,000,000 / 3,600,000 = 166,666.66 -> 166,667 nano/request.
+ return {
+ id: 'rtx-4070-12gb',
+ label: 'RTX 4070 12 GB',
+ warm: { hourly_nano_usd: 100000000, storage_gb: 0, storage_nano_usd_per_gb_month: 0, egress_bytes_per_request: 0, egress_nano_usd_per_gb: 0 },
+ scale_to_zero: { hourly_nano_usd: 200000000, startup_seconds: 0, requests_per_cold_start: 1, storage_gb: 0, storage_nano_usd_per_gb_month: 0, egress_bytes_per_request: 0, egress_nano_usd_per_gb: 0 },
+ };
+}
+
+function input(points, overrides = {}) {
+ return {
+ note: 'test fixture',
+ forecast: { horizon_days: 30, window_days: 28, series: points, ...(overrides.forecast || {}) },
+ request: {
+ input_tokens: 1000, cached_read_tokens: 0, cache_write_tokens: 0, output_tokens: 100,
+ ...(overrides.request || {}),
+ },
+ models: overrides.models || [model()],
+ gpu_classes: overrides.gpu_classes || [gpu()],
+ };
+}
+
+// ── Forecast ─────────────────────────────────────────────────────────────────────────────────────
+const steady = plan(input(series4()));
+assert.strictEqual(steady.forecast.method, 'moving average with day-of-week seasonality');
+assert.strictEqual(steady.forecast.source.points, 28);
+assert.strictEqual(steady.forecast.window.level_requests_per_day, 80);
+assert.strictEqual(steady.forecast.horizon.days, 30);
+assert.strictEqual(steady.forecast.horizon.start_date, '2026-11-02');
+assert.strictEqual(steady.forecast.horizon.end_date, '2026-12-01');
+assert.deepStrictEqual(steady.forecast.daily.slice(0, 7).map((d) => d.requests), [100, 100, 100, 100, 100, 40, 20]);
+assert.strictEqual(steady.forecast.monthly.requests, 2440);
+assert.strictEqual(steady.forecast.monthly.band, 0, 'an identical weekly pattern has no residual spread');
+assert.strictEqual(steady.forecast.monthly.high, 2440);
+assert.strictEqual(steady.forecast.monthly.low, 2440);
+
+// One Monday is 120 instead of 100. Monday's mean is 105, so Monday forecasts 105 while the other
+// weekdays stay 100: 5*105 + 5*100 + 12*100 + 4*40 + 4*20 = 2,465. The residuals are +15 for that
+// Monday and -5 for the other three Mondays: (225 + 3*25) / (28 - 7) = 300/21 = 14.2857;
+// sqrt = 3.77964; the 30-day band = ceil(1.96 * 3.77964 * sqrt(30)) = ceil(40.55) = 41.
+// So the band is 2,424–2,506.
+const oneOutlier = series4();
+oneOutlier[0].requests = 120;
+const banded = plan(input(oneOutlier));
+assert.strictEqual(banded.forecast.monthly.requests, 2465);
+assert.ok(Math.abs(banded.forecast.window.residual_sd_requests - 3.7796) < 1e-4);
+assert.strictEqual(banded.forecast.monthly.band, 41);
+assert.strictEqual(banded.forecast.monthly.low, 2424);
+assert.strictEqual(banded.forecast.monthly.high, 2506);
+assert.strictEqual(banded.forecast.daily[0].requests, 105, 'the next Monday follows its weekday factor');
+assert.strictEqual(banded.forecast.daily[1].requests, 100);
+
+// A 7-day window has one sample per weekday, so there is no residual degree of freedom; the band
+// falls back to the window's spread around its level instead of claiming zero uncertainty.
+// Spread: (5*20^2 + 40^2 + 60^2) / 6 = 7,200/6 = 1,200; sqrt = 34.641; band = ceil(1.96 *
+// 34.641 * sqrt(30)) = ceil(371.9) = 372.
+const sevenDayWindow = plan(input(series4(), { forecast: { window_days: 7 } }));
+assert.strictEqual(sevenDayWindow.forecast.monthly.requests, 2440);
+assert.strictEqual(sevenDayWindow.forecast.monthly.band, 372);
+
+// ── Monthly costs at the forecast (2,440 requests) ───────────────────────────────────────────────
+const planned = plan(input(series4()), { budgetNanoUsd: '1000000000' }); // $1/month
+const candidate = planned.candidates[0];
+assert.strictEqual(candidate.busy_per_request.total_ms, 3000);
+assert.strictEqual(candidate.costs.forecast.requests_per_month, 2440);
+assert.strictEqual(candidate.costs.forecast.warm.total_nano_usd, '73000000000');
+assert.strictEqual(candidate.costs.forecast.warm.idle_burn_nano_usd, '72796666666');
+assert.strictEqual(candidate.costs.forecast.scale_to_zero.total_nano_usd, '406667480');
+assert.strictEqual(candidate.costs.forecast.scale_to_zero.parts_nano_usd.busy, '406667480');
+assert.strictEqual(candidate.costs.forecast.wholesale.total_nano_usd, '341600000');
+assert.strictEqual(candidate.costs.forecast.wholesale.parts_nano_usd.input, '100000');
+assert.strictEqual(candidate.costs.forecast.wholesale.parts_nano_usd.output, '40000');
+// 2,440 * 0.8 = 1,952 expected successes.
+// Warm: 73,000,000,000 / 1,952 = 37,397,540.98 -> 37,397,541.
+// Scale: 406,667,480 / 1,952 = 208,333.75 -> 208,334.
+// Wholesale: 341,600,000 / 1,952 = 175,000 exactly.
+assert.strictEqual(candidate.costs.forecast.warm.cost_per_successful_task_nano_usd, '37397541');
+assert.strictEqual(candidate.costs.forecast.scale_to_zero.cost_per_successful_task_nano_usd, '208334');
+assert.strictEqual(candidate.costs.forecast.wholesale.cost_per_successful_task_nano_usd, '175000');
+assert.strictEqual(candidate.recommendation.mode, 'wholesale');
+assert.strictEqual(candidate.recommendation.stable, true);
+assert.strictEqual(candidate.recommendation.budget.status, 'within_budget');
+assert.match(candidate.recommendation.reason, /Wholesale API is cheapest/);
+
+// ── Break-even at the hand-computed points ───────────────────────────────────────────────────────
+// Warm vs scale-to-zero: 73,000,000,000 / 166,667 = 437,999.12...; at 437,999 scale is still
+// cheaper (72,999,979,333), at 438,000 it is 73,000,146,000, so 438,000 is the first warm month.
+// Warm vs wholesale: 73,000,000,000 / 140,000 = 521,428.57; at 521,428 wholesale is 72,999,920,000,
+// at 521,429 it is 73,000,060,000, so 521,429 is the first warm month.
+assert.deepStrictEqual(candidate.break_even.warm_vs_scale_to_zero, { requests_per_month: '438000', cheaper_above: 'warm' });
+assert.deepStrictEqual(candidate.break_even.warm_vs_wholesale, { requests_per_month: '521429', cheaper_above: 'warm' });
+assert.strictEqual(candidate.break_even.capacity_requests_per_month, '876000');
+const normalized = validate(input(series4()));
+const busy = busyMillis(normalized.models[0], normalized.request);
+const warm = (n) => warmCost(n, normalized.gpuClasses[0], busy).total;
+const scale = (n) => scaleToZeroCost(n, normalized.gpuClasses[0], busy).total;
+const wholesale = (n) => wholesaleCost(n, normalized.models[0], normalized.request).total;
+assert.ok(warm(437999n) > scale(437999n), 'below the scale crossover warm still costs more');
+assert.ok(warm(438000n) <= scale(438000n), 'at the scale crossover warm costs no more');
+assert.ok(warm(521428n) > wholesale(521428n), 'below the wholesale crossover warm still costs more');
+assert.ok(warm(521429n) <= wholesale(521429n), 'at the wholesale crossover warm costs no more');
+
+// A third of the way is still wholesale; 300× the series is 732,000 requests, above both
+// crossovers, so warm wins: 73,000,000,000 vs 102,480,000,000 wholesale.
+const high = plan(input(series4().map((p) => ({ ...p, requests: p.requests * 300 }))), { budgetNanoUsd: '100000000000' });
+assert.strictEqual(high.forecast.monthly.requests, 732000);
+assert.strictEqual(high.candidates[0].recommendation.mode, 'warm');
+assert.strictEqual(high.candidates[0].costs.forecast.warm.total_nano_usd, '73000000000');
+assert.strictEqual(high.candidates[0].costs.forecast.wholesale.total_nano_usd, '102480000000');
+
+// ── Integer nano-USD, beyond Number.MAX_SAFE_INTEGER ─────────────────────────────────────────────
+// 730 h * 1,000,000,000,000,000 nano/h = 730,000,000,000,000,000 nano = $730,000,000 a month.
+// A float would lose the units; BigInt keeps the exact integer and the output is a string.
+const hugeGpu = gpu();
+hugeGpu.warm.hourly_nano_usd = 1000000000000000;
+const huge = plan(input(series4(), { gpu_classes: [hugeGpu] }));
+assert.strictEqual(huge.candidates[0].costs.forecast.warm.total_nano_usd, '730000000000000000');
+assert.ok(BigInt(huge.candidates[0].costs.forecast.warm.total_nano_usd) > BigInt(Number.MAX_SAFE_INTEGER));
+
+// ── Budget lock ──────────────────────────────────────────────────────────────────────────────────
+const unset = plan(input(series4()));
+assert.strictEqual(unset.budget.configured, false);
+assert.strictEqual(unset.budget.valid, true);
+assert.strictEqual(unset.budget.configured_nano_usd, '0');
+assert.strictEqual(unset.candidates[0].recommendation.budget.status, 'needs_owner_budget');
+assert.match(unset.candidates[0].recommendation.reason, /Needs the owner's budget/);
+
+const invalid = plan(input(series4()), { budgetNanoUsd: '1.5' });
+assert.strictEqual(invalid.budget.valid, false);
+assert.strictEqual(invalid.budget.configured, true);
+assert.strictEqual(invalid.budget.configured_nano_usd, '0');
+assert.strictEqual(invalid.candidates[0].recommendation.budget.status, 'needs_owner_budget');
+assert.match(invalid.candidates[0].recommendation.reason, /not a non-negative integer/);
+
+// The high band can cross the budget even when the forecast does not: 2,465 * 140,000 =
+// 345,100,000 wholesale at the forecast and 2,506 * 140,000 = 350,840,000 at the high band. A
+// $0.348 budget passes the forecast and fails the high band, so it needs the owner's budget.
+const highBudget = plan(input(oneOutlier), { budgetNanoUsd: '348000000' });
+const highRecommendation = highBudget.candidates[0].recommendation;
+assert.strictEqual(highRecommendation.mode, 'wholesale');
+assert.strictEqual(highRecommendation.budget.exceeds_forecast, false);
+assert.strictEqual(highRecommendation.budget.exceeds_high_band, true);
+assert.strictEqual(highRecommendation.budget.status, 'needs_owner_budget');
+
+// A tie never recommends a rental: with zero-price modes the tie order is wholesale, scale, warm.
+const freeModel = model();
+freeModel.wholesale = { input_nano_usd_per_million: 0, cached_read_nano_usd_per_million: 0, cache_write_nano_usd_per_million: 0, output_nano_usd_per_million: 0, per_request_nano_usd: 0 };
+const freeGpu = gpu();
+freeGpu.warm.hourly_nano_usd = 0;
+freeGpu.scale_to_zero.hourly_nano_usd = 0;
+const free = plan(input(series4(), { models: [freeModel], gpu_classes: [freeGpu] }));
+assert.strictEqual(free.candidates[0].recommendation.mode, 'wholesale');
+
+// ── Validation names the field ──────────────────────────────────────────────────────────────────
+for (const [label, bad, pattern] of [
+ ['non-consecutive dates', (() => { const s = series4(); s.splice(10, 1); return input(s); })(), /forecast\.series\[10\]\.date: every point must be the next calendar day/],
+ ['window longer than series', input(series4(), { forecast: { window_days: 29 } }), /forecast\.window_days: must be <= 28/],
+ ['success rate zero', input(series4(), { models: [{ ...model(), success_bps: 0 }] }), /models\[0\]\.success_bps: must be >= 1/],
+ ['negative tokens', input(series4(), { request: { output_tokens: -1 } }), /request\.output_tokens: must be >= 0/],
+ ['zero throughput', input(series4(), { models: [{ ...model(), throughput: { prefill_tokens_per_sec: 0, decode_tokens_per_sec: 50 } }] }), /models\[0\]\.throughput\.prefill_tokens_per_sec: must be a positive number/],
+ ['duplicate model id', input(series4(), { models: [model(), model()] }), /models\.id: 'qwen\/qwen3-9b:q4_k_m@3f2a1c9' is listed twice/],
+ ['missing gpu class', (() => { const x = input(series4()); delete x.gpu_classes; return x; })(), /gpu_classes: is required/],
+]) {
+ assert.throws(() => plan(bad), pattern, label);
+}
+
+// ── parseBudget and formatUsd ────────────────────────────────────────────────────────────────────
+assert.deepStrictEqual(parseBudget(''), { nano: 0n, raw: '', configured: false, valid: true });
+assert.deepStrictEqual(parseBudget('42'), { nano: 42n, raw: '42', configured: true, valid: true });
+assert.deepStrictEqual(parseBudget('-1'), { nano: 0n, raw: '-1', configured: true, valid: false });
+assert.strictEqual(formatUsd('73000000000'), '$73.00');
+assert.strictEqual(formatUsd('341600000'), '$0.34');
+assert.strictEqual(formatUsd('999999', 6), '$0.001000');
+assert.strictEqual(formatUsd('0'), '$0.00');
+
+// ── The checked-in example and its schema ────────────────────────────────────────────────────────
+const examplePath = path.join(__dirname, '..', 'workers', 'llm-inference', 'examples', 'inference-planner.example.json');
+const example = JSON.parse(fs.readFileSync(examplePath, 'utf8'));
+assert.match(example.note, /example, not a quote/);
+const examplePlan = plan(example);
+assert.strictEqual(examplePlan.candidates.length, example.models.length * example.gpu_classes.length);
+assert.strictEqual(examplePlan.summary.actions.length, 0);
+assert.strictEqual(examplePlan.action, 'none');
+assert.strictEqual(examplePlan.executes, false);
+for (const c of examplePlan.candidates) {
+ assert.match(c.costs.forecast.warm.total_nano_usd, /^\d+$/);
+ assert.match(c.costs.high_band.wholesale.total_nano_usd, /^\d+$/);
+ assert.ok(c.recommendation.reason.length > 0);
+}
+const schema = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'scripts', 'inference-planner.schema.json'), 'utf8'));
+for (const key of ['forecast', 'request', 'models', 'gpu_classes']) assert.ok(schema.properties[key], `schema property ${key}`);
+assert.ok(schema.$defs.nanoUsd, 'schema defines integer nano-USD');
+for (const key of Object.keys(example)) {
+ if (key === '$schema') continue;
+ assert.ok(schema.properties[key], `schema property ${key}`);
+}
+
+// ── No network, no process spawn, no clock ──────────────────────────────────────────────────────
+const source = fs.readFileSync(path.join(__dirname, '..', 'server', 'inference-planner.js'), 'utf8');
+assert.doesNotMatch(source, /require\(\s*['"](?:fs|child_process|http|https|net|tls|dns|dgram|cluster|worker_threads)['"]\s*\)/);
+assert.doesNotMatch(source, /child_process/);
+assert.doesNotMatch(source, /\b(?:spawn|spawnSync|execFile|execSync|fork)\s*\(/);
+assert.doesNotMatch(source, /\bfetch\s*\(/);
+assert.doesNotMatch(source, /\b(?:setTimeout|setInterval|setImmediate)\s*\(/);
+assert.doesNotMatch(source, /process\.(?:spawn|exec|fork)\b/);
+const originalFetch = globalThis.fetch;
+globalThis.fetch = () => { throw new Error('the planner must not call fetch'); };
+try {
+ plan(input(series4()));
+} finally {
+ globalThis.fetch = originalFetch;
+}
+
+console.log('inference planner: forecast, integer costs, break-evens and budget checks passed');
new file mode 100644
new file mode 100644
@@ -0,0 +1,96 @@
@@ -0,0 +1,96 @@
+'use strict';
+/**
+ * D11 owner settings: the host.inference_planner namespace and /internal/config.
+ * Env pins win; host.config.manage is required; an owner change applies live; budget_nano_usd is x-danger and
+ * needs X-OV-Fresh; no KEK still works; a secret-class value never leaves a summary, snapshot or history.
+ */
+const assert = require('assert');
+const { boot, check, done, DASHBOARD } = require('./helpers/boot');
+
+const CAP = ['host.config.manage'];
+const OWNER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+const SECRET = 'sk_host_d11_toppsecret_123456';
+
+(async () => {
+ const t = await boot({ env: { HOST_INFERENCE_PLANNER: 'on', HOST_INFERENCE_BUDGET_NANO_USD: '0' } });
+ const admin = () => t.network.serviceToken('network', CAP);
+ try {
+ await check('the summary: schema, x-danger/x-unit, the env pins, sealed:false without a KEK', async () => {
+ const r = await t.api('GET', '/internal/config/host.inference_planner', { as: admin() });
+ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
+ assert.strictEqual(r.json().namespace, 'host.inference_planner');
+ assert.strictEqual(r.json().sealed, false);
+ assert.strictEqual(r.json().values.enabled, true);
+ assert.deepStrictEqual(r.json().pins.enabled, { env: 'HOST_INFERENCE_PLANNER', pinned: true });
+ assert.strictEqual(r.json().schema.properties.budget_nano_usd['x-danger'], true);
+ assert.strictEqual(r.json().schema.properties.budget_nano_usd['x-unit'], 'nano_usd');
+ assert.strictEqual(r.json().schema.properties.source.enum.join(','), 'json,ai');
+ });
+
+ await check('a service token without host.config.manage is refused', async () => {
+ const token = t.network.serviceToken('other', ['host.catalog.read']);
+ const r = await t.api('GET', '/internal/config/host.inference_planner', { as: token });
+ assert.strictEqual(r.status, 403);
+ assert.strictEqual(r.json().code, 'capability.denied');
+ });
+
+ await check('an env-pinned key is read-only', async () => {
+ const r = await t.api('POST', '/internal/config/host.inference_planner', {
+ as: admin(), headers: { 'x-ov-subject': OWNER }, json: { values: { enabled: false }, reason: 'off' },
+ });
+ assert.strictEqual(r.status, 409, r.text.slice(0, 300));
+ assert.strictEqual(r.json().code, 'config.pinned_in_env');
+ });
+ } finally { await t.close(); }
+
+ const live = await boot({ env: { HOST_INFERENCE_PLANNER_INPUT: require('path').join(__dirname, '..', 'workers', 'llm-inference', 'examples', 'inference-planner.example.json') } });
+ const liveAdmin = () => live.network.serviceToken('network', [...CAP, 'host.catalog.manage']);
+ const staff = live.user('planner-owner', { role: 'admin' });
+ try {
+ await check('a danger key without X-OV-Fresh is refused, with it applies live', async () => {
+ const stale = await live.api('POST', '/internal/config/host.inference_planner', {
+ as: liveAdmin(), headers: { 'x-ov-subject': OWNER }, json: { values: { budget_nano_usd: 500 } , reason: 'raise it' },
+ });
+ assert.strictEqual(stale.status, 403, stale.text.slice(0, 300));
+ assert.strictEqual(stale.json().code, 'config.fresh_required');
+ const fresh = await live.api('POST', '/internal/config/host.inference_planner', {
+ as: liveAdmin(), headers: { 'x-ov-subject': OWNER, 'x-ov-fresh': '1' }, json: { values: { budget_nano_usd: 500, enabled: true, source: 'json' }, reason: 'owner raised the budget' },
+ });
+ assert.strictEqual(fresh.status, 200, fresh.text.slice(0, 300));
+ assert.strictEqual(fresh.json().revision, 2);
+ assert.strictEqual(live.config.inferencePlanner.enabled, true);
+ assert.strictEqual(live.config.inferencePlanner.budgetNanoUsdRaw, '500');
+ const page = await live.api('GET', '/api/v1/catalog/inference-planner', { as: live.network.userToken(staff) });
+ assert.strictEqual(page.status, 200, page.text.slice(0, 300));
+ });
+
+ await check('with a KEK the store is sealed and boot reseals', async () => {
+ const sealed = await boot({ env: { OV_CONFIG_KEKS: 'k1:MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=', OV_CONFIG_KEK_CURRENT: 'k1' } });
+ try {
+ const token = sealed.network.serviceToken('network', CAP);
+ const r = await sealed.api('GET', '/internal/config/host.inference_planner', { as: token });
+ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
+ assert.strictEqual(r.json().sealed, true);
+ assert.strictEqual(r.json().kek, 'k1');
+ } finally { await sealed.close(); }
+ });
+
+ await check('a secret is write-only and never appears in a summary, snapshot or history', async () => {
+ const shared = require('openvibe-shared/config');
+ const extra = await shared.createConfigStore({
+ db: live.ctx.store.db, service: 'host', namespace: 'host.test_secrets',
+ schema: { type: 'object', additionalProperties: false, properties: { api_key: { type: 'string' } } },
+ classify: () => 'secret',
+ });
+ const marker = await extra.setSecret('api_key', SECRET, { actor: { type: 'user', id: OWNER }, reason: 'test' });
+ assert.strictEqual(marker.redacted, true);
+ assert.ok(!JSON.stringify(marker).includes(SECRET));
+ const summary = await extra.summary();
+ assert.strictEqual(summary.values.api_key.redacted, true);
+ assert.ok(!JSON.stringify(summary).includes(SECRET));
+ assert.ok(!JSON.stringify(await extra.history()).includes(SECRET));
+ assert.ok(!JSON.stringify(await extra.snapshot(1)).includes(SECRET));
+ });
+ } finally { await live.close(); }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -25,6 +25,7 @@ of long-running worker: it can run under a `workerUnits` unit or any GPU orchest
@@ -25,6 +25,7 @@ of long-running worker: it can run under a `workerUnits` unit or any GPU orchest
| `Caddyfile` | `/health`, `/ready`, `/metrics` and API passthrough; blocks LoRA paths |
| `Caddyfile` | `/health`, `/ready`, `/metrics` and API passthrough; blocks LoRA paths |
| `examples/qwen3-4b-abliterated.manifest.example.json` | one compact abliterated Qwen 4B artifact, revision and hash **TODO** |
| `examples/qwen3-4b-abliterated.manifest.example.json` | one compact abliterated Qwen 4B artifact, revision and hash **TODO** |
| `examples/inference-cost-model.example.json` | clearly labelled placeholder inputs for the cost model |
| `examples/inference-cost-model.example.json` | clearly labelled placeholder inputs for the cost model |
+| `examples/inference-planner.example.json` | clearly labelled placeholder inputs for the placement planner (daily series, models, GPU classes) |
## Build
## Build
@@ -206,11 +207,49 @@ built-in benchmarks. Replace them with dated complete quotes and measured tok/s
@@ -206,11 +207,49 @@ built-in benchmarks. Replace them with dated complete quotes and measured tok/s
The script uses floats for forecasting. Customer liability is Billing's integer nano-USD ledger
The script uses floats for forecasting. Customer liability is Billing's integer nano-USD ledger
(D1); this is not a settlement or quoting authority.
(D1); this is not a settlement or quoting authority.
+## Placement planner (staff, flag off)
+
+`server/inference-planner.js` (schema: `scripts/inference-planner.schema.json`) turns a daily
+request series into a 30-day forecast and, for every model × GPU-class candidate, compares the
+expected monthly cost of a **warm rented GPU**, **scale-to-zero** and **wholesale** at the forecast
+and at its high band. It is a pure module: no file, socket, clock or child process, and every
+amount is integer nano-USD (BigInt).
+
+- **Forecast:** level = mean of the trailing window; each weekday factor = that weekday's mean /
+ the level; a future day = level × factor. The band is ±1.96 × the pooled residual standard
+ deviation × √horizon, assuming independent daily residuals.
+- **Warm:** 730 h × the GPU hourly rate for each GPU the busy time needs, plus storage and egress.
+ The idle burn is the part of the rental not spent on busy requests.
+- **Scale-to-zero:** busy milliseconds at the serverless rate, one cold start every
+ `requests_per_cold_start`, plus storage and egress; idle is free.
+- **Wholesale:** per-million token rates plus the per-request fee, all-in.
+- **Recommendation:** the cheapest monthly cost, with a tie preferring wholesale, then
+ scale-to-zero — never a rental. If the forecast or high-band cost is above
+ `HOST_INFERENCE_BUDGET_NANO_USD` the row is marked **needs the owner's budget**; unset means 0.
+
+The staff page is `GET /catalog/inference` and the JSON is
+`GET /api/v1/catalog/inference-planner`. Both are mounted only when `HOST_INFERENCE_PLANNER=on`
+(default off), and both use Host's existing staff gate (`staff.site.configure`/role) and catalog
+page style. There is no action endpoint: nothing here starts, stops or rents a GPU. The owner
+approves every rental separately.
+
+```sh
+HOST_INFERENCE_PLANNER=on \
+HOST_INFERENCE_PLANNER_INPUT=workers/llm-inference/examples/inference-planner.example.json \
+HOST_INFERENCE_BUDGET_NANO_USD=0 \
+node server/index.js
+```
+
+The example file is **example, not a quote**: every price and throughput is a placeholder. The
+input may be JSON now; the AI usage feed is not connected.
+
## Tests
## Tests
```sh
```sh
node test/inference-artifact-verify.test.js
node test/inference-artifact-verify.test.js
node test/inference-cost-model.test.js
node test/inference-cost-model.test.js
+node test/inference-planner.test.js
+node test/inference-planner-http.test.js
# or, through the repository runner:
# or, through the repository runner:
npm test -- inference
npm test -- inference
```
```
@@ -218,7 +257,11 @@ npm test -- inference
@@ -218,7 +257,11 @@ npm test -- inference
The artifact test covers a correct download, a wrong hash (refused, no file left), a `TODO`
The artifact test covers a correct download, a wrong hash (refused, no file left), a `TODO`
placeholder, a traversal filename, a local URL without the smoke-test opt-in, a cached file, and
placeholder, a traversal filename, a local URL without the smoke-test opt-in, a cached file, and
the entrypoint's bounded-env refusals. The cost-model test checks the arithmetic against numbers
the entrypoint's bounded-env refusals. The cost-model test checks the arithmetic against numbers
-worked out by hand, invalid inputs and the checked-in example.
+worked out by hand, invalid inputs and the checked-in example. The planner test covers the
+forecast and its band on fixtures, integer nano-USD costs, hand-computed warm/scale/wholesale
+break-evens, the budget lock (including a high band that crosses it), validation, and asserts that
+the module has no network call, process spawn or clock. The HTTP test covers flag off (no route),
+the staff gate, the page and JSON, and a missing input.
## Not done here
## Not done here
new file mode 100644
new file mode 100644
@@ -0,0 +1,210 @@
@@ -0,0 +1,210 @@
+{
+ "$schema": "../../../scripts/inference-planner.schema.json",
+ "note": "example, not a quote — every price and throughput below is a placeholder for arithmetic only. Replace each one with a dated complete quote and a measured tok/s run before using this plan for a decision. The planner recommends; it never starts, stops or rents a GPU.",
+ "forecast": {
+ "horizon_days": 30,
+ "window_days": 28,
+ "series": [
+ {
+ "date": "2026-10-05",
+ "requests": 120
+ },
+ {
+ "date": "2026-10-06",
+ "requests": 120
+ },
+ {
+ "date": "2026-10-07",
+ "requests": 120
+ },
+ {
+ "date": "2026-10-08",
+ "requests": 120
+ },
+ {
+ "date": "2026-10-09",
+ "requests": 120
+ },
+ {
+ "date": "2026-10-10",
+ "requests": 45
+ },
+ {
+ "date": "2026-10-11",
+ "requests": 30
+ },
+ {
+ "date": "2026-10-12",
+ "requests": 130
+ },
+ {
+ "date": "2026-10-13",
+ "requests": 130
+ },
+ {
+ "date": "2026-10-14",
+ "requests": 130
+ },
+ {
+ "date": "2026-10-15",
+ "requests": 130
+ },
+ {
+ "date": "2026-10-16",
+ "requests": 130
+ },
+ {
+ "date": "2026-10-17",
+ "requests": 50
+ },
+ {
+ "date": "2026-10-18",
+ "requests": 35
+ },
+ {
+ "date": "2026-10-19",
+ "requests": 125
+ },
+ {
+ "date": "2026-10-20",
+ "requests": 125
+ },
+ {
+ "date": "2026-10-21",
+ "requests": 125
+ },
+ {
+ "date": "2026-10-22",
+ "requests": 125
+ },
+ {
+ "date": "2026-10-23",
+ "requests": 125
+ },
+ {
+ "date": "2026-10-24",
+ "requests": 48
+ },
+ {
+ "date": "2026-10-25",
+ "requests": 32
+ },
+ {
+ "date": "2026-10-26",
+ "requests": 140
+ },
+ {
+ "date": "2026-10-27",
+ "requests": 140
+ },
+ {
+ "date": "2026-10-28",
+ "requests": 140
+ },
+ {
+ "date": "2026-10-29",
+ "requests": 140
+ },
+ {
+ "date": "2026-10-30",
+ "requests": 140
+ },
+ {
+ "date": "2026-10-31",
+ "requests": 55
+ },
+ {
+ "date": "2026-11-01",
+ "requests": 38
+ }
+ ]
+ },
+ "request": {
+ "input_tokens": 1000,
+ "cached_read_tokens": 250,
+ "cache_write_tokens": 100,
+ "output_tokens": 500
+ },
+ "models": [
+ {
+ "id": "qwen/qwen3-9b:q4_k_m@3f2a1c9",
+ "label": "Qwen3 9B Q4 (example)",
+ "throughput": {
+ "prefill_tokens_per_sec": 800,
+ "decode_tokens_per_sec": 40,
+ "cached_read_tokens_per_sec": 800,
+ "concurrency": 1
+ },
+ "success_bps": 9500,
+ "wholesale": {
+ "input_nano_usd_per_million": 100000000,
+ "cached_read_nano_usd_per_million": 20000000,
+ "cache_write_nano_usd_per_million": 100000000,
+ "output_nano_usd_per_million": 400000000,
+ "per_request_nano_usd": 5000,
+ "success_bps": 9800
+ }
+ },
+ {
+ "id": "qwen/qwen3-4b:q4_k_m@c9e9066",
+ "label": "Qwen3 4B Q4 (example)",
+ "throughput": {
+ "prefill_tokens_per_sec": 1200,
+ "decode_tokens_per_sec": 60,
+ "cached_read_tokens_per_sec": 1200,
+ "concurrency": 1
+ },
+ "success_bps": 9700,
+ "wholesale": {
+ "input_nano_usd_per_million": 50000000,
+ "cached_read_nano_usd_per_million": 10000000,
+ "cache_write_nano_usd_per_million": 50000000,
+ "output_nano_usd_per_million": 200000000,
+ "per_request_nano_usd": 2000,
+ "success_bps": 9900
+ }
+ }
+ ],
+ "gpu_classes": [
+ {
+ "id": "rtx-4070-12gb",
+ "label": "RTX 4070 12 GB (example)",
+ "warm": {
+ "hourly_nano_usd": 70000000,
+ "storage_gb": 4,
+ "storage_nano_usd_per_gb_month": 100000000,
+ "egress_bytes_per_request": 200000,
+ "egress_nano_usd_per_gb": 90000000
+ },
+ "scale_to_zero": {
+ "hourly_nano_usd": 750000000,
+ "startup_seconds": 45,
+ "requests_per_cold_start": 1,
+ "storage_gb": 4,
+ "storage_nano_usd_per_gb_month": 100000000,
+ "egress_bytes_per_request": 200000,
+ "egress_nano_usd_per_gb": 90000000
+ }
+ },
+ {
+ "id": "rtx-3090-24gb",
+ "label": "RTX 3090 24 GB (example)",
+ "warm": {
+ "hourly_nano_usd": 90000000,
+ "storage_gb": 6,
+ "storage_nano_usd_per_gb_month": 100000000,
+ "egress_bytes_per_request": 200000,
+ "egress_nano_usd_per_gb": 90000000
+ },
+ "scale_to_zero": {
+ "hourly_nano_usd": 1200000000,
+ "startup_seconds": 60,
+ "requests_per_cold_start": 2,
+ "storage_gb": 6,
+ "storage_nano_usd_per_gb_month": 100000000,
+ "egress_bytes_per_request": 200000,
+ "egress_nano_usd_per_gb": 90000000
+ }
+ }
+ ]
+}