openvibers/openvibe.ai Commits
Propose verified provider prices as pending price-book changes (#447)
Changes
@@@ -45,3 +45,9 @@ description = "Made-up provider key in test/account-data.test.js (L36: the accoucondition = "AND"paths = ['''^test/account-data\.test\.js$''']regexes = ['''sk-self-ada-0123456789LEAK''']++++[[allowlists]]++description = "Model id in the 2026-11-10 price-book proposal data, flagged as a generic API key by entropy (a public model name, not a secret)"++condition = "AND"++paths = ['''^scripts/pricebook-proposals-2026-11-10\.json$''']++regexes = ['''^llama-3\.3-70b-instruct$''']@@@ -715,6 +715,12 @@ smaller one the job applies at once, and the model row carries the new price witlisting that cannot be read leaves every price alone: an unreadable page is not evidence of a change. A price aperson typed by hand is never touched.++A reviewed reading can also be put in front of the owner without waiting for the weekly job:++`POST /api/v1/admin/console/pricebook/proposals` (owner-only, `ai.provider.manage`) takes the same readings++`scripts/propose-pricebook.js` reads from a JSON file. Both write **pending** changes only — the source listing and++the day it was read travel with each row — and a model the registry does not know is created switched off with no++price, so there is a row to approve against. Nothing is applied until the owner approves it.++The book is **due-based**, not timer-based: AI is deployed several times a day, so a timer in the processwould be reset every time and the week would never come round. The last finished run is a row in the database(`price_book_state`, migration 0037) and the service asks once an hour whether the interval has passed sincennew file mode 100644@@@ -0,0 +1,477 @@++[++ {++ "provider_key": "atlas-cloud",++ "model_key": "moonshotai/kimi-k2.5",++ "type": "chat",++ "source": "https://api.atlascloud.ai/v1/models",++ "checked_at": "2026-11-10",++ "in_per_mtok": 0.49,++ "cached_per_mtok": 0.2,++ "out_per_mtok": 2.5,++ "context_window": 262144,++ "quantization": "int4"++ },++ {++ "provider_key": "atlas-cloud",++ "model_key": "moonshotai/kimi-k2.6",++ "type": "chat",++ "source": "https://api.atlascloud.ai/v1/models",++ "checked_at": "2026-11-10",++ "in_per_mtok": 0.95,++ "cached_per_mtok": 0.16,++ "out_per_mtok": 4.0,++ "context_window": 262144,++ "quantization": "int4"++ },++ {++ "provider_key": "atlas-cloud",++ "model_key": "moonshotai/kimi-k3",++ "type": "chat",++ "source": "https://api.atlascloud.ai/v1/models",++ "checked_at": "2026-11-10",++ "in_per_mtok": 3.0,++ "cached_per_mtok": 0.3,++ "out_per_mtok": 15.0,++ "context_window": 1048576,++ "quantization": "int4"++ },++ {++ "provider_key": "atlas-cloud",++ "model_key": "moonshotai/kimi-k3.1-pro",++ "type": "chat",++ "source": "https://api.atlascloud.ai/v1/models",++ "checked_at": "2026-11-10",++ "in_per_mtok": 2.0,++ "cached_per_mtok": 0.2,++ "out_per_mtok": 10.0,++ "context_window": 1048576,++ "quantization": "int4"++ },++ {++ "provider_key": "atlas-cloud",++ "model_key": "qwen/qwen3.8-max",++ "type": "chat",++ "source": "https://api.atlascloud.ai/v1/models",++ "checked_at": "2026-11-10",++ "in_per_mtok": 1.4,++ "cached_per_mtok": 0.175,++ "out_per_mtok": 4.2,++ "context_window": 1000000,++ "quantization": "fp8"++ },++ {++ "provider_key": "avian",++ "model_key": "minimax/minimax-m2.5",++ "type": "chat",++ "source": "https://api.avian.io/v1/models",++ "checked_at": "2026-11-10",++ "in_per_mtok": 0.27,++ "cached_per_mtok": 0.15,++ "out_per_mtok": 1.08,++ "context_window": 196608++ },++ {++ "provider_key": "avian",++ "model_key": "minimax/minimax-m3",++ "type": "chat",++ "source": "https://api.avian.io/v1/models",++ "checked_at": "2026-11-10",++ "in_per_mtok": 0.255,++ "cached_per_mtok": 0.051,++ "out_per_mtok": 1.02,++ "context_window": 524288++ },++ {++ "provider_key": "avian",++ "model_key": "moonshotai/kimi-k2.5",++ "type": "chat",++ "source": "https://api.avian.io/v1/models",++ "checked_at": "2026-11-10",++ "in_per_mtok": 0.45,++ "cached_per_mtok": 0.225,++ "out_per_mtok": 2.2,++ "context_window": 262144++ },++ {++ "provider_key": "avian",++ "model_key": "moonshotai/kimi-k2.6",++ "type": "chat",++ "source": "https://api.avian.io/v1/models",++ "checked_at": "2026-11-10",++ "in_per_mtok": 0.95,++ "cached_per_mtok": 0.16,++ "out_per_mtok": 4.0,++ "context_window": 262144++ },++ {++ "provider_key": "avian",++ "model_key": "moonshotai/kimi-k3",++ "type": "chat",++ "source": "https://api.avian.io/v1/models",++ "checked_at": "2026-11-10",++ "in_per_mtok": 2.55,++ "cached_per_mtok": 0.255,++ "out_per_mtok": 12.75,++ "context_window": 1048576++ },++ {++ "provider_key": "baseten",++ "model_key": "openai/gpt-oss-120b",++ "type": "chat",++ "source": "https://www.baseten.co/pricing/",++ "checked_at": "2026-11-10",++ "display_name": "GPT OSS 120B",++ "in_per_mtok": 0.1,++ "out_per_mtok": 0.5++ },++ {++ "provider_key": "deepinfra",++ "model_key": "Qwen/Qwen3-235B-A22B-Instruct-2507",++ "type": "chat",++ "source": "https://api.deepinfra.com/v1/models",++ "checked_at": "2026-11-10",++ "in_per_mtok": 0.09,++ "out_per_mtok": 0.55,++ "context_window": 262144++ },++ {++ "provider_key": "deepinfra",++ "model_key": "deepseek-ai/DeepSeek-V3",++ "type": "chat",++ "source": "https://api.deepinfra.com/v1/models",++ "checked_at": "2026-11-10",++ "in_per_mtok": 0.32,++ "out_per_mtok": 0.89,++ "context_window": 163840++ },++ {++ "provider_key": "deepinfra",++ "model_key": "meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo",++ "type": "chat",++ "source": "https://api.deepinfra.com/v1/models",++ "checked_at": "2026-11-10",++ "in_per_mtok": 0.02,++ "out_per_mtok": 0.04,++ "context_window": 131072++ },++ {++ "provider_key": "inference-net",++ "model_key": "deepseek-v3.2",++ "type": "chat",++ "source": "https://api.inference.net/v1/models",++ "checked_at": "2026-11-10",++ "in_per_mtok": 0.7,++ "cached_per_mtok": 0.35,++ "out_per_mtok": 2.0,++ "context_window": 163840++ },++ {++ "provider_key": "inference-net",++ "model_key": "deepseek-v4-flash",++ "type": "chat",++ "source": "https://api.inference.net/v1/models",++ "checked_at": "2026-11-10",++ "in_per_mtok": 0.23,++ "cached_per_mtok": 0.0028,++ "out_per_mtok": 0.62,++ "context_window": 1000000++ },++ {++ "provider_key": "inference-net",++ "model_key": "deepseek-v4-flash-0731",++ "type": "chat",++ "source": "https://api.inference.net/v1/models",++ "checked_at": "2026-11-10",++ "in_per_mtok": 0.53,++ "cached_per_mtok": 0.017,++ "out_per_mtok": 1.58,++ "context_window": 1048575++ },++ {++ "provider_key": "inference-net",++ "model_key": "deepseek-v4.1-flash-1017",++ "type": "chat",++ "source": "https://api.inference.net/v1/models",++ "checked_at": "2026-11-10",++ "in_per_mtok": 0.3,++ "cached_per_mtok": 0.007,++ "out_per_mtok": 1.0,++ "context_window": 1040000++ },++ {++ "provider_key": "inference-net",++ "model_key": "gemma-3-27b-it",++ "type": "chat",++ "source": "https://api.inference.net/v1/models",++ "checked_at": "2026-11-10",++ "in_per_mtok": 0.09,++ "out_per_mtok": 0.25,++ "context_window": 131072++ },++ {++ "provider_key": "inference-net",++ "model_key": "glm-5",++ "type": "chat",++ "source": "https://api.inference.net/v1/models",++ "checked_at": "2026-11-10",++ "in_per_mtok": 0.95,++ "cached_per_mtok": 0.2,++ "out_per_mtok": 2.55,++ "context_window": 204800++ },++ {++ "provider_key": "inference-net",++ "model_key": "glm-5.2",++ "type": "chat",++ "source": "https://api.inference.net/v1/models",++ "checked_at": "2026-11-10",++ "in_per_mtok": 1.3,++ "cached_per_mtok": 0.2,++ "out_per_mtok": 4.4,++ "context_window": 1048576++ },++ {++ "provider_key": "inference-net",++ "model_key": "glm-5.3",++ "type": "chat",++ "source": "https://api.inference.net/v1/models",++ "checked_at": "2026-11-10",++ "in_per_mtok": 0.9,++ "cached_per_mtok": 0.15,++ "out_per_mtok": 3.0,++ "context_window": 1048576++ },++ {++ "provider_key": "inference-net",++ "model_key": "glm-5.3-flash",++ "type": "chat",++ "source": "https://api.inference.net/v1/models",++ "checked_at": "2026-11-10",++ "in_per_mtok": 0.1,++ "cached_per_mtok": 0.03,++ "out_per_mtok": 0.45,++ "context_window": 1048576++ },++ {++ "provider_key": "inference-net",++ "model_key": "llama-3.3-70b-instruct",++ "type": "chat",++ "source": "https://api.inference.net/v1/models",++ "checked_at": "2026-11-10",++ "in_per_mtok": 0.13,++ "out_per_mtok": 0.4,++ "context_window": 131072++ },++ {++ "provider_key": "inference-net",++ "model_key": "moonshotai/kimi-k3",++ "type": "chat",++ "source": "https://api.inference.net/v1/models",++ "checked_at": "2026-11-10",++ "in_per_mtok": 3.0,++ "cached_per_mtok": 0.3,++ "out_per_mtok": 15.0,++ "context_window": 1048576++ },++ {++ "provider_key": "inference-net",++ "model_key": "moonshotai/kimi-k3.1-pro",++ "type": "chat",++ "source": "https://api.inference.net/v1/models",++ "checked_at": "2026-11-10",++ "in_per_mtok": 2.0,++ "cached_per_mtok": 0.2,++ "out_per_mtok": 10.0,++ "context_window": 1048576++ },++ {++ "provider_key": "inference-net",++ "model_key": "qwen3-14b",++ "type": "chat",++ "source": "https://api.inference.net/v1/models",++ "checked_at": "2026-11-10",++ "in_per_mtok": 0.2275,++ "out_per_mtok": 0.91,++ "context_window": 131072++ },++ {++ "provider_key": "inference-net",++ "model_key": "qwen3-30b-a3b",++ "type": "chat",++ "source": "https://api.inference.net/v1/models",++ "checked_at": "2026-11-10",++ "in_per_mtok": 0.12,++ "out_per_mtok": 0.5,++ "context_window": 40960++ },++ {++ "provider_key": "nebius",++ "model_key": "Qwen/Qwen3-Embedding-8B",++ "type": "embedding",++ "source": "https://nebius.com/services/token-factory/models/qwen-models-inference",++ "checked_at": "2026-11-10",++ "display_name": "Qwen3-Embedding-8B",++ "in_per_mtok": 0.01++ },++ {++ "provider_key": "novita",++ "model_key": "meta-llama/llama-3.1-8b-instruct",++ "type": "chat",++ "source": "https://api.novita.ai/v3/openai/models",++ "checked_at": "2026-11-10",++ "in_per_mtok": 0.02,++ "out_per_mtok": 0.05++ },++ {++ "provider_key": "parasail",++ "model_key": "parasail-deepseek-v4-flash",++ "type": "chat",++ "source": "https://docs.parasail.io/parasail-docs/products/overview/models",++ "checked_at": "2026-11-10",++ "in_per_mtok": 0.14,++ "cached_per_mtok": 0.07,++ "out_per_mtok": 0.28,++ "context_window": 1048576,++ "quantization": "fp8"++ },++ {++ "provider_key": "parasail",++ "model_key": "parasail-gemma3-27b-it",++ "type": "chat",++ "source": "https://docs.parasail.io/parasail-docs/products/overview/models",++ "checked_at": "2026-11-10",++ "in_per_mtok": 0.08,++ "cached_per_mtok": 0.04,++ "out_per_mtok": 0.45,++ "context_window": 131072,++ "quantization": "fp8"++ },++ {++ "provider_key": "parasail",++ "model_key": "parasail-glm-53",++ "type": "chat",++ "source": "https://docs.parasail.io/parasail-docs/products/overview/models",++ "checked_at": "2026-11-10",++ "in_per_mtok": 1.4,++ "cached_per_mtok": 0.26,++ "out_per_mtok": 4.4,++ "context_window": 1048576,++ "quantization": "fp8"++ },++ {++ "provider_key": "parasail",++ "model_key": "parasail-glm-53-flash",++ "type": "chat",++ "source": "https://docs.parasail.io/parasail-docs/products/overview/models",++ "checked_at": "2026-11-10",++ "in_per_mtok": 0.15,++ "cached_per_mtok": 0.03,++ "out_per_mtok": 0.5,++ "context_window": 1048576,++ "quantization": "fp4"++ },++ {++ "provider_key": "parasail",++ "model_key": "parasail-gpt-oss-120b",++ "type": "chat",++ "source": "https://docs.parasail.io/parasail-docs/products/overview/models",++ "checked_at": "2026-11-10",++ "in_per_mtok": 0.1,++ "cached_per_mtok": 0.055,++ "out_per_mtok": 0.75,++ "context_window": 131072,++ "quantization": "fp4"++ },++ {++ "provider_key": "parasail",++ "model_key": "parasail-gpt-oss-20b",++ "type": "chat",++ "source": "https://docs.parasail.io/parasail-docs/products/overview/models",++ "checked_at": "2026-11-10",++ "in_per_mtok": 0.03,++ "cached_per_mtok": 0.02,++ "out_per_mtok": 0.15,++ "context_window": 131072,++ "quantization": "fp4"++ },++ {++ "provider_key": "parasail",++ "model_key": "parasail-kimi-k26",++ "type": "chat",++ "source": "https://docs.parasail.io/parasail-docs/products/overview/models",++ "checked_at": "2026-11-10",++ "in_per_mtok": 0.75,++ "cached_per_mtok": 0.16,++ "out_per_mtok": 3.5,++ "context_window": 262144,++ "quantization": "int4"++ },++ {++ "provider_key": "parasail",++ "model_key": "parasail-kimi-k3",++ "type": "chat",++ "source": "https://docs.parasail.io/parasail-docs/products/overview/models",++ "checked_at": "2026-11-10",++ "in_per_mtok": 3.0,++ "cached_per_mtok": 0.3,++ "out_per_mtok": 15.0,++ "context_window": 1048576,++ "quantization": "fp4"++ },++ {++ "provider_key": "parasail",++ "model_key": "parasail-llama-33-70b-fp8",++ "type": "chat",++ "source": "https://docs.parasail.io/parasail-docs/products/overview/models",++ "checked_at": "2026-11-10",++ "in_per_mtok": 0.22,++ "cached_per_mtok": 0.11,++ "out_per_mtok": 0.5,++ "context_window": 131072,++ "quantization": "fp8"++ },++ {++ "provider_key": "parasail",++ "model_key": "parasail-minimax-m3",++ "type": "chat",++ "source": "https://docs.parasail.io/parasail-docs/products/overview/models",++ "checked_at": "2026-11-10",++ "in_per_mtok": 0.3,++ "cached_per_mtok": 0.06,++ "out_per_mtok": 1.2,++ "context_window": 1048576,++ "quantization": "fp8"++ },++ {++ "provider_key": "parasail",++ "model_key": "parasail-mistral-small-32-24b",++ "type": "chat",++ "source": "https://docs.parasail.io/parasail-docs/products/overview/models",++ "checked_at": "2026-11-10",++ "in_per_mtok": 0.09,++ "cached_per_mtok": 0.05,++ "out_per_mtok": 0.3,++ "context_window": 131072,++ "quantization": "bf16"++ },++ {++ "provider_key": "venice",++ "model_key": "bge-m3",++ "type": "embedding",++ "source": "https://docs.venice.ai/models/bge-m3",++ "checked_at": "2026-11-10",++ "display_name": "BGE-M3",++ "in_per_mtok": 0.15,++ "context_window": 8192++ },++ {++ "provider_key": "venice",++ "model_key": "qwen3-embedding-8b",++ "type": "embedding",++ "source": "https://docs.venice.ai/models/qwen3-embedding-8b",++ "checked_at": "2026-11-10",++ "display_name": "Qwen3 Embedding 8B",++ "in_per_mtok": 0.0125,++ "context_window": 32768++ }++]nnew file mode 100644@@@ -0,0 +1,75 @@++#!/usr/bin/env node++'use strict';++/**++ * Put reviewed provider readings in front of the owner as pending price changes.++ *++ * The weekly price book reads a provider's listing itself and proposes a change only when its own reading++ * differs from the live price. This script is the other door: it reads a JSON list of readings that a person++ * took from the provider's own page or keyless model list, and writes each one into `price_changes` as++ * pending. It never writes a price into a model row and never approves a change; the owner does that on the++ * operator console (ai.provider.manage).++ *++ * node scripts/propose-pricebook.js [file.json] [--dry-run]++ *++ * The file is an array of readings:++ *++ * {++ * "provider_key": "parasail",++ * "model_key": "parasail-deepseek-v4-flash",++ * "in_per_mtok": 0.14, "cached_per_mtok": 0.07, "out_per_mtok": 0.28,++ * "context_window": 1048576, "quantization": "fp8", "type": "chat",++ * "source": "https://docs.parasail.io/parasail-docs/products/overview/models",++ * "checked_at": "2026-11-10"++ * }++ *++ * Run it on the AI host with the service environment (the recipe is in the ov-prod-db-read skill), or use++ * POST /api/v1/admin/console/pricebook/proposals with the same list. A model the registry does not know is++ * created switched off with no price, so there is a row to approve against; it is not marked price-book-owned++ * until the owner approves a price.++ */++const fs = require('fs');++const path = require('path');++const { load } = require('../server/config');++const { openDb } = require('../server/db');++const { createRegistry } = require('../server/registry');++const { createPriceStore } = require('../server/pricebook/store');++const { proposePriceChanges } = require('../server/pricebook/propose');++++const DEFAULT_FILE = path.join(__dirname, 'pricebook-proposals-2026-11-10.json');++++async function main() {++ const args = process.argv.slice(2);++ const dryRun = args.includes('--dry-run');++ const file = args.find((a) => !a.startsWith('--')) || DEFAULT_FILE;++ let proposals;++ try {++ proposals = JSON.parse(fs.readFileSync(file, 'utf8'));++ } catch (err) {++ throw new Error(`cannot read ${file}: ${err.message}`);++ }++ const config = load(process.env);++ const db = await openDb(config);++ try {++ const registry = createRegistry(db, { env: process.env });++ const store = createPriceStore({ db });++ const out = await proposePriceChanges({ registry, store, proposals, actor: 'owner:pricebook-propose', dryRun });++ if (!dryRun) {++ await registry.audit('owner:pricebook-propose', 'price_changes.proposed', 'price_book', 'propose', {++ metadata: { proposed: out.proposed.length, unchanged: out.unchanged.length, models_created: out.models_created.length, file: path.basename(file) },++ });++ }++ console.log(JSON.stringify(out, null, 2));++ console.log(`${dryRun ? 'would propose' : 'proposed'} ${out.proposed.length}; ${out.unchanged.length} already at that price; ${out.models_created.length} model row(s) ${dryRun ? 'would be created' : 'created'}`);++ } finally {++ await db.close();++ }++}++++if (require.main === module) {++ main().catch((err) => {++ console.error(`pricebook proposals: ${err.message}`);++ process.exitCode = 1;++ });++}++++module.exports = { main, DEFAULT_FILE };@@@ -260,6 +260,17 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,await registry.audit(actor(req), 'pricebook.run', 'pricebook', 'run', { trace: req.ov && req.ov.traceId, metadata: { sources: (report.sources || []).length, applied: (report.applied || []).length, pending: (report.pending || []).length } });return report;}));++ // A reviewed reading goes into the log as pending; only the owner's approve writes the live price. This is how++ // a provider or a model the weekly job has not read yet gets in front of the owner.++ r.post('/api/v1/admin/console/pricebook/proposals', write, json, wrap(async (req) => {++ if (!priceBook) throw new AiError(409, 'ai.conflict', 'the price book is off on this host');++ const body = req.body || {};++ const proposals = Array.isArray(body) ? body : body.proposals;++ const out = await priceBook.propose(proposals, { actor: actor(req) });++ await registry.audit(actor(req), 'price_changes.proposed', 'price_book', 'propose', { trace: req.ov && req.ov.traceId,++ metadata: { proposed: out.proposed.length, unchanged: out.unchanged.length, models_created: out.models_created.length } });++ return out;++ }));r.post('/api/v1/admin/console/runs/:id/cancel', workflowWrite, wrap(async (req) => {const subject = actor(req);const run = await runs.cancel(req.params.id, { sub: subject, subject: { type: 'user', id: subject } }, {@@@ -8,7 +8,8 @@*/const { createPriceBook } = require('./job');const { createPriceStore } = require('./store');++const { proposePriceChanges } = require('./propose');const { SOURCES } = require('./sources');const { readListing } = require('./read');--module.exports = { createPriceBook, createPriceStore, SOURCES, readListing };++module.exports = { createPriceBook, createPriceStore, proposePriceChanges, SOURCES, readListing };@@@ -27,6 +27,7 @@ const { SOURCES } = require('./sources');const { readListing } = require('./read');const { createPriceStore, FIELDS, FIELD_NAMES } = require('./store');const { createPriceState } = require('./state');++const { proposePriceChanges } = require('./propose');/** A price that differs by less than this is the same price: the providers publish floats (0.205999992). */const EPSILON = 1e-6;@@@ -398,6 +399,13 @@ function createPriceBook({ db, registry, config = {}, clock = { now: () => Date.const live = (limit) => store.live(limit);const changes = (opts) => store.list(opts);++ /**++ * Put a reviewed reading into the log as a pending change. The owner's approval is still what moves the++ * live price; nothing here writes a price into a model row.++ */++ const propose = (proposals, { actor = 'owner', dryRun = false } = {}) =>++ proposePriceChanges({ registry, store, proposals, clock, actor, dryRun });++/*** The owner's decision on a waiting change. Approving it writes the price into the model row; rejecting it* leaves the live price alone and the log keeps the story.@@@ -458,7 +466,7 @@ function createPriceBook({ db, registry, config = {}, clock = { now: () => Date.if (first) { clearTimeout(first); first = null; }}-- return { runOnce, tick, due, start, stop, decide, pending, live, changes, store, state, settings, threshold: limit, get last() { return last; } };++ return { runOnce, tick, due, start, stop, decide, pending, live, changes, propose, store, state, settings, threshold: limit, get last() { return last; } };}module.exports = { createPriceBook, eligible, findPrice, catalogueSources, EPSILON, DEFAULT_THRESHOLD, WEEK_MS };nnew file mode 100644@@@ -0,0 +1,155 @@++'use strict';++/**++ * Propose price changes the owner decides on, never applies.++ *++ * The weekly job proposes a change only when it reads a listing itself. A reviewed reading of a model the++ * book does not own yet (a new cheap host, a model the catalogue has not synced) needs another door: this++ * module reads a list of verified readings and writes them into price_changes as pending, with the provider's++ * own listing and the day it was read. It never writes a price into a model row; approving the change on the++ * operator console is what moves the live price (server/pricebook/job.js decide()).++ *++ * A model the registry does not know yet is created switched off, with no price, so the owner has a row to++ * approve against. It is not marked price_book-owned until a price is approved, so the weekly job leaves it++ * alone until then. Context length and quantisation are recorded in the fields the model row already has.++ */++const { FIELDS } = require('./store');++const { AiError } = require('../util');++++const EPSILON = 1e-6;++const MAX_PROPOSALS = 500;++const TYPES = ['chat', 'vision', 'embedding', 'stt', 'image', 'video', 'speech', 'rerank'];++const DATE = /^\d{4}-\d{2}-\d{2}$/;++++const numeric = (v) => {++ if (v === null || v === undefined || v === '') return null;++ const n = Number(v);++ return Number.isFinite(n) ? n : null;++};++++/** One reading, checked before anything is written: a malformed batch is refused whole, not half-written. */++function check(input, index) {++ const at = `proposals[${index}]`;++ if (!input || typeof input !== 'object' || Array.isArray(input)) throw new AiError(422, 'ai.invalid', `${at} must be an object`);++ const provider_key = String(input.provider_key || '').trim();++ const model_key = String(input.model_key || '').trim();++ if (!provider_key || provider_key.length > 80) throw new AiError(422, 'ai.invalid', `${at}.provider_key is required`);++ if (!model_key || model_key.length > 200) throw new AiError(422, 'ai.invalid', `${at}.model_key is required`);++ const source = String(input.source || '').trim();++ let url = null;++ try { url = new URL(source); } catch { /* refused below */ }++ if (!url || url.protocol !== 'https:' || !url.hostname || url.username || url.password) throw new AiError(422, 'ai.invalid', `${at}.source must be an https URL`);++ const checked_at = String(input.checked_at || '').trim();++ if (!DATE.test(checked_at)) throw new AiError(422, 'ai.invalid', `${at}.checked_at must be YYYY-MM-DD`);++ const type = input.type === undefined ? 'chat' : String(input.type);++ if (!TYPES.includes(type)) throw new AiError(422, 'ai.invalid', `${at}.type must be one of ${TYPES.join(', ')}`);++ const prices = {};++ for (const field of FIELDS) {++ if (input[field] === null || input[field] === undefined) continue;++ const n = numeric(input[field]);++ if (n === null || n < 0) throw new AiError(422, 'ai.invalid', `${at}.${field} must be a price of 0 or more`);++ prices[field] = n;++ }++ if (!Object.keys(prices).length) throw new AiError(422, 'ai.invalid', `${at} carries no price`);++ const context_window = input.context_window === null || input.context_window === undefined ? null : Number(input.context_window);++ if (context_window !== null && (!Number.isInteger(context_window) || context_window <= 0)) throw new AiError(422, 'ai.invalid', `${at}.context_window must be a positive integer`);++ const quantization = input.quantization === null || input.quantization === undefined ? null : String(input.quantization).trim().slice(0, 40) || null;++ return {++ provider_key, model_key, type, source: url.href, checked_at, prices, context_window, quantization,++ display_name: input.display_name ? String(input.display_name).slice(0, 200) : null,++ };++}++++/**++ * Write the readings as pending changes. `dryRun` says what would happen without touching the database.++ * Returns what was proposed, what already matched the live price, and the models that had to be created.++ */++async function proposePriceChanges({ registry, store, proposals, clock = { now: () => Date.now() }, actor = 'owner', dryRun = false }) {++ if (!Array.isArray(proposals) || !proposals.length) throw new AiError(422, 'ai.invalid', 'proposals must be a non-empty list');++ if (proposals.length > MAX_PROPOSALS) throw new AiError(422, 'ai.invalid', `at most ${MAX_PROPOSALS} proposals at a time`);++ const checked = proposals.map(check);++ const days = [...new Set(checked.map((p) => p.checked_at))];++ const out = { proposed: [], unchanged: [], models_created: [], checked_at: days.length === 1 ? days[0] : null, dry_run: Boolean(dryRun) };++++ for (const p of checked) {++ const provider = await registry.getProvider(p.provider_key);++ if (!provider) throw new AiError(422, 'ai.invalid', `no provider ${p.provider_key}`);++ let row = await registry.getModel(p.provider_key, p.model_key);++ if (!row) {++ if (dryRun) {++ row = { provider_key: p.provider_key, model_key: p.model_key, cost: {}, metadata: {}, context_window: null, type: p.type, status: 'disabled' };++ out.models_created.push({ provider_key: p.provider_key, model_key: p.model_key, type: p.type });++ } else {++ row = await registry.upsertModel({++ provider_key: p.provider_key,++ model_key: p.model_key,++ display_name: p.display_name || p.model_key,++ type: p.type,++ status: 'disabled',++ context_window: p.context_window,++ cost: {},++ supports: {},++ metadata: { catalog: 'pricebook-proposal', ...(p.quantization ? { quantization: p.quantization } : {}) },++ }, { actor });++ out.models_created.push({ provider_key: p.provider_key, model_key: p.model_key, type: p.type });++ }++ } else {++ // The row exists: record what the listing says if its fields are still empty. A price is never written here.++ const metadata = { ...(row.metadata || {}) };++ const missing = [];++ if (p.context_window && !row.context_window) missing.push('context_window');++ if (p.quantization && !metadata.quantization) { missing.push('quantization'); metadata.quantization = p.quantization; }++ if (missing.length && !dryRun) {++ await registry.upsertModel({++ provider_key: row.provider_key,++ model_key: row.model_key,++ display_name: row.display_name,++ type: row.type,++ status: row.status,++ context_window: row.context_window || p.context_window,++ max_output: row.max_output,++ cost: row.cost,++ supports: row.supports,++ metadata,++ }, { actor });++ }++ }++++ const changes = [];++ for (const field of FIELDS) {++ const next = p.prices[field];++ if (next === undefined) continue;++ const old = numeric(row.cost ? row.cost[field] : null);++ if (old !== null && Math.abs(old - next) <= EPSILON) continue;++ changes.push({ field, old, next, pct: old === null || old === 0 ? null : (next - old) / old });++ }++ if (!changes.length) {++ out.unchanged.push({ provider_key: p.provider_key, model_key: p.model_key, fields: Object.keys(p.prices) });++ continue;++ }++ if (!dryRun) {++ for (const change of changes) {++ await store.supersede({ provider_key: p.provider_key, model_key: p.model_key, field: change.field });++ await store.record({++ provider_key: p.provider_key,++ model_key: p.model_key,++ field: change.field,++ old_price: change.old,++ new_price: change.next,++ change_pct: change.pct,++ source: p.source,++ checked_at: p.checked_at,++ status: 'pending',++ note: 'proposed from the provider\'s own listing, waiting for the owner',++ actor,++ });++ }++ }++ out.proposed.push({++ provider_key: p.provider_key, model_key: p.model_key, source: p.source, checked_at: p.checked_at,++ fields: changes.map((c) => ({ field: c.field, old: c.old, next: c.next, pct: c.pct })),++ });++ }++ return out;++}++++module.exports = { proposePriceChanges, check, EPSILON, MAX_PROPOSALS };@@@ -10,7 +10,7 @@const assert = require('assert');const fs = require('fs');const path = require('path');--const { boot, request, suite, silent } = require('./helpers');++const { boot, request, token, suite, silent } = require('./helpers');const parsers = require('../server/pricebook/parsers');const { readListing } = require('../server/pricebook/read');const { createPriceBook } = require('../server/pricebook/job');@@@ -303,6 +303,108 @@ t.test('a run that reads no listing leaves the book due', async () => {assert.ok(!row || row.last_run_at == null, 'the book is still due');});++++t.test('a reviewed reading waits as pending and never moves the live price', async () => {++ const book = createPriceBook({ db: h.db, registry: h.registry, config: { priceBook: { enabled: true }, nodeEnv: 'test' }, clock: CLOCK, log: silent });++ const before = await h.registry.getModel('deepinfra', 'Qwen/Qwen3-14B');++ const oldIn = Number(before.cost.in_per_mtok);++ const out = await book.propose([++ { provider_key: 'deepinfra', model_key: 'Qwen/Qwen3-14B', in_per_mtok: oldIn * 2, out_per_mtok: 0.99,++ source: 'https://api.deepinfra.com/v1/models', checked_at: DAY },++ { provider_key: 'parasail', model_key: 'parasail-deepseek-v4-flash', in_per_mtok: 0.14, cached_per_mtok: 0.07,++ out_per_mtok: 0.28, context_window: 1048576, quantization: 'fp8', type: 'chat',++ source: 'https://docs.parasail.io/parasail-docs/products/overview/models', checked_at: DAY },++ ]);++ assert.strictEqual(out.proposed.length, 2);++ assert.strictEqual(out.models_created.length, 1, 'the unknown model got a row to approve against');++ // The live price did not move: only the owner's approval does that.++ const after = await h.registry.getModel('deepinfra', 'Qwen/Qwen3-14B');++ assert.strictEqual(Number(after.cost.in_per_mtok), oldIn);++ // The new model is off, has no price, and is not marked price-book-owned until a price is approved.++ const made = await h.registry.getModel('parasail', 'parasail-deepseek-v4-flash');++ assert.strictEqual(made.status, 'disabled');++ assert.strictEqual(made.context_window, 1048576);++ assert.strictEqual(made.metadata.quantization, 'fp8');++ assert.strictEqual(made.cost.in_per_mtok, null);++ assert.strictEqual(made.metadata.price_book, undefined);++ // Three fields wait for the owner, each with the provider's own listing and the day it was read.++ const waiting = await h.db.prepare("SELECT * FROM price_changes WHERE status = 'pending' AND provider_key = 'parasail'").all();++ assert.strictEqual(waiting.length, 3);++ for (const row of waiting) {++ assert.strictEqual(row.source, 'https://docs.parasail.io/parasail-docs/products/overview/models');++ assert.strictEqual(String(row.checked_at).slice(0, 10), DAY);++ }++ // A second reading replaces the first pending one instead of stacking a second.++ const again = await book.propose([{ provider_key: 'parasail', model_key: 'parasail-deepseek-v4-flash', in_per_mtok: 0.15,++ source: 'https://docs.parasail.io/parasail-docs/products/overview/models', checked_at: '2026-11-14' }]);++ assert.strictEqual(again.proposed.length, 1);++ const now = await h.db.prepare("SELECT * FROM price_changes WHERE status = 'pending' AND provider_key = 'parasail' AND field = 'in_per_mtok'").all();++ assert.strictEqual(now.length, 1);++ assert.strictEqual(Number(now[0].new_price), 0.15);++ // Approving one field writes that price and marks the row price-book-owned.++ const ok = await book.decide(now[0].id, { decision: 'approve', actor: 'usr_owner' });++ assert.strictEqual(ok.ok, true);++ const live = await h.registry.getModel('parasail', 'parasail-deepseek-v4-flash');++ assert.strictEqual(Number(live.cost.in_per_mtok), 0.15);++ assert.strictEqual(live.metadata.price_book.source, 'https://docs.parasail.io/parasail-docs/products/overview/models');++});++++t.test('a reading at the live price changes nothing', async () => {++ const book = createPriceBook({ db: h.db, registry: h.registry, config: { nodeEnv: 'test' }, clock: CLOCK, log: silent });++ const cur = await h.registry.getModel('deepinfra', 'Qwen/Qwen3-14B');++ const out = await book.propose([{++ provider_key: 'deepinfra', model_key: 'Qwen/Qwen3-14B',++ in_per_mtok: Number(cur.cost.in_per_mtok), out_per_mtok: Number(cur.cost.out_per_mtok),++ source: 'https://api.deepinfra.com/v1/models', checked_at: DAY,++ }]);++ assert.strictEqual(out.proposed.length, 0);++ assert.strictEqual(out.unchanged.length, 1);++ assert.strictEqual(out.models_created.length, 0);++});++++t.test('a dry run writes nothing', async () => {++ const book = createPriceBook({ db: h.db, registry: h.registry, config: { nodeEnv: 'test' }, clock: CLOCK, log: silent });++ const out = await book.propose([{ provider_key: 'parasail', model_key: 'parasail-dry-run-model', in_per_mtok: 1.23,++ source: 'https://docs.parasail.io/parasail-docs/products/overview/models', checked_at: DAY }], { dryRun: true });++ assert.strictEqual(out.proposed.length, 1);++ assert.strictEqual(out.models_created.length, 1);++ assert.strictEqual(await h.registry.getModel('parasail', 'parasail-dry-run-model'), null);++ const logged = await h.db.prepare("SELECT * FROM price_changes WHERE model_key = 'parasail-dry-run-model'").all();++ assert.strictEqual(logged.length, 0);++});++++t.test('the reviewed 2026-11-10 readings are a valid batch', async () => {++ const readings = require('../scripts/pricebook-proposals-2026-11-10.json');++ assert.strictEqual(readings.length, 43);++ for (const p of readings) {++ assert.match(p.source, /^https:\/\//);++ assert.strictEqual(p.checked_at, '2026-11-10');++ }++ const book = createPriceBook({ db: h.db, registry: h.registry, config: { nodeEnv: 'test' }, clock: CLOCK, log: silent });++ const out = await book.propose(readings, { dryRun: true });++ assert.strictEqual(out.proposed.length, 43);++ let known = 0;++ for (const p of readings) if (await h.registry.getModel(p.provider_key, p.model_key)) known++;++ assert.strictEqual(out.models_created.length, 43 - known, 'only models the registry does not know are created');++});++++t.test('the owner-only proposal route puts a reading in the queue', async () => {++ const owner = token('exchange', ['ai.provider.manage', 'ai.usage.read']);++ const reader = token('exchange', ['ai.usage.read']);++ const body = { proposals: [{ provider_key: 'parasail', model_key: 'parasail-route-model', in_per_mtok: 0.42,++ source: 'https://docs.parasail.io/parasail-docs/products/overview/models', checked_at: '2026-11-15' }] };++ const denied = await request(h.base, 'POST', '/api/v1/admin/console/pricebook/proposals', { tok: reader, body });++ assert.strictEqual(denied.status, 403);++ const res = await request(h.base, 'POST', '/api/v1/admin/console/pricebook/proposals', { tok: owner, body });++ assert.strictEqual(res.status, 200, res.text);++ assert.strictEqual(res.body.proposed.length, 1);++ const waiting = await h.db.prepare("SELECT * FROM price_changes WHERE status = 'pending' AND model_key = 'parasail-route-model'").all();++ assert.strictEqual(waiting.length, 1);++ assert.strictEqual(waiting[0].source, 'https://docs.parasail.io/parasail-docs/products/overview/models');++ const audit = await h.db.prepare("SELECT * FROM audit_log WHERE action = 'price_changes.proposed' ORDER BY id DESC LIMIT 1").get();++ assert.ok(audit, 'the proposal is audited');++});++t.test('stop', async () => {await h.stop();});
@@ -45,3 +45,9 @@ description = "Made-up provider key in test/account-data.test.js (L36: the accou
@@ -45,3 +45,9 @@ description = "Made-up provider key in test/account-data.test.js (L36: the accou
condition = "AND"
condition = "AND"
paths = ['''^test/account-data\.test\.js$''']
paths = ['''^test/account-data\.test\.js$''']
regexes = ['''sk-self-ada-0123456789LEAK''']
regexes = ['''sk-self-ada-0123456789LEAK''']
+
+[[allowlists]]
+description = "Model id in the 2026-11-10 price-book proposal data, flagged as a generic API key by entropy (a public model name, not a secret)"
+condition = "AND"
+paths = ['''^scripts/pricebook-proposals-2026-11-10\.json$''']
+regexes = ['''^llama-3\.3-70b-instruct$''']
@@ -715,6 +715,12 @@ smaller one the job applies at once, and the model row carries the new price wit
@@ -715,6 +715,12 @@ smaller one the job applies at once, and the model row carries the new price wit
listing that cannot be read leaves every price alone: an unreadable page is not evidence of a change. A price a
listing that cannot be read leaves every price alone: an unreadable page is not evidence of a change. A price a
person typed by hand is never touched.
person typed by hand is never touched.
+A reviewed reading can also be put in front of the owner without waiting for the weekly job:
+`POST /api/v1/admin/console/pricebook/proposals` (owner-only, `ai.provider.manage`) takes the same readings
+`scripts/propose-pricebook.js` reads from a JSON file. Both write **pending** changes only — the source listing and
+the day it was read travel with each row — and a model the registry does not know is created switched off with no
+price, so there is a row to approve against. Nothing is applied until the owner approves it.
+
The book is **due-based**, not timer-based: AI is deployed several times a day, so a timer in the process
The book is **due-based**, not timer-based: AI is deployed several times a day, so a timer in the process
would be reset every time and the week would never come round. The last finished run is a row in the database
would be reset every time and the week would never come round. The last finished run is a row in the database
(`price_book_state`, migration 0037) and the service asks once an hour whether the interval has passed since
(`price_book_state`, migration 0037) and the service asks once an hour whether the interval has passed since
new file mode 100644
new file mode 100644
@@ -0,0 +1,477 @@
@@ -0,0 +1,477 @@
+[
+ {
+ "provider_key": "atlas-cloud",
+ "model_key": "moonshotai/kimi-k2.5",
+ "type": "chat",
+ "source": "https://api.atlascloud.ai/v1/models",
+ "checked_at": "2026-11-10",
+ "in_per_mtok": 0.49,
+ "cached_per_mtok": 0.2,
+ "out_per_mtok": 2.5,
+ "context_window": 262144,
+ "quantization": "int4"
+ },
+ {
+ "provider_key": "atlas-cloud",
+ "model_key": "moonshotai/kimi-k2.6",
+ "type": "chat",
+ "source": "https://api.atlascloud.ai/v1/models",
+ "checked_at": "2026-11-10",
+ "in_per_mtok": 0.95,
+ "cached_per_mtok": 0.16,
+ "out_per_mtok": 4.0,
+ "context_window": 262144,
+ "quantization": "int4"
+ },
+ {
+ "provider_key": "atlas-cloud",
+ "model_key": "moonshotai/kimi-k3",
+ "type": "chat",
+ "source": "https://api.atlascloud.ai/v1/models",
+ "checked_at": "2026-11-10",
+ "in_per_mtok": 3.0,
+ "cached_per_mtok": 0.3,
+ "out_per_mtok": 15.0,
+ "context_window": 1048576,
+ "quantization": "int4"
+ },
+ {
+ "provider_key": "atlas-cloud",
+ "model_key": "moonshotai/kimi-k3.1-pro",
+ "type": "chat",
+ "source": "https://api.atlascloud.ai/v1/models",
+ "checked_at": "2026-11-10",
+ "in_per_mtok": 2.0,
+ "cached_per_mtok": 0.2,
+ "out_per_mtok": 10.0,
+ "context_window": 1048576,
+ "quantization": "int4"
+ },
+ {
+ "provider_key": "atlas-cloud",
+ "model_key": "qwen/qwen3.8-max",
+ "type": "chat",
+ "source": "https://api.atlascloud.ai/v1/models",
+ "checked_at": "2026-11-10",
+ "in_per_mtok": 1.4,
+ "cached_per_mtok": 0.175,
+ "out_per_mtok": 4.2,
+ "context_window": 1000000,
+ "quantization": "fp8"
+ },
+ {
+ "provider_key": "avian",
+ "model_key": "minimax/minimax-m2.5",
+ "type": "chat",
+ "source": "https://api.avian.io/v1/models",
+ "checked_at": "2026-11-10",
+ "in_per_mtok": 0.27,
+ "cached_per_mtok": 0.15,
+ "out_per_mtok": 1.08,
+ "context_window": 196608
+ },
+ {
+ "provider_key": "avian",
+ "model_key": "minimax/minimax-m3",
+ "type": "chat",
+ "source": "https://api.avian.io/v1/models",
+ "checked_at": "2026-11-10",
+ "in_per_mtok": 0.255,
+ "cached_per_mtok": 0.051,
+ "out_per_mtok": 1.02,
+ "context_window": 524288
+ },
+ {
+ "provider_key": "avian",
+ "model_key": "moonshotai/kimi-k2.5",
+ "type": "chat",
+ "source": "https://api.avian.io/v1/models",
+ "checked_at": "2026-11-10",
+ "in_per_mtok": 0.45,
+ "cached_per_mtok": 0.225,
+ "out_per_mtok": 2.2,
+ "context_window": 262144
+ },
+ {
+ "provider_key": "avian",
+ "model_key": "moonshotai/kimi-k2.6",
+ "type": "chat",
+ "source": "https://api.avian.io/v1/models",
+ "checked_at": "2026-11-10",
+ "in_per_mtok": 0.95,
+ "cached_per_mtok": 0.16,
+ "out_per_mtok": 4.0,
+ "context_window": 262144
+ },
+ {
+ "provider_key": "avian",
+ "model_key": "moonshotai/kimi-k3",
+ "type": "chat",
+ "source": "https://api.avian.io/v1/models",
+ "checked_at": "2026-11-10",
+ "in_per_mtok": 2.55,
+ "cached_per_mtok": 0.255,
+ "out_per_mtok": 12.75,
+ "context_window": 1048576
+ },
+ {
+ "provider_key": "baseten",
+ "model_key": "openai/gpt-oss-120b",
+ "type": "chat",
+ "source": "https://www.baseten.co/pricing/",
+ "checked_at": "2026-11-10",
+ "display_name": "GPT OSS 120B",
+ "in_per_mtok": 0.1,
+ "out_per_mtok": 0.5
+ },
+ {
+ "provider_key": "deepinfra",
+ "model_key": "Qwen/Qwen3-235B-A22B-Instruct-2507",
+ "type": "chat",
+ "source": "https://api.deepinfra.com/v1/models",
+ "checked_at": "2026-11-10",
+ "in_per_mtok": 0.09,
+ "out_per_mtok": 0.55,
+ "context_window": 262144
+ },
+ {
+ "provider_key": "deepinfra",
+ "model_key": "deepseek-ai/DeepSeek-V3",
+ "type": "chat",
+ "source": "https://api.deepinfra.com/v1/models",
+ "checked_at": "2026-11-10",
+ "in_per_mtok": 0.32,
+ "out_per_mtok": 0.89,
+ "context_window": 163840
+ },
+ {
+ "provider_key": "deepinfra",
+ "model_key": "meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo",
+ "type": "chat",
+ "source": "https://api.deepinfra.com/v1/models",
+ "checked_at": "2026-11-10",
+ "in_per_mtok": 0.02,
+ "out_per_mtok": 0.04,
+ "context_window": 131072
+ },
+ {
+ "provider_key": "inference-net",
+ "model_key": "deepseek-v3.2",
+ "type": "chat",
+ "source": "https://api.inference.net/v1/models",
+ "checked_at": "2026-11-10",
+ "in_per_mtok": 0.7,
+ "cached_per_mtok": 0.35,
+ "out_per_mtok": 2.0,
+ "context_window": 163840
+ },
+ {
+ "provider_key": "inference-net",
+ "model_key": "deepseek-v4-flash",
+ "type": "chat",
+ "source": "https://api.inference.net/v1/models",
+ "checked_at": "2026-11-10",
+ "in_per_mtok": 0.23,
+ "cached_per_mtok": 0.0028,
+ "out_per_mtok": 0.62,
+ "context_window": 1000000
+ },
+ {
+ "provider_key": "inference-net",
+ "model_key": "deepseek-v4-flash-0731",
+ "type": "chat",
+ "source": "https://api.inference.net/v1/models",
+ "checked_at": "2026-11-10",
+ "in_per_mtok": 0.53,
+ "cached_per_mtok": 0.017,
+ "out_per_mtok": 1.58,
+ "context_window": 1048575
+ },
+ {
+ "provider_key": "inference-net",
+ "model_key": "deepseek-v4.1-flash-1017",
+ "type": "chat",
+ "source": "https://api.inference.net/v1/models",
+ "checked_at": "2026-11-10",
+ "in_per_mtok": 0.3,
+ "cached_per_mtok": 0.007,
+ "out_per_mtok": 1.0,
+ "context_window": 1040000
+ },
+ {
+ "provider_key": "inference-net",
+ "model_key": "gemma-3-27b-it",
+ "type": "chat",
+ "source": "https://api.inference.net/v1/models",
+ "checked_at": "2026-11-10",
+ "in_per_mtok": 0.09,
+ "out_per_mtok": 0.25,
+ "context_window": 131072
+ },
+ {
+ "provider_key": "inference-net",
+ "model_key": "glm-5",
+ "type": "chat",
+ "source": "https://api.inference.net/v1/models",
+ "checked_at": "2026-11-10",
+ "in_per_mtok": 0.95,
+ "cached_per_mtok": 0.2,
+ "out_per_mtok": 2.55,
+ "context_window": 204800
+ },
+ {
+ "provider_key": "inference-net",
+ "model_key": "glm-5.2",
+ "type": "chat",
+ "source": "https://api.inference.net/v1/models",
+ "checked_at": "2026-11-10",
+ "in_per_mtok": 1.3,
+ "cached_per_mtok": 0.2,
+ "out_per_mtok": 4.4,
+ "context_window": 1048576
+ },
+ {
+ "provider_key": "inference-net",
+ "model_key": "glm-5.3",
+ "type": "chat",
+ "source": "https://api.inference.net/v1/models",
+ "checked_at": "2026-11-10",
+ "in_per_mtok": 0.9,
+ "cached_per_mtok": 0.15,
+ "out_per_mtok": 3.0,
+ "context_window": 1048576
+ },
+ {
+ "provider_key": "inference-net",
+ "model_key": "glm-5.3-flash",
+ "type": "chat",
+ "source": "https://api.inference.net/v1/models",
+ "checked_at": "2026-11-10",
+ "in_per_mtok": 0.1,
+ "cached_per_mtok": 0.03,
+ "out_per_mtok": 0.45,
+ "context_window": 1048576
+ },
+ {
+ "provider_key": "inference-net",
+ "model_key": "llama-3.3-70b-instruct",
+ "type": "chat",
+ "source": "https://api.inference.net/v1/models",
+ "checked_at": "2026-11-10",
+ "in_per_mtok": 0.13,
+ "out_per_mtok": 0.4,
+ "context_window": 131072
+ },
+ {
+ "provider_key": "inference-net",
+ "model_key": "moonshotai/kimi-k3",
+ "type": "chat",
+ "source": "https://api.inference.net/v1/models",
+ "checked_at": "2026-11-10",
+ "in_per_mtok": 3.0,
+ "cached_per_mtok": 0.3,
+ "out_per_mtok": 15.0,
+ "context_window": 1048576
+ },
+ {
+ "provider_key": "inference-net",
+ "model_key": "moonshotai/kimi-k3.1-pro",
+ "type": "chat",
+ "source": "https://api.inference.net/v1/models",
+ "checked_at": "2026-11-10",
+ "in_per_mtok": 2.0,
+ "cached_per_mtok": 0.2,
+ "out_per_mtok": 10.0,
+ "context_window": 1048576
+ },
+ {
+ "provider_key": "inference-net",
+ "model_key": "qwen3-14b",
+ "type": "chat",
+ "source": "https://api.inference.net/v1/models",
+ "checked_at": "2026-11-10",
+ "in_per_mtok": 0.2275,
+ "out_per_mtok": 0.91,
+ "context_window": 131072
+ },
+ {
+ "provider_key": "inference-net",
+ "model_key": "qwen3-30b-a3b",
+ "type": "chat",
+ "source": "https://api.inference.net/v1/models",
+ "checked_at": "2026-11-10",
+ "in_per_mtok": 0.12,
+ "out_per_mtok": 0.5,
+ "context_window": 40960
+ },
+ {
+ "provider_key": "nebius",
+ "model_key": "Qwen/Qwen3-Embedding-8B",
+ "type": "embedding",
+ "source": "https://nebius.com/services/token-factory/models/qwen-models-inference",
+ "checked_at": "2026-11-10",
+ "display_name": "Qwen3-Embedding-8B",
+ "in_per_mtok": 0.01
+ },
+ {
+ "provider_key": "novita",
+ "model_key": "meta-llama/llama-3.1-8b-instruct",
+ "type": "chat",
+ "source": "https://api.novita.ai/v3/openai/models",
+ "checked_at": "2026-11-10",
+ "in_per_mtok": 0.02,
+ "out_per_mtok": 0.05
+ },
+ {
+ "provider_key": "parasail",
+ "model_key": "parasail-deepseek-v4-flash",
+ "type": "chat",
+ "source": "https://docs.parasail.io/parasail-docs/products/overview/models",
+ "checked_at": "2026-11-10",
+ "in_per_mtok": 0.14,
+ "cached_per_mtok": 0.07,
+ "out_per_mtok": 0.28,
+ "context_window": 1048576,
+ "quantization": "fp8"
+ },
+ {
+ "provider_key": "parasail",
+ "model_key": "parasail-gemma3-27b-it",
+ "type": "chat",
+ "source": "https://docs.parasail.io/parasail-docs/products/overview/models",
+ "checked_at": "2026-11-10",
+ "in_per_mtok": 0.08,
+ "cached_per_mtok": 0.04,
+ "out_per_mtok": 0.45,
+ "context_window": 131072,
+ "quantization": "fp8"
+ },
+ {
+ "provider_key": "parasail",
+ "model_key": "parasail-glm-53",
+ "type": "chat",
+ "source": "https://docs.parasail.io/parasail-docs/products/overview/models",
+ "checked_at": "2026-11-10",
+ "in_per_mtok": 1.4,
+ "cached_per_mtok": 0.26,
+ "out_per_mtok": 4.4,
+ "context_window": 1048576,
+ "quantization": "fp8"
+ },
+ {
+ "provider_key": "parasail",
+ "model_key": "parasail-glm-53-flash",
+ "type": "chat",
+ "source": "https://docs.parasail.io/parasail-docs/products/overview/models",
+ "checked_at": "2026-11-10",
+ "in_per_mtok": 0.15,
+ "cached_per_mtok": 0.03,
+ "out_per_mtok": 0.5,
+ "context_window": 1048576,
+ "quantization": "fp4"
+ },
+ {
+ "provider_key": "parasail",
+ "model_key": "parasail-gpt-oss-120b",
+ "type": "chat",
+ "source": "https://docs.parasail.io/parasail-docs/products/overview/models",
+ "checked_at": "2026-11-10",
+ "in_per_mtok": 0.1,
+ "cached_per_mtok": 0.055,
+ "out_per_mtok": 0.75,
+ "context_window": 131072,
+ "quantization": "fp4"
+ },
+ {
+ "provider_key": "parasail",
+ "model_key": "parasail-gpt-oss-20b",
+ "type": "chat",
+ "source": "https://docs.parasail.io/parasail-docs/products/overview/models",
+ "checked_at": "2026-11-10",
+ "in_per_mtok": 0.03,
+ "cached_per_mtok": 0.02,
+ "out_per_mtok": 0.15,
+ "context_window": 131072,
+ "quantization": "fp4"
+ },
+ {
+ "provider_key": "parasail",
+ "model_key": "parasail-kimi-k26",
+ "type": "chat",
+ "source": "https://docs.parasail.io/parasail-docs/products/overview/models",
+ "checked_at": "2026-11-10",
+ "in_per_mtok": 0.75,
+ "cached_per_mtok": 0.16,
+ "out_per_mtok": 3.5,
+ "context_window": 262144,
+ "quantization": "int4"
+ },
+ {
+ "provider_key": "parasail",
+ "model_key": "parasail-kimi-k3",
+ "type": "chat",
+ "source": "https://docs.parasail.io/parasail-docs/products/overview/models",
+ "checked_at": "2026-11-10",
+ "in_per_mtok": 3.0,
+ "cached_per_mtok": 0.3,
+ "out_per_mtok": 15.0,
+ "context_window": 1048576,
+ "quantization": "fp4"
+ },
+ {
+ "provider_key": "parasail",
+ "model_key": "parasail-llama-33-70b-fp8",
+ "type": "chat",
+ "source": "https://docs.parasail.io/parasail-docs/products/overview/models",
+ "checked_at": "2026-11-10",
+ "in_per_mtok": 0.22,
+ "cached_per_mtok": 0.11,
+ "out_per_mtok": 0.5,
+ "context_window": 131072,
+ "quantization": "fp8"
+ },
+ {
+ "provider_key": "parasail",
+ "model_key": "parasail-minimax-m3",
+ "type": "chat",
+ "source": "https://docs.parasail.io/parasail-docs/products/overview/models",
+ "checked_at": "2026-11-10",
+ "in_per_mtok": 0.3,
+ "cached_per_mtok": 0.06,
+ "out_per_mtok": 1.2,
+ "context_window": 1048576,
+ "quantization": "fp8"
+ },
+ {
+ "provider_key": "parasail",
+ "model_key": "parasail-mistral-small-32-24b",
+ "type": "chat",
+ "source": "https://docs.parasail.io/parasail-docs/products/overview/models",
+ "checked_at": "2026-11-10",
+ "in_per_mtok": 0.09,
+ "cached_per_mtok": 0.05,
+ "out_per_mtok": 0.3,
+ "context_window": 131072,
+ "quantization": "bf16"
+ },
+ {
+ "provider_key": "venice",
+ "model_key": "bge-m3",
+ "type": "embedding",
+ "source": "https://docs.venice.ai/models/bge-m3",
+ "checked_at": "2026-11-10",
+ "display_name": "BGE-M3",
+ "in_per_mtok": 0.15,
+ "context_window": 8192
+ },
+ {
+ "provider_key": "venice",
+ "model_key": "qwen3-embedding-8b",
+ "type": "embedding",
+ "source": "https://docs.venice.ai/models/qwen3-embedding-8b",
+ "checked_at": "2026-11-10",
+ "display_name": "Qwen3 Embedding 8B",
+ "in_per_mtok": 0.0125,
+ "context_window": 32768
+ }
+]
new file mode 100644
new file mode 100644
@@ -0,0 +1,75 @@
@@ -0,0 +1,75 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * Put reviewed provider readings in front of the owner as pending price changes.
+ *
+ * The weekly price book reads a provider's listing itself and proposes a change only when its own reading
+ * differs from the live price. This script is the other door: it reads a JSON list of readings that a person
+ * took from the provider's own page or keyless model list, and writes each one into `price_changes` as
+ * pending. It never writes a price into a model row and never approves a change; the owner does that on the
+ * operator console (ai.provider.manage).
+ *
+ * node scripts/propose-pricebook.js [file.json] [--dry-run]
+ *
+ * The file is an array of readings:
+ *
+ * {
+ * "provider_key": "parasail",
+ * "model_key": "parasail-deepseek-v4-flash",
+ * "in_per_mtok": 0.14, "cached_per_mtok": 0.07, "out_per_mtok": 0.28,
+ * "context_window": 1048576, "quantization": "fp8", "type": "chat",
+ * "source": "https://docs.parasail.io/parasail-docs/products/overview/models",
+ * "checked_at": "2026-11-10"
+ * }
+ *
+ * Run it on the AI host with the service environment (the recipe is in the ov-prod-db-read skill), or use
+ * POST /api/v1/admin/console/pricebook/proposals with the same list. A model the registry does not know is
+ * created switched off with no price, so there is a row to approve against; it is not marked price-book-owned
+ * until the owner approves a price.
+ */
+const fs = require('fs');
+const path = require('path');
+const { load } = require('../server/config');
+const { openDb } = require('../server/db');
+const { createRegistry } = require('../server/registry');
+const { createPriceStore } = require('../server/pricebook/store');
+const { proposePriceChanges } = require('../server/pricebook/propose');
+
+const DEFAULT_FILE = path.join(__dirname, 'pricebook-proposals-2026-11-10.json');
+
+async function main() {
+ const args = process.argv.slice(2);
+ const dryRun = args.includes('--dry-run');
+ const file = args.find((a) => !a.startsWith('--')) || DEFAULT_FILE;
+ let proposals;
+ try {
+ proposals = JSON.parse(fs.readFileSync(file, 'utf8'));
+ } catch (err) {
+ throw new Error(`cannot read ${file}: ${err.message}`);
+ }
+ const config = load(process.env);
+ const db = await openDb(config);
+ try {
+ const registry = createRegistry(db, { env: process.env });
+ const store = createPriceStore({ db });
+ const out = await proposePriceChanges({ registry, store, proposals, actor: 'owner:pricebook-propose', dryRun });
+ if (!dryRun) {
+ await registry.audit('owner:pricebook-propose', 'price_changes.proposed', 'price_book', 'propose', {
+ metadata: { proposed: out.proposed.length, unchanged: out.unchanged.length, models_created: out.models_created.length, file: path.basename(file) },
+ });
+ }
+ console.log(JSON.stringify(out, null, 2));
+ console.log(`${dryRun ? 'would propose' : 'proposed'} ${out.proposed.length}; ${out.unchanged.length} already at that price; ${out.models_created.length} model row(s) ${dryRun ? 'would be created' : 'created'}`);
+ } finally {
+ await db.close();
+ }
+}
+
+if (require.main === module) {
+ main().catch((err) => {
+ console.error(`pricebook proposals: ${err.message}`);
+ process.exitCode = 1;
+ });
+}
+
+module.exports = { main, DEFAULT_FILE };
@@ -260,6 +260,17 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,
@@ -260,6 +260,17 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,
await registry.audit(actor(req), 'pricebook.run', 'pricebook', 'run', { trace: req.ov && req.ov.traceId, metadata: { sources: (report.sources || []).length, applied: (report.applied || []).length, pending: (report.pending || []).length } });
await registry.audit(actor(req), 'pricebook.run', 'pricebook', 'run', { trace: req.ov && req.ov.traceId, metadata: { sources: (report.sources || []).length, applied: (report.applied || []).length, pending: (report.pending || []).length } });
return report;
return report;
}));
}));
+ // A reviewed reading goes into the log as pending; only the owner's approve writes the live price. This is how
+ // a provider or a model the weekly job has not read yet gets in front of the owner.
+ r.post('/api/v1/admin/console/pricebook/proposals', write, json, wrap(async (req) => {
+ if (!priceBook) throw new AiError(409, 'ai.conflict', 'the price book is off on this host');
+ const body = req.body || {};
+ const proposals = Array.isArray(body) ? body : body.proposals;
+ const out = await priceBook.propose(proposals, { actor: actor(req) });
+ await registry.audit(actor(req), 'price_changes.proposed', 'price_book', 'propose', { trace: req.ov && req.ov.traceId,
+ metadata: { proposed: out.proposed.length, unchanged: out.unchanged.length, models_created: out.models_created.length } });
+ return out;
+ }));
r.post('/api/v1/admin/console/runs/:id/cancel', workflowWrite, wrap(async (req) => {
r.post('/api/v1/admin/console/runs/:id/cancel', workflowWrite, wrap(async (req) => {
const subject = actor(req);
const subject = actor(req);
const run = await runs.cancel(req.params.id, { sub: subject, subject: { type: 'user', id: subject } }, {
const run = await runs.cancel(req.params.id, { sub: subject, subject: { type: 'user', id: subject } }, {
@@ -8,7 +8,8 @@
@@ -8,7 +8,8 @@
*/
*/
const { createPriceBook } = require('./job');
const { createPriceBook } = require('./job');
const { createPriceStore } = require('./store');
const { createPriceStore } = require('./store');
+const { proposePriceChanges } = require('./propose');
const { SOURCES } = require('./sources');
const { SOURCES } = require('./sources');
const { readListing } = require('./read');
const { readListing } = require('./read');
-module.exports = { createPriceBook, createPriceStore, SOURCES, readListing };
+module.exports = { createPriceBook, createPriceStore, proposePriceChanges, SOURCES, readListing };
@@ -27,6 +27,7 @@ const { SOURCES } = require('./sources');
@@ -27,6 +27,7 @@ const { SOURCES } = require('./sources');
const { readListing } = require('./read');
const { readListing } = require('./read');
const { createPriceStore, FIELDS, FIELD_NAMES } = require('./store');
const { createPriceStore, FIELDS, FIELD_NAMES } = require('./store');
const { createPriceState } = require('./state');
const { createPriceState } = require('./state');
+const { proposePriceChanges } = require('./propose');
/** A price that differs by less than this is the same price: the providers publish floats (0.205999992). */
/** A price that differs by less than this is the same price: the providers publish floats (0.205999992). */
const EPSILON = 1e-6;
const EPSILON = 1e-6;
@@ -398,6 +399,13 @@ function createPriceBook({ db, registry, config = {}, clock = { now: () => Date.
@@ -398,6 +399,13 @@ function createPriceBook({ db, registry, config = {}, clock = { now: () => Date.
const live = (limit) => store.live(limit);
const live = (limit) => store.live(limit);
const changes = (opts) => store.list(opts);
const changes = (opts) => store.list(opts);
+ /**
+ * Put a reviewed reading into the log as a pending change. The owner's approval is still what moves the
+ * live price; nothing here writes a price into a model row.
+ */
+ const propose = (proposals, { actor = 'owner', dryRun = false } = {}) =>
+ proposePriceChanges({ registry, store, proposals, clock, actor, dryRun });
+
/**
/**
* The owner's decision on a waiting change. Approving it writes the price into the model row; rejecting it
* The owner's decision on a waiting change. Approving it writes the price into the model row; rejecting it
* leaves the live price alone and the log keeps the story.
* leaves the live price alone and the log keeps the story.
@@ -458,7 +466,7 @@ function createPriceBook({ db, registry, config = {}, clock = { now: () => Date.
@@ -458,7 +466,7 @@ function createPriceBook({ db, registry, config = {}, clock = { now: () => Date.
if (first) { clearTimeout(first); first = null; }
if (first) { clearTimeout(first); first = null; }
}
}
- return { runOnce, tick, due, start, stop, decide, pending, live, changes, store, state, settings, threshold: limit, get last() { return last; } };
+ return { runOnce, tick, due, start, stop, decide, pending, live, changes, propose, store, state, settings, threshold: limit, get last() { return last; } };
}
}
module.exports = { createPriceBook, eligible, findPrice, catalogueSources, EPSILON, DEFAULT_THRESHOLD, WEEK_MS };
module.exports = { createPriceBook, eligible, findPrice, catalogueSources, EPSILON, DEFAULT_THRESHOLD, WEEK_MS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,155 @@
@@ -0,0 +1,155 @@
+'use strict';
+/**
+ * Propose price changes the owner decides on, never applies.
+ *
+ * The weekly job proposes a change only when it reads a listing itself. A reviewed reading of a model the
+ * book does not own yet (a new cheap host, a model the catalogue has not synced) needs another door: this
+ * module reads a list of verified readings and writes them into price_changes as pending, with the provider's
+ * own listing and the day it was read. It never writes a price into a model row; approving the change on the
+ * operator console is what moves the live price (server/pricebook/job.js decide()).
+ *
+ * A model the registry does not know yet is created switched off, with no price, so the owner has a row to
+ * approve against. It is not marked price_book-owned until a price is approved, so the weekly job leaves it
+ * alone until then. Context length and quantisation are recorded in the fields the model row already has.
+ */
+const { FIELDS } = require('./store');
+const { AiError } = require('../util');
+
+const EPSILON = 1e-6;
+const MAX_PROPOSALS = 500;
+const TYPES = ['chat', 'vision', 'embedding', 'stt', 'image', 'video', 'speech', 'rerank'];
+const DATE = /^\d{4}-\d{2}-\d{2}$/;
+
+const numeric = (v) => {
+ if (v === null || v === undefined || v === '') return null;
+ const n = Number(v);
+ return Number.isFinite(n) ? n : null;
+};
+
+/** One reading, checked before anything is written: a malformed batch is refused whole, not half-written. */
+function check(input, index) {
+ const at = `proposals[${index}]`;
+ if (!input || typeof input !== 'object' || Array.isArray(input)) throw new AiError(422, 'ai.invalid', `${at} must be an object`);
+ const provider_key = String(input.provider_key || '').trim();
+ const model_key = String(input.model_key || '').trim();
+ if (!provider_key || provider_key.length > 80) throw new AiError(422, 'ai.invalid', `${at}.provider_key is required`);
+ if (!model_key || model_key.length > 200) throw new AiError(422, 'ai.invalid', `${at}.model_key is required`);
+ const source = String(input.source || '').trim();
+ let url = null;
+ try { url = new URL(source); } catch { /* refused below */ }
+ if (!url || url.protocol !== 'https:' || !url.hostname || url.username || url.password) throw new AiError(422, 'ai.invalid', `${at}.source must be an https URL`);
+ const checked_at = String(input.checked_at || '').trim();
+ if (!DATE.test(checked_at)) throw new AiError(422, 'ai.invalid', `${at}.checked_at must be YYYY-MM-DD`);
+ const type = input.type === undefined ? 'chat' : String(input.type);
+ if (!TYPES.includes(type)) throw new AiError(422, 'ai.invalid', `${at}.type must be one of ${TYPES.join(', ')}`);
+ const prices = {};
+ for (const field of FIELDS) {
+ if (input[field] === null || input[field] === undefined) continue;
+ const n = numeric(input[field]);
+ if (n === null || n < 0) throw new AiError(422, 'ai.invalid', `${at}.${field} must be a price of 0 or more`);
+ prices[field] = n;
+ }
+ if (!Object.keys(prices).length) throw new AiError(422, 'ai.invalid', `${at} carries no price`);
+ const context_window = input.context_window === null || input.context_window === undefined ? null : Number(input.context_window);
+ if (context_window !== null && (!Number.isInteger(context_window) || context_window <= 0)) throw new AiError(422, 'ai.invalid', `${at}.context_window must be a positive integer`);
+ const quantization = input.quantization === null || input.quantization === undefined ? null : String(input.quantization).trim().slice(0, 40) || null;
+ return {
+ provider_key, model_key, type, source: url.href, checked_at, prices, context_window, quantization,
+ display_name: input.display_name ? String(input.display_name).slice(0, 200) : null,
+ };
+}
+
+/**
+ * Write the readings as pending changes. `dryRun` says what would happen without touching the database.
+ * Returns what was proposed, what already matched the live price, and the models that had to be created.
+ */
+async function proposePriceChanges({ registry, store, proposals, clock = { now: () => Date.now() }, actor = 'owner', dryRun = false }) {
+ if (!Array.isArray(proposals) || !proposals.length) throw new AiError(422, 'ai.invalid', 'proposals must be a non-empty list');
+ if (proposals.length > MAX_PROPOSALS) throw new AiError(422, 'ai.invalid', `at most ${MAX_PROPOSALS} proposals at a time`);
+ const checked = proposals.map(check);
+ const days = [...new Set(checked.map((p) => p.checked_at))];
+ const out = { proposed: [], unchanged: [], models_created: [], checked_at: days.length === 1 ? days[0] : null, dry_run: Boolean(dryRun) };
+
+ for (const p of checked) {
+ const provider = await registry.getProvider(p.provider_key);
+ if (!provider) throw new AiError(422, 'ai.invalid', `no provider ${p.provider_key}`);
+ let row = await registry.getModel(p.provider_key, p.model_key);
+ if (!row) {
+ if (dryRun) {
+ row = { provider_key: p.provider_key, model_key: p.model_key, cost: {}, metadata: {}, context_window: null, type: p.type, status: 'disabled' };
+ out.models_created.push({ provider_key: p.provider_key, model_key: p.model_key, type: p.type });
+ } else {
+ row = await registry.upsertModel({
+ provider_key: p.provider_key,
+ model_key: p.model_key,
+ display_name: p.display_name || p.model_key,
+ type: p.type,
+ status: 'disabled',
+ context_window: p.context_window,
+ cost: {},
+ supports: {},
+ metadata: { catalog: 'pricebook-proposal', ...(p.quantization ? { quantization: p.quantization } : {}) },
+ }, { actor });
+ out.models_created.push({ provider_key: p.provider_key, model_key: p.model_key, type: p.type });
+ }
+ } else {
+ // The row exists: record what the listing says if its fields are still empty. A price is never written here.
+ const metadata = { ...(row.metadata || {}) };
+ const missing = [];
+ if (p.context_window && !row.context_window) missing.push('context_window');
+ if (p.quantization && !metadata.quantization) { missing.push('quantization'); metadata.quantization = p.quantization; }
+ if (missing.length && !dryRun) {
+ await registry.upsertModel({
+ provider_key: row.provider_key,
+ model_key: row.model_key,
+ display_name: row.display_name,
+ type: row.type,
+ status: row.status,
+ context_window: row.context_window || p.context_window,
+ max_output: row.max_output,
+ cost: row.cost,
+ supports: row.supports,
+ metadata,
+ }, { actor });
+ }
+ }
+
+ const changes = [];
+ for (const field of FIELDS) {
+ const next = p.prices[field];
+ if (next === undefined) continue;
+ const old = numeric(row.cost ? row.cost[field] : null);
+ if (old !== null && Math.abs(old - next) <= EPSILON) continue;
+ changes.push({ field, old, next, pct: old === null || old === 0 ? null : (next - old) / old });
+ }
+ if (!changes.length) {
+ out.unchanged.push({ provider_key: p.provider_key, model_key: p.model_key, fields: Object.keys(p.prices) });
+ continue;
+ }
+ if (!dryRun) {
+ for (const change of changes) {
+ await store.supersede({ provider_key: p.provider_key, model_key: p.model_key, field: change.field });
+ await store.record({
+ provider_key: p.provider_key,
+ model_key: p.model_key,
+ field: change.field,
+ old_price: change.old,
+ new_price: change.next,
+ change_pct: change.pct,
+ source: p.source,
+ checked_at: p.checked_at,
+ status: 'pending',
+ note: 'proposed from the provider\'s own listing, waiting for the owner',
+ actor,
+ });
+ }
+ }
+ out.proposed.push({
+ provider_key: p.provider_key, model_key: p.model_key, source: p.source, checked_at: p.checked_at,
+ fields: changes.map((c) => ({ field: c.field, old: c.old, next: c.next, pct: c.pct })),
+ });
+ }
+ return out;
+}
+
+module.exports = { proposePriceChanges, check, EPSILON, MAX_PROPOSALS };
@@ -10,7 +10,7 @@
@@ -10,7 +10,7 @@
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
-const { boot, request, suite, silent } = require('./helpers');
+const { boot, request, token, suite, silent } = require('./helpers');
const parsers = require('../server/pricebook/parsers');
const parsers = require('../server/pricebook/parsers');
const { readListing } = require('../server/pricebook/read');
const { readListing } = require('../server/pricebook/read');
const { createPriceBook } = require('../server/pricebook/job');
const { createPriceBook } = require('../server/pricebook/job');
@@ -303,6 +303,108 @@ t.test('a run that reads no listing leaves the book due', async () => {
@@ -303,6 +303,108 @@ t.test('a run that reads no listing leaves the book due', async () => {
assert.ok(!row || row.last_run_at == null, 'the book is still due');
assert.ok(!row || row.last_run_at == null, 'the book is still due');
});
});
+
+t.test('a reviewed reading waits as pending and never moves the live price', async () => {
+ const book = createPriceBook({ db: h.db, registry: h.registry, config: { priceBook: { enabled: true }, nodeEnv: 'test' }, clock: CLOCK, log: silent });
+ const before = await h.registry.getModel('deepinfra', 'Qwen/Qwen3-14B');
+ const oldIn = Number(before.cost.in_per_mtok);
+ const out = await book.propose([
+ { provider_key: 'deepinfra', model_key: 'Qwen/Qwen3-14B', in_per_mtok: oldIn * 2, out_per_mtok: 0.99,
+ source: 'https://api.deepinfra.com/v1/models', checked_at: DAY },
+ { provider_key: 'parasail', model_key: 'parasail-deepseek-v4-flash', in_per_mtok: 0.14, cached_per_mtok: 0.07,
+ out_per_mtok: 0.28, context_window: 1048576, quantization: 'fp8', type: 'chat',
+ source: 'https://docs.parasail.io/parasail-docs/products/overview/models', checked_at: DAY },
+ ]);
+ assert.strictEqual(out.proposed.length, 2);
+ assert.strictEqual(out.models_created.length, 1, 'the unknown model got a row to approve against');
+ // The live price did not move: only the owner's approval does that.
+ const after = await h.registry.getModel('deepinfra', 'Qwen/Qwen3-14B');
+ assert.strictEqual(Number(after.cost.in_per_mtok), oldIn);
+ // The new model is off, has no price, and is not marked price-book-owned until a price is approved.
+ const made = await h.registry.getModel('parasail', 'parasail-deepseek-v4-flash');
+ assert.strictEqual(made.status, 'disabled');
+ assert.strictEqual(made.context_window, 1048576);
+ assert.strictEqual(made.metadata.quantization, 'fp8');
+ assert.strictEqual(made.cost.in_per_mtok, null);
+ assert.strictEqual(made.metadata.price_book, undefined);
+ // Three fields wait for the owner, each with the provider's own listing and the day it was read.
+ const waiting = await h.db.prepare("SELECT * FROM price_changes WHERE status = 'pending' AND provider_key = 'parasail'").all();
+ assert.strictEqual(waiting.length, 3);
+ for (const row of waiting) {
+ assert.strictEqual(row.source, 'https://docs.parasail.io/parasail-docs/products/overview/models');
+ assert.strictEqual(String(row.checked_at).slice(0, 10), DAY);
+ }
+ // A second reading replaces the first pending one instead of stacking a second.
+ const again = await book.propose([{ provider_key: 'parasail', model_key: 'parasail-deepseek-v4-flash', in_per_mtok: 0.15,
+ source: 'https://docs.parasail.io/parasail-docs/products/overview/models', checked_at: '2026-11-14' }]);
+ assert.strictEqual(again.proposed.length, 1);
+ const now = await h.db.prepare("SELECT * FROM price_changes WHERE status = 'pending' AND provider_key = 'parasail' AND field = 'in_per_mtok'").all();
+ assert.strictEqual(now.length, 1);
+ assert.strictEqual(Number(now[0].new_price), 0.15);
+ // Approving one field writes that price and marks the row price-book-owned.
+ const ok = await book.decide(now[0].id, { decision: 'approve', actor: 'usr_owner' });
+ assert.strictEqual(ok.ok, true);
+ const live = await h.registry.getModel('parasail', 'parasail-deepseek-v4-flash');
+ assert.strictEqual(Number(live.cost.in_per_mtok), 0.15);
+ assert.strictEqual(live.metadata.price_book.source, 'https://docs.parasail.io/parasail-docs/products/overview/models');
+});
+
+t.test('a reading at the live price changes nothing', async () => {
+ const book = createPriceBook({ db: h.db, registry: h.registry, config: { nodeEnv: 'test' }, clock: CLOCK, log: silent });
+ const cur = await h.registry.getModel('deepinfra', 'Qwen/Qwen3-14B');
+ const out = await book.propose([{
+ provider_key: 'deepinfra', model_key: 'Qwen/Qwen3-14B',
+ in_per_mtok: Number(cur.cost.in_per_mtok), out_per_mtok: Number(cur.cost.out_per_mtok),
+ source: 'https://api.deepinfra.com/v1/models', checked_at: DAY,
+ }]);
+ assert.strictEqual(out.proposed.length, 0);
+ assert.strictEqual(out.unchanged.length, 1);
+ assert.strictEqual(out.models_created.length, 0);
+});
+
+t.test('a dry run writes nothing', async () => {
+ const book = createPriceBook({ db: h.db, registry: h.registry, config: { nodeEnv: 'test' }, clock: CLOCK, log: silent });
+ const out = await book.propose([{ provider_key: 'parasail', model_key: 'parasail-dry-run-model', in_per_mtok: 1.23,
+ source: 'https://docs.parasail.io/parasail-docs/products/overview/models', checked_at: DAY }], { dryRun: true });
+ assert.strictEqual(out.proposed.length, 1);
+ assert.strictEqual(out.models_created.length, 1);
+ assert.strictEqual(await h.registry.getModel('parasail', 'parasail-dry-run-model'), null);
+ const logged = await h.db.prepare("SELECT * FROM price_changes WHERE model_key = 'parasail-dry-run-model'").all();
+ assert.strictEqual(logged.length, 0);
+});
+
+t.test('the reviewed 2026-11-10 readings are a valid batch', async () => {
+ const readings = require('../scripts/pricebook-proposals-2026-11-10.json');
+ assert.strictEqual(readings.length, 43);
+ for (const p of readings) {
+ assert.match(p.source, /^https:\/\//);
+ assert.strictEqual(p.checked_at, '2026-11-10');
+ }
+ const book = createPriceBook({ db: h.db, registry: h.registry, config: { nodeEnv: 'test' }, clock: CLOCK, log: silent });
+ const out = await book.propose(readings, { dryRun: true });
+ assert.strictEqual(out.proposed.length, 43);
+ let known = 0;
+ for (const p of readings) if (await h.registry.getModel(p.provider_key, p.model_key)) known++;
+ assert.strictEqual(out.models_created.length, 43 - known, 'only models the registry does not know are created');
+});
+
+t.test('the owner-only proposal route puts a reading in the queue', async () => {
+ const owner = token('exchange', ['ai.provider.manage', 'ai.usage.read']);
+ const reader = token('exchange', ['ai.usage.read']);
+ const body = { proposals: [{ provider_key: 'parasail', model_key: 'parasail-route-model', in_per_mtok: 0.42,
+ source: 'https://docs.parasail.io/parasail-docs/products/overview/models', checked_at: '2026-11-15' }] };
+ const denied = await request(h.base, 'POST', '/api/v1/admin/console/pricebook/proposals', { tok: reader, body });
+ assert.strictEqual(denied.status, 403);
+ const res = await request(h.base, 'POST', '/api/v1/admin/console/pricebook/proposals', { tok: owner, body });
+ assert.strictEqual(res.status, 200, res.text);
+ assert.strictEqual(res.body.proposed.length, 1);
+ const waiting = await h.db.prepare("SELECT * FROM price_changes WHERE status = 'pending' AND model_key = 'parasail-route-model'").all();
+ assert.strictEqual(waiting.length, 1);
+ assert.strictEqual(waiting[0].source, 'https://docs.parasail.io/parasail-docs/products/overview/models');
+ const audit = await h.db.prepare("SELECT * FROM audit_log WHERE action = 'price_changes.proposed' ORDER BY id DESC LIMIT 1").get();
+ assert.ok(audit, 'the proposal is audited');
+});
+
t.test('stop', async () => {
t.test('stop', async () => {
await h.stop();
await h.stop();
});
});