openvibers/openvibe.ai Commits
Provider billing profiles (metered, subscription, payg, free, byok) and a shared pool key: prepaid providers place without rate cards (#21)
Changes
nnew file mode 100644@@@ -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@@@ -34,7 +34,9 @@ function adminRouter({ db, registry, pool, quotas, cache, runs, auth, ops = creaconst 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()));@@@ -136,9 +136,28 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.nofunction 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.noconst 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.noasync 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;@@@ -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.ekey: 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.eif (!['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.ecapabilities: 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);@@@ -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;}nnew file mode 100644@@@ -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();
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -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
@@ -34,7 +34,9 @@ function adminRouter({ db, registry, pool, quotas, cache, runs, auth, ops = crea
@@ -34,7 +34,9 @@ function adminRouter({ db, registry, pool, quotas, cache, runs, auth, ops = crea
const usageRead = auth.requireCap(CAPS.usageRead);
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 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 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 : {});
const body = (req) => (req.body && typeof req.body === 'object' ? req.body : {});
r.get('/api/v1/status', usageRead, wrap(async () => await ops.status()));
r.get('/api/v1/status', usageRead, wrap(async () => await ops.status()));
@@ -136,9 +136,28 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -136,9 +136,28 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
function isLocalProvider(p) {
function isLocalProvider(p) {
return p.kind === 'whisper' || Boolean(p.metadata && p.metadata.local);
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) {
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 offerId = (provider, model) => (model ? `${provider}:${model}` : provider);
const splitOfferId = (id) => {
const splitOfferId = (id) => {
@@ -173,7 +192,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -173,7 +192,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
const id = offerId(p.key, model);
const id = offerId(p.key, model);
if (seen.has(id)) return;
if (seen.has(id)) return;
seen.add(id);
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 h = await health(p.key);
const st = stats.get(p.key) || { p95: null, requests: 0, errors: 0 };
const st = stats.get(p.key) || { p95: null, requests: 0, errors: 0 };
const errorRate = st.requests ? st.errors / st.requests : 0;
const errorRate = st.requests ? st.errors / st.requests : 0;
@@ -293,7 +312,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -293,7 +312,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
async function skipReason(p, a, features) {
async function skipReason(p, a, features) {
if (!p || !a) return 'unknown_provider';
if (!p || !a) return 'unknown_provider';
if (p.status !== 'active') return 'disabled';
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 (features.some(f => !a.supports(f))) return 'unsupported';
if ((await health(p.key)).state === 'open') return 'circuit_open';
if ((await health(p.key)).state === 'open') return 'circuit_open';
return null;
return null;
@@ -14,6 +14,7 @@ const schemas = require('./schemas');
@@ -14,6 +14,7 @@ const schemas = require('./schemas');
const { provenance } = require('./providers/rate-cards');
const { provenance } = require('./providers/rate-cards');
const PROVIDER_KINDS = ['stub', 'openai', 'responses', 'anthropic', 'http', 'whisper'];
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 FEATURES = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'embed', 'vision', 'json', 'transcribe'];
const LIFECYCLE = ['draft', 'active', 'deprecated', 'archived'];
const LIFECYCLE = ['draft', 'active', 'deprecated', 'archived'];
const KEY_RE = /^[a-z][a-z0-9_-]*(\.[a-z0-9_-]+)*$/;
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
@@ -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,
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,
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,
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,
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
@@ -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 (!['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 (!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 (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(', ')}`);
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
@@ -81,15 +93,21 @@ function createRegistry(db, { clock = { now: () => Date.now() }, env = process.e
capabilities: input.capabilities || (prev && prev.capabilities) || [],
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),
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),
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) || {},
metadata: input.metadata || (prev && prev.metadata) || {},
};
};
checkProvider(p);
checkProvider(p);
const t = now();
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,
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,
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 });
.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 } });
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);
return await getProvider(p.key);
@@ -41,22 +41,22 @@ function sharedProviderRecord(config, env) {
@@ -41,22 +41,22 @@ function sharedProviderRecord(config, env) {
capabilities: kind === 'anthropic'
capabilities: kind === 'anthropic'
? ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'json', 'vision']
? ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'json', 'vision']
: ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'json', 'vision', 'embed', 'transcribe'],
: ['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) {
function providerRecords(config, env) {
const out = [
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),
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) {
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).
// 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) {
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;
return out;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,175 @@
@@ -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();