openvibers/openvibe.ai Commits
Track provider credits and add session readers (#374)
Changes
nnew file mode 100644@@@ -0,0 +1,13 @@++# Provider session readers++++AI accepts a session cookie only for the fixed read-only readers below. The API does not accept a URL, HTTP method, host, path, or header name from a caller. Requests use the outbound guarded fetch, HTTPS, exact host checks, a response timeout, and refused redirects.++++| Provider | Request | Authentication | Data |++| --- | --- | --- | --- |++| Anthropic | `GET https://console.anthropic.com/api/organizations/{org_id}/prepaid/credits` | `Cookie: sessionKey=…` | Purchased balance |++| Anthropic | `GET https://console.anthropic.com/api/organizations/{org_id}/current_spend` | `Cookie: sessionKey=…` | Current spend |++| OpenAI | `GET https://api.openai.com/dashboard/billing/credit_grants` | `Authorization: Bearer sess-…` | Grant totals and grant rows |++++The Anthropic organization ID is a non-secret setting stored with the credential. There is no Anthropic promotional-grant reader configured. A 401 or 403 marks the session expired and returns the estimated balance instead. Credentials are AES-GCM encrypted; API responses contain metadata only.++++Session-cookie access is powerful: a valid cookie can do anything its browser session can do on that provider site. OpenVibe sends it only to the allowlisted GET readers above. Signing out at the provider ends the session.nnew file mode 100644@@@ -0,0 +1,18 @@++-- phase: expand++CREATE TABLE IF NOT EXISTS provider_credit_grants (++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,++ provider_key text COLLATE "C" NOT NULL,++ kind text COLLATE "C" NOT NULL CHECK (kind IN ('promotional', 'purchased')),++ amount_usd numeric(14, 6) NOT NULL CHECK (amount_usd >= 0),++ as_of text COLLATE "C" NOT NULL,++ expires_at text COLLATE "C",++ applies_to text COLLATE "C",++ created_at text COLLATE "C" NOT NULL++);++CREATE INDEX IF NOT EXISTS provider_credit_grants_provider ON provider_credit_grants(provider_key, kind, as_of);++CREATE TABLE IF NOT EXISTS provider_credit_preferences (++ provider_key text COLLATE "C" PRIMARY KEY,++ use_promotional_first bigint NOT NULL DEFAULT 1,++ promotional_floor_usd numeric(14, 6) NOT NULL DEFAULT 5 CHECK (promotional_floor_usd >= 0),++ updated_at text COLLATE "C" NOT NULL++);nnew file mode 100644@@@ -0,0 +1,7 @@++-- phase: expand++ALTER TABLE provider_credit_grants ADD COLUMN IF NOT EXISTS received_at text COLLATE "C";++ALTER TABLE provider_credit_grants ADD COLUMN IF NOT EXISTS remaining_as_of_usd numeric(14, 6);++ALTER TABLE provider_credit_grants ADD COLUMN IF NOT EXISTS remaining_as_of text COLLATE "C";++UPDATE provider_credit_grants SET received_at = as_of WHERE received_at IS NULL;++UPDATE provider_credit_grants SET remaining_as_of_usd = amount_usd WHERE remaining_as_of_usd IS NULL;++UPDATE provider_credit_grants SET remaining_as_of = as_of WHERE remaining_as_of IS NULL;nnew file mode 100644@@@ -0,0 +1,17 @@++-- phase: expand++CREATE TABLE IF NOT EXISTS session_credentials (++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,++ owner text COLLATE "C" NOT NULL,++ subject text COLLATE "C" NOT NULL,++ provider text COLLATE "C" NOT NULL,++ label text COLLATE "C" NOT NULL,++ secret_enc text COLLATE "C" NOT NULL,++ created_at text COLLATE "C" NOT NULL,++ last_used_at text COLLATE "C",++ expires_at text COLLATE "C",++ expired bigint NOT NULL DEFAULT 0,++ notified_expired bigint NOT NULL DEFAULT 0,++ settings_json text COLLATE "C" NOT NULL DEFAULT '{}',++ UNIQUE(owner, subject, provider)++);++CREATE INDEX IF NOT EXISTS session_credentials_subject ON session_credentials(owner, subject);@@@ -30,6 +30,9 @@ const TABLES = [{ table: 'person_sessions', subject: 'subject', file: 'person-sessions.json', columns: ['username', 'created_at', 'expires_at', 'revoked_at'], order: 'created_at' },{ table: 'subject_credentials', subject: 'subject', file: 'provider-credentials.json', order: 'updated_at',columns: ['owner', 'subject', 'provider', 'base_url', 'machine', 'key_hint', 'models', 'budget_usd_day', 'is_default', 'spend_key', 'status', 'checked_at', 'last_error', 'models_count', 'created_at', 'updated_at', 'last_used_at'] },++ // Session-cookie values are never exported; only the person's credential metadata and non-secret reader settings.++ { table: 'session_credentials', subject: 'subject', file: 'provider-session-credentials.json', order: 'created_at',++ columns: ['owner', 'subject', 'provider', 'label', 'created_at', 'last_used_at', 'expires_at', 'expired', 'settings_json'] },// L36 Y3: how the person's runs route (only_my_keys, free_first, the price ceiling and the defaults). Their own// settings, so they go with the account; the row holds no key.{ table: 'ai_person_preferences', subject: 'subject', file: 'ai-preferences.json', columns: ['preferences', 'created_at', 'updated_at'], order: 'created_at' },@@@ -22,7 +22,7 @@ const pkg = require('../package.json');const { adminRoutes } = require('openvibe-shared/config');const ownerInputs = require('./owner-inputs');--function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, settings = null, benchEvents = null, runBudgets = null, memory = null, memoryImport = null, memoryServices = null, memorySharing = null, community = null, endpoints = null, webhooks = null, presets = null, receipts = null, balance = null, gpus = null, abuse = null, priceBook = null, ownEndpoints = null, refusal = null, batches = null }) {++function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, settings = null, benchEvents = null, runBudgets = null, memory = null, memoryImport = null, memoryServices = null, memorySharing = null, community = null, endpoints = null, webhooks = null, presets = null, receipts = null, balance = null, creditGrants = null, sessionCookies = null, gpus = null, abuse = null, priceBook = null, ownEndpoints = null, refusal = null, batches = null }) {const app = express();// /.well-known/agent.json (openvibe-shared/agent-layer, SEO-2): what this site serves, for agents.require('openvibe-shared/agent-layer').mount(app, { descriptor: require('./agent-descriptor') });@@@ -34,7 +34,7 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys// GET /metrics answers direct loopback callers only (Track O).const metrics = instrument(app, { service: 'ai', release: release.release });app.use(require('openvibe-sdk/service').telemetryMiddleware); // request telemetry: a no-op until server/index.js calls telemetry.init()-- registerAiGauges(metrics.registry, { runs, registry, pool, balance, gpus, abuse, db });++ registerAiGauges(metrics.registry, { runs, registry, pool, balance, creditGrants, gpus, abuse, db });app.use(http.middleware());// Plan E5: the signed HttpOnly device id and the request's trust metadata on the model and Exchange APIs.// The address/device stay in the request: only keyed hashes reach the database (server/abuse/).@@@ -129,6 +129,37 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys// ai.exchange, ai.provider_keys and ai.gpus (the GPU provisioner's marketplace keys and its monthly cap). Loopback only (the /internal guard above), guarded by ai.config.manage; the// actor is Network's X-OV-Subject (usr_…), and a secret or x-danger change needs X-OV-Fresh: 1.if (settings) {++ if (creditGrants) {++ const express = require('express');++ const creditsRouter = express.Router();++ creditsRouter.use(auth.requireCap(CAPS.configManage));++ creditsRouter.get('/', async (req, res) => {++ try { res.json({ providers: await Promise.all(['anthropic', 'openai'].map(async (provider) => ({ provider, grants: await creditGrants.list(provider), estimate: creditGrants.values().find((x) => x.provider === provider) || null, preference: await creditGrants.preference(provider) }))) }); }++ catch (err) { sendError(res, err, req.ov, log); }++ });++ creditsRouter.post('/', async (req, res) => {++ try {++ const provider = ['anthropic', 'openai'].includes(String(req.body.provider)) ? String(req.body.provider) : 'anthropic';++ const grant = await creditGrants.add({ ...req.body, provider_key: provider });++ creditGrants.poll().catch((err) => log.warn(`[ai] credit estimate failed: ${String(err && err.message || err).slice(0, 180)}`));++ res.status(201).json({ grant });++ } catch (err) { sendError(res, err, req.ov, log); }++ });++ creditsRouter.put('/preference', async (req, res) => {++ try { res.json({ preference: await creditGrants.setPreference({ enabled: req.body.enabled, floor_usd: req.body.floor_usd, provider_key: ['anthropic', 'openai'].includes(String(req.body.provider)) ? String(req.body.provider) : 'anthropic' }) }); }++ catch (err) { sendError(res, err, req.ov, log); }++ });++ app.use('/internal/provider-credits', creditsRouter);++ }++ if (sessionCookies) {++ const express = require('express'); const cookiesRouter = express.Router();++ cookiesRouter.use(auth.requireCap(CAPS.configManage));++ cookiesRouter.get('/', async (req, res) => { try { res.set('Cache-Control', 'no-store').json({ credentials: await sessionCookies.list('operator', 'owner') }); } catch (err) { sendError(res, err, req.ov, log); } });++ cookiesRouter.post('/', async (req, res) => { try { res.status(201).set('Cache-Control', 'no-store').json({ credential: await sessionCookies.add('operator', 'owner', req.body || {}) }); } catch (err) { sendError(res, err, req.ov, log); } });++ cookiesRouter.delete('/:id', async (req, res) => { try { const gone = await sessionCookies.remove('operator', 'owner', req.params.id); res.status(gone ? 204 : 404).end(); } catch (err) { sendError(res, err, req.ov, log); } });++ cookiesRouter.get('/:id/balance', async (req, res) => { try { res.set('Cache-Control', 'no-store').json({ balance: await sessionCookies.balance('operator', 'owner', req.params.id) }); } catch (err) { sendError(res, err, req.ov, log); } });++ app.use('/internal/provider-session-credentials', cookiesRouter);++ }const configRoutes = adminRoutes([settings.exchange, settings.providerKeys.proxy, settings.gpus.proxy], {basePath: '/internal/config',requireAdmin: auth.requireCap(CAPS.configManage),@@@ -196,11 +227,11 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys// The person's own AI settings (L36 Y1/Y3): their keys under owner `self`, and their routing preferences. Only// the person's Network token or their AI site session (same origin) reaches them; a service token is refused.if (credentials && preferences && grants) {-- app.use(require('./me').meRouter({ credentials, preferences, grants, auth, registry, config, clock, log, personSessions, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));++ app.use(require('./me').meRouter({ credentials, preferences, grants, auth, registry, config, clock, log, personSessions, sessionCookies, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));// The same settings as a page for people (GET /you, plain forms on AI's own person session).app.use(require('./you-page').youPageRouter({ credentials, preferences, grants, personSessions, config, log, machines: pool && pool.machines }));}-- app.use(consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env, clock, fetchImpl, log, vault, settings, personSessions, abuse, priceBook }));++ app.use(consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env, clock, fetchImpl, log, vault, settings, personSessions, abuse, priceBook, creditGrants, sessionCookies }));app.use(runPageRouter({ runs, personSessions, log }));// ── The public pages moved to OpenVibe.Exchange (2026-11-07) ────@@@ -353,7 +353,7 @@ function load(env = process.env) {cooldownMs: int(env.AI_BREAKER_COOLDOWN_MS, 60000),// A provider that answered "insufficient balance" is out of money, not flaky: its circuit stays open this// long (10 minutes), shared by every request, before one probe is allowed to spend again.-- exhaustedCooldownMs: Math.max(60000, int(env.AI_BREAKER_EXHAUSTED_COOLDOWN_MS, 10 * 60000)),++ exhaustedCooldownMs: Math.max(60000, int(env.AI_BREAKER_EXHAUSTED_COOLDOWN_MS, 15 * 60000)),},// One retry on a transient failure (timeout, 429, 5xx, connection reset) before the// router moves on to the fallback — Live's rule.@@@ -116,7 +116,7 @@ const DEF_KINDS = {};const DAY_MS = 86400000;--function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, vault = null, settings = null, personSessions = null, abuse = null, priceBook = null }) {++function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, vault = null, settings = null, personSessions = null, abuse = null, priceBook = null, creditGrants = null, sessionCookies = null }) {const cc = config.console;const creditsOn = Boolean(config.credits && config.credits.enabled);const routingOn = Boolean(config.routingEvidence && config.routingEvidence.enabled);@@@ -336,11 +336,39 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, e}));// ── Providers ────────────────────────────────────────────-- r.get('/console/providers', read, wrap(async (req, res) => await send(res, 200, pages.providers({++ r.get('/console/providers', read, wrap(async (req, res) => {++ const estimates = creditGrants ? await Promise.all(['anthropic', 'openai'].map(async (provider) => {++ const grantRows = await creditGrants.list(provider);++ const estimate = creditGrants.values().find((x) => x.provider === provider);++ return { provider, estimate: estimate || (grantRows.length ? { provider, promotional: null, purchased: null, grants: grantRows, source: null, at: null } : null), preference: await creditGrants.preference(provider) };++ })) : [];++ const canManageCookies = req.staff.caps.includes(CAPS.configManage);++ const sessionCredentials = sessionCookies && canManageCookies ? await sessionCookies.list('operator', 'owner') : [];++ const sessionBalances = sessionCookies && canManageCookies ? await Promise.all(sessionCredentials.map((c) => sessionCookies.balance('operator', 'owner', c.id))) : [];++ return await send(res, 200, pages.providers({...common(req), rows: (await Promise.all((await registry.listProviders()).map(ops.providerView))), models: await registry.listModels(), stubFallback: config.stubFallback,canManage: req.staff.caps.includes(CAPS.providerManage), notice: notice(req), keyHints: vault ? await vault.hints() : new Map(),-- breaker: config.breaker, now: clock.now(),-- }))));++ breaker: config.breaker, now: clock.now(), creditEstimates: estimates.map(({ provider, estimate, preference }) => ({ provider, estimate: estimate || { provider, promotional: null, purchased: null, grants: [], source: null, at: null }, preference })), sessionCredentials, sessionBalances, canManageCookies,++ }));++ }));++ r.post('/console/provider-credits', needs(CAPS.providerManage), wrap(async (req, res) => {++ if (!creditGrants) throw new Error('credit grants are unavailable');++ const provider = ['anthropic', 'openai'].includes(String(req.body.provider)) ? String(req.body.provider) : 'anthropic';++ await creditGrants.add({ provider_key: provider, kind: String(req.body.kind || 'promotional'), amount_usd: Number(req.body.amount_usd), as_of: String(req.body.as_of || ''), received_at: String(req.body.received_at || req.body.as_of || ''), remaining_as_of_usd: req.body.remaining_as_of_usd == null ? Number(req.body.amount_usd) : Number(req.body.remaining_as_of_usd), remaining_as_of: String(req.body.remaining_as_of || req.body.as_of || ''), expires_at: req.body.expires_at ? new Date(`${req.body.expires_at}T23:59:59Z`).toISOString() : null, applies_to: String(req.body.applies_to || '').slice(0, 200) || null });++ res.redirect(303, '/console/providers?done=provider_credit');++ }));++ r.post('/console/provider-credit-preference', needs(CAPS.providerManage), wrap(async (req, res) => {++ if (!creditGrants) throw new Error('credit preferences are unavailable');++ const provider = ['anthropic', 'openai'].includes(String(req.body.provider)) ? String(req.body.provider) : 'anthropic';++ await creditGrants.setPreference({ provider_key: provider, enabled: req.body.enabled === '1', floor_usd: Number(req.body.floor_usd) });++ creditGrants.poll().catch((err) => log.warn(`[ai] credit estimate failed: ${String(err && err.message || err).slice(0, 180)}`));++ res.redirect(303, '/console/providers?done=provider_credit');++ }));++ r.post('/console/provider-session-credentials', needs(CAPS.configManage), wrap(async (req, res) => {++ if (!sessionCookies) throw new Error('session credentials are unavailable');++ await sessionCookies.add('operator', 'owner', { provider: req.body.provider, cookie: String(req.body.cookie || ''), label: req.body.label, expires_at: req.body.expires_at || null, org_id: req.body.org_id });++ res.redirect(303, '/console/providers?done=session_credential');++ }));r.get('/console/glossary', read, wrap(async (req, res) => await send(res, 200, pages.glossary({ ...common(req) }))));r.post('/console/providers/:key/status', needs(CAPS.providerManage), wrap(async (req, res) => {await ops.setProviderStatus(req.params.key, String(req.body.status || ''), who(req));@@@ -274,7 +274,7 @@ function limitsUsed(c) {return parts.join(' · ') || `${n(c.used.requests)} requests`;}--function providers({ staff, csrf, rows, models, canManage, stubFallback, notice, error, keyHints = new Map(), breaker = {}, now = Date.now() }) {++function providers({ staff, csrf, rows, models, canManage, canManageCookies = false, stubFallback, notice, error, keyHints = new Map(), breaker = {}, now = Date.now(), creditEstimates = [], sessionCredentials = [], sessionBalances = [], creditEstimate = null, creditPreference = { enabled: true, floor_usd: 5 } }) {const healthCell = (p) => {const h = healthText(p.health, { breaker, now });return html`<span class="${h.cls}">${h.text}</span> <span class="muted">${(p.health && p.health.state) || 'closed'}</span>${p.health && p.health.last_error ? html`<br><span class="muted">last error: ${clip(p.health.last_error, 160)}</span>` : ''}`;@@@ -296,6 +296,9 @@ function providers({ staff, csrf, rows, models, canManage, stubFallback, notice,title: 'AI providers', section: 'providers', staff, csrf, notice, error, body: html`<h1>AI providers</h1><p class="muted">The companies whose models OpenVibe can use, and the key for each. Add one to make its models available; a provider with no working key is skipped.</p>${canManage ? html`<p><a class="button" href="/console/providers/new">Add a provider</a> <span class="muted">DeepSeek, OpenRouter, Groq, Gemini, Mistral, Together, Fireworks, Cerebras, xAI, OpenAI, Anthropic or any OpenAI-compatible API. Then choose it on a routing rule's page.</span></p>` : ''}++${creditEstimates.length ? creditEstimates.map(({ provider, estimate, preference }) => html`<div class="card"><strong>${provider === 'openai' ? 'OpenAI' : 'Anthropic'} credits (estimate)</strong><p>${estimate.promotional == null ? 'unknown' : estimate.promotional.toFixed(2)} promotional left of ${estimate.grants.filter((g) => g.kind === 'promotional').reduce((n, g) => n + Number(g.amount_usd), 0).toFixed(2)} · ${estimate.purchased == null ? 'unknown' : estimate.purchased.toFixed(2)} purchased left of ${estimate.grants.filter((g) => g.kind === 'purchased').reduce((n, g) => n + Number(g.amount_usd), 0).toFixed(2)}. ${estimate.source ? html`Estimated from ${estimate.source}, as of ${when(estimate.at)}.` : 'The spend estimate is not available yet.'}</p>${provider === 'openai' ? estimate.grants.map((g) => { const left = Number(estimate.grantBalances && estimate.grantBalances[g.id] != null ? estimate.grantBalances[g.id] : g.remaining_as_of_usd ?? g.amount_usd); return html`<p class="muted">Received ${when(g.received_at || g.as_of)} · ${g.expires_at && Date.parse(g.expires_at) <= now ? 'Expired' : left <= 0 ? 'Used' : 'Available'} · $${left.toFixed(2)} left of $${Number(g.amount_usd).toFixed(2)} · expires ${g.expires_at ? when(g.expires_at) : 'never'}</p>`; }) : ''}${canManage ? html`<form method="post" action="/console/provider-credit-preference" class="inline">${csrfField(csrf)}<input type="hidden" name="provider" value="${provider}"><label><input type="checkbox" name="enabled" value="1"${preference.enabled ? ' checked' : ''}> Use free credit first</label> <label>Keep at least USD <input name="floor_usd" type="number" min="0" step="0.01" value="${preference.floor_usd}" required></label><button type="submit">Save preference</button></form><form method="post" action="/console/provider-credits" class="inline">${csrfField(csrf)}<input type="hidden" name="provider" value="${provider}"><label>Grant <select name="kind"><option value="promotional">Promotional</option><option value="purchased">Purchased</option></select></label> <label>Amount USD <input name="amount_usd" type="number" min="0" step="0.01" required></label> <label>As of / remaining as of <input name="as_of" type="datetime-local" required></label> ${provider === 'openai' ? html`<label>Remaining USD <input name="remaining_as_of_usd" type="number" min="0" step="0.01" required></label>` : ''}<label>Expires <input name="expires_at" type="date"></label><button type="submit">Record grant</button></form>` : ''}</div>`)++ : creditEstimate ? html`<div class="card"><strong>Anthropic credits</strong><p class="muted">No grant recorded yet; balance is unknown.</p></div>` : canManage ? html`<div class="card"><strong>Anthropic and OpenAI grants</strong><p class="muted">No grant recorded yet; balances are unknown.</p></div>` : ''}++${canManageCookies ? html`<div class="card"><strong>Provider console sessions</strong><p class="muted">A session cookie can do anything you can do on that site while valid. OpenVibe uses it only for the listed read-only balance readers. Signing out there ends it.</p>${sessionCredentials.map((c) => html`<p>${c.provider}: ${c.label} · ${c.expired ? 'expired' : 'active'} · added ${when(c.created)}</p>`)}${sessionBalances.map((b) => b ? html`<p>${b.credential.provider}: ${b.source === 'provider console' && b.credential.provider === 'openai' && b.values.credit_grants ? html`$${Number(b.values.credit_grants.total_available).toFixed(2)} left of $${Number(b.values.credit_grants.total_granted).toFixed(2)} in grants · from the provider's console, read at ${when(b.at)}` : b.source === 'provider console' && b.credential.provider === 'anthropic' && Number.isFinite(b.values.purchased) ? html`$${b.values.purchased.toFixed(2)} purchased · from the provider's console, read at ${when(b.at)}` : `Estimated from the stored grants and run spend; as of ${when(b.at)}`}</p>` : '')}<form method="post" action="/console/provider-session-credentials" class="inline">${csrfField(csrf)}<label>Provider <select name="provider"><option value="anthropic">Anthropic</option><option value="openai">OpenAI</option></select></label><label>Session cookie <input type="password" name="cookie" required minlength="16" autocomplete="off"></label><label>Label <input name="label" maxlength="80"></label><label>Anthropic org ID <input name="org_id" maxlength="120"></label><button type="submit">Save read-only session</button></form><p class="muted">Readers: Anthropic prepaid credits and current spend on console.anthropic.com; OpenAI credit grants on api.openai.com.</p></div>` : ''}<p class="muted">A key entered here is locked with AI's own lock key, bound to the provider's address and never shown again (only its last four characters). A key from the server's settings file is named, never shown. When a provider fails, the request goes to the next one in this order.</p><div class="card"><p class="m0">Practice answers when every provider fails: <strong>${stubFallback ? 'on' : 'off'}</strong> <span class="muted">(<code>AI_STUB_FALLBACK</code>)</span>. ${STUB_WORDS}</p></div>${table('AI providers, in the order they are tried', ['Provider', 'Kind', 'Status', 'Key', 'Health', 'Who pays · terms', 'Base URL', 'Default model', 'Can do', 'Order · time limit', ...(canManage ? ['Actions'] : [])], rows.map((p) => html`<tr>@@@ -83,7 +83,9 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now// The price book's off-peak windows (BA1): what a provider's own price is inside the hours it publishes.// The router reads them to know when a provider is at its off-peak price (server/providers/tiers.js).const offPeak = require('./pricebook/offpeak').createOffPeak({ db, clock });-- const pool = createProviderPool({ db, registry, config, clock, fetchImpl, env, log, credentialFetch, machineModels: machines, community, bench, variantEvidence, adultStatus, offPeak });++ const creditGrants = require('./providers/credit-grants').createCreditGrants({ db, env, fetchImpl, clock });++ const sessionCookies = require('./session-cookies').createSessionCookies({ db, config, clock });++ const pool = createProviderPool({ db, registry, config, clock, fetchImpl, env, log, credentialFetch, machineModels: machines, community, bench, variantEvidence, adultStatus, offPeak, creditGrants });// The hourly provider balance probe (server/providers/balance.js): a gauge for the operator alert, started// only when the service listens (never in tests) and off under a restore drill.const balance = require('./providers/balance').createBalanceProbe({ registry, config, env, fetchImpl, clock, log });@@@ -201,7 +203,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.nowclientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,}) : null;const benchEvents = createBenchEvents({ bench, accountData, accountSend, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });-- const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, settings, benchEvents, runBudgets, memory, memoryImport, memoryServices, memorySharing, community, endpoints, webhooks, presets, receipts, balance, gpus, abuse, priceBook, ownEndpoints, refusal, batches });++ const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, settings, benchEvents, runBudgets, memory, memoryImport, memoryServices, memorySharing, community, endpoints, webhooks, presets, receipts, balance, creditGrants, sessionCookies, gpus, abuse, priceBook, ownEndpoints, refusal, batches });let jwks = null;let keyLoaded = Promise.resolve(null);if (!keys.pinned && config.nodeEnv !== 'test') {@@@ -243,6 +245,13 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now// The signed webhook retry worker: never in test mode (tests call deliverDue with their own clock).if (listen && config.nodeEnv !== 'test') webhooks.start();if (listen) balance.start();++ let creditTimer = null;++ if (listen && config.balance.enabled) {++ const tickCredits = () => creditGrants.poll().catch((err) => log.warn(`[ai] credit estimate failed: ${String(err && err.message || err).slice(0, 180)}`));++ creditTimer = setInterval(tickCredits, config.balance.intervalMs);++ if (creditTimer.unref) creditTimer.unref();++ setTimeout(tickCredits, 5000).unref?.();++ }if (listen && config.memoryJobs && config.memoryJobs.enabled) memoryJobs.start();// Team sharing's expiry sweep and delivery queue: production only, so a test's database has no background writers.if (listen && config.nodeEnv !== 'test' && memorySharing) memorySharing.start();@@@ -287,6 +296,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.nowclearInterval(housekeeping);if (gpuReconciler) clearInterval(gpuReconciler);balance.stop();++ if (creditTimer) clearInterval(creditTimer);if (benchSubscription) benchSubscription.stop();if (accountSubscription) accountSubscription.stop();userModules.stop();@@@ -56,7 +56,7 @@ function sameOrigin(req, config, { write = false } = {}) {return true;}--function meRouter({ credentials, preferences, grants, auth, registry, config, clock = { now: () => Date.now() }, log = console, personSessions = null, sendError }) {++function meRouter({ credentials, preferences, grants, auth, registry, config, clock = { now: () => Date.now() }, log = console, personSessions = null, sessionCookies = null, sendError }) {const r = express.Router();/** Authenticate a person, never a principal. Sets req.principal = { kind: 'user', subject }. */@@@ -118,7 +118,17 @@ function meRouter({ credentials, preferences, grants, auth, registry, config, cltry { await registry.audit(personOf(req), action, 'credential', personOf(req), { trace: req.ov && req.ov.traceId, metadata }); } catch { /* audit never breaks the person's request */ }};-- r.get('/api/v1/me/credentials', guard, run(async (req) => ({ credentials: await credentials.listSelf(personOf(req)) })));++ r.get('/api/v1/me/credentials', guard, run(async (req) => ({ credentials: [...(await credentials.listSelf(personOf(req))).map((c) => ({ ...c, id: c.provider, kind: 'api_key', label: c.provider, created: c.created_at, last_used: c.last_used_at || null })), ...(sessionCookies ? await sessionCookies.list('self', personOf(req)) : [])] })));++ if (sessionCookies) {++ r.post('/api/v1/me/credentials', guard, run(async (req) => {++ const body = req.body || {};++ if (body.kind === 'session_cookie' || body.cookie != null) return { credential: await sessionCookies.add('self', personOf(req), body) };++ const saved = await credentials.putSelf(personOf(req), body.provider, body);++ return { credential: { id: saved.provider, kind: 'api_key', provider: saved.provider, created: saved.created_at, label: body.label || saved.provider } };++ }));++ r.get('/api/v1/me/credentials/:id/balance', guard, run(async (req) => ({ balance: await sessionCookies.balance('self', personOf(req), req.params.id) })));++ r.delete('/api/v1/me/credentials/:id', guard, async (req, res) => { try { const gone = await sessionCookies.remove('self', personOf(req), req.params.id) || await credentials.removeSelf(personOf(req), req.params.id); res.status(gone ? 204 : 404).end(); } catch (err) { sendError(res, err, req.ov); } });++ }r.put('/api/v1/me/credentials/:provider', guard, run(async (req) => {const out = await credentials.putSelf(personOf(req), req.params.provider, { ...(req.body || {}), ...(req.body && req.body.provider == null ? { provider: req.params.provider } : {}) });await audit(req, 'credential.self.put', { provider: out.provider, status: out.status, base_url: out.base_url });@@@ -88,7 +88,7 @@ function createAiReadiness({ db, registry, pool, keys, runs, release = null }) {}/** AI gauges on the openvibe-shared/metrics registry. */--function registerAiGauges(registry, { runs, registry: ai, pool, balance = null, gpus = null, abuse = null, db = null }) {++function registerAiGauges(registry, { runs, registry: ai, pool, balance = null, creditGrants = null, gpus = null, abuse = null, db = null }) {registry.gauge({name: 'ai_runs', help: 'Runs waiting for a slot (queued) and holding one (running)', labelNames: ['state'],collect: () => {@@@ -108,8 +108,11 @@ function registerAiGauges(registry, { runs, registry: ai, pool, balance = null,});// The hourly balance probe's last reading (server/providers/balance.js), never a live call on scrape.registry.gauge({-- name: 'ai_provider_balance_usd', help: 'Provider account balance in USD, as last read by the hourly probe', labelNames: ['provider'],-- collect: () => (balance ? balance.values() : []).map(r => ({ labels: { provider: r.provider }, value: r.usd })),++ name: 'ai_provider_balance_usd', help: 'Provider balance in USD; grant balances are estimates', labelNames: ['provider', 'kind'],++ collect: () => [++ ...(balance ? balance.values().map(r => ({ labels: { provider: r.provider, kind: 'available' }, value: r.usd })) : []),++ ...(creditGrants ? creditGrants.values().flatMap((r) => ['promotional', 'purchased'].map((kind) => ({ labels: { provider: r.provider, kind }, value: r[kind] }))) : []),++ ],});// Plan E5: free-tier requests throttled in the last 15 minutes, by reason (velocity, rate, hosting, network).// OpenVibe.Host's prometheus rule alerts when the sum stays high: the automatic throttle is working, and annnew file mode 100644@@@ -0,0 +1,122 @@++'use strict';++++const { resolveSecret } = require('../util');++++function centsToUsd(value) {++ if (typeof value !== 'string' || !/^\d+(?:\.\d{1,6})?$/.test(value)) return null;++ const n = Number(value);++ return Number.isFinite(n) ? n / 100 : null;++}++++function allocateDetailed(grants, spentUsd, now = Date.now()) {++ let remaining = Math.max(0, Number(spentUsd) || 0);++ const ordered = [...grants].sort((a, b) => {++ if (a.kind !== b.kind) return a.kind === 'promotional' ? -1 : 1;++ return (a.expires_at || '9999').localeCompare(b.expires_at || '9999') || String(a.as_of).localeCompare(String(b.as_of));++ });++ const out = { promotional: 0, purchased: 0 };++ const grantBalances = {};++ for (const g of ordered) {++ if (g.expires_at && Date.parse(g.expires_at) <= now) { grantBalances[g.id] = 0; continue; }++ const amount = Math.max(0, Number(g.remaining_as_of_usd == null ? g.amount_usd : g.remaining_as_of_usd) || 0);++ const used = Math.min(amount, remaining);++ remaining -= used;++ out[g.kind] += amount - used;++ grantBalances[g.id] = amount - used;++ }++ return { ...out, grantBalances };++}++function allocate(grants, spentUsd, now = Date.now()) { const { promotional, purchased } = allocateDetailed(grants, spentUsd, now); return { promotional, purchased }; }++++function createCreditGrants({ db, env = process.env, fetchImpl = globalThis.fetch, clock = { now: () => Date.now() } }) {++ const latest = new Map();++ let running = null;++ const list = async (provider = 'anthropic') => db.prepare('SELECT id, provider_key, kind, amount_usd, as_of, expires_at, applies_to, received_at, remaining_as_of_usd, remaining_as_of FROM provider_credit_grants WHERE provider_key = ? ORDER BY as_of DESC, id DESC').all(provider);++ async function add(grant) {++ const { provider_key = 'anthropic', kind, amount_usd, as_of, expires_at = null, applies_to = null, received_at = as_of, remaining_as_of_usd = amount_usd, remaining_as_of = as_of } = grant || {};++ if (!['purchased', 'promotional'].includes(kind) || !Number.isFinite(Number(amount_usd)) || Number(amount_usd) < 0 || !as_of || Number.isNaN(Date.parse(as_of))) throw new TypeError('grant requires kind, nonnegative amount_usd, and as_of');++ const result = await db.prepare('INSERT INTO provider_credit_grants (provider_key, kind, amount_usd, as_of, expires_at, applies_to, created_at, received_at, remaining_as_of_usd, remaining_as_of) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id').get(provider_key, kind, Number(amount_usd), as_of, expires_at, applies_to, new Date(clock.now()).toISOString(), received_at, Number(remaining_as_of_usd), remaining_as_of);++ return result;++ }++ async function preference(provider = 'anthropic') {++ const row = await db.prepare('SELECT use_promotional_first, promotional_floor_usd FROM provider_credit_preferences WHERE provider_key = ?').get(provider);++ return row ? { enabled: Boolean(row.use_promotional_first), floor_usd: Number(row.promotional_floor_usd) } : { enabled: true, floor_usd: 5 };++ }++ async function setPreference({ provider_key = 'anthropic', enabled = true, floor_usd = 5 }) {++ if (typeof enabled !== 'boolean' || !Number.isFinite(Number(floor_usd)) || Number(floor_usd) < 0 || Number(floor_usd) > 1000000) throw new TypeError('preference requires a boolean enabled and nonnegative floor_usd');++ await db.prepare(`INSERT INTO provider_credit_preferences (provider_key, use_promotional_first, promotional_floor_usd, updated_at) VALUES (?, ?, ?, ?)++ ON CONFLICT (provider_key) DO UPDATE SET use_promotional_first = excluded.use_promotional_first, promotional_floor_usd = excluded.promotional_floor_usd, updated_at = excluded.updated_at`)++ .run(provider_key, enabled ? 1 : 0, Number(floor_usd), new Date(clock.now()).toISOString());++ return preference(provider_key);++ }++ function shouldPrefer(provider = 'anthropic') {++ const balance = latest.get(provider);++ if (!balance) return false;++ // Preferences are loaded in the hourly poll, so routing never needs a database read per request.++ const pref = balance.preference || { enabled: true, floor_usd: 5 };++ return pref.enabled && balance.promotional > pref.floor_usd;++ }++ async function spend(provider, grants) {++ const ref = env[`AI_${provider.toUpperCase()}_ADMIN_KEY_REF`];++ const key = resolveSecret(ref, env);++ const last = lastAdminAtByProvider.get(provider) || 0;++ if (provider === 'anthropic' && key && clock.now() - last >= 60000) {++ lastAdminAtByProvider.set(provider, clock.now());++ try {++ const since = grants.map((g) => Date.parse(g.as_of)).filter(Number.isFinite).reduce((a, b) => Math.min(a, b), clock.now());++ const url = new URL('https://api.anthropic.com/v1/organizations/analytics/cost');++ url.searchParams.set('starting_at', new Date(since).toISOString());++ url.searchParams.set('ending_at', new Date(clock.now()).toISOString());++ url.searchParams.set('bucket_width', '1d');++ const response = await fetchImpl(url, { headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01', 'anthropic-beta': 'analytics-usage-cost-2026-08-24', Accept: 'application/json' }, signal: AbortSignal.timeout(10000) });++ if (response.ok) {++ const data = await response.json();++ const values = (data.data || data.rows || []).map((row) => centsToUsd(row.cost && row.cost.amount)).filter((x) => x != null);++ if (values.length) return { usd: values.reduce((a, b) => a + b, 0), source: 'Admin API' };++ }++ } catch { /* Admin analytics unavailable: use the service's own metering below. */ }++ }++ if (provider === 'openai' && key && clock.now() - last >= 60000) {++ lastAdminAtByProvider.set(provider, clock.now());++ try {++ const since = grants.map((g) => Date.parse(g.remaining_as_of || g.as_of)).filter(Number.isFinite).reduce((a, b) => Math.min(a, b), clock.now());++ let after = null; let total = 0; let pages = 0;++ do {++ const url = new URL('https://api.openai.com/v1/organization/costs');++ url.searchParams.set('start_time', String(Math.floor(since / 1000)));++ url.searchParams.set('bucket_width', '1d');++ if (after) url.searchParams.set('page', after);++ const response = await fetchImpl(url, { headers: { Authorization: `Bearer ${key}`, Accept: 'application/json' }, signal: AbortSignal.timeout(10000), redirect: 'error' });++ if (!response.ok) break;++ const data = await response.json();++ for (const bucket of data.data || []) for (const row of bucket.results || []) total += Number(row.amount && row.amount.value) || 0;++ after = data.has_more ? data.next_page : null;++ pages++;++ } while (after && pages < 100);++ if (pages) return { usd: total, source: 'Admin API' };++ } catch { /* Admin analytics unavailable: use the service's own metering below. */ }++ }++ const since = grants.map((g) => Date.parse(g.as_of)).filter(Number.isFinite).reduce((a, b) => Math.min(a, b), clock.now());++ const r = await db.prepare("SELECT COALESCE(SUM(cost_usd), 0) AS spent FROM requests WHERE provider_key = ? AND status = 'ok' AND created_at >= ?").get(provider, new Date(since).toISOString());++ return { usd: Number(r && r.spent) || 0, source: "OpenVibe's own metering" };++ }++ async function poll() {++ if (running) return running;++ running = (async () => {++ for (const provider of ['anthropic', 'openai']) {++ const grants = await list(provider);++ if (!grants.length) continue;++ const spentResult = await spend(provider, grants);++ const balances = allocateDetailed(grants, spentResult.usd, clock.now());++ latest.set(provider, { ...balances, grants, preference: await preference(provider), spent_usd: spentResult.usd, source: spentResult.source, at: clock.now() });++ }++ return values();++ })().finally(() => { running = null; });++ return running;++ }++ function values() { return [...latest.entries()].map(([provider, value]) => ({ provider, ...value })); }++ const lastAdminAtByProvider = new Map();++ return { add, list, poll, values, preference, setPreference, shouldPrefer };++}++++module.exports = { createCreditGrants, centsToUsd, allocate, allocateDetailed };@@@ -216,7 +216,7 @@ function selfModelFor(cred, operation, req, ctx, prefs) {return { model, kind, why };}--function createProviderPool({ db, registry, config, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, credentialFetch = null, machineModels = null, community = null, bench = null, variantEvidence = null, adultStatus = null, offPeak = null }) {++function createProviderPool({ db, registry, config, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, credentialFetch = null, machineModels = null, community = null, bench = null, variantEvidence = null, adultStatus = null, offPeak = null, creditGrants = null }) {// A person's own machine as their model (credential provider node, server/machines.js).const machines = machineModels || require('../machines').createMachineModels({ config, env, fetchImpl });const cache = new Map(); // key -> { stamp, adapter }@@@ -542,6 +542,12 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noconst addOffer = async (p, model, caps) => {const id = offerId(p.key, model);if (seen.has(id)) return;++ // The private objective means first-party capacity only; operator-paid public APIs never receive it.++ if ((ctx.aliasPolicy === 'private' || (route.constraints && route.constraints.objective === 'private'))++ && p.kind === 'anthropic' && !(p.metadata && p.metadata.local === true && modelCatalog.accessOf(p) === 'OWN_DEPLOYMENT')) {++ accessExcluded.push({ provider: p.key, model, eligible: false, excluded_reason: 'private requests exclude operator-paid Anthropic capacity', cost: null, latency: null });++ return;++ }// The content policy narrows the pool before placement: an uncensored offer never answers a standard// request, and the adult tier has no other offer to run on.if (!(await policyAllows(registry, p, model, ctx.contentPolicy || 'standard'))) return;@@@ -636,6 +642,17 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.norateCards, states, now: clock.now(), current, minGain: config.placement.minGain,weights: { cost: config.placement.costWeight, latency: config.placement.latencyWeight },});++ // Spend an entered promotional grant first, but only among candidates the route's own tier, capability,++ // trust, price cap and health checks already accepted. A person's own-key choice never reaches this pool.++ const preferredCreditProvider = creditGrants && ['openai', 'anthropic'].find((provider) => creditGrants.shouldPrefer(provider));++ if (preferredCreditProvider && ctx.personPays !== true && !route.pinned?.length++ && ctx.aliasPolicy !== 'private' && !(route.constraints && route.constraints.objective === 'private')) {++ const promotional = result.candidates.find((x) => x.eligible && (x.provider || splitOfferId(x.id).provider) === preferredCreditProvider);++ if (promotional) {++ result.selected = promotional.id;++ result.reasons = [`${preferredCreditProvider} promotional credit is available above its configured floor`, ...result.reasons];++ }++ }const byId = new Map(offers.map(o => [o.offer_id, o]));const explain = {objective: result.objective, reasons: result.reasons, selected: result.selected,nnew file mode 100644@@@ -0,0 +1,90 @@++'use strict';++++// Provider readers are deliberately a closed allowlist. Callers cannot supply a URL, method, host, path, or header.++const crypto = require('crypto');++const { guardedFetch } = require('./providers/guarded-fetch');++// An Anthropic organization id is a UUID; anything else never reaches a reader's path (no '..' or extra segments).++const ORG_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;++const READERS = Object.freeze({++ anthropic: Object.freeze([++ Object.freeze({ name: 'purchased', method: 'GET', host: 'console.anthropic.com', path: (s) => `/api/organizations/${encodeURIComponent(s.org_id)}/prepaid/credits`, header: (secret) => ({ Cookie: `sessionKey=${secret}` }), parse: (j) => Number(j.amount) / 100 }),++ Object.freeze({ name: 'current_spend', method: 'GET', host: 'console.anthropic.com', path: (s) => `/api/organizations/${encodeURIComponent(s.org_id)}/current_spend`, header: (secret) => ({ Cookie: `sessionKey=${secret}` }), parse: (j) => j }),++ ]),++ openai: Object.freeze([++ Object.freeze({ name: 'credit_grants', method: 'GET', host: 'api.openai.com', path: () => '/dashboard/billing/credit_grants', header: (secret) => ({ Authorization: `Bearer ${secret}` }), parse: (j) => ({ total_granted: j.total_granted, total_used: j.total_used, total_available: j.total_available, grants: Array.isArray(j.grants) ? j.grants : [] }) }),++ ]),++});++++function orgIdOf(value) {++ const id = String(value || '').trim();++ if (!ORG_ID.test(id)) throw new TypeError('org_id must be the organization id from the provider console (a UUID)');++ return id.toLowerCase();++}++++function createSessionCookies({ db, config, fetchImpl = guardedFetch, clock = { now: () => Date.now() }, notify = async () => {} }) {++ const key = () => {++ const hex = String(config.credentialsKey || '');++ if (!/^[0-9a-f]{64}$/i.test(hex)) throw new Error('session credentials are unavailable');++ return Buffer.from(hex, 'hex');++ };++ const seal = (plain, aad) => {++ const iv = crypto.randomBytes(12); const c = crypto.createCipheriv('aes-256-gcm', key(), iv); c.setAAD(Buffer.from(aad));++ const body = Buffer.concat([c.update(String(plain), 'utf8'), c.final()]);++ return `v1:${iv.toString('base64')}:${c.getAuthTag().toString('base64')}:${body.toString('base64')}`;++ };++ const open = (sealed, aad) => {++ const [v, iv, tag, body] = String(sealed).split(':');++ if (v !== 'v1') throw new Error('session credential unavailable');++ const d = crypto.createDecipheriv('aes-256-gcm', key(), Buffer.from(iv, 'base64')); d.setAAD(Buffer.from(aad)); d.setAuthTag(Buffer.from(tag, 'base64'));++ return Buffer.concat([d.update(Buffer.from(body, 'base64')), d.final()]).toString('utf8');++ };++ const aad = (r) => `${r.owner}|${r.subject}|${r.provider}|session_cookie`;++ const view = (r) => ({ id: String(r.id), kind: 'session_cookie', provider: r.provider, label: r.label, created: r.created_at, last_used: r.last_used_at || null, expires: r.expires_at || null, expired: Boolean(r.expired) });++ async function add(owner, subject, body = {}) {++ const provider = String(body.provider || '');++ if (!READERS[provider] || typeof body.cookie !== 'string' || body.cookie.length < 16 || body.cookie.length > 4096) throw new TypeError('provider and session cookie are required');++ const now = new Date(clock.now()).toISOString();++ const row = { owner, subject, provider };++ await db.prepare(`INSERT INTO session_credentials(owner,subject,provider,label,secret_enc,created_at,expires_at,settings_json,expired,notified_expired) VALUES(?,?,?,?,?,?,?,?,0,0)++ ON CONFLICT(owner,subject,provider) DO UPDATE SET label=excluded.label, secret_enc=excluded.secret_enc, created_at=excluded.created_at, last_used_at=NULL, expires_at=excluded.expires_at, settings_json=excluded.settings_json, expired=0, notified_expired=0`)++ .run(owner, subject, provider, String(body.label || `${provider} browser session`).slice(0, 80), seal(body.cookie, aad(row)), now, body.expires_at || null, JSON.stringify(provider === 'anthropic' ? { org_id: orgIdOf(body.org_id) } : {}));++ return view({ ...row, label: String(body.label || `${provider} browser session`).slice(0, 80), created_at: now, expires_at: body.expires_at || null, expired: 0 });++ }++ async function list(owner, subject) { return (await db.prepare('SELECT * FROM session_credentials WHERE owner=? AND subject=? ORDER BY provider').all(owner, subject)).map(view); }++ async function remove(owner, subject, id) {++ if (!/^\d+$/.test(String(id))) return false;++ return (await db.prepare('DELETE FROM session_credentials WHERE owner=? AND subject=? AND id=?').run(owner, subject, id)).changes > 0;++ }++ async function balance(owner, subject, id = null) {++ const row = id == null ? await db.prepare('SELECT * FROM session_credentials WHERE owner=? AND subject=? ORDER BY provider LIMIT 1').get(owner, subject) : await db.prepare('SELECT * FROM session_credentials WHERE owner=? AND subject=? AND id=?').get(owner, subject, id);++ if (!row) return null;++ if (row.expires_at && Date.parse(row.expires_at) <= clock.now()) {++ await db.prepare('UPDATE session_credentials SET expired=1 WHERE id=?').run(row.id);++ return { credential: view({ ...row, expired: 1 }), source: 'estimate', at: new Date(clock.now()).toISOString(), values: {} };++ }++ const secret = open(row.secret_enc, aad(row)); const settings = JSON.parse(row.settings_json || '{}'); const values = {};++ try {++ for (const reader of READERS[row.provider] || []) {++ if (row.provider === 'anthropic' && !ORG_ID.test(String(settings.org_id || ''))) throw new Error('reader allowlist rejected request');++ const url = new URL(reader.path(settings), `https://${reader.host}`);++ if (url.hostname !== reader.host || url.protocol !== 'https:' || !reader.path(settings).startsWith('/') || reader.method !== 'GET') throw new Error('reader allowlist rejected request');++ const response = await fetchImpl(url, { method: 'GET', headers: reader.header(secret), redirect: 'error', signal: AbortSignal.timeout(10000) });++ if (response.status === 401 || response.status === 403) {++ await db.prepare('UPDATE session_credentials SET expired=1,notified_expired=1 WHERE id=?').run(row.id);++ if (!row.notified_expired) await notify({ owner, subject, provider: row.provider });++ return { credential: view({ ...row, expired: 1 }), source: 'estimate', at: new Date(clock.now()).toISOString(), values: {} };++ }++ if (!response.ok) continue;++ values[reader.name] = reader.parse(await response.json());++ }++ await db.prepare('UPDATE session_credentials SET last_used_at=?,expired=0 WHERE id=?').run(new Date(clock.now()).toISOString(), row.id);++ return { credential: view(row), source: 'provider console', at: new Date(clock.now()).toISOString(), values };++ } catch {++ // Provider secrets and upstream response text are intentionally not propagated.++ return { credential: view(row), source: 'estimate', at: new Date(clock.now()).toISOString(), values: {} };++ }++ }++ return { add, list, remove, balance };++}++++module.exports = { createSessionCookies, READERS };@@@ -54,6 +54,7 @@ t.test('an export carries the person\'s sessions and credential hints, and a delawait h.db.exec("INSERT INTO console_sessions (id_hash, subject, username, staff, csrf, created_at, expires_at) VALUES ($1, $2, $3, '{}', 'c', '2026-10-01', '2027-10-01')", [`h_${name}`, subject, name]);await h.db.exec("INSERT INTO person_sessions (id_hash, subject, username, created_at, expires_at) VALUES ($1, $2, $3, '2026-10-01', '2027-10-01')", [`p_${name}`, subject, name]);await h.db.exec("INSERT INTO subject_credentials (owner, subject, provider, key_enc, key_hint, models, created_at, updated_at) VALUES ('proj_1', $1, 'openai', 'v1:secret', $2, '{}', '2026-10-01', '2026-10-02')", [subject, `hint-${name}`]);++ await h.db.exec("INSERT INTO session_credentials (owner, subject, provider, label, secret_enc, created_at) VALUES ('self', $1, 'openai', 'browser', 'v1:cookie-secret', '2026-10-01')", [subject]);await h.db.exec("INSERT INTO free_allowance_usage (subject, provider, metric, period_start, period_end, free_used, updated_at) VALUES ($1, 'openai', 'input-tokens', 1, 2, 10, '2026-10-01')", [subject]);// Plan E5: the free-tier counters are erased with the account (the shared device counter only when its// last account goes).@@@ -76,6 +77,9 @@ t.test('an export carries the person\'s sessions and credential hints, and a delassert.strictEqual(files['person-sessions.json'].length, 1);assert.strictEqual(files['provider-credentials.json'].length, 1);assert.strictEqual(files['provider-credentials.json'][0].key_hint, 'hint-ada');++ assert.strictEqual(files['provider-session-credentials.json'].length, 1);++ assert.strictEqual(files['provider-session-credentials.json'][0].label, 'browser');++ assert.ok(!JSON.stringify(part.body).includes('v1:cookie-secret'), 'the encrypted session cookie is never exported');assert.ok(!JSON.stringify(part.body).includes('v1:secret'), 'the encrypted key is never exported');assert.ok(!JSON.stringify(part.body).includes(B), 'nobody else\'s rows');@@@ -87,6 +91,7 @@ t.test('an export carries the person\'s sessions and credential hints, and a delassert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM abuse_devices WHERE subject = $1', [B])), 1, "nobody else's device row");assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM person_sessions WHERE subject = $1', [A])), 0);assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM subject_credentials WHERE subject = $1', [A])), 0);++ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM session_credentials WHERE subject = $1', [A])), 0, 'the session cookie is erased');assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM free_allowance_usage WHERE subject = $1', [A])), 1, 'the allowance ledger stays');assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM routes WHERE created_by = $1', [A])), 1, 'the staff catalogue stays');assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM console_sessions WHERE subject = $1', [B])), 1, 'Bo stays');@@@ -5,6 +5,7 @@const assert = require('assert');const { createRegistry } = require('openvibe-shared/metrics');const { createBalanceProbe, deepseekUsd } = require('../server/providers/balance');++const { centsToUsd, allocate, createCreditGrants } = require('../server/providers/credit-grants');const { registerAiGauges } = require('../server/observability');const { load } = require('../server/config');const { suite } = require('./helpers');@@@ -41,7 +42,48 @@ t.test('DeepSeek balance: USD total read, gauge exposed, key never logged', asynbalance: probe,});const text = await metrics.metricsAsync();-- assert.ok(/ai_provider_balance_usd\{provider="shared"\} 0\.05\n/.test(text), text);++ assert.ok(/ai_provider_balance_usd\{provider="shared",kind="available"\} 0\.05\n/.test(text), text);++});++++t.test('grant cost uses cents strings and spends promotional credit in expiry order', () => {++ assert.strictEqual(centsToUsd('41280.000000'), 412.8);++ assert.strictEqual(centsToUsd('0.05'), 0.0005);++ assert.strictEqual(centsToUsd(5), null);++ const left = allocate([++ { kind: 'purchased', amount_usd: '40', as_of: '2026-01-01' },++ { kind: 'promotional', amount_usd: '25', expires_at: '2026-12-01', as_of: '2026-01-01' },++ { kind: 'promotional', amount_usd: '10', expires_at: '2026-11-01', as_of: '2026-01-01' },++ ], 12, Date.parse('2026-10-01'));++ assert.deepStrictEqual(left, { promotional: 23, purchased: 40 });++ assert.deepStrictEqual(allocate([{ kind: 'promotional', amount_usd: 10, expires_at: '2026-01-01' }], 0, Date.parse('2026-02-01')), { promotional: 0, purchased: 0 });++});++++t.test('OpenAI admin cost rows are summed in USD and paged', async () => {++ const urls = [];++ const db = { prepare(sql) { return { all: async () => [{ kind: 'promotional', amount_usd: '110', as_of: '2026-11-01', remaining_as_of_usd: '110', remaining_as_of: '2026-11-01' }], get: async () => ({ spent: 0 }) }; } };++ const credits = createCreditGrants({ db, env: { AI_OPENAI_ADMIN_KEY_REF: 'env:OPENAI_ADMIN', OPENAI_ADMIN: 'admin-secret' }, clock: { now: () => Date.parse('2026-11-08T01:00:00Z') }, fetchImpl: async (url) => {++ urls.push(String(url));++ return { ok: true, json: async () => urls.length === 1 ? { data: [{ results: [{ amount: { value: '1.43' } }] }], has_more: true, next_page: 'page2' } : { data: [{ results: [{ amount: { value: '0.00' } }, { amount: { value: '0.00' } }] }], has_more: false } };++ } });++ const result = await credits.poll();++ assert.strictEqual(urls.length, 2);++ assert.match(urls[0], /start_time=1793491200/);++ assert.strictEqual(result.find((r) => r.provider === 'openai').spent_usd, 1.43);++});++++t.test('the optional Admin API key is sent only on its cost request and never returned', async () => {++ const calls = [];++ const db = { prepare(sql) { return { all: async () => [{ kind: 'promotional', amount_usd: '200', as_of: '2026-11-08T00:00:00Z', expires_at: null }], get: async () => ({ spent: 0 }) }; } };++ const credits = createCreditGrants({ db, env: { AI_ANTHROPIC_ADMIN_KEY_REF: 'env:ANTHROPIC_ADMIN', ANTHROPIC_ADMIN: 'admin-secret' }, clock: { now: () => Date.parse('2026-11-08T01:00:00Z') }, fetchImpl: async (url, options) => {++ calls.push({ url: String(url), key: options.headers['x-api-key'] });++ return { ok: true, json: async () => ({ data: [{ cost: { amount: '360.000000' } }] }) };++ } });++ const result = await credits.poll();++ assert.strictEqual(calls.length, 1);++ assert.ok(calls[0].url.includes('starting_at='));++ assert.strictEqual(calls[0].key, 'admin-secret');++ assert.strictEqual(result[0].promotional, 196.4);++ assert.ok(!JSON.stringify(result).includes('admin-secret'));});t.test('a failed probe keeps the last reading and does not fail the caller', async () => {nnew file mode 100644@@@ -0,0 +1,36 @@++'use strict';++const t = require('node:test');++const assert = require('node:assert/strict');++const { READERS, createSessionCookies } = require('../server/session-cookies');++++t.test('session-cookie readers are fixed GET endpoints with exact provider hosts', () => {++ for (const [provider, readers] of Object.entries(READERS)) for (const reader of readers) {++ assert.equal(reader.method, 'GET');++ assert.equal(new URL(reader.path({ org_id: 'org-test' }), `https://${reader.host}`).hostname, reader.host);++ assert.ok(['console.anthropic.com', 'api.openai.com'].includes(reader.host));++ }++});++++t.test('a rejected cookie reader response falls back without exposing cookie material', async () => {++ const updates = [];++ const row = { id: '1', owner: 'self', subject: 'usr_01ARZ3NDEKTSV4RRFFQ69G5FAV', provider: 'openai', label: 'session', secret_enc: '', created_at: new Date(0).toISOString(), settings_json: '{}', expired: 0, notified_expired: 0 };++ const db = { prepare(sql) { return { get: async () => row, run: async (...args) => { updates.push([sql, args]); if (sql.startsWith('INSERT INTO session_credentials')) { row.secret_enc = args[4]; row.created_at = args[5]; } return { changes: 1 }; }, all: async () => [] }; } };++ const api = createSessionCookies({ db, config: { credentialsKey: 'a'.repeat(64) }, clock: { now: () => 10 }, fetchImpl: async (url, init) => { assert.equal(url.hostname, 'api.openai.com'); assert.equal(url.pathname, '/dashboard/billing/credit_grants'); assert.equal(init.method, 'GET'); assert.equal(init.redirect, 'error'); assert.ok(init.headers.Authorization.startsWith('Bearer sess-')); return { status: 403, ok: false }; } });++ // Seed using add, then return the inserted row to the balance reader.++ await api.add('self', row.subject, { provider: 'openai', cookie: 'sess-secret-value-long-enough' });++ const balance = await api.balance('self', row.subject, '1');++ assert.equal(balance.source, 'estimate');++ assert.equal(JSON.stringify(balance).includes('sess-secret'), false);++ assert.equal(updates.length, 2);++});++++t.test('an Anthropic organization id must be a UUID, so nothing else reaches a reader path', async () => {++ let wrote = false;++ const db = { prepare() { return { get: async () => null, run: async () => { wrote = true; return { changes: 1 }; } }; } };++ const api = createSessionCookies({ db, config: { credentialsKey: 'a'.repeat(64) }, clock: { now: () => 10 }, fetchImpl: async () => { throw new Error('no fetch expected'); } });++ const subject = 'usr_01ARZ3NDEKTSV4RRFFQ69G5FAV';++ for (const org_id of ['..', '../../v1/x', 'org-test', '']) {++ await assert.rejects(api.add('self', subject, { provider: 'anthropic', cookie: 'sk-ant-sid01-long-enough-value', org_id }), /org_id/);++ }++ assert.equal(wrote, false, 'a refused org id is never stored');++});
new file mode 100644
new file mode 100644
@@ -0,0 +1,13 @@
@@ -0,0 +1,13 @@
+# Provider session readers
+
+AI accepts a session cookie only for the fixed read-only readers below. The API does not accept a URL, HTTP method, host, path, or header name from a caller. Requests use the outbound guarded fetch, HTTPS, exact host checks, a response timeout, and refused redirects.
+
+| Provider | Request | Authentication | Data |
+| --- | --- | --- | --- |
+| Anthropic | `GET https://console.anthropic.com/api/organizations/{org_id}/prepaid/credits` | `Cookie: sessionKey=…` | Purchased balance |
+| Anthropic | `GET https://console.anthropic.com/api/organizations/{org_id}/current_spend` | `Cookie: sessionKey=…` | Current spend |
+| OpenAI | `GET https://api.openai.com/dashboard/billing/credit_grants` | `Authorization: Bearer sess-…` | Grant totals and grant rows |
+
+The Anthropic organization ID is a non-secret setting stored with the credential. There is no Anthropic promotional-grant reader configured. A 401 or 403 marks the session expired and returns the estimated balance instead. Credentials are AES-GCM encrypted; API responses contain metadata only.
+
+Session-cookie access is powerful: a valid cookie can do anything its browser session can do on that provider site. OpenVibe sends it only to the allowlisted GET readers above. Signing out at the provider ends the session.
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
+-- phase: expand
+CREATE TABLE IF NOT EXISTS provider_credit_grants (
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ provider_key text COLLATE "C" NOT NULL,
+ kind text COLLATE "C" NOT NULL CHECK (kind IN ('promotional', 'purchased')),
+ amount_usd numeric(14, 6) NOT NULL CHECK (amount_usd >= 0),
+ as_of text COLLATE "C" NOT NULL,
+ expires_at text COLLATE "C",
+ applies_to text COLLATE "C",
+ created_at text COLLATE "C" NOT NULL
+);
+CREATE INDEX IF NOT EXISTS provider_credit_grants_provider ON provider_credit_grants(provider_key, kind, as_of);
+CREATE TABLE IF NOT EXISTS provider_credit_preferences (
+ provider_key text COLLATE "C" PRIMARY KEY,
+ use_promotional_first bigint NOT NULL DEFAULT 1,
+ promotional_floor_usd numeric(14, 6) NOT NULL DEFAULT 5 CHECK (promotional_floor_usd >= 0),
+ updated_at text COLLATE "C" NOT NULL
+);
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
+-- phase: expand
+ALTER TABLE provider_credit_grants ADD COLUMN IF NOT EXISTS received_at text COLLATE "C";
+ALTER TABLE provider_credit_grants ADD COLUMN IF NOT EXISTS remaining_as_of_usd numeric(14, 6);
+ALTER TABLE provider_credit_grants ADD COLUMN IF NOT EXISTS remaining_as_of text COLLATE "C";
+UPDATE provider_credit_grants SET received_at = as_of WHERE received_at IS NULL;
+UPDATE provider_credit_grants SET remaining_as_of_usd = amount_usd WHERE remaining_as_of_usd IS NULL;
+UPDATE provider_credit_grants SET remaining_as_of = as_of WHERE remaining_as_of IS NULL;
new file mode 100644
new file mode 100644
@@ -0,0 +1,17 @@
@@ -0,0 +1,17 @@
+-- phase: expand
+CREATE TABLE IF NOT EXISTS session_credentials (
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ owner text COLLATE "C" NOT NULL,
+ subject text COLLATE "C" NOT NULL,
+ provider text COLLATE "C" NOT NULL,
+ label text COLLATE "C" NOT NULL,
+ secret_enc text COLLATE "C" NOT NULL,
+ created_at text COLLATE "C" NOT NULL,
+ last_used_at text COLLATE "C",
+ expires_at text COLLATE "C",
+ expired bigint NOT NULL DEFAULT 0,
+ notified_expired bigint NOT NULL DEFAULT 0,
+ settings_json text COLLATE "C" NOT NULL DEFAULT '{}',
+ UNIQUE(owner, subject, provider)
+);
+CREATE INDEX IF NOT EXISTS session_credentials_subject ON session_credentials(owner, subject);
@@ -30,6 +30,9 @@ const TABLES = [
@@ -30,6 +30,9 @@ const TABLES = [
{ table: 'person_sessions', subject: 'subject', file: 'person-sessions.json', columns: ['username', 'created_at', 'expires_at', 'revoked_at'], order: 'created_at' },
{ table: 'person_sessions', subject: 'subject', file: 'person-sessions.json', columns: ['username', 'created_at', 'expires_at', 'revoked_at'], order: 'created_at' },
{ table: 'subject_credentials', subject: 'subject', file: 'provider-credentials.json', order: 'updated_at',
{ table: 'subject_credentials', subject: 'subject', file: 'provider-credentials.json', order: 'updated_at',
columns: ['owner', 'subject', 'provider', 'base_url', 'machine', 'key_hint', 'models', 'budget_usd_day', 'is_default', 'spend_key', 'status', 'checked_at', 'last_error', 'models_count', 'created_at', 'updated_at', 'last_used_at'] },
columns: ['owner', 'subject', 'provider', 'base_url', 'machine', 'key_hint', 'models', 'budget_usd_day', 'is_default', 'spend_key', 'status', 'checked_at', 'last_error', 'models_count', 'created_at', 'updated_at', 'last_used_at'] },
+ // Session-cookie values are never exported; only the person's credential metadata and non-secret reader settings.
+ { table: 'session_credentials', subject: 'subject', file: 'provider-session-credentials.json', order: 'created_at',
+ columns: ['owner', 'subject', 'provider', 'label', 'created_at', 'last_used_at', 'expires_at', 'expired', 'settings_json'] },
// L36 Y3: how the person's runs route (only_my_keys, free_first, the price ceiling and the defaults). Their own
// L36 Y3: how the person's runs route (only_my_keys, free_first, the price ceiling and the defaults). Their own
// settings, so they go with the account; the row holds no key.
// settings, so they go with the account; the row holds no key.
{ table: 'ai_person_preferences', subject: 'subject', file: 'ai-preferences.json', columns: ['preferences', 'created_at', 'updated_at'], order: 'created_at' },
{ table: 'ai_person_preferences', subject: 'subject', file: 'ai-preferences.json', columns: ['preferences', 'created_at', 'updated_at'], order: 'created_at' },
@@ -22,7 +22,7 @@ const pkg = require('../package.json');
@@ -22,7 +22,7 @@ const pkg = require('../package.json');
const { adminRoutes } = require('openvibe-shared/config');
const { adminRoutes } = require('openvibe-shared/config');
const ownerInputs = require('./owner-inputs');
const ownerInputs = require('./owner-inputs');
-function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, settings = null, benchEvents = null, runBudgets = null, memory = null, memoryImport = null, memoryServices = null, memorySharing = null, community = null, endpoints = null, webhooks = null, presets = null, receipts = null, balance = null, gpus = null, abuse = null, priceBook = null, ownEndpoints = null, refusal = null, batches = null }) {
+function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, settings = null, benchEvents = null, runBudgets = null, memory = null, memoryImport = null, memoryServices = null, memorySharing = null, community = null, endpoints = null, webhooks = null, presets = null, receipts = null, balance = null, creditGrants = null, sessionCookies = null, gpus = null, abuse = null, priceBook = null, ownEndpoints = null, refusal = null, batches = null }) {
const app = express();
const app = express();
// /.well-known/agent.json (openvibe-shared/agent-layer, SEO-2): what this site serves, for agents.
// /.well-known/agent.json (openvibe-shared/agent-layer, SEO-2): what this site serves, for agents.
require('openvibe-shared/agent-layer').mount(app, { descriptor: require('./agent-descriptor') });
require('openvibe-shared/agent-layer').mount(app, { descriptor: require('./agent-descriptor') });
@@ -34,7 +34,7 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
@@ -34,7 +34,7 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
// GET /metrics answers direct loopback callers only (Track O).
// GET /metrics answers direct loopback callers only (Track O).
const metrics = instrument(app, { service: 'ai', release: release.release });
const metrics = instrument(app, { service: 'ai', release: release.release });
app.use(require('openvibe-sdk/service').telemetryMiddleware); // request telemetry: a no-op until server/index.js calls telemetry.init()
app.use(require('openvibe-sdk/service').telemetryMiddleware); // request telemetry: a no-op until server/index.js calls telemetry.init()
- registerAiGauges(metrics.registry, { runs, registry, pool, balance, gpus, abuse, db });
+ registerAiGauges(metrics.registry, { runs, registry, pool, balance, creditGrants, gpus, abuse, db });
app.use(http.middleware());
app.use(http.middleware());
// Plan E5: the signed HttpOnly device id and the request's trust metadata on the model and Exchange APIs.
// Plan E5: the signed HttpOnly device id and the request's trust metadata on the model and Exchange APIs.
// The address/device stay in the request: only keyed hashes reach the database (server/abuse/).
// The address/device stay in the request: only keyed hashes reach the database (server/abuse/).
@@ -129,6 +129,37 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
@@ -129,6 +129,37 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
// ai.exchange, ai.provider_keys and ai.gpus (the GPU provisioner's marketplace keys and its monthly cap). Loopback only (the /internal guard above), guarded by ai.config.manage; the
// ai.exchange, ai.provider_keys and ai.gpus (the GPU provisioner's marketplace keys and its monthly cap). Loopback only (the /internal guard above), guarded by ai.config.manage; the
// actor is Network's X-OV-Subject (usr_…), and a secret or x-danger change needs X-OV-Fresh: 1.
// actor is Network's X-OV-Subject (usr_…), and a secret or x-danger change needs X-OV-Fresh: 1.
if (settings) {
if (settings) {
+ if (creditGrants) {
+ const express = require('express');
+ const creditsRouter = express.Router();
+ creditsRouter.use(auth.requireCap(CAPS.configManage));
+ creditsRouter.get('/', async (req, res) => {
+ try { res.json({ providers: await Promise.all(['anthropic', 'openai'].map(async (provider) => ({ provider, grants: await creditGrants.list(provider), estimate: creditGrants.values().find((x) => x.provider === provider) || null, preference: await creditGrants.preference(provider) }))) }); }
+ catch (err) { sendError(res, err, req.ov, log); }
+ });
+ creditsRouter.post('/', async (req, res) => {
+ try {
+ const provider = ['anthropic', 'openai'].includes(String(req.body.provider)) ? String(req.body.provider) : 'anthropic';
+ const grant = await creditGrants.add({ ...req.body, provider_key: provider });
+ creditGrants.poll().catch((err) => log.warn(`[ai] credit estimate failed: ${String(err && err.message || err).slice(0, 180)}`));
+ res.status(201).json({ grant });
+ } catch (err) { sendError(res, err, req.ov, log); }
+ });
+ creditsRouter.put('/preference', async (req, res) => {
+ try { res.json({ preference: await creditGrants.setPreference({ enabled: req.body.enabled, floor_usd: req.body.floor_usd, provider_key: ['anthropic', 'openai'].includes(String(req.body.provider)) ? String(req.body.provider) : 'anthropic' }) }); }
+ catch (err) { sendError(res, err, req.ov, log); }
+ });
+ app.use('/internal/provider-credits', creditsRouter);
+ }
+ if (sessionCookies) {
+ const express = require('express'); const cookiesRouter = express.Router();
+ cookiesRouter.use(auth.requireCap(CAPS.configManage));
+ cookiesRouter.get('/', async (req, res) => { try { res.set('Cache-Control', 'no-store').json({ credentials: await sessionCookies.list('operator', 'owner') }); } catch (err) { sendError(res, err, req.ov, log); } });
+ cookiesRouter.post('/', async (req, res) => { try { res.status(201).set('Cache-Control', 'no-store').json({ credential: await sessionCookies.add('operator', 'owner', req.body || {}) }); } catch (err) { sendError(res, err, req.ov, log); } });
+ cookiesRouter.delete('/:id', async (req, res) => { try { const gone = await sessionCookies.remove('operator', 'owner', req.params.id); res.status(gone ? 204 : 404).end(); } catch (err) { sendError(res, err, req.ov, log); } });
+ cookiesRouter.get('/:id/balance', async (req, res) => { try { res.set('Cache-Control', 'no-store').json({ balance: await sessionCookies.balance('operator', 'owner', req.params.id) }); } catch (err) { sendError(res, err, req.ov, log); } });
+ app.use('/internal/provider-session-credentials', cookiesRouter);
+ }
const configRoutes = adminRoutes([settings.exchange, settings.providerKeys.proxy, settings.gpus.proxy], {
const configRoutes = adminRoutes([settings.exchange, settings.providerKeys.proxy, settings.gpus.proxy], {
basePath: '/internal/config',
basePath: '/internal/config',
requireAdmin: auth.requireCap(CAPS.configManage),
requireAdmin: auth.requireCap(CAPS.configManage),
@@ -196,11 +227,11 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
@@ -196,11 +227,11 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
// The person's own AI settings (L36 Y1/Y3): their keys under owner `self`, and their routing preferences. Only
// The person's own AI settings (L36 Y1/Y3): their keys under owner `self`, and their routing preferences. Only
// the person's Network token or their AI site session (same origin) reaches them; a service token is refused.
// the person's Network token or their AI site session (same origin) reaches them; a service token is refused.
if (credentials && preferences && grants) {
if (credentials && preferences && grants) {
- app.use(require('./me').meRouter({ credentials, preferences, grants, auth, registry, config, clock, log, personSessions, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
+ app.use(require('./me').meRouter({ credentials, preferences, grants, auth, registry, config, clock, log, personSessions, sessionCookies, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
// The same settings as a page for people (GET /you, plain forms on AI's own person session).
// The same settings as a page for people (GET /you, plain forms on AI's own person session).
app.use(require('./you-page').youPageRouter({ credentials, preferences, grants, personSessions, config, log, machines: pool && pool.machines }));
app.use(require('./you-page').youPageRouter({ credentials, preferences, grants, personSessions, config, log, machines: pool && pool.machines }));
}
}
- app.use(consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env, clock, fetchImpl, log, vault, settings, personSessions, abuse, priceBook }));
+ app.use(consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env, clock, fetchImpl, log, vault, settings, personSessions, abuse, priceBook, creditGrants, sessionCookies }));
app.use(runPageRouter({ runs, personSessions, log }));
app.use(runPageRouter({ runs, personSessions, log }));
// ── The public pages moved to OpenVibe.Exchange (2026-11-07) ────
// ── The public pages moved to OpenVibe.Exchange (2026-11-07) ────
@@ -353,7 +353,7 @@ function load(env = process.env) {
@@ -353,7 +353,7 @@ function load(env = process.env) {
cooldownMs: int(env.AI_BREAKER_COOLDOWN_MS, 60000),
cooldownMs: int(env.AI_BREAKER_COOLDOWN_MS, 60000),
// A provider that answered "insufficient balance" is out of money, not flaky: its circuit stays open this
// A provider that answered "insufficient balance" is out of money, not flaky: its circuit stays open this
// long (10 minutes), shared by every request, before one probe is allowed to spend again.
// long (10 minutes), shared by every request, before one probe is allowed to spend again.
- exhaustedCooldownMs: Math.max(60000, int(env.AI_BREAKER_EXHAUSTED_COOLDOWN_MS, 10 * 60000)),
+ exhaustedCooldownMs: Math.max(60000, int(env.AI_BREAKER_EXHAUSTED_COOLDOWN_MS, 15 * 60000)),
},
},
// One retry on a transient failure (timeout, 429, 5xx, connection reset) before the
// One retry on a transient failure (timeout, 429, 5xx, connection reset) before the
// router moves on to the fallback — Live's rule.
// router moves on to the fallback — Live's rule.
@@ -116,7 +116,7 @@ const DEF_KINDS = {
@@ -116,7 +116,7 @@ const DEF_KINDS = {
};
};
const DAY_MS = 86400000;
const DAY_MS = 86400000;
-function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, vault = null, settings = null, personSessions = null, abuse = null, priceBook = null }) {
+function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, vault = null, settings = null, personSessions = null, abuse = null, priceBook = null, creditGrants = null, sessionCookies = null }) {
const cc = config.console;
const cc = config.console;
const creditsOn = Boolean(config.credits && config.credits.enabled);
const creditsOn = Boolean(config.credits && config.credits.enabled);
const routingOn = Boolean(config.routingEvidence && config.routingEvidence.enabled);
const routingOn = Boolean(config.routingEvidence && config.routingEvidence.enabled);
@@ -336,11 +336,39 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, e
@@ -336,11 +336,39 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, e
}));
}));
// ── Providers ────────────────────────────────────────────
// ── Providers ────────────────────────────────────────────
- r.get('/console/providers', read, wrap(async (req, res) => await send(res, 200, pages.providers({
+ r.get('/console/providers', read, wrap(async (req, res) => {
+ const estimates = creditGrants ? await Promise.all(['anthropic', 'openai'].map(async (provider) => {
+ const grantRows = await creditGrants.list(provider);
+ const estimate = creditGrants.values().find((x) => x.provider === provider);
+ return { provider, estimate: estimate || (grantRows.length ? { provider, promotional: null, purchased: null, grants: grantRows, source: null, at: null } : null), preference: await creditGrants.preference(provider) };
+ })) : [];
+ const canManageCookies = req.staff.caps.includes(CAPS.configManage);
+ const sessionCredentials = sessionCookies && canManageCookies ? await sessionCookies.list('operator', 'owner') : [];
+ const sessionBalances = sessionCookies && canManageCookies ? await Promise.all(sessionCredentials.map((c) => sessionCookies.balance('operator', 'owner', c.id))) : [];
+ return await send(res, 200, pages.providers({
...common(req), rows: (await Promise.all((await registry.listProviders()).map(ops.providerView))), models: await registry.listModels(), stubFallback: config.stubFallback,
...common(req), rows: (await Promise.all((await registry.listProviders()).map(ops.providerView))), models: await registry.listModels(), stubFallback: config.stubFallback,
canManage: req.staff.caps.includes(CAPS.providerManage), notice: notice(req), keyHints: vault ? await vault.hints() : new Map(),
canManage: req.staff.caps.includes(CAPS.providerManage), notice: notice(req), keyHints: vault ? await vault.hints() : new Map(),
- breaker: config.breaker, now: clock.now(),
- }))));
+ breaker: config.breaker, now: clock.now(), creditEstimates: estimates.map(({ provider, estimate, preference }) => ({ provider, estimate: estimate || { provider, promotional: null, purchased: null, grants: [], source: null, at: null }, preference })), sessionCredentials, sessionBalances, canManageCookies,
+ }));
+ }));
+ r.post('/console/provider-credits', needs(CAPS.providerManage), wrap(async (req, res) => {
+ if (!creditGrants) throw new Error('credit grants are unavailable');
+ const provider = ['anthropic', 'openai'].includes(String(req.body.provider)) ? String(req.body.provider) : 'anthropic';
+ await creditGrants.add({ provider_key: provider, kind: String(req.body.kind || 'promotional'), amount_usd: Number(req.body.amount_usd), as_of: String(req.body.as_of || ''), received_at: String(req.body.received_at || req.body.as_of || ''), remaining_as_of_usd: req.body.remaining_as_of_usd == null ? Number(req.body.amount_usd) : Number(req.body.remaining_as_of_usd), remaining_as_of: String(req.body.remaining_as_of || req.body.as_of || ''), expires_at: req.body.expires_at ? new Date(`${req.body.expires_at}T23:59:59Z`).toISOString() : null, applies_to: String(req.body.applies_to || '').slice(0, 200) || null });
+ res.redirect(303, '/console/providers?done=provider_credit');
+ }));
+ r.post('/console/provider-credit-preference', needs(CAPS.providerManage), wrap(async (req, res) => {
+ if (!creditGrants) throw new Error('credit preferences are unavailable');
+ const provider = ['anthropic', 'openai'].includes(String(req.body.provider)) ? String(req.body.provider) : 'anthropic';
+ await creditGrants.setPreference({ provider_key: provider, enabled: req.body.enabled === '1', floor_usd: Number(req.body.floor_usd) });
+ creditGrants.poll().catch((err) => log.warn(`[ai] credit estimate failed: ${String(err && err.message || err).slice(0, 180)}`));
+ res.redirect(303, '/console/providers?done=provider_credit');
+ }));
+ r.post('/console/provider-session-credentials', needs(CAPS.configManage), wrap(async (req, res) => {
+ if (!sessionCookies) throw new Error('session credentials are unavailable');
+ await sessionCookies.add('operator', 'owner', { provider: req.body.provider, cookie: String(req.body.cookie || ''), label: req.body.label, expires_at: req.body.expires_at || null, org_id: req.body.org_id });
+ res.redirect(303, '/console/providers?done=session_credential');
+ }));
r.get('/console/glossary', read, wrap(async (req, res) => await send(res, 200, pages.glossary({ ...common(req) }))));
r.get('/console/glossary', read, wrap(async (req, res) => await send(res, 200, pages.glossary({ ...common(req) }))));
r.post('/console/providers/:key/status', needs(CAPS.providerManage), wrap(async (req, res) => {
r.post('/console/providers/:key/status', needs(CAPS.providerManage), wrap(async (req, res) => {
await ops.setProviderStatus(req.params.key, String(req.body.status || ''), who(req));
await ops.setProviderStatus(req.params.key, String(req.body.status || ''), who(req));
@@ -274,7 +274,7 @@ function limitsUsed(c) {
@@ -274,7 +274,7 @@ function limitsUsed(c) {
return parts.join(' · ') || `${n(c.used.requests)} requests`;
return parts.join(' · ') || `${n(c.used.requests)} requests`;
}
}
-function providers({ staff, csrf, rows, models, canManage, stubFallback, notice, error, keyHints = new Map(), breaker = {}, now = Date.now() }) {
+function providers({ staff, csrf, rows, models, canManage, canManageCookies = false, stubFallback, notice, error, keyHints = new Map(), breaker = {}, now = Date.now(), creditEstimates = [], sessionCredentials = [], sessionBalances = [], creditEstimate = null, creditPreference = { enabled: true, floor_usd: 5 } }) {
const healthCell = (p) => {
const healthCell = (p) => {
const h = healthText(p.health, { breaker, now });
const h = healthText(p.health, { breaker, now });
return html`<span class="${h.cls}">${h.text}</span> <span class="muted">${(p.health && p.health.state) || 'closed'}</span>${p.health && p.health.last_error ? html`<br><span class="muted">last error: ${clip(p.health.last_error, 160)}</span>` : ''}`;
return html`<span class="${h.cls}">${h.text}</span> <span class="muted">${(p.health && p.health.state) || 'closed'}</span>${p.health && p.health.last_error ? html`<br><span class="muted">last error: ${clip(p.health.last_error, 160)}</span>` : ''}`;
@@ -296,6 +296,9 @@ function providers({ staff, csrf, rows, models, canManage, stubFallback, notice,
@@ -296,6 +296,9 @@ function providers({ staff, csrf, rows, models, canManage, stubFallback, notice,
title: 'AI providers', section: 'providers', staff, csrf, notice, error, body: html`<h1>AI providers</h1>
title: 'AI providers', section: 'providers', staff, csrf, notice, error, body: html`<h1>AI providers</h1>
<p class="muted">The companies whose models OpenVibe can use, and the key for each. Add one to make its models available; a provider with no working key is skipped.</p>
<p class="muted">The companies whose models OpenVibe can use, and the key for each. Add one to make its models available; a provider with no working key is skipped.</p>
${canManage ? html`<p><a class="button" href="/console/providers/new">Add a provider</a> <span class="muted">DeepSeek, OpenRouter, Groq, Gemini, Mistral, Together, Fireworks, Cerebras, xAI, OpenAI, Anthropic or any OpenAI-compatible API. Then choose it on a routing rule's page.</span></p>` : ''}
${canManage ? html`<p><a class="button" href="/console/providers/new">Add a provider</a> <span class="muted">DeepSeek, OpenRouter, Groq, Gemini, Mistral, Together, Fireworks, Cerebras, xAI, OpenAI, Anthropic or any OpenAI-compatible API. Then choose it on a routing rule's page.</span></p>` : ''}
+${creditEstimates.length ? creditEstimates.map(({ provider, estimate, preference }) => html`<div class="card"><strong>${provider === 'openai' ? 'OpenAI' : 'Anthropic'} credits (estimate)</strong><p>${estimate.promotional == null ? 'unknown' : estimate.promotional.toFixed(2)} promotional left of ${estimate.grants.filter((g) => g.kind === 'promotional').reduce((n, g) => n + Number(g.amount_usd), 0).toFixed(2)} · ${estimate.purchased == null ? 'unknown' : estimate.purchased.toFixed(2)} purchased left of ${estimate.grants.filter((g) => g.kind === 'purchased').reduce((n, g) => n + Number(g.amount_usd), 0).toFixed(2)}. ${estimate.source ? html`Estimated from ${estimate.source}, as of ${when(estimate.at)}.` : 'The spend estimate is not available yet.'}</p>${provider === 'openai' ? estimate.grants.map((g) => { const left = Number(estimate.grantBalances && estimate.grantBalances[g.id] != null ? estimate.grantBalances[g.id] : g.remaining_as_of_usd ?? g.amount_usd); return html`<p class="muted">Received ${when(g.received_at || g.as_of)} · ${g.expires_at && Date.parse(g.expires_at) <= now ? 'Expired' : left <= 0 ? 'Used' : 'Available'} · $${left.toFixed(2)} left of $${Number(g.amount_usd).toFixed(2)} · expires ${g.expires_at ? when(g.expires_at) : 'never'}</p>`; }) : ''}${canManage ? html`<form method="post" action="/console/provider-credit-preference" class="inline">${csrfField(csrf)}<input type="hidden" name="provider" value="${provider}"><label><input type="checkbox" name="enabled" value="1"${preference.enabled ? ' checked' : ''}> Use free credit first</label> <label>Keep at least USD <input name="floor_usd" type="number" min="0" step="0.01" value="${preference.floor_usd}" required></label><button type="submit">Save preference</button></form><form method="post" action="/console/provider-credits" class="inline">${csrfField(csrf)}<input type="hidden" name="provider" value="${provider}"><label>Grant <select name="kind"><option value="promotional">Promotional</option><option value="purchased">Purchased</option></select></label> <label>Amount USD <input name="amount_usd" type="number" min="0" step="0.01" required></label> <label>As of / remaining as of <input name="as_of" type="datetime-local" required></label> ${provider === 'openai' ? html`<label>Remaining USD <input name="remaining_as_of_usd" type="number" min="0" step="0.01" required></label>` : ''}<label>Expires <input name="expires_at" type="date"></label><button type="submit">Record grant</button></form>` : ''}</div>`)
+ : creditEstimate ? html`<div class="card"><strong>Anthropic credits</strong><p class="muted">No grant recorded yet; balance is unknown.</p></div>` : canManage ? html`<div class="card"><strong>Anthropic and OpenAI grants</strong><p class="muted">No grant recorded yet; balances are unknown.</p></div>` : ''}
+${canManageCookies ? html`<div class="card"><strong>Provider console sessions</strong><p class="muted">A session cookie can do anything you can do on that site while valid. OpenVibe uses it only for the listed read-only balance readers. Signing out there ends it.</p>${sessionCredentials.map((c) => html`<p>${c.provider}: ${c.label} · ${c.expired ? 'expired' : 'active'} · added ${when(c.created)}</p>`)}${sessionBalances.map((b) => b ? html`<p>${b.credential.provider}: ${b.source === 'provider console' && b.credential.provider === 'openai' && b.values.credit_grants ? html`$${Number(b.values.credit_grants.total_available).toFixed(2)} left of $${Number(b.values.credit_grants.total_granted).toFixed(2)} in grants · from the provider's console, read at ${when(b.at)}` : b.source === 'provider console' && b.credential.provider === 'anthropic' && Number.isFinite(b.values.purchased) ? html`$${b.values.purchased.toFixed(2)} purchased · from the provider's console, read at ${when(b.at)}` : `Estimated from the stored grants and run spend; as of ${when(b.at)}`}</p>` : '')}<form method="post" action="/console/provider-session-credentials" class="inline">${csrfField(csrf)}<label>Provider <select name="provider"><option value="anthropic">Anthropic</option><option value="openai">OpenAI</option></select></label><label>Session cookie <input type="password" name="cookie" required minlength="16" autocomplete="off"></label><label>Label <input name="label" maxlength="80"></label><label>Anthropic org ID <input name="org_id" maxlength="120"></label><button type="submit">Save read-only session</button></form><p class="muted">Readers: Anthropic prepaid credits and current spend on console.anthropic.com; OpenAI credit grants on api.openai.com.</p></div>` : ''}
<p class="muted">A key entered here is locked with AI's own lock key, bound to the provider's address and never shown again (only its last four characters). A key from the server's settings file is named, never shown. When a provider fails, the request goes to the next one in this order.</p>
<p class="muted">A key entered here is locked with AI's own lock key, bound to the provider's address and never shown again (only its last four characters). A key from the server's settings file is named, never shown. When a provider fails, the request goes to the next one in this order.</p>
<div class="card"><p class="m0">Practice answers when every provider fails: <strong>${stubFallback ? 'on' : 'off'}</strong> <span class="muted">(<code>AI_STUB_FALLBACK</code>)</span>. ${STUB_WORDS}</p></div>
<div class="card"><p class="m0">Practice answers when every provider fails: <strong>${stubFallback ? 'on' : 'off'}</strong> <span class="muted">(<code>AI_STUB_FALLBACK</code>)</span>. ${STUB_WORDS}</p></div>
${table('AI providers, in the order they are tried', ['Provider', 'Kind', 'Status', 'Key', 'Health', 'Who pays · terms', 'Base URL', 'Default model', 'Can do', 'Order · time limit', ...(canManage ? ['Actions'] : [])], rows.map((p) => html`<tr>
${table('AI providers, in the order they are tried', ['Provider', 'Kind', 'Status', 'Key', 'Health', 'Who pays · terms', 'Base URL', 'Default model', 'Can do', 'Order · time limit', ...(canManage ? ['Actions'] : [])], rows.map((p) => html`<tr>
@@ -83,7 +83,9 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
@@ -83,7 +83,9 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
// The price book's off-peak windows (BA1): what a provider's own price is inside the hours it publishes.
// The price book's off-peak windows (BA1): what a provider's own price is inside the hours it publishes.
// The router reads them to know when a provider is at its off-peak price (server/providers/tiers.js).
// The router reads them to know when a provider is at its off-peak price (server/providers/tiers.js).
const offPeak = require('./pricebook/offpeak').createOffPeak({ db, clock });
const offPeak = require('./pricebook/offpeak').createOffPeak({ db, clock });
- const pool = createProviderPool({ db, registry, config, clock, fetchImpl, env, log, credentialFetch, machineModels: machines, community, bench, variantEvidence, adultStatus, offPeak });
+ const creditGrants = require('./providers/credit-grants').createCreditGrants({ db, env, fetchImpl, clock });
+ const sessionCookies = require('./session-cookies').createSessionCookies({ db, config, clock });
+ const pool = createProviderPool({ db, registry, config, clock, fetchImpl, env, log, credentialFetch, machineModels: machines, community, bench, variantEvidence, adultStatus, offPeak, creditGrants });
// The hourly provider balance probe (server/providers/balance.js): a gauge for the operator alert, started
// The hourly provider balance probe (server/providers/balance.js): a gauge for the operator alert, started
// only when the service listens (never in tests) and off under a restore drill.
// only when the service listens (never in tests) and off under a restore drill.
const balance = require('./providers/balance').createBalanceProbe({ registry, config, env, fetchImpl, clock, log });
const balance = require('./providers/balance').createBalanceProbe({ registry, config, env, fetchImpl, clock, log });
@@ -201,7 +203,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
@@ -201,7 +203,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
clientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,
clientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,
}) : null;
}) : null;
const benchEvents = createBenchEvents({ bench, accountData, accountSend, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });
const benchEvents = createBenchEvents({ bench, accountData, accountSend, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });
- const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, settings, benchEvents, runBudgets, memory, memoryImport, memoryServices, memorySharing, community, endpoints, webhooks, presets, receipts, balance, gpus, abuse, priceBook, ownEndpoints, refusal, batches });
+ const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, settings, benchEvents, runBudgets, memory, memoryImport, memoryServices, memorySharing, community, endpoints, webhooks, presets, receipts, balance, creditGrants, sessionCookies, gpus, abuse, priceBook, ownEndpoints, refusal, batches });
let jwks = null;
let jwks = null;
let keyLoaded = Promise.resolve(null);
let keyLoaded = Promise.resolve(null);
if (!keys.pinned && config.nodeEnv !== 'test') {
if (!keys.pinned && config.nodeEnv !== 'test') {
@@ -243,6 +245,13 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
@@ -243,6 +245,13 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
// The signed webhook retry worker: never in test mode (tests call deliverDue with their own clock).
// The signed webhook retry worker: never in test mode (tests call deliverDue with their own clock).
if (listen && config.nodeEnv !== 'test') webhooks.start();
if (listen && config.nodeEnv !== 'test') webhooks.start();
if (listen) balance.start();
if (listen) balance.start();
+ let creditTimer = null;
+ if (listen && config.balance.enabled) {
+ const tickCredits = () => creditGrants.poll().catch((err) => log.warn(`[ai] credit estimate failed: ${String(err && err.message || err).slice(0, 180)}`));
+ creditTimer = setInterval(tickCredits, config.balance.intervalMs);
+ if (creditTimer.unref) creditTimer.unref();
+ setTimeout(tickCredits, 5000).unref?.();
+ }
if (listen && config.memoryJobs && config.memoryJobs.enabled) memoryJobs.start();
if (listen && config.memoryJobs && config.memoryJobs.enabled) memoryJobs.start();
// Team sharing's expiry sweep and delivery queue: production only, so a test's database has no background writers.
// Team sharing's expiry sweep and delivery queue: production only, so a test's database has no background writers.
if (listen && config.nodeEnv !== 'test' && memorySharing) memorySharing.start();
if (listen && config.nodeEnv !== 'test' && memorySharing) memorySharing.start();
@@ -287,6 +296,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
@@ -287,6 +296,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
clearInterval(housekeeping);
clearInterval(housekeeping);
if (gpuReconciler) clearInterval(gpuReconciler);
if (gpuReconciler) clearInterval(gpuReconciler);
balance.stop();
balance.stop();
+ if (creditTimer) clearInterval(creditTimer);
if (benchSubscription) benchSubscription.stop();
if (benchSubscription) benchSubscription.stop();
if (accountSubscription) accountSubscription.stop();
if (accountSubscription) accountSubscription.stop();
userModules.stop();
userModules.stop();
@@ -56,7 +56,7 @@ function sameOrigin(req, config, { write = false } = {}) {
@@ -56,7 +56,7 @@ function sameOrigin(req, config, { write = false } = {}) {
return true;
return true;
}
}
-function meRouter({ credentials, preferences, grants, auth, registry, config, clock = { now: () => Date.now() }, log = console, personSessions = null, sendError }) {
+function meRouter({ credentials, preferences, grants, auth, registry, config, clock = { now: () => Date.now() }, log = console, personSessions = null, sessionCookies = null, sendError }) {
const r = express.Router();
const r = express.Router();
/** Authenticate a person, never a principal. Sets req.principal = { kind: 'user', subject }. */
/** Authenticate a person, never a principal. Sets req.principal = { kind: 'user', subject }. */
@@ -118,7 +118,17 @@ function meRouter({ credentials, preferences, grants, auth, registry, config, cl
@@ -118,7 +118,17 @@ function meRouter({ credentials, preferences, grants, auth, registry, config, cl
try { await registry.audit(personOf(req), action, 'credential', personOf(req), { trace: req.ov && req.ov.traceId, metadata }); } catch { /* audit never breaks the person's request */ }
try { await registry.audit(personOf(req), action, 'credential', personOf(req), { trace: req.ov && req.ov.traceId, metadata }); } catch { /* audit never breaks the person's request */ }
};
};
- r.get('/api/v1/me/credentials', guard, run(async (req) => ({ credentials: await credentials.listSelf(personOf(req)) })));
+ r.get('/api/v1/me/credentials', guard, run(async (req) => ({ credentials: [...(await credentials.listSelf(personOf(req))).map((c) => ({ ...c, id: c.provider, kind: 'api_key', label: c.provider, created: c.created_at, last_used: c.last_used_at || null })), ...(sessionCookies ? await sessionCookies.list('self', personOf(req)) : [])] })));
+ if (sessionCookies) {
+ r.post('/api/v1/me/credentials', guard, run(async (req) => {
+ const body = req.body || {};
+ if (body.kind === 'session_cookie' || body.cookie != null) return { credential: await sessionCookies.add('self', personOf(req), body) };
+ const saved = await credentials.putSelf(personOf(req), body.provider, body);
+ return { credential: { id: saved.provider, kind: 'api_key', provider: saved.provider, created: saved.created_at, label: body.label || saved.provider } };
+ }));
+ r.get('/api/v1/me/credentials/:id/balance', guard, run(async (req) => ({ balance: await sessionCookies.balance('self', personOf(req), req.params.id) })));
+ r.delete('/api/v1/me/credentials/:id', guard, async (req, res) => { try { const gone = await sessionCookies.remove('self', personOf(req), req.params.id) || await credentials.removeSelf(personOf(req), req.params.id); res.status(gone ? 204 : 404).end(); } catch (err) { sendError(res, err, req.ov); } });
+ }
r.put('/api/v1/me/credentials/:provider', guard, run(async (req) => {
r.put('/api/v1/me/credentials/:provider', guard, run(async (req) => {
const out = await credentials.putSelf(personOf(req), req.params.provider, { ...(req.body || {}), ...(req.body && req.body.provider == null ? { provider: req.params.provider } : {}) });
const out = await credentials.putSelf(personOf(req), req.params.provider, { ...(req.body || {}), ...(req.body && req.body.provider == null ? { provider: req.params.provider } : {}) });
await audit(req, 'credential.self.put', { provider: out.provider, status: out.status, base_url: out.base_url });
await audit(req, 'credential.self.put', { provider: out.provider, status: out.status, base_url: out.base_url });
@@ -88,7 +88,7 @@ function createAiReadiness({ db, registry, pool, keys, runs, release = null }) {
@@ -88,7 +88,7 @@ function createAiReadiness({ db, registry, pool, keys, runs, release = null }) {
}
}
/** AI gauges on the openvibe-shared/metrics registry. */
/** AI gauges on the openvibe-shared/metrics registry. */
-function registerAiGauges(registry, { runs, registry: ai, pool, balance = null, gpus = null, abuse = null, db = null }) {
+function registerAiGauges(registry, { runs, registry: ai, pool, balance = null, creditGrants = null, gpus = null, abuse = null, db = null }) {
registry.gauge({
registry.gauge({
name: 'ai_runs', help: 'Runs waiting for a slot (queued) and holding one (running)', labelNames: ['state'],
name: 'ai_runs', help: 'Runs waiting for a slot (queued) and holding one (running)', labelNames: ['state'],
collect: () => {
collect: () => {
@@ -108,8 +108,11 @@ function registerAiGauges(registry, { runs, registry: ai, pool, balance = null,
@@ -108,8 +108,11 @@ function registerAiGauges(registry, { runs, registry: ai, pool, balance = null,
});
});
// The hourly balance probe's last reading (server/providers/balance.js), never a live call on scrape.
// The hourly balance probe's last reading (server/providers/balance.js), never a live call on scrape.
registry.gauge({
registry.gauge({
- name: 'ai_provider_balance_usd', help: 'Provider account balance in USD, as last read by the hourly probe', labelNames: ['provider'],
- collect: () => (balance ? balance.values() : []).map(r => ({ labels: { provider: r.provider }, value: r.usd })),
+ name: 'ai_provider_balance_usd', help: 'Provider balance in USD; grant balances are estimates', labelNames: ['provider', 'kind'],
+ collect: () => [
+ ...(balance ? balance.values().map(r => ({ labels: { provider: r.provider, kind: 'available' }, value: r.usd })) : []),
+ ...(creditGrants ? creditGrants.values().flatMap((r) => ['promotional', 'purchased'].map((kind) => ({ labels: { provider: r.provider, kind }, value: r[kind] }))) : []),
+ ],
});
});
// Plan E5: free-tier requests throttled in the last 15 minutes, by reason (velocity, rate, hosting, network).
// Plan E5: free-tier requests throttled in the last 15 minutes, by reason (velocity, rate, hosting, network).
// OpenVibe.Host's prometheus rule alerts when the sum stays high: the automatic throttle is working, and an
// OpenVibe.Host's prometheus rule alerts when the sum stays high: the automatic throttle is working, and an
new file mode 100644
new file mode 100644
@@ -0,0 +1,122 @@
@@ -0,0 +1,122 @@
+'use strict';
+
+const { resolveSecret } = require('../util');
+
+function centsToUsd(value) {
+ if (typeof value !== 'string' || !/^\d+(?:\.\d{1,6})?$/.test(value)) return null;
+ const n = Number(value);
+ return Number.isFinite(n) ? n / 100 : null;
+}
+
+function allocateDetailed(grants, spentUsd, now = Date.now()) {
+ let remaining = Math.max(0, Number(spentUsd) || 0);
+ const ordered = [...grants].sort((a, b) => {
+ if (a.kind !== b.kind) return a.kind === 'promotional' ? -1 : 1;
+ return (a.expires_at || '9999').localeCompare(b.expires_at || '9999') || String(a.as_of).localeCompare(String(b.as_of));
+ });
+ const out = { promotional: 0, purchased: 0 };
+ const grantBalances = {};
+ for (const g of ordered) {
+ if (g.expires_at && Date.parse(g.expires_at) <= now) { grantBalances[g.id] = 0; continue; }
+ const amount = Math.max(0, Number(g.remaining_as_of_usd == null ? g.amount_usd : g.remaining_as_of_usd) || 0);
+ const used = Math.min(amount, remaining);
+ remaining -= used;
+ out[g.kind] += amount - used;
+ grantBalances[g.id] = amount - used;
+ }
+ return { ...out, grantBalances };
+}
+function allocate(grants, spentUsd, now = Date.now()) { const { promotional, purchased } = allocateDetailed(grants, spentUsd, now); return { promotional, purchased }; }
+
+function createCreditGrants({ db, env = process.env, fetchImpl = globalThis.fetch, clock = { now: () => Date.now() } }) {
+ const latest = new Map();
+ let running = null;
+ const list = async (provider = 'anthropic') => db.prepare('SELECT id, provider_key, kind, amount_usd, as_of, expires_at, applies_to, received_at, remaining_as_of_usd, remaining_as_of FROM provider_credit_grants WHERE provider_key = ? ORDER BY as_of DESC, id DESC').all(provider);
+ async function add(grant) {
+ const { provider_key = 'anthropic', kind, amount_usd, as_of, expires_at = null, applies_to = null, received_at = as_of, remaining_as_of_usd = amount_usd, remaining_as_of = as_of } = grant || {};
+ if (!['purchased', 'promotional'].includes(kind) || !Number.isFinite(Number(amount_usd)) || Number(amount_usd) < 0 || !as_of || Number.isNaN(Date.parse(as_of))) throw new TypeError('grant requires kind, nonnegative amount_usd, and as_of');
+ const result = await db.prepare('INSERT INTO provider_credit_grants (provider_key, kind, amount_usd, as_of, expires_at, applies_to, created_at, received_at, remaining_as_of_usd, remaining_as_of) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id').get(provider_key, kind, Number(amount_usd), as_of, expires_at, applies_to, new Date(clock.now()).toISOString(), received_at, Number(remaining_as_of_usd), remaining_as_of);
+ return result;
+ }
+ async function preference(provider = 'anthropic') {
+ const row = await db.prepare('SELECT use_promotional_first, promotional_floor_usd FROM provider_credit_preferences WHERE provider_key = ?').get(provider);
+ return row ? { enabled: Boolean(row.use_promotional_first), floor_usd: Number(row.promotional_floor_usd) } : { enabled: true, floor_usd: 5 };
+ }
+ async function setPreference({ provider_key = 'anthropic', enabled = true, floor_usd = 5 }) {
+ if (typeof enabled !== 'boolean' || !Number.isFinite(Number(floor_usd)) || Number(floor_usd) < 0 || Number(floor_usd) > 1000000) throw new TypeError('preference requires a boolean enabled and nonnegative floor_usd');
+ await db.prepare(`INSERT INTO provider_credit_preferences (provider_key, use_promotional_first, promotional_floor_usd, updated_at) VALUES (?, ?, ?, ?)
+ ON CONFLICT (provider_key) DO UPDATE SET use_promotional_first = excluded.use_promotional_first, promotional_floor_usd = excluded.promotional_floor_usd, updated_at = excluded.updated_at`)
+ .run(provider_key, enabled ? 1 : 0, Number(floor_usd), new Date(clock.now()).toISOString());
+ return preference(provider_key);
+ }
+ function shouldPrefer(provider = 'anthropic') {
+ const balance = latest.get(provider);
+ if (!balance) return false;
+ // Preferences are loaded in the hourly poll, so routing never needs a database read per request.
+ const pref = balance.preference || { enabled: true, floor_usd: 5 };
+ return pref.enabled && balance.promotional > pref.floor_usd;
+ }
+ async function spend(provider, grants) {
+ const ref = env[`AI_${provider.toUpperCase()}_ADMIN_KEY_REF`];
+ const key = resolveSecret(ref, env);
+ const last = lastAdminAtByProvider.get(provider) || 0;
+ if (provider === 'anthropic' && key && clock.now() - last >= 60000) {
+ lastAdminAtByProvider.set(provider, clock.now());
+ try {
+ const since = grants.map((g) => Date.parse(g.as_of)).filter(Number.isFinite).reduce((a, b) => Math.min(a, b), clock.now());
+ const url = new URL('https://api.anthropic.com/v1/organizations/analytics/cost');
+ url.searchParams.set('starting_at', new Date(since).toISOString());
+ url.searchParams.set('ending_at', new Date(clock.now()).toISOString());
+ url.searchParams.set('bucket_width', '1d');
+ const response = await fetchImpl(url, { headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01', 'anthropic-beta': 'analytics-usage-cost-2026-08-24', Accept: 'application/json' }, signal: AbortSignal.timeout(10000) });
+ if (response.ok) {
+ const data = await response.json();
+ const values = (data.data || data.rows || []).map((row) => centsToUsd(row.cost && row.cost.amount)).filter((x) => x != null);
+ if (values.length) return { usd: values.reduce((a, b) => a + b, 0), source: 'Admin API' };
+ }
+ } catch { /* Admin analytics unavailable: use the service's own metering below. */ }
+ }
+ if (provider === 'openai' && key && clock.now() - last >= 60000) {
+ lastAdminAtByProvider.set(provider, clock.now());
+ try {
+ const since = grants.map((g) => Date.parse(g.remaining_as_of || g.as_of)).filter(Number.isFinite).reduce((a, b) => Math.min(a, b), clock.now());
+ let after = null; let total = 0; let pages = 0;
+ do {
+ const url = new URL('https://api.openai.com/v1/organization/costs');
+ url.searchParams.set('start_time', String(Math.floor(since / 1000)));
+ url.searchParams.set('bucket_width', '1d');
+ if (after) url.searchParams.set('page', after);
+ const response = await fetchImpl(url, { headers: { Authorization: `Bearer ${key}`, Accept: 'application/json' }, signal: AbortSignal.timeout(10000), redirect: 'error' });
+ if (!response.ok) break;
+ const data = await response.json();
+ for (const bucket of data.data || []) for (const row of bucket.results || []) total += Number(row.amount && row.amount.value) || 0;
+ after = data.has_more ? data.next_page : null;
+ pages++;
+ } while (after && pages < 100);
+ if (pages) return { usd: total, source: 'Admin API' };
+ } catch { /* Admin analytics unavailable: use the service's own metering below. */ }
+ }
+ const since = grants.map((g) => Date.parse(g.as_of)).filter(Number.isFinite).reduce((a, b) => Math.min(a, b), clock.now());
+ const r = await db.prepare("SELECT COALESCE(SUM(cost_usd), 0) AS spent FROM requests WHERE provider_key = ? AND status = 'ok' AND created_at >= ?").get(provider, new Date(since).toISOString());
+ return { usd: Number(r && r.spent) || 0, source: "OpenVibe's own metering" };
+ }
+ async function poll() {
+ if (running) return running;
+ running = (async () => {
+ for (const provider of ['anthropic', 'openai']) {
+ const grants = await list(provider);
+ if (!grants.length) continue;
+ const spentResult = await spend(provider, grants);
+ const balances = allocateDetailed(grants, spentResult.usd, clock.now());
+ latest.set(provider, { ...balances, grants, preference: await preference(provider), spent_usd: spentResult.usd, source: spentResult.source, at: clock.now() });
+ }
+ return values();
+ })().finally(() => { running = null; });
+ return running;
+ }
+ function values() { return [...latest.entries()].map(([provider, value]) => ({ provider, ...value })); }
+ const lastAdminAtByProvider = new Map();
+ return { add, list, poll, values, preference, setPreference, shouldPrefer };
+}
+
+module.exports = { createCreditGrants, centsToUsd, allocate, allocateDetailed };
@@ -216,7 +216,7 @@ function selfModelFor(cred, operation, req, ctx, prefs) {
@@ -216,7 +216,7 @@ function selfModelFor(cred, operation, req, ctx, prefs) {
return { model, kind, why };
return { model, kind, why };
}
}
-function createProviderPool({ db, registry, config, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, credentialFetch = null, machineModels = null, community = null, bench = null, variantEvidence = null, adultStatus = null, offPeak = null }) {
+function createProviderPool({ db, registry, config, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, credentialFetch = null, machineModels = null, community = null, bench = null, variantEvidence = null, adultStatus = null, offPeak = null, creditGrants = null }) {
// A person's own machine as their model (credential provider node, server/machines.js).
// A person's own machine as their model (credential provider node, server/machines.js).
const machines = machineModels || require('../machines').createMachineModels({ config, env, fetchImpl });
const machines = machineModels || require('../machines').createMachineModels({ config, env, fetchImpl });
const cache = new Map(); // key -> { stamp, adapter }
const cache = new Map(); // key -> { stamp, adapter }
@@ -542,6 +542,12 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -542,6 +542,12 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
const addOffer = async (p, model, caps) => {
const addOffer = async (p, model, caps) => {
const id = offerId(p.key, model);
const id = offerId(p.key, model);
if (seen.has(id)) return;
if (seen.has(id)) return;
+ // The private objective means first-party capacity only; operator-paid public APIs never receive it.
+ if ((ctx.aliasPolicy === 'private' || (route.constraints && route.constraints.objective === 'private'))
+ && p.kind === 'anthropic' && !(p.metadata && p.metadata.local === true && modelCatalog.accessOf(p) === 'OWN_DEPLOYMENT')) {
+ accessExcluded.push({ provider: p.key, model, eligible: false, excluded_reason: 'private requests exclude operator-paid Anthropic capacity', cost: null, latency: null });
+ return;
+ }
// The content policy narrows the pool before placement: an uncensored offer never answers a standard
// The content policy narrows the pool before placement: an uncensored offer never answers a standard
// request, and the adult tier has no other offer to run on.
// request, and the adult tier has no other offer to run on.
if (!(await policyAllows(registry, p, model, ctx.contentPolicy || 'standard'))) return;
if (!(await policyAllows(registry, p, model, ctx.contentPolicy || 'standard'))) return;
@@ -636,6 +642,17 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -636,6 +642,17 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
rateCards, states, now: clock.now(), current, minGain: config.placement.minGain,
rateCards, states, now: clock.now(), current, minGain: config.placement.minGain,
weights: { cost: config.placement.costWeight, latency: config.placement.latencyWeight },
weights: { cost: config.placement.costWeight, latency: config.placement.latencyWeight },
});
});
+ // Spend an entered promotional grant first, but only among candidates the route's own tier, capability,
+ // trust, price cap and health checks already accepted. A person's own-key choice never reaches this pool.
+ const preferredCreditProvider = creditGrants && ['openai', 'anthropic'].find((provider) => creditGrants.shouldPrefer(provider));
+ if (preferredCreditProvider && ctx.personPays !== true && !route.pinned?.length
+ && ctx.aliasPolicy !== 'private' && !(route.constraints && route.constraints.objective === 'private')) {
+ const promotional = result.candidates.find((x) => x.eligible && (x.provider || splitOfferId(x.id).provider) === preferredCreditProvider);
+ if (promotional) {
+ result.selected = promotional.id;
+ result.reasons = [`${preferredCreditProvider} promotional credit is available above its configured floor`, ...result.reasons];
+ }
+ }
const byId = new Map(offers.map(o => [o.offer_id, o]));
const byId = new Map(offers.map(o => [o.offer_id, o]));
const explain = {
const explain = {
objective: result.objective, reasons: result.reasons, selected: result.selected,
objective: result.objective, reasons: result.reasons, selected: result.selected,
new file mode 100644
new file mode 100644
@@ -0,0 +1,90 @@
@@ -0,0 +1,90 @@
+'use strict';
+
+// Provider readers are deliberately a closed allowlist. Callers cannot supply a URL, method, host, path, or header.
+const crypto = require('crypto');
+const { guardedFetch } = require('./providers/guarded-fetch');
+// An Anthropic organization id is a UUID; anything else never reaches a reader's path (no '..' or extra segments).
+const ORG_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+const READERS = Object.freeze({
+ anthropic: Object.freeze([
+ Object.freeze({ name: 'purchased', method: 'GET', host: 'console.anthropic.com', path: (s) => `/api/organizations/${encodeURIComponent(s.org_id)}/prepaid/credits`, header: (secret) => ({ Cookie: `sessionKey=${secret}` }), parse: (j) => Number(j.amount) / 100 }),
+ Object.freeze({ name: 'current_spend', method: 'GET', host: 'console.anthropic.com', path: (s) => `/api/organizations/${encodeURIComponent(s.org_id)}/current_spend`, header: (secret) => ({ Cookie: `sessionKey=${secret}` }), parse: (j) => j }),
+ ]),
+ openai: Object.freeze([
+ Object.freeze({ name: 'credit_grants', method: 'GET', host: 'api.openai.com', path: () => '/dashboard/billing/credit_grants', header: (secret) => ({ Authorization: `Bearer ${secret}` }), parse: (j) => ({ total_granted: j.total_granted, total_used: j.total_used, total_available: j.total_available, grants: Array.isArray(j.grants) ? j.grants : [] }) }),
+ ]),
+});
+
+function orgIdOf(value) {
+ const id = String(value || '').trim();
+ if (!ORG_ID.test(id)) throw new TypeError('org_id must be the organization id from the provider console (a UUID)');
+ return id.toLowerCase();
+}
+
+function createSessionCookies({ db, config, fetchImpl = guardedFetch, clock = { now: () => Date.now() }, notify = async () => {} }) {
+ const key = () => {
+ const hex = String(config.credentialsKey || '');
+ if (!/^[0-9a-f]{64}$/i.test(hex)) throw new Error('session credentials are unavailable');
+ return Buffer.from(hex, 'hex');
+ };
+ const seal = (plain, aad) => {
+ const iv = crypto.randomBytes(12); const c = crypto.createCipheriv('aes-256-gcm', key(), iv); c.setAAD(Buffer.from(aad));
+ const body = Buffer.concat([c.update(String(plain), 'utf8'), c.final()]);
+ return `v1:${iv.toString('base64')}:${c.getAuthTag().toString('base64')}:${body.toString('base64')}`;
+ };
+ const open = (sealed, aad) => {
+ const [v, iv, tag, body] = String(sealed).split(':');
+ if (v !== 'v1') throw new Error('session credential unavailable');
+ const d = crypto.createDecipheriv('aes-256-gcm', key(), Buffer.from(iv, 'base64')); d.setAAD(Buffer.from(aad)); d.setAuthTag(Buffer.from(tag, 'base64'));
+ return Buffer.concat([d.update(Buffer.from(body, 'base64')), d.final()]).toString('utf8');
+ };
+ const aad = (r) => `${r.owner}|${r.subject}|${r.provider}|session_cookie`;
+ const view = (r) => ({ id: String(r.id), kind: 'session_cookie', provider: r.provider, label: r.label, created: r.created_at, last_used: r.last_used_at || null, expires: r.expires_at || null, expired: Boolean(r.expired) });
+ async function add(owner, subject, body = {}) {
+ const provider = String(body.provider || '');
+ if (!READERS[provider] || typeof body.cookie !== 'string' || body.cookie.length < 16 || body.cookie.length > 4096) throw new TypeError('provider and session cookie are required');
+ const now = new Date(clock.now()).toISOString();
+ const row = { owner, subject, provider };
+ await db.prepare(`INSERT INTO session_credentials(owner,subject,provider,label,secret_enc,created_at,expires_at,settings_json,expired,notified_expired) VALUES(?,?,?,?,?,?,?,?,0,0)
+ ON CONFLICT(owner,subject,provider) DO UPDATE SET label=excluded.label, secret_enc=excluded.secret_enc, created_at=excluded.created_at, last_used_at=NULL, expires_at=excluded.expires_at, settings_json=excluded.settings_json, expired=0, notified_expired=0`)
+ .run(owner, subject, provider, String(body.label || `${provider} browser session`).slice(0, 80), seal(body.cookie, aad(row)), now, body.expires_at || null, JSON.stringify(provider === 'anthropic' ? { org_id: orgIdOf(body.org_id) } : {}));
+ return view({ ...row, label: String(body.label || `${provider} browser session`).slice(0, 80), created_at: now, expires_at: body.expires_at || null, expired: 0 });
+ }
+ async function list(owner, subject) { return (await db.prepare('SELECT * FROM session_credentials WHERE owner=? AND subject=? ORDER BY provider').all(owner, subject)).map(view); }
+ async function remove(owner, subject, id) {
+ if (!/^\d+$/.test(String(id))) return false;
+ return (await db.prepare('DELETE FROM session_credentials WHERE owner=? AND subject=? AND id=?').run(owner, subject, id)).changes > 0;
+ }
+ async function balance(owner, subject, id = null) {
+ const row = id == null ? await db.prepare('SELECT * FROM session_credentials WHERE owner=? AND subject=? ORDER BY provider LIMIT 1').get(owner, subject) : await db.prepare('SELECT * FROM session_credentials WHERE owner=? AND subject=? AND id=?').get(owner, subject, id);
+ if (!row) return null;
+ if (row.expires_at && Date.parse(row.expires_at) <= clock.now()) {
+ await db.prepare('UPDATE session_credentials SET expired=1 WHERE id=?').run(row.id);
+ return { credential: view({ ...row, expired: 1 }), source: 'estimate', at: new Date(clock.now()).toISOString(), values: {} };
+ }
+ const secret = open(row.secret_enc, aad(row)); const settings = JSON.parse(row.settings_json || '{}'); const values = {};
+ try {
+ for (const reader of READERS[row.provider] || []) {
+ if (row.provider === 'anthropic' && !ORG_ID.test(String(settings.org_id || ''))) throw new Error('reader allowlist rejected request');
+ const url = new URL(reader.path(settings), `https://${reader.host}`);
+ if (url.hostname !== reader.host || url.protocol !== 'https:' || !reader.path(settings).startsWith('/') || reader.method !== 'GET') throw new Error('reader allowlist rejected request');
+ const response = await fetchImpl(url, { method: 'GET', headers: reader.header(secret), redirect: 'error', signal: AbortSignal.timeout(10000) });
+ if (response.status === 401 || response.status === 403) {
+ await db.prepare('UPDATE session_credentials SET expired=1,notified_expired=1 WHERE id=?').run(row.id);
+ if (!row.notified_expired) await notify({ owner, subject, provider: row.provider });
+ return { credential: view({ ...row, expired: 1 }), source: 'estimate', at: new Date(clock.now()).toISOString(), values: {} };
+ }
+ if (!response.ok) continue;
+ values[reader.name] = reader.parse(await response.json());
+ }
+ await db.prepare('UPDATE session_credentials SET last_used_at=?,expired=0 WHERE id=?').run(new Date(clock.now()).toISOString(), row.id);
+ return { credential: view(row), source: 'provider console', at: new Date(clock.now()).toISOString(), values };
+ } catch {
+ // Provider secrets and upstream response text are intentionally not propagated.
+ return { credential: view(row), source: 'estimate', at: new Date(clock.now()).toISOString(), values: {} };
+ }
+ }
+ return { add, list, remove, balance };
+}
+
+module.exports = { createSessionCookies, READERS };
@@ -54,6 +54,7 @@ t.test('an export carries the person\'s sessions and credential hints, and a del
@@ -54,6 +54,7 @@ t.test('an export carries the person\'s sessions and credential hints, and a del
await h.db.exec("INSERT INTO console_sessions (id_hash, subject, username, staff, csrf, created_at, expires_at) VALUES ($1, $2, $3, '{}', 'c', '2026-10-01', '2027-10-01')", [`h_${name}`, subject, name]);
await h.db.exec("INSERT INTO console_sessions (id_hash, subject, username, staff, csrf, created_at, expires_at) VALUES ($1, $2, $3, '{}', 'c', '2026-10-01', '2027-10-01')", [`h_${name}`, subject, name]);
await h.db.exec("INSERT INTO person_sessions (id_hash, subject, username, created_at, expires_at) VALUES ($1, $2, $3, '2026-10-01', '2027-10-01')", [`p_${name}`, subject, name]);
await h.db.exec("INSERT INTO person_sessions (id_hash, subject, username, created_at, expires_at) VALUES ($1, $2, $3, '2026-10-01', '2027-10-01')", [`p_${name}`, subject, name]);
await h.db.exec("INSERT INTO subject_credentials (owner, subject, provider, key_enc, key_hint, models, created_at, updated_at) VALUES ('proj_1', $1, 'openai', 'v1:secret', $2, '{}', '2026-10-01', '2026-10-02')", [subject, `hint-${name}`]);
await h.db.exec("INSERT INTO subject_credentials (owner, subject, provider, key_enc, key_hint, models, created_at, updated_at) VALUES ('proj_1', $1, 'openai', 'v1:secret', $2, '{}', '2026-10-01', '2026-10-02')", [subject, `hint-${name}`]);
+ await h.db.exec("INSERT INTO session_credentials (owner, subject, provider, label, secret_enc, created_at) VALUES ('self', $1, 'openai', 'browser', 'v1:cookie-secret', '2026-10-01')", [subject]);
await h.db.exec("INSERT INTO free_allowance_usage (subject, provider, metric, period_start, period_end, free_used, updated_at) VALUES ($1, 'openai', 'input-tokens', 1, 2, 10, '2026-10-01')", [subject]);
await h.db.exec("INSERT INTO free_allowance_usage (subject, provider, metric, period_start, period_end, free_used, updated_at) VALUES ($1, 'openai', 'input-tokens', 1, 2, 10, '2026-10-01')", [subject]);
// Plan E5: the free-tier counters are erased with the account (the shared device counter only when its
// Plan E5: the free-tier counters are erased with the account (the shared device counter only when its
// last account goes).
// last account goes).
@@ -76,6 +77,9 @@ t.test('an export carries the person\'s sessions and credential hints, and a del
@@ -76,6 +77,9 @@ t.test('an export carries the person\'s sessions and credential hints, and a del
assert.strictEqual(files['person-sessions.json'].length, 1);
assert.strictEqual(files['person-sessions.json'].length, 1);
assert.strictEqual(files['provider-credentials.json'].length, 1);
assert.strictEqual(files['provider-credentials.json'].length, 1);
assert.strictEqual(files['provider-credentials.json'][0].key_hint, 'hint-ada');
assert.strictEqual(files['provider-credentials.json'][0].key_hint, 'hint-ada');
+ assert.strictEqual(files['provider-session-credentials.json'].length, 1);
+ assert.strictEqual(files['provider-session-credentials.json'][0].label, 'browser');
+ assert.ok(!JSON.stringify(part.body).includes('v1:cookie-secret'), 'the encrypted session cookie is never exported');
assert.ok(!JSON.stringify(part.body).includes('v1:secret'), 'the encrypted key is never exported');
assert.ok(!JSON.stringify(part.body).includes('v1:secret'), 'the encrypted key is never exported');
assert.ok(!JSON.stringify(part.body).includes(B), 'nobody else\'s rows');
assert.ok(!JSON.stringify(part.body).includes(B), 'nobody else\'s rows');
@@ -87,6 +91,7 @@ t.test('an export carries the person\'s sessions and credential hints, and a del
@@ -87,6 +91,7 @@ t.test('an export carries the person\'s sessions and credential hints, and a del
assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM abuse_devices WHERE subject = $1', [B])), 1, "nobody else's device row");
assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM abuse_devices WHERE subject = $1', [B])), 1, "nobody else's device row");
assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM person_sessions WHERE subject = $1', [A])), 0);
assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM person_sessions WHERE subject = $1', [A])), 0);
assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM subject_credentials WHERE subject = $1', [A])), 0);
assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM subject_credentials WHERE subject = $1', [A])), 0);
+ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM session_credentials WHERE subject = $1', [A])), 0, 'the session cookie is erased');
assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM free_allowance_usage WHERE subject = $1', [A])), 1, 'the allowance ledger stays');
assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM free_allowance_usage WHERE subject = $1', [A])), 1, 'the allowance ledger stays');
assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM routes WHERE created_by = $1', [A])), 1, 'the staff catalogue stays');
assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM routes WHERE created_by = $1', [A])), 1, 'the staff catalogue stays');
assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM console_sessions WHERE subject = $1', [B])), 1, 'Bo stays');
assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM console_sessions WHERE subject = $1', [B])), 1, 'Bo stays');
@@ -5,6 +5,7 @@
@@ -5,6 +5,7 @@
const assert = require('assert');
const assert = require('assert');
const { createRegistry } = require('openvibe-shared/metrics');
const { createRegistry } = require('openvibe-shared/metrics');
const { createBalanceProbe, deepseekUsd } = require('../server/providers/balance');
const { createBalanceProbe, deepseekUsd } = require('../server/providers/balance');
+const { centsToUsd, allocate, createCreditGrants } = require('../server/providers/credit-grants');
const { registerAiGauges } = require('../server/observability');
const { registerAiGauges } = require('../server/observability');
const { load } = require('../server/config');
const { load } = require('../server/config');
const { suite } = require('./helpers');
const { suite } = require('./helpers');
@@ -41,7 +42,48 @@ t.test('DeepSeek balance: USD total read, gauge exposed, key never logged', asyn
@@ -41,7 +42,48 @@ t.test('DeepSeek balance: USD total read, gauge exposed, key never logged', asyn
balance: probe,
balance: probe,
});
});
const text = await metrics.metricsAsync();
const text = await metrics.metricsAsync();
- assert.ok(/ai_provider_balance_usd\{provider="shared"\} 0\.05\n/.test(text), text);
+ assert.ok(/ai_provider_balance_usd\{provider="shared",kind="available"\} 0\.05\n/.test(text), text);
+});
+
+t.test('grant cost uses cents strings and spends promotional credit in expiry order', () => {
+ assert.strictEqual(centsToUsd('41280.000000'), 412.8);
+ assert.strictEqual(centsToUsd('0.05'), 0.0005);
+ assert.strictEqual(centsToUsd(5), null);
+ const left = allocate([
+ { kind: 'purchased', amount_usd: '40', as_of: '2026-01-01' },
+ { kind: 'promotional', amount_usd: '25', expires_at: '2026-12-01', as_of: '2026-01-01' },
+ { kind: 'promotional', amount_usd: '10', expires_at: '2026-11-01', as_of: '2026-01-01' },
+ ], 12, Date.parse('2026-10-01'));
+ assert.deepStrictEqual(left, { promotional: 23, purchased: 40 });
+ assert.deepStrictEqual(allocate([{ kind: 'promotional', amount_usd: 10, expires_at: '2026-01-01' }], 0, Date.parse('2026-02-01')), { promotional: 0, purchased: 0 });
+});
+
+t.test('OpenAI admin cost rows are summed in USD and paged', async () => {
+ const urls = [];
+ const db = { prepare(sql) { return { all: async () => [{ kind: 'promotional', amount_usd: '110', as_of: '2026-11-01', remaining_as_of_usd: '110', remaining_as_of: '2026-11-01' }], get: async () => ({ spent: 0 }) }; } };
+ const credits = createCreditGrants({ db, env: { AI_OPENAI_ADMIN_KEY_REF: 'env:OPENAI_ADMIN', OPENAI_ADMIN: 'admin-secret' }, clock: { now: () => Date.parse('2026-11-08T01:00:00Z') }, fetchImpl: async (url) => {
+ urls.push(String(url));
+ return { ok: true, json: async () => urls.length === 1 ? { data: [{ results: [{ amount: { value: '1.43' } }] }], has_more: true, next_page: 'page2' } : { data: [{ results: [{ amount: { value: '0.00' } }, { amount: { value: '0.00' } }] }], has_more: false } };
+ } });
+ const result = await credits.poll();
+ assert.strictEqual(urls.length, 2);
+ assert.match(urls[0], /start_time=1793491200/);
+ assert.strictEqual(result.find((r) => r.provider === 'openai').spent_usd, 1.43);
+});
+
+t.test('the optional Admin API key is sent only on its cost request and never returned', async () => {
+ const calls = [];
+ const db = { prepare(sql) { return { all: async () => [{ kind: 'promotional', amount_usd: '200', as_of: '2026-11-08T00:00:00Z', expires_at: null }], get: async () => ({ spent: 0 }) }; } };
+ const credits = createCreditGrants({ db, env: { AI_ANTHROPIC_ADMIN_KEY_REF: 'env:ANTHROPIC_ADMIN', ANTHROPIC_ADMIN: 'admin-secret' }, clock: { now: () => Date.parse('2026-11-08T01:00:00Z') }, fetchImpl: async (url, options) => {
+ calls.push({ url: String(url), key: options.headers['x-api-key'] });
+ return { ok: true, json: async () => ({ data: [{ cost: { amount: '360.000000' } }] }) };
+ } });
+ const result = await credits.poll();
+ assert.strictEqual(calls.length, 1);
+ assert.ok(calls[0].url.includes('starting_at='));
+ assert.strictEqual(calls[0].key, 'admin-secret');
+ assert.strictEqual(result[0].promotional, 196.4);
+ assert.ok(!JSON.stringify(result).includes('admin-secret'));
});
});
t.test('a failed probe keeps the last reading and does not fail the caller', async () => {
t.test('a failed probe keeps the last reading and does not fail the caller', async () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
+'use strict';
+const t = require('node:test');
+const assert = require('node:assert/strict');
+const { READERS, createSessionCookies } = require('../server/session-cookies');
+
+t.test('session-cookie readers are fixed GET endpoints with exact provider hosts', () => {
+ for (const [provider, readers] of Object.entries(READERS)) for (const reader of readers) {
+ assert.equal(reader.method, 'GET');
+ assert.equal(new URL(reader.path({ org_id: 'org-test' }), `https://${reader.host}`).hostname, reader.host);
+ assert.ok(['console.anthropic.com', 'api.openai.com'].includes(reader.host));
+ }
+});
+
+t.test('a rejected cookie reader response falls back without exposing cookie material', async () => {
+ const updates = [];
+ const row = { id: '1', owner: 'self', subject: 'usr_01ARZ3NDEKTSV4RRFFQ69G5FAV', provider: 'openai', label: 'session', secret_enc: '', created_at: new Date(0).toISOString(), settings_json: '{}', expired: 0, notified_expired: 0 };
+ const db = { prepare(sql) { return { get: async () => row, run: async (...args) => { updates.push([sql, args]); if (sql.startsWith('INSERT INTO session_credentials')) { row.secret_enc = args[4]; row.created_at = args[5]; } return { changes: 1 }; }, all: async () => [] }; } };
+ const api = createSessionCookies({ db, config: { credentialsKey: 'a'.repeat(64) }, clock: { now: () => 10 }, fetchImpl: async (url, init) => { assert.equal(url.hostname, 'api.openai.com'); assert.equal(url.pathname, '/dashboard/billing/credit_grants'); assert.equal(init.method, 'GET'); assert.equal(init.redirect, 'error'); assert.ok(init.headers.Authorization.startsWith('Bearer sess-')); return { status: 403, ok: false }; } });
+ // Seed using add, then return the inserted row to the balance reader.
+ await api.add('self', row.subject, { provider: 'openai', cookie: 'sess-secret-value-long-enough' });
+ const balance = await api.balance('self', row.subject, '1');
+ assert.equal(balance.source, 'estimate');
+ assert.equal(JSON.stringify(balance).includes('sess-secret'), false);
+ assert.equal(updates.length, 2);
+});
+
+t.test('an Anthropic organization id must be a UUID, so nothing else reaches a reader path', async () => {
+ let wrote = false;
+ const db = { prepare() { return { get: async () => null, run: async () => { wrote = true; return { changes: 1 }; } }; } };
+ const api = createSessionCookies({ db, config: { credentialsKey: 'a'.repeat(64) }, clock: { now: () => 10 }, fetchImpl: async () => { throw new Error('no fetch expected'); } });
+ const subject = 'usr_01ARZ3NDEKTSV4RRFFQ69G5FAV';
+ for (const org_id of ['..', '../../v1/x', 'org-test', '']) {
+ await assert.rejects(api.add('self', subject, { provider: 'anthropic', cookie: 'sk-ant-sid01-long-enough-value', org_id }), /org_id/);
+ }
+ assert.equal(wrote, false, 'a refused org id is never stored');
+});