openvibers/openvibe.ai Commits

Propose verified provider prices as pending price-book changes (#447)

OP OpenVibers committed 3 days ago · 9ff4585766 · 9 files, +844 −3

Adds an owner-only proposal path (route and script) that writes reviewed provider readings as pending price_changes without moving a live price, plus the 43 readings verified on 2026-11-10 and tests.

Changes

ddiff --git a/.gitleaks.toml b/.gitleaks.toml
iindex 603344e..5be849f 100644
---- a/.gitleaks.toml
++++ b/.gitleaks.toml
@@@ -45,3 +45,9 @@ description = "Made-up provider key in test/account-data.test.js (L36: the accou
condition = "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$''']
ddiff --git a/README.md b/README.md
iindex 0fadfd6..86e0354 100644
---- a/README.md
++++ b/README.md
@@@ -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
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
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
ddiff --git a/scripts/pricebook-proposals-2026-11-10.json b/scripts/pricebook-proposals-2026-11-10.json
nnew file mode 100644
iindex 0000000..3f1ee15
---- /dev/null
++++ b/scripts/pricebook-proposals-2026-11-10.json
@@@ -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
++ }
++]
ddiff --git a/scripts/propose-pricebook.js b/scripts/propose-pricebook.js
nnew file mode 100644
iindex 0000000..01091c2
---- /dev/null
++++ b/scripts/propose-pricebook.js
@@@ -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 };
ddiff --git a/server/api/console-data.js b/server/api/console-data.js
iindex 8ec6dfe..def8345 100644
---- a/server/api/console-data.js
++++ b/server/api/console-data.js
@@@ -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 } }, {
ddiff --git a/server/pricebook/index.js b/server/pricebook/index.js
iindex 580ee0f..73ea84d 100644
---- a/server/pricebook/index.js
++++ b/server/pricebook/index.js
@@@ -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 };
ddiff --git a/server/pricebook/job.js b/server/pricebook/job.js
iindex c7e127e..f6f81e2 100644
---- a/server/pricebook/job.js
++++ b/server/pricebook/job.js
@@@ -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 };
ddiff --git a/server/pricebook/propose.js b/server/pricebook/propose.js
nnew file mode 100644
iindex 0000000..6748bf5
---- /dev/null
++++ b/server/pricebook/propose.js
@@@ -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 };
ddiff --git a/test/price-book.test.js b/test/price-book.test.js
iindex 76d97c7..4a67ca0 100644
---- a/test/price-book.test.js
++++ b/test/price-book.test.js
@@@ -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();
});