openvibers/openvibe.ai Commits

Provider billing profiles (metered, subscription, payg, free, byok) and a shared pool key: prepaid providers place without rate cards (#21)

OP OpenVibers committed 5 Oct 2026 · 9351cee4f3 · 6 files, +237 −13

* Provider billing profiles (metered, subscription, payg, free, byok) and a shared pool key: prepaid providers place without rate cards

**Changes (file:line)**
- `migrations/0008_provider_billing.sql:1` — new migration (not 0007: PR #20's `0007_usage_sample_set.sql` already holds that number); adds `providers.billing_profile text COLLATE "C" NOT NULL DEFAULT 'metered'` and `providers.pool_key text COLLATE "C"`.
- `server/providers/index.js:143-148` — added `PREPAID_PROFILES`/`billingProfile`/`isPrepaidProvider`, and `isFreeProvider` now reads `metadata.billing_profile` so subscription/free/byok are prepaid (rateCardsFor:105 returns null via it).
- `server/providers/index.js:183` — offer `creds` treats prepaid providers as credentialed, so they stay placement candidates (pricing `{model:'prepaid'}` at :190, no cards at :188).
- `server/providers/index.js:303` — `skipReason` no longer returns `no_credentials` for prepaid providers.
- `server/workflows/seed.js:44,50,52,56,59` — seeded records write `metadata.billing_profile` (shared/http-seam `metered`; stub/whisper/local `free`).
- `server/registry.js:50,71,91-92,97-101` — decode/validate/upsert the two columns; the column is mirrored from `metadata.billing_profile` so seeded providers and the API agree.
- `server/api/admin.js:39` — `/api/v1/providers` view surfaces `billing_profile`/`pool_key` (names only; secret values still never returned).
- `test/provider-billing.test.js:1` — new stubbed-db/registry test: subscription offer is `{model:'prepaid'}` and absent from `rateCards` while the metered provider keeps its three cards; a byok provider with no authority credential answers instead of being skipped.

**Where the brief differed:** migration is `0008` (brief said `0007`); the pool's `isFreeProvider` is at `:147` (brief `:140`), offer builder at `:183-192` (brief `:181-184`), `rateCardsFor` at `:105` (brief `:103`), `skipReason` at `:300-304` (brief `:293-296`), and `/api/v1/providers` list is `admin.js:45`. The brief omitted `server/registry.js`, which had to decode/persist the new columns for the API to expose them.

**Tests:** `ov test test/provider-router.test.js test/provider-billing.test.js` → 2 passed in 9 s (billing 0.7 s, router 8.4 s). Nothing committed or pushed; no prices authored or rate-card amounts edited.

* A keyless byok provider is skipped, the column is the billing profile's authority, pool_key validated, prepaid attempts still metered (review)

Fixed all four findings; no commit or push.

**Finding 1 — keyless byok treated as credentialed** — Split "prepaid pricing" from "no authority credential": new `credentialNotRequired()` exempts `free` and a pooled `subscription` (no `secret_ref`), but not `byok`, which still needs a resolvable `secret_ref`. Now used by both the offer's `creds` and `skipReason`, so a keyless byok provider is `down` in placement and skipped `no_credentials` at call time — `server/providers/index.js:153`, applied at `:195` and `:315`.

**Finding 2 — router ignored the column** — `billingProfile()` now reads the `providers.billing_profile` column, never metadata (`server/providers/index.js:146`). `registry.upsertProvider` seeds the column from `metadata.billing_profile` only on create; an existing column wins, so a later metadata edit cannot re-route (`server/registry.js:99`). Unknown values are refused in both the column and metadata (`server/registry.js:71,75`).

**Finding 3 — status disagreed with routing** — Resolved by finding 1: a keyless byok record is now skipped by the router, so `publicProvider`'s `credentials: 'missing'` agrees (`server/registry.js:58`); asserted by the new registry-view test.

**Finding 4 — `pool_key` unvalidated** — `checkProvider` now requires a non-null `pool_key` to be a string matching `KEY_RE` (rejecting a pasted secret), else 422 (`server/registry.js:77-78`).

**Review-suggested test added** — a prepaid (`free`) provider's attempt is asserted to still queue one reading per attempt per kind (`in`/`cached`/`out`) with `cost_estimate: 0` and quantities kept — `test/provider-billing.test.js:151`.

Tests: `ov test test/provider-billing.test.js test/provider-router.test.js test/usage-sample.test.js test/quota.test.js` → **4 passed in 24 s** (provider-billing now 9/9).

Changes

ddiff --git a/migrations/0008_provider_billing.sql b/migrations/0008_provider_billing.sql
nnew file mode 100644
iindex 0000000..4c2d0bd
---- /dev/null
++++ b/migrations/0008_provider_billing.sql
@@@ -0,0 +1,10 @@
++-- phase: expand
++-- B25: a provider's billing profile and the shared subscription pool it draws on. 'metered' (the default)
++-- and 'payg' carry per-token rate cards; 'subscription', 'free' and 'byok' are prepaid — the router offers
++-- them with no rate card. Free servers and pooled subscriptions need no authority credential; a byok pool
++-- record still needs a resolvable secret_ref and is skipped no_credentials without one (server/providers/index.js).
++-- pool_key names the shared subscription pool a prepaid provider's capacity belongs to; it is a name, never a
++-- secret value (secrets stay in secret_ref, which is itself only an env:NAME reference). Additive only: two new
++-- columns, every existing row keeps the metered default.
++ALTER TABLE providers ADD COLUMN billing_profile text COLLATE "C" NOT NULL DEFAULT 'metered'; -- metered | subscription | payg | free | byok
++ALTER TABLE providers ADD COLUMN pool_key text COLLATE "C"; -- shared subscription pool name (subscription), or NULL
ddiff --git a/server/api/admin.js b/server/api/admin.js
iindex a82203f..41c03e9 100644
---- a/server/api/admin.js
++++ b/server/api/admin.js
@@@ -34,7 +34,9 @@ function adminRouter({ db, registry, pool, quotas, cache, runs, auth, ops = crea
const usageRead = auth.requireCap(CAPS.usageRead);
const wrap = (fn) => async (req, res) => { try { const out = await fn(req, res); if (out !== undefined) res.json(out); } catch (err) { sendError(res, err, req.ov, log); } };
const actor = (req) => ({ actor: req.principal.sub, trace: req.ov.traceId });
-- const view = ops.providerView;
++ // Operators see the billing profile and shared pool name (migrations/0008) alongside the secret reference:
++ // names only, never a value.
++ const view = async (p) => { const v = await ops.providerView(p); return v && { ...v, billing_profile: p.billing_profile, pool_key: p.pool_key }; };
const body = (req) => (req.body && typeof req.body === 'object' ? req.body : {});
r.get('/api/v1/status', usageRead, wrap(async () => await ops.status()));
ddiff --git a/server/providers/index.js b/server/providers/index.js
iindex 3d4adfd..a63f7e4 100644
---- a/server/providers/index.js
++++ b/server/providers/index.js
@@@ -136,9 +136,28 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
function isLocalProvider(p) {
return p.kind === 'whisper' || Boolean(p.metadata && p.metadata.local);
}
-- /** A provider that is never billed: the deterministic stub and free or local servers. */
++ /**
++ * Billing profiles whose capacity is already paid for: a subscription, a free server or a person's own key.
++ * They carry no per-token rate card and are offered as prepaid. The profile is the providers.billing_profile
++ * column (migrations/0008): registry.upsertProvider seeds it from metadata on create, the column is the
++ * authority afterwards, and the router never reads metadata.
++ */
++ const PREPAID_PROFILES = ['subscription', 'free', 'byok'];
++ function billingProfile(p) { return (p && p.billing_profile) || 'metered'; }
++ function isPrepaidProvider(p) { return PREPAID_PROFILES.includes(billingProfile(p)); }
++ /**
++ * Prepaid capacity that runs with no authority credential in secret_ref: a free server, or a pooled
++ * subscription whose credential is held outside the provider record. A byok key is the caller's own
++ * (executeWithCredential), so a pool record for it still needs a resolvable secret_ref or is skipped.
++ */
++ function credentialNotRequired(p) {
++ const profile = billingProfile(p);
++ if (profile === 'free') return true;
++ return profile === 'subscription' && (p.secret_ref == null || p.secret_ref === '');
++ }
++ /** A provider that is never metered: the deterministic stub, free or local servers, and every prepaid profile. */
function isFreeProvider(p) {
-- return p.kind === 'stub' || isLocalProvider(p) || Boolean(p.metadata && p.metadata.paid === false);
++ return p.kind === 'stub' || isLocalProvider(p) || Boolean(p.metadata && p.metadata.paid === false) || isPrepaidProvider(p);
}
const offerId = (provider, model) => (model ? `${provider}:${model}` : provider);
const splitOfferId = (id) => {
@@@ -173,7 +192,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
const id = offerId(p.key, model);
if (seen.has(id)) return;
seen.add(id);
-- const creds = p.auth_mode === 'none' || Boolean(resolveSecret(p.secret_ref, env));
++ const creds = p.auth_mode === 'none' || credentialNotRequired(p) || Boolean(resolveSecret(p.secret_ref, env));
const h = await health(p.key);
const st = stats.get(p.key) || { p95: null, requests: 0, errors: 0 };
const errorRate = st.requests ? st.errors / st.requests : 0;
@@@ -293,7 +312,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
async function skipReason(p, a, features) {
if (!p || !a) return 'unknown_provider';
if (p.status !== 'active') return 'disabled';
-- if (p.auth_mode !== 'none' && !resolveSecret(p.secret_ref, env)) return 'no_credentials';
++ if (p.auth_mode !== 'none' && !credentialNotRequired(p) && !resolveSecret(p.secret_ref, env)) return 'no_credentials';
if (features.some(f => !a.supports(f))) return 'unsupported';
if ((await health(p.key)).state === 'open') return 'circuit_open';
return null;
ddiff --git a/server/registry.js b/server/registry.js
iindex 85cec41..407c7d7 100644
---- a/server/registry.js
++++ b/server/registry.js
@@@ -14,6 +14,7 @@ const schemas = require('./schemas');
const { provenance } = require('./providers/rate-cards');
const PROVIDER_KINDS = ['stub', 'openai', 'responses', 'anthropic', 'http', 'whisper'];
++const BILLING_PROFILES = ['metered', 'subscription', 'payg', 'free', 'byok'];
const FEATURES = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'embed', 'vision', 'json', 'transcribe'];
const LIFECYCLE = ['draft', 'active', 'deprecated', 'archived'];
const KEY_RE = /^[a-z][a-z0-9_-]*(\.[a-z0-9_-]+)*$/;
@@@ -44,6 +45,9 @@ function createRegistry(db, { clock = { now: () => Date.now() }, env = process.e
key: r.key, display_name: r.display_name, kind: r.kind, status: r.status, base_url: r.base_url,
auth_mode: r.auth_mode, secret_ref: r.secret_ref, default_model: r.default_model,
capabilities: parseJson(r.capabilities, []), timeout_ms: r.timeout_ms, priority: r.priority,
++ // The billing profile and shared pool (migrations/0008): exposed on every provider response; pool_key
++ // is a name, never a secret value.
++ billing_profile: r.billing_profile || 'metered', pool_key: r.pool_key == null ? null : r.pool_key,
metadata: parseJson(r.metadata, {}), origin: r.origin, created_at: r.created_at, updated_at: r.updated_at,
};
}
@@@ -64,6 +68,14 @@ function createRegistry(db, { clock = { now: () => Date.now() }, env = process.e
if (!['none', 'bearer', 'x-api-key'].includes(p.auth_mode)) throw new AiError(422, 'ai.invalid', 'auth_mode must be none, bearer or x-api-key');
if (!secretRefValid(p.secret_ref)) throw new AiError(422, 'ai.invalid', "secret_ref must be 'env:NAME' (a reference, never a secret value)");
if (p.base_url && !/^https?:\/\/[^\s]+$/i.test(p.base_url)) throw new AiError(422, 'ai.invalid', 'base_url must be an http(s) URL');
++ if (!BILLING_PROFILES.includes(p.billing_profile)) throw new AiError(422, 'ai.invalid', `billing_profile must be one of ${BILLING_PROFILES.join(', ')}`);
++ // metadata.billing_profile only seeds the column (upsertProvider), but it is refused here too so an
++ // unknown value can never be stored as an inert, silently-ignored profile.
++ if (p.metadata && p.metadata.billing_profile !== undefined && !BILLING_PROFILES.includes(p.metadata.billing_profile))
++ throw new AiError(422, 'ai.invalid', `metadata.billing_profile must be one of ${BILLING_PROFILES.join(', ')}`);
++ // The shared subscription pool is a name, never a secret: the same shape as a provider key.
++ if (p.pool_key != null && (typeof p.pool_key !== 'string' || !KEY_RE.test(p.pool_key)))
++ throw new AiError(422, 'ai.invalid', 'pool_key must be a lowercase dotted/dashed name');
if (!Array.isArray(p.capabilities) || p.capabilities.some(f => !FEATURES.includes(f))) throw new AiError(422, 'ai.invalid', `capabilities must be a subset of ${FEATURES.join(', ')}`);
}
@@@ -81,15 +93,21 @@ function createRegistry(db, { clock = { now: () => Date.now() }, env = process.e
capabilities: input.capabilities || (prev && prev.capabilities) || [],
timeout_ms: Number.isFinite(Number(input.timeout_ms)) ? Math.max(1000, Math.min(600000, Number(input.timeout_ms))) : (prev ? prev.timeout_ms : 30000),
priority: Number.isFinite(Number(input.priority)) ? Number(input.priority) : (prev ? prev.priority : 100),
++ // The providers.billing_profile column is the profile the router reads (server/providers/index.js).
++ // metadata.billing_profile only seeds it when the row is created; once the column exists it wins, so
++ // a later metadata edit can never silently re-route the provider.
++ billing_profile: input.billing_profile || (prev && prev.billing_profile) || (input.metadata && input.metadata.billing_profile) || 'metered',
++ pool_key: input.pool_key !== undefined ? (input.pool_key || null) : (prev ? prev.pool_key : null),
metadata: input.metadata || (prev && prev.metadata) || {},
};
checkProvider(p);
const t = now();
-- await db.prepare(`INSERT INTO providers (key, display_name, kind, status, base_url, auth_mode, secret_ref, default_model, capabilities, timeout_ms, priority, metadata, origin, created_at, updated_at)
-- VALUES (@key, @display_name, @kind, @status, @base_url, @auth_mode, @secret_ref, @default_model, @capabilities, @timeout_ms, @priority, @metadata, @origin, @t, @t)
++ await db.prepare(`INSERT INTO providers (key, display_name, kind, status, base_url, auth_mode, secret_ref, default_model, capabilities, timeout_ms, priority, billing_profile, pool_key, metadata, origin, created_at, updated_at)
++ VALUES (@key, @display_name, @kind, @status, @base_url, @auth_mode, @secret_ref, @default_model, @capabilities, @timeout_ms, @priority, @billing_profile, @pool_key, @metadata, @origin, @t, @t)
ON CONFLICT(key) DO UPDATE SET display_name = excluded.display_name, kind = excluded.kind, status = excluded.status, base_url = excluded.base_url,
auth_mode = excluded.auth_mode, secret_ref = excluded.secret_ref, default_model = excluded.default_model, capabilities = excluded.capabilities,
-- timeout_ms = excluded.timeout_ms, priority = excluded.priority, metadata = excluded.metadata, origin = excluded.origin, updated_at = excluded.updated_at`)
++ timeout_ms = excluded.timeout_ms, priority = excluded.priority, billing_profile = excluded.billing_profile, pool_key = excluded.pool_key,
++ metadata = excluded.metadata, origin = excluded.origin, updated_at = excluded.updated_at`)
.run({ ...p, capabilities: JSON.stringify(p.capabilities), metadata: JSON.stringify(p.metadata), origin, t });
await audit(actor, prev ? 'provider.update' : 'provider.create', 'provider', p.key, { trace, metadata: { kind: p.kind, status: p.status, base_url: p.base_url, secret_ref: p.secret_ref, origin } });
return await getProvider(p.key);
ddiff --git a/server/workflows/seed.js b/server/workflows/seed.js
iindex e619ea2..cbb0f1e 100644
---- a/server/workflows/seed.js
++++ b/server/workflows/seed.js
@@@ -41,22 +41,22 @@ function sharedProviderRecord(config, env) {
capabilities: kind === 'anthropic'
? ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'json', 'vision']
: ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'json', 'vision', 'embed', 'transcribe'],
-- timeout_ms: s.timeoutMs, priority: 10, metadata: { configured_by: 'AI_PROVIDER/AI_BASE_URL/AI_API_KEY/AI_MODEL', provider_name: s.provider || 'openai-compatible' },
++ timeout_ms: s.timeoutMs, priority: 10, metadata: { configured_by: 'AI_PROVIDER/AI_BASE_URL/AI_API_KEY/AI_MODEL', provider_name: s.provider || 'openai-compatible', billing_profile: 'metered' },
};
}
function providerRecords(config, env) {
const out = [
-- { key: 'stub', display_name: 'Deterministic stub (synthetic output)', kind: 'stub', status: 'active', auth_mode: 'none', default_model: 'stub-1', capabilities: ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'embed', 'json', 'vision', 'transcribe'], timeout_ms: 10000, priority: 1000, metadata: { synthetic: true } },
++ { key: 'stub', display_name: 'Deterministic stub (synthetic output)', kind: 'stub', status: 'active', auth_mode: 'none', default_model: 'stub-1', capabilities: ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'embed', 'json', 'vision', 'transcribe'], timeout_ms: 10000, priority: 1000, metadata: { synthetic: true, billing_profile: 'free' } },
sharedProviderRecord(config, env),
-- { key: 'whisper', display_name: 'whisper.cpp (local speech-to-text)', kind: 'whisper', status: 'active', auth_mode: 'none', default_model: 'whisper.cpp', capabilities: ['transcribe'], timeout_ms: 600000, priority: 20, metadata: { configured_by: 'WHISPER_*' } },
++ { key: 'whisper', display_name: 'whisper.cpp (local speech-to-text)', kind: 'whisper', status: 'active', auth_mode: 'none', default_model: 'whisper.cpp', capabilities: ['transcribe'], timeout_ms: 600000, priority: 20, metadata: { configured_by: 'WHISPER_*', billing_profile: 'free' } },
];
if (config.localLlm && config.localLlm.url) {
// A local model server (llama.cpp, Ollama) speaking the OpenAI API: no key, never paid (WS-O task 5).
-- out.push({ key: 'local', display_name: 'Local model (OpenAI-compatible server on this host)', kind: 'openai', status: 'active', base_url: config.localLlm.url, auth_mode: 'none', secret_ref: null, default_model: config.localLlm.model || 'local', capabilities: ['chat', 'generate', 'summarize', 'classify', 'extract'], timeout_ms: config.localLlm.timeoutMs, priority: 5, metadata: { configured_by: 'AI_LOCAL_LLM_URL', local: true, paid: false } });
++ out.push({ key: 'local', display_name: 'Local model (OpenAI-compatible server on this host)', kind: 'openai', status: 'active', base_url: config.localLlm.url, auth_mode: 'none', secret_ref: null, default_model: config.localLlm.model || 'local', capabilities: ['chat', 'generate', 'summarize', 'classify', 'extract'], timeout_ms: config.localLlm.timeoutMs, priority: 5, metadata: { configured_by: 'AI_LOCAL_LLM_URL', local: true, paid: false, billing_profile: 'free' } });
}
if (config.httpSeamUrl) {
-- out.push({ key: 'http-seam', display_name: 'Local HTTP seam', kind: 'http', status: 'active', base_url: config.httpSeamUrl, auth_mode: 'none', default_model: null, capabilities: [], timeout_ms: 30000, priority: 60, metadata: { configured_by: 'AI_HTTP_SEAM_URL' } });
++ out.push({ key: 'http-seam', display_name: 'Local HTTP seam', kind: 'http', status: 'active', base_url: config.httpSeamUrl, auth_mode: 'none', default_model: null, capabilities: [], timeout_ms: 30000, priority: 60, metadata: { configured_by: 'AI_HTTP_SEAM_URL', billing_profile: 'metered' } });
}
return out;
}
ddiff --git a/test/provider-billing.test.js b/test/provider-billing.test.js
nnew file mode 100644
iindex 0000000..96a610a
---- /dev/null
++++ b/test/provider-billing.test.js
@@@ -0,0 +1,175 @@
++"use strict";
++// B25: provider billing profiles. A metered or payg provider keeps its platform.rate-card@1 offers; a
++// subscription, free or byok provider is prepaid - offered as pricing { model: 'prepaid' } with no rate
++// card, still a placement candidate. No authority credential is needed by a free server or a pooled
++// subscription; a byok pool record without a resolvable secret_ref is skipped no_credentials
++// (server/providers/index.js). The providers.billing_profile column is the profile authority: it is seeded
++// from metadata on create and validated (server/registry.js). The pool is built directly over a stubbed
++// db/registry so the offers the planner receives are inspectable; registry persistence is tested on a real
++// migrated database.
++const assert = require('assert');
++const placement = require('openvibe-sdk/placement');
++const { suite, seamServer, boot, request, token, ALL } = require('./helpers');
++const { createProviderPool } = require('../server/providers');
++const { createRegistry } = require('../server/registry');
++const { testDb } = require('./db');
++
++const t = suite('provider-billing');
++const noop = { get: async () => undefined, run: async () => ({}), all: async () => [] };
++const iso = (n) => new Date(n).toISOString();
++
++function provider(key, extra = {}) {
++ return {
++ key, kind: 'openai', status: 'active', auth_mode: 'none', secret_ref: null, default_model: `${key}-m`,
++ capabilities: ['chat'], timeout_ms: 30000, priority: 10, updated_at: iso(0), billing_profile: 'metered', metadata: {}, ...extra,
++ };
++}
++
++/** A pool over a fixed provider map; models is keyed '<provider>/<model>' -> a models row. */
++function poolFor(providers, models = {}) {
++ const registry = {
++ getProvider: async (k) => providers[k] || null,
++ listProviders: async () => Object.values(providers),
++ listModels: async () => [],
++ getModel: async (pk, mk) => models[`${pk}/${mk}`] || null,
++ audit: async () => {},
++ };
++ const config = {
++ breaker: { failureThreshold: 3, cooldownMs: 1000 }, providerRetries: 0, retryDelayMs: 1, stubFallback: false,
++ placement: { minGain: 0, costWeight: 1, latencyWeight: 1 }, pricing: { table: {}, inputPerMtok: 0.5, outputPerMtok: 1.5 },
++ };
++ return createProviderPool({ db: { prepare: () => noop }, registry, config, env: {} });
++}
++
++/** Capture the offers handed to openvibe-sdk/placement while really planning the route. */
++async function offersFor(pool, route) {
++ const original = placement.plan;
++ let offers = null;
++ placement.plan = (req, list, opts) => { offers = list; return original(req, list, opts); };
++ let out;
++ try { out = await pool.candidates(route, ['chat'], 'chat'); } finally { placement.plan = original; }
++ return { out, offers };
++}
++
++const CHAT = { messages: [{ role: 'user', content: 'x' }], timeoutMs: 3000 };
++
++t.test('the billing_profile column prices the offer; metadata is inert', async () => {
++ const providers = {
++ // The column says metered although metadata says subscription: the metered offer wins.
++ meter: provider('meter', { billing_profile: 'metered', metadata: { billing_profile: 'subscription' } }),
++ // The column says subscription although metadata says metered: the prepaid offer wins.
++ sub: provider('sub', { billing_profile: 'subscription', metadata: { billing_profile: 'metered' } }),
++ };
++ const models = { 'meter/meter-m': { cost: { in_per_mtok: 2, out_per_mtok: 4, cached_per_mtok: 1 } } };
++ const pool = poolFor(providers, models);
++ const route = { key: 'test.pool-billing', capability: 'chat', constraints: { objective: 'cheapest' }, pinned: [], fallbacks: [] };
++
++ const { out, offers } = await offersFor(pool, route);
++ const byId = Object.fromEntries(offers.map((o) => [o.offer_id, o]));
++
++ assert.deepStrictEqual(byId['sub:sub-m'].pricing, { model: 'prepaid' }, 'the column profile is prepaid');
++ assert.strictEqual(byId['meter:meter-m'].pricing.model, 'per-operation', 'the column profile is metered');
++ assert.strictEqual(byId['meter:meter-m'].pricing.rate_card, 'meter:meter-m/input-tokens');
++
++ assert.deepStrictEqual(out.rateCards.map((c) => c.provider), ['meter', 'meter', 'meter'], 'only the metered provider hands the planner cards');
++ assert.ok(out.rateCards.every((c) => c.metric.endsWith(':meter-m')), 'the cards are the metered provider\'s');
++
++ // Both stay placement candidates.
++ assert.ok(out.order.some((c) => c.provider === 'sub'), 'the prepaid provider stays in the pool');
++ assert.ok(out.order.some((c) => c.provider === 'meter'), 'the metered provider stays in the pool');
++});
++
++t.test('a byok provider with no resolvable credential is skipped no_credentials', async () => {
++ const providers = { byok: provider('byok', { kind: 'http', base_url: 'http://127.0.0.1:1/x', auth_mode: 'bearer', secret_ref: 'env:BYOK_NOT_SET', billing_profile: 'byok' }) };
++ const pool = poolFor(providers);
++ const route = { key: 'test.byok', primary: { provider: 'byok', model: null }, fallbacks: [], timeout_ms: 3000 };
++ const logs = [];
++ await assert.rejects(
++ pool.execute(route, 'chat', CHAT, { routeKey: route.key, logRequest: (e) => logs.push(e) }),
++ (err) => err.status === 503 && err.code === 'provider.unavailable' && JSON.stringify(err.extra.tried).includes('no_credentials'),
++ 'a keyless byok provider cannot answer',
++ );
++ assert.ok(logs.some((e) => e.status === 'skipped' && e.skip_reason === 'no_credentials'), 'the attempt is logged as a credential skip');
++});
++
++t.test('free and pooled-subscription capacity needs no authority credential', async () => {
++ const seam = await seamServer(() => ({ text: 'a prepaid answer', model: 'm', usage: { input: 2, output: 1 } }));
++ const providers = {
++ free: provider('free', { kind: 'http', base_url: seam.url, auth_mode: 'bearer', secret_ref: 'env:FREE_NOT_SET', billing_profile: 'free' }),
++ sub: provider('sub', { kind: 'http', base_url: seam.url, auth_mode: 'bearer', secret_ref: null, billing_profile: 'subscription' }),
++ };
++ const pool = poolFor(providers);
++ for (const key of ['free', 'sub']) {
++ const route = { key: `test.${key}`, primary: { provider: key, model: null }, fallbacks: [], timeout_ms: 3000 };
++ const logs = [];
++ const exec = await pool.execute(route, 'chat', CHAT, { routeKey: route.key, logRequest: (e) => logs.push(e) });
++ assert.strictEqual(exec.provider, key, `${key} answers without an authority credential`);
++ assert.strictEqual(exec.cost, 0, `${key} is prepaid, not metered`);
++ assert.ok(logs.every((e) => e.skip_reason !== 'no_credentials'), `${key} was not skipped for a missing credential`);
++ }
++ await seam.close();
++});
++
++// -- The column is the authority: registry persistence on a real migrated database --
++let rdb = null; let registry = null;
++async function registryFixture() {
++ if (!registry) { rdb = await testDb(); registry = createRegistry(rdb.db, { env: {} }); }
++ return registry;
++}
++
++t.test('metadata seeds the billing_profile column on create; the column then wins', async () => {
++ const r = await registryFixture();
++ const created = await r.upsertProvider({ key: 'seeded', kind: 'stub', auth_mode: 'none', capabilities: ['chat'], metadata: { billing_profile: 'subscription' } });
++ assert.strictEqual(created.billing_profile, 'subscription', 'metadata seeded the column');
++ const edited = await r.upsertProvider({ key: 'seeded', metadata: { billing_profile: 'free' } });
++ assert.strictEqual(edited.billing_profile, 'subscription', 'a later metadata edit does not re-route an existing provider');
++ const direct = await r.upsertProvider({ key: 'seeded', billing_profile: 'byok' });
++ assert.strictEqual(direct.billing_profile, 'byok', 'the column is set directly');
++});
++
++t.test('an unknown billing profile is refused in the column and in metadata', async () => {
++ const r = await registryFixture();
++ await assert.rejects(r.upsertProvider({ key: 'bad-col', kind: 'stub', auth_mode: 'none', capabilities: ['chat'], billing_profile: 'gold' }), /billing_profile/);
++ await assert.rejects(r.upsertProvider({ key: 'bad-meta', kind: 'stub', auth_mode: 'none', capabilities: ['chat'], metadata: { billing_profile: 'gold' } }), /billing_profile/);
++});
++
++t.test('a keyless byok provider is reported missing by the registry view (agrees with the router)', async () => {
++ const r = await registryFixture();
++ const p = await r.upsertProvider({ key: 'byo', kind: 'stub', auth_mode: 'bearer', secret_ref: 'env:BYOK_UNSET', capabilities: ['chat'], billing_profile: 'byok' });
++ assert.strictEqual(r.publicProvider(p).credentials, 'missing');
++});
++
++t.test('pool_key is validated against the key pattern', async () => {
++ const r = await registryFixture();
++ await assert.rejects(r.upsertProvider({ key: 'pooled', kind: 'stub', auth_mode: 'none', capabilities: ['chat'], pool_key: 'sk-liveOrSecretValue' }), /pool_key/);
++ const ok = await r.upsertProvider({ key: 'pooled', kind: 'stub', auth_mode: 'none', capabilities: ['chat'], pool_key: 'live.pool-1' });
++ assert.strictEqual(ok.pool_key, 'live.pool-1');
++});
++
++// A prepaid provider never gets a rate card (cost 0), but its attempt still carries the tokens each kind
++// used, and account() writes one reading per attempt per kind with cost 0 (server/quota.js).
++t.test('a prepaid provider\'s attempt still emits its per-kind readings with cost 0', async () => {
++ const seam = await seamServer(() => ({ text: 'free answer', model: 'fm', usage: { input: 7, output: 3 } }));
++ const tok = token('live', ALL);
++ const h = await boot({ env: { AI_STUB_FALLBACK: 'false' } });
++ try {
++ assert.strictEqual((await request(h.base, 'POST', '/api/v1/providers', { tok, body: { key: 'freeby', kind: 'http', base_url: seam.url, auth_mode: 'none', capabilities: ['generate', 'chat'], billing_profile: 'free' } })).status, 201);
++ assert.strictEqual((await request(h.base, 'POST', '/api/v1/models', { tok, body: { provider_key: 'freeby', model_key: 'fm', type: 'chat' } })).status, 201);
++ const rte = await request(h.base, 'POST', '/api/v1/routes/default.chat/versions', { tok, body: { primary: { provider: 'freeby', model: 'fm' }, fallbacks: [], response_format: 'text' } });
++ assert.strictEqual(rte.status, 201, rte.text);
++ const r = await request(h.base, 'POST', '/api/v1/generate', { tok, body: { prompt: 'prepaid', options: { cache: false } } });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.strictEqual(r.body.run.provenance.provider, 'freeby');
++ assert.strictEqual(r.body.run.usage.cost_usd, 0, 'prepaid capacity is not metered');
++ const rows = (await h.db.prepare('SELECT event_id, envelope FROM usage_sample_outbox ORDER BY id').all()).map((x) => ({ event_id: x.event_id, envelope: typeof x.envelope === 'string' ? JSON.parse(x.envelope) : x.envelope }));
++ const mine = rows.filter((x) => x.event_id.startsWith(`ai:${r.body.run.id}:`));
++ assert.deepStrictEqual(mine.map((x) => x.envelope.idempotency_key), ['in', 'cached', 'out'].map((k) => `ai:${r.body.run.id}:0:${k}`), 'one reading per attempt per kind');
++ assert.ok(mine.every((x) => x.envelope.cost_estimate === 0), 'every prepaid reading costs 0');
++ assert.deepStrictEqual(Object.fromEntries(mine.map((x) => [x.envelope.idempotency_key.split(':')[3], x.envelope.quantity])), { in: 7, cached: 0, out: 3 }, 'the quantities are kept');
++ } finally {
++ await h.stop(); await seam.close();
++ }
++});
++
++t.test('shutdown', async () => { if (rdb) await rdb.close(); });
++t.run();