openvibers/openvibe.ai Commits
Execute Exchange routing, presets and generation accounting (#418)
Changes
nnew file mode 100644@@@ -0,0 +1,45 @@++# Exchange compatible requests++++POST `/v1/chat/completions` and `/v1/messages` accept `provider` with `order`,++`only`, `ignore`, `allow_fallbacks`, `sort`, `max_price`, `data_collection`, and `zdr`.++`models` is an ordered list of model fallbacks (at most eight distinct models,++including `model`). Each model's eligible providers are tried before the next++model. A streamed answer cannot be followed by another model after output starts.++All attempts remain one run and one Exchange key hold. Idempotent replays do not++reserve or call a provider again. Every requested fallback must be allowed by the key.++++Sorts are `price`, `throughput`, `latency`, `quality`, `value`. Quality reads the++Bench board ratings shown by Report, through AI's existing `bench.rating.changed`++evidence store. Value divides that rating by the sum of input and output USD per++million tokens. Provisional ratings and ratings without confidence intervals are excluded. Throughput and latency use measured decode tokens/second and TTFT. Missing++quality or speed evidence excludes the offer. Price and privacy limits fail closed.++`max_price.prompt` and `max_price.completion` are USD per million tokens.++`data_collection: "deny"` requires declared no-training; `zdr: true` requires no retention.++`:free` requires both prices to be zero, `:floor` sorts by price, and `:nitro` by throughput.++Explicit credentials and only-my-keys mode cannot currently combine with this routing;++they return a validation error. Network routing does not silently bypass those policies.++++`usage: { "include": true }` adds `cost` (USD), `latency` (milliseconds), and `provider`++to token usage, including the final chat stream usage. `GET /v1/generation?id=<id>`++returns `{ data: { id, model, provider, cost, total_cost, latency, tokens_prompt,++tokens_completion, created_at } }` only to the original requester. It accepts the run++id or the chat/message response id; another owner's id returns 404.++++A person token can save via `POST /v1/presets/import` or++`POST /api/v1/exchange/presets`, list via `GET /api/v1/exchange/presets`, and delete++via `DELETE /api/v1/exchange/presets/:name`. Exchange inference keys never authorize++provisioning. Names are per person and invoked as `model: "@preset/name"`.++The saved model, fallbacks, system prompt, provider policy, and sampling defaults++are applied before key model checks and billing admission. Request fields override++saved sampling/routing fields. Preset edits change the idempotent request fingerprint.++`POST /v1/presets/import/preview` remains write-free and redacts submitted values.++++`POST /v1/completions` supports one string prompt and streaming through chat completions.++`POST /v1/embeddings` supports strings/string arrays with float vectors (no token-id++arrays or dimension override). `POST /v1/images/generations` uses the existing image++adapter and requires a configured non-metered image route; paid image calls are++refused until per-image accounting is wired; no provider is enabled by this change.++`GET /v1/models` adds pricing as USD per token strings, context length, architecture,++and top-provider limits to catalogue model entries. Unknown prices remain null.++++Tests use loopback fixture servers only. No production data or paid provider calls.nnew file mode 100644@@@ -0,0 +1,8 @@++-- phase: expand++-- OpenRouter request presets belong to a person, independently of routing-only presets.++CREATE TABLE IF NOT EXISTS exchange_compat_presets (++ owner TEXT NOT NULL,++ name TEXT NOT NULL,++ definition JSONB NOT NULL,++ PRIMARY KEY (owner, name)++);@@@ -13,7 +13,7 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.375.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.380.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.179.0","pg": "^8.23.0"@@@ -1543,9 +1543,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.375.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.375.0",-- "integrity": "sha512-+HI3XAkqkDW1ANR0SyGSXAYTko/z0SsDB9uIdswQCiYixoS/9M3RYGqPOFr+VevvZH9CG9hXvW0Tp3ecc0qaXw==",++ "version": "0.380.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.380.0",++ "integrity": "sha512-uPvVxDKkWxu69dhn3sX/eRcX9CaGEmqHZHPT1HXRpIW9XgoKIBeCMFmvtYoQ6JTSx6cpgCQXSdMf6b2c3XS1Kg==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -29,7 +29,7 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.375.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.380.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.179.0","pg": "^8.23.0"@@@ -26,6 +26,7 @@ const PERSON_RUN = `on_behalf_of IS NOT NULL AND on_behalf_of::jsonb ->> 'type'const RUN_COLUMNS = 'id, workflow_key, workflow_version, template_key, template_version, route_key, status, model_key, provider_key, input, output, citations_count, error_code, error_detail, tokens_in, tokens_out, cost_usd, created_at, started_at, finished_at';const TABLES = [++ { table: 'exchange_compat_presets', subject: 'owner', file: 'exchange-request-presets.json', columns: ['name', 'definition'], erase: 'delete' },{ table: 'exchange_api_keys', subject: 'owner', file: 'exchange-keys.json', columns: ['id','name','prefix','monthly_cap_usd','allowed_models','created_at','revoked_at','expires_at'], erase: { keep: 'extraErase deletes key usage and cap rows before deleting keys' } },{ table: 'exchange_key_requests', subject: 'owner', file: 'exchange-key-requests.json', columns: ['idempotency_key','request_hash','max_cost_usd'], erase: 'delete' },{ table: 'exchange_supply_settings', subject: 'actor', file: null, erase: { keep: 'owner pricing decisions are the operator audit trail' } },nnew file mode 100644@@@ -0,0 +1,81 @@++'use strict';++const express = require('express');++const { CAPS } = require('../auth');++const { AiError, sendError } = require('../util');++function accounting(run) {++ return { cost: Number(run.usage.cost_usd || 0), latency: Math.max(0, Date.parse(run.finished_at) - Date.parse(run.started_at || run.created_at)) || 0,++ provider: run.provenance?.provider || null };++}++function compatExtra({ runs, auth, config, log }) {++ const r = express.Router();++ const allow = auth.appOrCap(CAPS.appRun, CAPS.runCreate);++ const wrap = fn => async (req, res) => { try { await fn(req, res); } catch (err) { sendError(res, err, req.ov, log); } };++ r.get('/v1/generation', allow, wrap(async (req, res) => {++ const id = String(req.query.id || '').replace(/^(chatcmpl-|msg_)/, '');++ const run = await runs.get(id);++ const p = req.principal;++ // Never use an on_behalf_of claim to widen access to another requester's history.++ const owner = p.subject?.id;++ const type = p.subject?.type;++ if (!run || run.requester.type !== type || run.requester.id !== owner)++ throw new AiError(404, 'generation.not_found', 'Generation not found');++ res.set('Cache-Control', 'no-store').json({ data: { id: run.id, model: run.provenance.model,++ ...accounting(run), total_cost: Number(run.usage.cost_usd || 0),++ tokens_prompt: run.usage.tokens_in, tokens_completion: run.usage.tokens_out, created_at: run.created_at } });++ }));++ r.post('/v1/embeddings', allow, wrap(async (req, res) => {++ const b = req.body || {};++ if (b.encoding_format && b.encoding_format !== 'float') throw new AiError(400, 'input.invalid', 'Only float embeddings are supported');++ if (b.dimensions !== undefined) throw new AiError(400, 'input.invalid', 'Custom dimensions are not supported');++ const input = { input: b.input, max_cost_usd: b.max_cost_usd };++ if (b.model || b.provider || b.models) input.compat_routing = require('../exchange/compat-routing').request(b);++ const created = await runs.create({ workflow: 'ai.embed', input, idempotency_key: req.get('idempotency-key') || undefined }, req.principal, { trace: req.ov?.traceId });++ res.locals.inferenceAdmitted = true;++ if (!created.created) res.set('x-openvibe-replayed', '1');++ const run = await runs.wait(created, Math.max(config.runs.maxWaitMs, 120000));++ if (['succeeded', 'cached'].includes(run.status) || (['failed', 'cancelled'].includes(run.status) && run.usage.attempts === 0)) res.locals.inferenceCharge = Number(run.usage.cost_usd || 0);++ if (!['succeeded', 'cached'].includes(run.status)) throw new AiError(503, run.error?.code || 'run.failed', run.error?.detail || 'Embedding unavailable');++ res.json({ id: run.id, object: 'list', model: run.provenance.model,++ data: run.output.vectors.map((embedding, index) => ({ object: 'embedding', index, embedding })),++ usage: { prompt_tokens: run.usage.tokens_in, total_tokens: run.usage.tokens_in, ...(b.usage?.include ? accounting(run) : {}) },++ x_openvibe: { run_id: run.id, cost_usd: run.usage.cost_usd, provider: run.provenance.provider } });++ }));++ r.post('/v1/images/generations', allow, wrap(async (req, res) => {++ const b = req.body || {};++ if (b.response_format && !['url', 'b64_json'].includes(b.response_format)) throw new AiError(400, 'input.invalid', 'Unknown image response format');++ const input = { prompt: b.prompt, ...(b.size ? { size: b.size } : {}), ...(b.n ? { n: b.n } : {}), max_cost_usd: b.max_cost_usd };++ input.compat_routing = require('../exchange/compat-routing').request(b);++ const created = await runs.create({ workflow: 'ai.image', input, idempotency_key: req.get('idempotency-key') || undefined }, req.principal, { trace: req.ov?.traceId });++ res.locals.inferenceAdmitted = true;++ if (!created.created) res.set('x-openvibe-replayed', '1');++ const run = await runs.wait(created, Math.max(config.runs.maxWaitMs, 120000));++ if (['succeeded', 'cached'].includes(run.status) || (['failed', 'cancelled'].includes(run.status) && run.usage.attempts === 0)) res.locals.inferenceCharge = Number(run.usage.cost_usd || 0);++ if (!['succeeded', 'cached'].includes(run.status)) throw new AiError(503, run.error?.code || 'run.failed', run.error?.detail || 'Image provider unavailable');++ res.json({ id: run.id, created: Math.floor(Date.parse(run.created_at) / 1000),++ data: run.output.images.map(i => i.b64 ? { b64_json: i.b64 } : { url: i.url }),++ usage: { prompt_tokens: run.usage.tokens_in, completion_tokens: run.usage.tokens_out, ...(b.usage?.include ? accounting(run) : {}) },++ x_openvibe: { run_id: run.id, cost_usd: run.usage.cost_usd, provider: run.provenance.provider } });++ }));++ // Reuse the complete workflow and its single hold, including streaming and idempotency.++ r.post('/v1/completions', (req, res, next) => {++ if (typeof req.body?.prompt !== 'string' || !req.body.prompt.length)++ return res.status(400).json({ error: { code: 'input.invalid', message: 'prompt must be a non-empty string' } });++ const convert = v => v?.choices ? { ...v, object: 'text_completion', choices: v.choices.map(c => ({ index: c.index, text: c.message?.content || c.delta?.content || '', logprobs: c.logprobs || null, finish_reason: c.finish_reason })) } : v;++ const json = res.json.bind(res);++ res.json = v => json(convert(v));++ const write = res.write.bind(res);++ res.write = (chunk, ...args) => {++ const value = String(chunk);++ if (value.startsWith('data: ') && !value.startsWith('data: [DONE]')) {++ try { chunk = 'data: ' + JSON.stringify(convert(JSON.parse(value.slice(6)))) + '\n\n'; } catch {}++ }++ return write(chunk, ...args);++ };++ req.body = { ...req.body, messages: [...(req.body.messages || []), { role: 'user', content: req.body.prompt }] };++ delete req.body.prompt;++ req.url = '/v1/chat/completions';++ next();++ });++ return r;++}++module.exports = { compatExtra, accounting };@@@ -69,6 +69,7 @@ function createEventStream(res) {let routeInfo = null;let commented = false;let finalEvent = null;++ let responseId = null;const waiting = [];const alive = () => !res.writableEnded && !res.destroyed;const comment = () => {@@@ -81,6 +82,7 @@ function createEventStream(res) {if (!data) return;// message_stop is held until the run has settled: the final chunk carries the whole x_openvibe object (run_id,// provider, cost_usd and the route disclosure), and the cost is only known once the provider's usage is recorded.++ if (event === 'message_start' && data.message && responseId) data = { ...data, message: { ...data.message, id: responseId } };if (event === 'message_stop') { finalEvent = data; return; }write(event, data);};@@@ -94,6 +96,7 @@ function createEventStream(res) {emit(e.event, e.data);},open(r) {++ responseId = 'msg_' + r.id;res.status(200);res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');res.setHeader('Cache-Control', 'no-store');@@@ -109,10 +112,10 @@ function createEventStream(res) {emit,sentAny: () => any,/** The held message_stop with the final x_openvibe object (and the receipt), then end the stream. */-- finish(xOpenVibe, receipt) {++ finish(xOpenVibe, receipt, usage) {const data = finalEvent || { type: 'message_stop' };finalEvent = null;-- write('message_stop', xOpenVibe ? { ...data, x_openvibe: xOpenVibe, ...(receipt ? { receipt } : {}) } : data);++ write('message_stop', xOpenVibe ? { ...data, x_openvibe: xOpenVibe, ...(receipt ? { receipt } : {}), ...(usage ? { usage } : {}) } : data);return out.done();},fail(message, code) {@@@ -142,7 +145,7 @@ function replay(res, message, run) {res.emit('message_stop', { type: 'message_stop' });}--function messagesRouter({ runs, auth, config, registry, presets = null, receipts = null, log = console }) {++function messagesRouter({ runs, auth, config, registry, presets = null, receipts = null, db = null, log = console }) {const r = express.Router();// Developer apps call with ai.app.run, the public capability they already hold (ADR-014; AI Exchange D8').const allow = auth.appOrCap(CAPS.appRun, CAPS.runCreate);@@@ -169,7 +172,10 @@ function messagesRouter({ runs, auth, config, registry, presets = null, receipts};r.post('/v1/messages', asAnthropicErrors, allow, async (req, res) => {-- const b = req.body && typeof req.body === 'object' ? req.body : {};++ let b = req.body && typeof req.body === 'object' ? req.body : {};++ if (db && req.principal.kind === 'user') {++ try { b = await require('../exchange/compat-presets').createCompatPresets(db).expand(req.principal.subject.id, b, 'messages'); } catch (err) { return res.status(err.status || 400).json({ error: { message: err.message, code: err.code } }); }++ }if (!Array.isArray(b.messages) || !b.messages.length) return anthropicError(res, 400, 'messages: must be a non-empty array');if (!Number.isInteger(b.max_tokens) || b.max_tokens < 1) return anthropicError(res, 400, 'max_tokens: must be a positive integer');const input = { messages: b.messages, max_tokens: Math.min(b.max_tokens, MAX_TOKENS) };@@@ -212,6 +218,12 @@ function messagesRouter({ runs, auth, config, registry, presets = null, receipts}}}++ try {++ if (b.provider !== undefined || b.models !== undefined || (typeof b.model === 'string' && /:(free|floor|nitro)$/.test(b.model)) || (typeof b.model === 'string' && b.model.includes('/') && !b.model.startsWith('@preset/') && !b.model.startsWith('openvibe') && !EXACT_ID.test(b.model))) {++ input.compat_routing = require('../exchange/compat-routing').request(b);++ delete input.model;++ }++ } catch (err) { return anthropicError(res, err.status || 400, err.message, null, err.code); }let body = { workflow: 'ai.messages', input, idempotency_key: req.get('idempotency-key') || undefined };// The preset's rules ride the run into the router.if (preset) body = { ...body, preset: { name: preset.name, scope: preset.scope, policy: preset.policy } };@@@ -235,6 +247,7 @@ function messagesRouter({ runs, auth, config, registry, presets = null, receiptsconst created = await runs.create(body, req.principal, { trace: req.ov && req.ov.traceId, requestId: req.ov && req.ov.requestId, abuse: req.abuse,...(live ? { onDelta: live.event } : {}),...(live && config.modelCatalog && config.modelCatalog.enabled ? { onRoute: (info) => live.route(info) } : {}) });++ res.locals.inferenceAdmitted = true;if (created.created === false) res.setHeader('x-openvibe-replayed', '1');if (live) live.open(created.run);run = await runs.explained(await runs.wait(created, Math.max(config.runs.maxWaitMs, 120000)));@@@ -243,6 +256,7 @@ function messagesRouter({ runs, auth, config, registry, presets = null, receiptsif ((err && err.status) >= 500) log.warn(`[ai] /v1/messages: ${err && err.message}`);return anthropicError(res, (err && err.status) || 500, err && (err.detail || err.message), err && err.code === 'credential.unsupported' ? 'invalid_request_error' : null);}++ if (['succeeded', 'cached'].includes(run.status) || (['failed', 'cancelled'].includes(run.status) && run.usage.attempts === 0)) res.locals.inferenceCharge = Number(run.usage.cost_usd || 0);if (live && (run.status === 'failed' || run.status === 'cancelled' || !run.output || !Array.isArray(run.output.content))) {const code = (run.error && run.error.code) || (['failed', 'cancelled'].includes(run.status) ? 'run.failed' : 'run.timeout');return live.fail((run.error && run.error.detail) || (code === 'run.timeout' ? `the message is still running; GET /api/v1/runs/${run.id}` : code), code);@@@ -262,7 +276,7 @@ function messagesRouter({ runs, auth, config, registry, presets = null, receipts}const out = run.output;const message = {-- id: out.id || `msg_${run.id}`, type: 'message', role: 'assistant',++ id: `msg_${run.id}`, type: 'message', role: 'assistant',model: out.model || (run.provenance && run.provenance.model) || 'openvibe',content: out.content, stop_reason: out.stop_reason || 'end_turn', stop_sequence: out.stop_sequence || null,usage: {@@@ -272,6 +286,7 @@ function messagesRouter({ runs, auth, config, registry, presets = null, receipts...(out.usage && out.usage.cache_creation_input_tokens != null ? { cache_creation_input_tokens: out.usage.cache_creation_input_tokens } : {}),},};++ if (b.usage?.include === true) Object.assign(message.usage, require('./compat-extra').accounting(run));const disclosure = await models.disclosureOf({ registry, config, run });const community = (disclosure && disclosure.community) || (run.explain && run.explain.community) || null;if (live) live.route(disclosure || (community ? { community } : null));@@@ -288,13 +303,13 @@ function messagesRouter({ runs, auth, config, registry, presets = null, receiptsmessage.policy = run.output.policy || 'uncensored';res.setHeader('X-OpenVibe-Content-Policy', message.policy);}-- const xOpenVibe = (disclosure || community || run.output.label || receipt) ? { ...x, ...(disclosure || {}) } : null;++ const xOpenVibe = { ...x, ...(disclosure || {}) };if (live) {// The events already went out as they arrived; an answer that came in one piece (an upstream that cannot// stream, a replayed run) is sent as the events it is. finish() sends the held message_stop with the whole// x_openvibe object, then ends the stream.if (!live.sentAny()) replay(live, message, run);-- return live.finish(xOpenVibe, receiptView);++ return live.finish(xOpenVibe, receiptView, b.usage?.include ? message.usage : null);}res.setHeader('X-OpenVibe-Run-Id', run.id);if (receipt) {@@@ -42,6 +42,12 @@ const catalogOpenAi = (x) => ({created: epochOf(x.entry.created_at),owned_by: x.entry.provider_key,dynamic: false,++ name: x.entry.display_name || x.entry.model_key,++ context_length: x.entry.context_window,++ pricing: { prompt: x.entry.cost.in_per_mtok == null ? null : String(x.entry.cost.in_per_mtok / 1e6),++ completion: x.entry.cost.out_per_mtok == null ? null : String(x.entry.cost.out_per_mtok / 1e6) },++ architecture: { input_modalities: x.entry.modalities || ['text'], output_modalities: [x.entry.type === 'image' ? 'image' : 'text'] },++ top_provider: { context_length: x.entry.context_window, max_completion_tokens: x.entry.max_output },});const catalogAnthropic = (x) => ({...x.entry,@@@ -124,7 +124,7 @@ function openaiError(res, status, message, code, type) {res.status(status).json({ error: { message: String(message || 'error').slice(0, 1000), type: type || (status === 401 || status === 403 ? 'authentication_error' : status === 429 ? 'rate_limit_error' : status === 402 ? 'insufficient_quota' : status >= 500 ? 'api_error' : 'invalid_request_error'), code: code || null, param: null } });}--function openaiRouter({ runs, auth, config, registry, presets = null, receipts = null, log = console }) {++function openaiRouter({ runs, auth, config, registry, presets = null, receipts = null, db = null, log = console }) {const r = express.Router();// Developer apps call with ai.app.run, the public capability they already hold (ADR-014; AI Exchange D8').const allow = auth.appOrCap(CAPS.appRun, CAPS.runCreate);@@@ -155,7 +155,10 @@ function openaiRouter({ runs, auth, config, registry, presets = null, receipts =};r.post('/v1/chat/completions', asOpenAiErrors, allow, async (req, res) => {-- const b = req.body && typeof req.body === 'object' ? req.body : {};++ let b = req.body && typeof req.body === 'object' ? req.body : {};++ if (db && req.principal.kind === 'user') {++ try { b = await require('../exchange/compat-presets').createCompatPresets(db).expand(req.principal.subject.id, b, 'openai'); } catch (err) { return res.status(err.status || 400).json({ error: { message: err.message, code: err.code } }); }++ }if (!Array.isArray(b.messages) || !b.messages.length) return openaiError(res, 400, 'messages must be a non-empty array', 'input.invalid');if (b.n != null && Number(b.n) !== 1) return openaiError(res, 400, 'n must be 1', 'input.invalid');const input = {};@@@ -197,6 +200,12 @@ function openaiRouter({ runs, auth, config, registry, presets = null, receipts =}}}++ try {++ if (b.provider !== undefined || b.models !== undefined || (typeof b.model === 'string' && /:(free|floor|nitro)$/.test(b.model)) || (typeof b.model === 'string' && b.model.includes('/') && !b.model.startsWith('@preset/') && !b.model.startsWith('openvibe') && !EXACT_ID.test(b.model))) {++ input.compat_routing = require('../exchange/compat-routing').request(b);++ delete input.model;++ }++ } catch (err) { return openaiError(res, err.status || 400, err.message, err.code); }const maxTokens = b.max_completion_tokens != null ? b.max_completion_tokens : b.max_tokens;if (maxTokens != null) input.max_tokens = Math.max(1, Math.min(Number(maxTokens) || 1, 16000));// AI Exchange F: the caller's liability cap for the Balance hold (an OpenVibe extension, like x_openvibe).@@@ -238,6 +247,7 @@ function openaiRouter({ runs, auth, config, registry, presets = null, receipts =const created = await runs.create(body, req.principal, { trace: req.ov && req.ov.traceId, requestId: req.ov && req.ov.requestId, abuse: req.abuse,...(live ? { onDelta: live.delta } : {}),...(live && config.modelCatalog && config.modelCatalog.enabled ? { onRoute: (info) => live.route(info) } : {}) });++ res.locals.inferenceAdmitted = true;if (created.created === false) res.setHeader('x-openvibe-replayed', '1');if (live) live.open(created.run);run = await runs.explained(await runs.wait(created, Math.max(config.runs.maxWaitMs, 120000)));@@@ -248,6 +258,7 @@ function openaiRouter({ runs, auth, config, registry, presets = null, receipts =if (status >= 500) log.warn(`[ai] /v1/chat/completions: ${err && err.message}`);return openaiError(res, status, err && (err.detail || err.message), err && err.code);}++ if (['succeeded', 'cached'].includes(run.status) || (['failed', 'cancelled'].includes(run.status) && run.usage.attempts === 0)) res.locals.inferenceCharge = Number(run.usage.cost_usd || 0);if (live && (run.status === 'failed' || run.status === 'cancelled' || !run.output || !Array.isArray(run.output.choices))) {const code = (run.error && run.error.code) || (['failed', 'cancelled'].includes(run.status) ? 'run.failed' : 'run.timeout');return live.fail((run.error && run.error.detail) || (code === 'run.timeout' ? `the completion is still running; GET /api/v1/runs/${run.id}` : code), code);@@@ -275,6 +286,7 @@ function openaiRouter({ runs, auth, config, registry, presets = null, receipts =const id = `chatcmpl-${run.id}`;const model = run.output.model || (run.provenance && run.provenance.model) || 'openvibe';const usage = { prompt_tokens: run.usage.tokens_in || 0, completion_tokens: run.usage.tokens_out || 0, total_tokens: (run.usage.tokens_in || 0) + (run.usage.tokens_out || 0) };++ if (b.usage?.include === true) Object.assign(usage, require('./compat-extra').accounting(run));const x = {run_id: run.id, provider: run.provenance && run.provenance.provider,fallback_used: Boolean(run.provenance && run.provenance.fallback_used), cost_usd: run.usage.cost_usd,@@@ -304,7 +316,7 @@ function openaiRouter({ runs, auth, config, registry, presets = null, receipts =send({ ...base, choices: [{ index: c.index || 0, delta, finish_reason: null }] });send({ ...base, choices: [{ index: c.index || 0, delta: {}, finish_reason: c.finish_reason || 'stop' }] });}-- if (b.stream_options && b.stream_options.include_usage) send({ ...base, choices: [], usage });++ if ((b.stream_options && b.stream_options.include_usage) || b.usage?.include === true) send({ ...base, choices: [], usage });// A stream's headers went out before the answer was known, so the receipt travels in the last chunk// (its id was in the headers, and the full receipt is one GET away).send({ ...base, choices: [], x_openvibe: disclosure ? { ...x, ...disclosure } : x, receipt: receiptView });@@@ -216,11 +216,12 @@ function createApp({ choices = null, searchFetch = null, config, db, registry, pconst ops = createOps({ db, registry, pool, quotas, cache, runs });app.use(runsRouter({ choices, runs, registry, auth, config, log }));app.use(actionsRouter({ runs, registry, auth, log }));++ app.use(require('./api/compat-extra').compatExtra({ runs, auth, config, log }));// The OpenAI-compatible endpoint (POST /v1/chat/completions, GET /v1/models): runs of ai.complete.app.use(require('./api/routing-preview').routingPreview({ auth, registry, log }));-- app.use(require('./api/openai').openaiRouter({ runs, auth, config, registry, presets, receipts, log }));++ app.use(require('./api/openai').openaiRouter({ runs, auth, config, registry, presets, receipts, db, log }));// The Anthropic-compatible endpoint (POST /v1/messages, GET /v1/models): runs of ai.messages (Codes C3).-- app.use(require('./api/messages').messagesRouter({ runs, auth, config, registry, presets, receipts, log }));++ app.use(require('./api/messages').messagesRouter({ runs, auth, config, registry, presets, receipts, db, log }));app.use(require('./api/attribution-quotas').attributionQuotasRouter({ db, quotas, auth, clock, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));if (memory) app.use(require('./memory').memoryRouter({ memory, memoryImport, services: memoryServices, sharing: memorySharing, auth, config, clock, log, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));// Memory M5: the uniform share routes for kind memory-store (and /api/v1/share/people, /api/v1/shared-with-me).nnew file mode 100644@@@ -0,0 +1,27 @@++'use strict';++const provider = { type: 'object', additionalProperties: false, properties: {++ ...Object.fromEntries(['order', 'only', 'ignore'].map(k => [k, { type: 'array', maxItems: 64, items: { type: 'string' } }])),++ allow_fallbacks: { type: 'boolean' }, sort: { enum: ['price', 'throughput', 'latency', 'quality', 'value'] },++ max_price: { type: 'object', additionalProperties: false, description: 'USD per million tokens; unknown prices fail the cap.', properties: { prompt: { type: 'number', minimum: 0 }, completion: { type: 'number', minimum: 0 } } },++ data_collection: { enum: ['allow', 'deny'] }, zdr: { type: 'boolean' },++} };++const properties = { model: { type: 'string', description: 'maker/model, @preset/name, or model suffix :free, :floor, :nitro.' },++ models: { type: 'array', minItems: 1, maxItems: 8, items: { type: 'string' }, description: 'Ordered model fallbacks in the same run and hold.' },++ provider, usage: { type: 'object', properties: { include: { type: 'boolean' } } },++};++const operation = (operationId, summary, props, required = []) => ({ operationId, summary, tags: ['exchange'], security: [{ bearer: [] }],++ parameters: [{ in: 'header', name: 'Idempotency-Key', schema: { type: 'string' }, description: 'Owner-scoped replay; changed input or endpoint conflicts.' }],++ requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', properties: props, required } } } },++ responses: { 200: { description: 'Provider result, token usage and x_openvibe accounting. usage.include adds cost (USD), latency (milliseconds), and provider.' }, 400: { description: 'Invalid request' }, 401: { description: 'Invalid or expired key' }, 403: { description: 'Model outside key scope' }, 409: { description: 'Idempotency conflict' }, 429: { description: 'Key cap reached' }, 503: { description: 'No eligible provider' } },++});++const paths = {++ '/v1/models': { get: { operationId: 'compatModels', summary: 'Catalogue models with OpenRouter pricing and context fields', tags: ['exchange'], security: [{ bearer: [] }], responses: { 200: { description: 'data model entries: id, name, pricing (USD per token strings), context_length, architecture, top_provider. Anthropic header selects Anthropic format. Catalogue flag off retains the virtual model.' } } } },++ '/v1/chat/completions': { post: operation('compatChatCompletions', 'Chat completion with provider routing', { ...properties, messages: { type: 'array', items: { type: 'object' } }, stream: { type: 'boolean' } }, ['messages']) },++ '/v1/messages': { post: operation('compatMessages', 'Anthropic Messages with provider routing', { ...properties, messages: { type: 'array', items: { type: 'object' } }, max_tokens: { type: 'integer', minimum: 1 }, stream: { type: 'boolean' } }, ['messages', 'max_tokens']) },++ '/v1/completions': { post: operation('compatCompletions', 'Legacy string-prompt completion', { ...properties, prompt: { type: 'string', minLength: 1 }, stream: { type: 'boolean' } }, ['prompt']) },++ '/v1/embeddings': { post: operation('compatEmbeddings', 'Float embeddings from a registered provider', { ...properties, input: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }] }, encoding_format: { const: 'float' } }, ['input']) },++ '/v1/images/generations': { post: operation('compatImageGeneration', 'Image generation on configured non-metered capacity; paid image accounting is not yet supported', { ...properties, prompt: { type: 'string' }, n: { type: 'integer', minimum: 1, maximum: 4 }, size: { type: 'string' } }, ['prompt']) },++ '/v1/generation': { get: { operationId: 'compatGeneration', summary: 'Read accounting for your own generation', tags: ['exchange'], security: [{ bearer: [] }], parameters: [{ in: 'query', name: 'id', required: true, schema: { type: 'string' } }], responses: { 200: { description: 'data: id, model, provider, cost, total_cost (USD), latency (ms), tokens_prompt, tokens_completion, created_at. No prompt or output.' }, 404: { description: 'Missing or owned by another requester' } } } },++ '/v1/presets/import': { post: operation('compatPresetImport', 'Save a validated request preset for the signed-in person', { ...properties, name: { type: 'string' }, system: { type: 'string' }, temperature: { type: 'number' }, top_p: { type: 'number' }, max_tokens: { type: 'integer' } }, ['name']) },++};++module.exports = { paths, provider };nnew file mode 100644@@@ -0,0 +1,37 @@++'use strict';++const { normalizePreset } = require('./import-preset');++const { AiError } = require('../util');++function createCompatPresets(db) {++ const decode = row => row ? (typeof row.definition === 'string' ? JSON.parse(row.definition) : row.definition) : null;++ async function get(owner, name) {++ return decode(await db.prepare('SELECT definition FROM exchange_compat_presets WHERE owner = ? AND name = ?').get(owner, name));++ }++ async function expand(owner, body, protocol) {++ if (typeof body.model !== 'string' || !body.model.startsWith('@preset/')) return body;++ const saved = await get(owner, body.model.slice(8));++ if (!saved) return body; // Existing routing-only presets retain their namespace.++ const { name, system, ...defaults } = saved;++ const out = { ...defaults, ...body, model: defaults.model || defaults.models[0] };++ if (system && protocol === 'messages') out.system = body.system ?? system;++ if (system && protocol !== 'messages') out.messages = [{ role: 'system', content: system }, ...(body.messages || [])];++ return out;++ }++ return {++ get, expand,++ async list(owner) {++ return (await db.prepare('SELECT definition FROM exchange_compat_presets WHERE owner = ? ORDER BY name').all(owner)).map(decode);++ },++ async put(owner, body) {++ const value = normalizePreset(body);++ await db.prepare(`INSERT INTO exchange_compat_presets(owner,name,definition) VALUES(?,?,?)++ ON CONFLICT(owner,name) DO UPDATE SET definition = excluded.definition`).run(owner, value.name, JSON.stringify(value));++ return value;++ },++ async remove(owner, name) {++ if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(name)) throw new AiError(400, 'input.invalid', 'Invalid preset name');++ const result = await db.prepare('DELETE FROM exchange_compat_presets WHERE owner = ? AND name = ?').run(owner, name);++ return { name, deleted: result.changes > 0 };++ },++ };++}++module.exports = { createCompatPresets };@@@ -126,6 +126,7 @@ async function select(list, policy, facts) {}if (policy.sort === 'quality' || policy.sort === 'value') {if (!finite(f.quality)) reason = 'provider.rating_unavailable';++ if (policy.sort === 'value' && cost === null) reason = 'provider.price_unavailable';score =policy.sort === 'quality'? -f.quality@@@ -155,4 +156,14 @@ async function select(list, policy, facts) {excluded,};}--module.exports = { normalize, policyFor, modelName, select };++// Validated once at admission, retained in the run input and never forwarded upstream.++function request(body) {++ if (body.models !== undefined && (!Array.isArray(body.models) || !body.models.length || body.models.length > 8))++ bad('models must contain one to eight model ids');++ const names = [...new Set([...(body.model ? [body.model] : []), ...(body.models || [])])];++ if (names.length > 8) bad('At most eight distinct models are supported');++ const entries = (names.length ? names : ['openvibe/auto']).map(modelName);++ const policy = normalize(body.provider);++ return { entries, policy };++}++module.exports = { normalize, policyFor, modelName, select, request };@@@ -14,7 +14,7 @@ const accepted = ['response_format',];// Import preview is intentionally pure: no keys, URLs, provider calls or writes.--function importPreset(source) {++function normalizePreset(source) {const bad = (detail) => {throw new AiError(400, 'input.invalid', detail);};@@@ -92,6 +92,10 @@ function importPreset(source) {JSON.stringify(source.response_format),);}++ return out;++}++function importPreset(source) {++ const out = normalizePreset(source);return {valid: true,fields: Object.keys(out),@@@ -101,4 +105,4 @@ function importPreset(source) {],};}--module.exports = { importPreset };++module.exports = { importPreset, normalizePreset };@@@ -21,7 +21,7 @@* server/runs.js decode(). Nothing is inferred.*/const pkg = require('../package.json');--const { paths: searchPaths } = require('./search/openapi');++const searchPaths = { ...require('./search/openapi').paths, ...require('./exchange/compat-openapi').paths };const MOUNT = '/api/v1';// The public base of this API (BASE_URL in production).@@@ -556,6 +556,9 @@ route('get', '/exchange/key-usage', {description: 'Only a person. One row per day, key, model and status (settled, reserved, unresolved): requests, tokens and dollars. Private to the owner.',responses: { 200: json('The usage rows', { type: 'object', required: ['rows'], properties: { rows: arrayOf({ type: 'object' }) } }), ...errors([401, 403]) },});++route('get', '/exchange/presets', { operationId: 'exchangeRequestPresets', summary: 'List your saved request presets', tags: ['exchange'], security: [{ bearer: [] }], responses: { 200: json('Owner presets', { type: 'object', properties: { data: arrayOf({ type: 'object' }) } }), ...errors([401, 403]) } });++route('post', '/exchange/presets', { operationId: 'exchangeRequestPresetSave', summary: 'Save your request preset', tags: ['exchange'], security: [{ bearer: [] }], requestBody: { required: true, content: { 'application/json': { schema: { type: 'object' } } } }, responses: { 200: json('Saved preset', { type: 'object' }), ...errors([400, 401, 403]) } });++route('delete', '/exchange/presets/{name}', { operationId: 'exchangeRequestPresetDelete', summary: 'Delete your request preset', tags: ['exchange'], security: [{ bearer: [] }], parameters: [{ in: 'path', name: 'name', required: true, schema: str() }], responses: { 200: json('Deletion result', { type: 'object' }), ...errors([400, 401, 403]) } });route('get', '/exchange/search/engines', {operationId: 'exchangeSearchEngines', summary: 'The search engines behind web, image and icon search', tags: ['exchange'], security: [],description: 'Public. Engine IDs, kinds (web, images, icons, fetch), availability of shared keys, BYOK support, health, supported freshness filters, price_per_1000 in USD (null when variable), pricing_note, source URL and checked_on date. Personal key availability is private. Tariffs exclude free allowances and volume discounts.',@@@ -7,6 +7,7 @@ const { claimRequest } = require('./idempotency');const { createSearch, CHARGE } = require('../search');function platform({ db, auth, clock, log, searchFetch, credentials, env = process.env }) {const keys = createKeys({ db, clock });++ const presets = require('../exchange/compat-presets').createCompatPresets(db);const search = createSearch({...(searchFetch ? { fetchImpl: searchFetch } : {}),now: () => clock.now(), credentials, env,@@@ -55,6 +56,10 @@ function platform({ db, auth, clock, log, searchFetch, credentials, env = procesperson,wrap((req) => keys.usage(owner(req))),);++ r.get('/api/v1/exchange/presets', person, wrap(async req => ({ data: await presets.list(owner(req)) })));++ r.post('/api/v1/exchange/presets', person, wrap(req => presets.put(owner(req), req.body)));++ r.post('/v1/presets/import', person, wrap(req => presets.put(owner(req), req.body)));++ r.delete('/api/v1/exchange/presets/:name', person, wrap(req => presets.remove(owner(req), req.params.name)));const searchContext = req => ({subject: req.principal?.kind === 'user' ? req.principal.subject.id : null,plan: req.searchPlan, budget: req.searchBudget,@@@ -111,8 +116,8 @@ function platform({ db, auth, clock, log, searchFetch, credentials, env = proces'/v1/fetch',].includes(req.path);const inference =-- req.method === 'POST' && req.path === '/v1/chat/completions';-- const models = req.method === 'GET' && req.path === '/v1/models';++ req.method === 'POST' && ['/v1/chat/completions', '/v1/messages', '/v1/completions', '/v1/embeddings', '/v1/images/generations'].includes(req.path);++ const models = req.method === 'GET' && ['/v1/models', '/v1/generation'].includes(req.path);if (!isSearch && !inference && !models)throw new AiError(403,@@@ -126,18 +131,20 @@ function platform({ db, auth, clock, log, searchFetch, credentials, env = proces'token.invalid','Invalid, expired or revoked Exchange key',);++ if (inference) req.body = await presets.expand(key.owner, req.body || {}, req.path === '/v1/messages' ? 'messages' : 'openai');++ if (inference) require('../exchange/compat-routing').request(req.body || {});const allowed = JSON.parse(key.allowed_models);-- if (inference && allowed.length && !allowed.includes(req.body?.model))++ if (inference && allowed.length && [req.body?.model || req.body?.models?.[0] || 'openvibe/auto', ...(req.body?.models || [])].some(model => !allowed.includes(require('../exchange/compat-routing').modelName(model).model)))throw new AiError(403, 'exchange.model_denied', 'This model is not allowed by this key');if (inference && req.get('idempotency-key'))-- claim = await claimRequest(db, key.owner, req.get('idempotency-key'), req.body);++ claim = await claimRequest(db, key.owner, req.get('idempotency-key'), req.path === '/v1/chat/completions' ? req.body : { path: req.path, body: req.body });if (isSearch) {const kind = req.path === '/v1/fetch' ? 'fetch' : req.path.split('/')[3] || 'web';req.searchPlan = await search.prepare(kind, req.body || {}, { subject: key.owner });}const searchCeiling = req.searchPlan?.ceiling || 0;const hold = models || claim?.replay ? null : await keys.reserve(-- key, isSearch ? 'search:' + req.path : String(req.body?.model || ''),++ key, isSearch ? 'search:' + req.path : require('../exchange/compat-routing').modelName(req.body?.model || req.body?.models?.[0] || 'openvibe/auto').model,isSearch && searchCeiling === 0,isSearch ? searchCeiling : Number.isFinite(req.body?.max_cost_usd) ? req.body.max_cost_usd : null,isSearch,@@@ -171,6 +178,7 @@ function platform({ db, auth, clock, log, searchFetch, credentials, env = procesexchangeKeyId: key.id,};if (hold) {++ let completed = false;let result = null,tail = '';const json = res.json.bind(res);@@@ -186,6 +194,8 @@ function platform({ db, auth, clock, log, searchFetch, credentials, env = proces// 'close' without 'finish' is a client that hung up: the hold still settles (at the hold, when// the cost is unknown), or the key would keep it until the month ends.const done = () => {++ if (completed) return;++ completed = true;if (!result && tail)for (const line of tail.split('\n')@@@ -201,14 +211,14 @@ function platform({ db, auth, clock, log, searchFetch, credentials, env = procesconst cost =isSearch ? result?.[CHARGE] ?? res.locals.searchCharge: res.getHeader('x-openvibe-replayed') === '1' && res.statusCode < 400-- ? 0 : result?.x_openvibe?.cost_usd;++ ? 0 : result?.x_openvibe?.cost_usd ?? res.locals.inferenceCharge ?? (!res.locals.inferenceAdmitted && res.writableFinished && res.statusCode < 500 && res.statusCode >= 400 ? 0 : undefined);// Search uses only its own charge, never inference metadata.// An early disconnect can precede run insertion. Keep its claim// until a response finishes so a retry cannot take another hold.hold.settle({cost_usd: cost,-- tokens_in: usage.prompt_tokens || 0,-- tokens_out: usage.completion_tokens || 0,++ tokens_in: usage.prompt_tokens || usage.input_tokens || 0,++ tokens_out: usage.completion_tokens || usage.output_tokens || 0,}).then(() => res.writableFinished ? claim?.abandon?.() : undefined).catch(() =>log.error('Exchange usage settlement failed'),);@@@ -1054,6 +1054,8 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no}async function execute(route, operation, req, ctx = {}) {++ if (ctx.compatRouting && (ctx.credential || ctx.byok || ctx.selfPreferences?.only_my_keys))++ throw new AiError(400, 'input.invalid', 'Provider routing cannot be combined with an explicit credential or only_my_keys');if (ctx.taskChoice) return require('../choices/execute').executeChoice({ execute, registry, poolRates: rateCardsFor, maxAttempts: config.providerRetries + 1, route, operation, req, ctx });const contentPolicy = ctx.contentPolicy || 'standard';const refuseFloor = (r) => new AiError(403, 'policy.floor_refused', r.detail, { rule: r.rule, policy: contentPolicy });@@@ -1122,7 +1124,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noconst selfAttempts = [];// The adult tier is network supply only: a person's own provider key is not an offer marked uncensored, and// only such an offer may answer an uncensored request. Their key is still used for every standard request.-- const selfList = uncensored ? [] : (Array.isArray(ctx.selfCredentials) ? ctx.selfCredentials : []);++ const selfList = uncensored || ctx.compatRouting ? [] : (Array.isArray(ctx.selfCredentials) ? ctx.selfCredentials : []);if (!uncensored && (selfList.length || (ctx.selfSubject && prefs.only_my_keys))) {const order = [...selfList].sort((a, b) => {const rank = (x) => (x.status === 'failing' ? 2 : x.status === 'untested' ? 1 : 0);@@@ -1185,7 +1187,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no// Community placement runs before the catalog pool. A preset with price, privacy, region, or quality// rules must use the catalog path so those rules are checked before any offer is called.const communityPresetSafe = !ctx.presetPolicy || Object.keys(ctx.presetPolicy).every((key) => key === 'community');-- if (community && ctx.communityOptIn && communityPresetSafe && !ctx.credential && !ctx.byok && !ctx.exact) {++ if (!ctx.compatRouting && community && ctx.communityOptIn && communityPresetSafe && !ctx.credential && !ctx.byok && !ctx.exact) {// Community capacity is a free-tier source only when the allowlist names it: without that, the// ordinary network answers and the skip is named in the explain, never silently used.if (freeSources && !allowsCommunity(freeSources)) {@@@ -1224,6 +1226,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noconst card = row ? modelCatalog.retailRate(row) : null;const policyOk = p ? await policyAllows(registry, p, offer.model, ctx.contentPolicy || 'standard') : false;if (!p) why.push('unknown_provider');++ else if (!appOffered(ctx, p)) why.push('app_provider_not_allowed');else if (!policyOk) why.push('content_policy');else if (ctx.creditsRequired && !isFreeProvider(p) && card && card.version) { why.push('payer_required'); payerSkipped = true; }else if (freeSources && !allowsProvider(p.key, p, freeSources)) {@@@ -1365,6 +1368,45 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noexplain.reasons = [picked.view.reason, ...(explain.reasons || [])];}}++ if (ctx.compatRouting) {++ const { select, policyFor } = require('../exchange/compat-routing');++ const ordered = [];++ const ratingEvidence = require('../bench/evidence');++ let ratings = [];++ if (bench && ['quality', 'value'].includes(ctx.compatRouting.policy.sort)) {++ // Messages shares completion ratings; this is a provider method, not a public capability.++ const ratingOperation = operation === 'messages' ? 'complete' : operation;++ const ratingRoute = operation === 'messages' ? { ...route, capability: ratingOperation } : route;++ for (const kind of bench.kindCandidates(ratingRoute, ratingOperation)) {++ ratings = await bench.list(kind, bench.categoryFor(route));++ if (ratings.length) break;++ }++ }++ for (const group of [...new Set(ctx.compatRouting.offers.map(o => o.group))]) {++ const offers = ctx.compatRouting.offers.filter(o => o.group === group);++ const variant = offers[0]?.variant;++ const { policy: requestedPolicy } = policyFor({ model: 'openvibe/auto' + (variant ? ':' + variant : ''), provider: ctx.compatRouting.policy });++ const groupList = list.filter(c => offers.some(o => o.provider === c.provider && o.model === c.model));++ const result = await select(groupList, requestedPolicy, async c => {++ const d = await describeCandidate(c);++ const privacy = exchangePrivacy.dataPolicyOf(d.record) || {};++ const metrics = await createProviderMetrics({ db, clock }).get(c.provider, c.model);++ // Report's board pages read Bench. Use the same event-fed ratings, never a declared tier.++ const rating = ratings.find(row => ratingEvidence.usable(row) && (++ ratingEvidence.matchesOffer(row, c) || ratingEvidence.matchesOffer(row, {++ ...c, model: require('../exchange/catalog').canonicalOf(d.row, d.record),++ })++ ));++ return { priceIn: d.row?.cost?.in_per_mtok ?? null, priceOut: d.row?.cost?.out_per_mtok ?? null, training: privacy.training,++ retention: privacy.retention, latency: metrics?.ttft_ms, throughput: metrics?.decode_tps,++ quality: rating ? Number(rating.rating) : null };++ });++ ordered.push(...result.allowed);++ for (const x of result.excluded) explain.candidates.push({ ...x, eligible: false, excluded_reason: x.reason });++ }++ list = ordered;++ if (!list.length) throw new AiError(503, 'ai.routing_unavailable', 'No provider satisfies the requested routing policy', { explain });++ }const evidenceReason = explain.evidence ? explain.evidence.reason : null;explain.content_policy = contentPolicy;if (uncensored) explain.label = tier.LABEL;@@@ -82,7 +82,7 @@ const workflows = [},{key: 'ai.embed', name: 'Embed text', namespace: 'ai',-- input_schema: OBJ({ input: { anyOf: [STR(32000), { type: 'array', minItems: 1, maxItems: 64, items: STR(32000) }] } }, ['input']),++ input_schema: OBJ({ compat_routing: { type: 'object' }, max_cost_usd: COMMON.max_cost_usd, input: { anyOf: [STR(32000), { type: 'array', minItems: 1, maxItems: 64, items: STR(32000) }] } }, ['input']),output_schema: OBJ({ vectors: { type: 'array', items: { type: 'array', items: { type: 'number' } } }, dimensions: { type: 'integer' } }, ['vectors', 'dimensions']),steps: [{ kind: 'embed', route: 'default.embedding' }], cache_mode: 'private',},@@@ -94,6 +94,7 @@ const workflows = [input_schema: OBJ({// CHECKPOINT D3: an exact id or a dynamic alias, when AI_MODEL_CATALOG is on (server/api/openai.js).model: STR(256),++ compat_routing: { type: 'object' },messages: { type: 'array', minItems: 1, maxItems: 500, items: { type: 'object', required: ['role'], properties: {role: { enum: ['system', 'developer', 'user', 'assistant', 'tool'] },content: { anyOf: [{ type: 'string', maxLength: 400000 }, { type: 'array', maxItems: 100 }, { type: 'null' }] },@@@ -122,6 +123,7 @@ const workflows = [input_schema: OBJ({// CHECKPOINT D3: an exact id or a dynamic alias, when AI_MODEL_CATALOG is on (server/api/messages.js).model: STR(256),++ compat_routing: { type: 'object' },messages: { type: 'array', minItems: 1, maxItems: 500, items: { type: 'object', required: ['role'], properties: {role: { enum: ['user', 'assistant'] },content: { anyOf: [STR(400000), { type: 'array', maxItems: 500 }, { type: 'null' }] },@@@ -148,7 +150,7 @@ const workflows = [{// Off until AI_IMAGE_MODEL seeds route default.image: without it a run answers 503 route.unavailable.key: 'ai.image', name: 'Generate an image', namespace: 'ai',-- input_schema: OBJ({ prompt: STR(4000), size: { type: 'string', pattern: '^[0-9]{3,4}x[0-9]{3,4}$' }, n: { type: 'integer', minimum: 1, maximum: 4 } }, ['prompt']),++ input_schema: OBJ({ compat_routing: { type: 'object' }, max_cost_usd: COMMON.max_cost_usd, prompt: STR(4000), size: { type: 'string', pattern: '^[0-9]{3,4}x[0-9]{3,4}$' }, n: { type: 'integer', minimum: 1, maximum: 4 } }, ['prompt']),output_schema: OBJ({ images: { type: 'array', minItems: 1, items: OBJ({ b64: { type: ['string', 'null'] }, url: { type: ['string', 'null'] } }) }, count: { type: 'integer', minimum: 1 } }, ['images', 'count']),// No cache: several MB of base64 per run, and a fresh image is what a caller asks for.steps: [{ kind: 'image', route: 'default.image' }], cache_mode: 'none',@@@ -285,6 +285,33 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa* never called, so today's route selection is unchanged.*/async function selectedRoute(base, input) {++ if (input.compat_routing) {++ const routing = require('../exchange/compat-routing');++ const source = input.compat_routing;++ if (!Array.isArray(source.entries) || !source.entries.length || source.entries.length > 8)++ throw new AiError(400, 'input.invalid', 'One to eight routing models are required');++ const entries = source.entries.map(e => routing.modelName(e.model + (e.variant ? ':' + e.variant : '')));++ const policy = routing.normalize(source.policy);++ const providers = new Map((await registry.listProviders()).map(p => [p.key, p]));++ const rows = await registry.listModels();++ const offers = [];++ for (const [group, entry] of entries.entries()) {++ const dynamic = ['openvibe', 'openvibe/auto'].includes(entry.model);++ for (const row of rows) {++ const provider = providers.get(row.provider_key);++ if (!provider || row.status !== 'active' || provider.status !== 'active') continue;++ const wantedType = base.capability === 'embed' ? 'embedding' : base.capability === 'image' ? 'image' : 'chat';++ if (row.type !== wantedType && !(wantedType === 'chat' && row.type === 'vision')) continue;++ if (!modelCatalog.managedAccessAllowed(provider) || provider.metadata?.hidden || provider.metadata?.synthetic) continue;++ if (!dynamic && require('../exchange/catalog').canonicalOf(row, provider) !== entry.model && row.metadata?.id !== entry.model) continue;++ if (row.metadata?.hidden || row.metadata?.synthetic || row.metadata?.restricted || row.metadata?.byok) continue;++ if (['CUSTOMER_BYOK', 'RESTRICTED'].includes(row.provider_access || row.metadata?.provider_access)) continue;++ offers.push({ provider: row.provider_key, model: row.model_key, group, variant: entry.variant });++ }++ }++ if (!offers.length) throw new AiError(404, 'ai.model_not_found', 'No requested model is available');++ return { route: base, selection: { kind: 'compat', offers, policy } };++ }if (!(config.modelCatalog && config.modelCatalog.enabled) || typeof input.model !== 'string' || !input.model.trim()) return { route: base, selection: null };const selection = await modelCatalog.select({ registry, model: input.model.trim(), route: base });return { route: selection.route, selection };@@@ -292,6 +319,8 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spafunction selectionCtx(selection) {if (!selection) return {};++ if (selection.kind === 'compat') return { compatRouting: selection,++ exact: { id: 'requested model fallbacks', offers: selection.offers } };return selection.kind === 'exact'? { policy: 'exact', exact: { id: selection.id, offers: selection.offers.map((o) => ({ provider: o.provider, model: o.model })) } }: { policy: selection.policy, aliasPolicy: selection.policy };@@@ -418,20 +447,20 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa}async function embedStep(step, input, wf, run, ctx) {-- const route = await routeFor(step.route || 'default.embedding');++ const { route, selection } = await selectedRoute(await routeFor(step.route || 'default.embedding'), input);const inputs = Array.isArray(input.input) ? input.input : [input.input];-- const exec = await pool.execute(route, 'embed', { input: inputs, timeoutMs: route.timeout_ms || 30000 }, { ...ctx, routeKey: route.key, routeVersion: route.version, promptHash: null });++ const exec = await pool.execute(route, 'embed', { input: inputs, timeoutMs: route.timeout_ms || 30000 }, { ...ctx, ...selectionCtx(selection), routeKey: route.key, routeVersion: route.version, promptHash: null });const vectors = exec.result.vectors || [];if (vectors.length !== inputs.length) throw new AiError(502, 'output.invalid', 'embedding count does not match the input');return { output: { vectors, dimensions: vectors[0] ? vectors[0].length : 0 }, exec, route };}async function imageStep(step, input, wf, run, ctx) {-- const route = await routeFor(step.route || 'default.image');++ const { route, selection } = await selectedRoute(await routeFor(step.route || 'default.image'), input);const prompt = String(input.prompt || '').trim();if (!prompt) throw new AiError(422, 'input.invalid', 'image needs a prompt');const req = { prompt, size: input.size || (step.params && step.params.size) || (config.image && config.image.size) || null, n: Math.max(1, Math.min(Number(input.n) || 1, 4)), timeoutMs: route.timeout_ms || 120000 };-- const exec = await pool.execute(route, 'image', req, { ...ctx, routeKey: route.key, routeVersion: route.version, promptHash: sha256({ p: prompt }) });++ const exec = await pool.execute(route, 'image', req, { ...ctx, ...(input.compat_routing ? { freeOnly: true } : {}), ...selectionCtx(selection), routeKey: route.key, routeVersion: route.version, promptHash: sha256({ p: prompt }) });const images = (exec.result.images || []).filter(i => i && (i.b64 || i.url));if (!images.length) throw new AiError(502, 'output.invalid', 'no image was returned');return { output: { images: images.map(i => ({ b64: i.b64 || null, url: i.url || null })), count: images.length }, exec, route };@@@ -51,6 +51,7 @@ t.test('an export carries the person\'s sessions and credential hints, and a delconst h = await boot({ env: { AI_EVENTS_SECRET: SECRET, AI_EVENTS_SUBSCRIBE: '0', OV_NETWORK_INTERNAL_URL: stub.url, OV_OAUTH_CLIENT_SECRET: 'ai-secret' } });try {for (const [subject, name] of [[A, 'ada'], [B, 'bo']]) {++ await h.db.exec('INSERT INTO exchange_compat_presets(owner,name,definition) VALUES ($1,$2,$3)', [subject, 'writer-' + name, JSON.stringify({ name: 'writer-' + name, model: 'openvibe/auto', system: 'Prompt for ' + name })]);await h.db.exec('INSERT INTO exchange_key_requests(owner,idempotency_key,request_hash,max_cost_usd) VALUES ($1,$2,$3,1)', [subject, `request-${name}`, `hash-${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]);@@@ -74,6 +75,9 @@ t.test('an export carries the person\'s sessions and credential hints, and a delconst part = stub.calls.find((c) => c.url === `/internal/account-exports/${EXP}/parts`);assert.ok(part, 'the part was pushed to Network');const files = Object.fromEntries(part.body.files.map((f) => [f.name, f.content]));++ assert.strictEqual(files['exchange-request-presets.json'].length, 1);++ assert.strictEqual(files['exchange-request-presets.json'][0].name, 'writer-ada');++ assert.ok(JSON.stringify(files['exchange-request-presets.json']).includes('Prompt for ada'));assert.strictEqual(files['console-sessions.json'].length, 1);assert.strictEqual(files['person-sessions.json'].length, 1);assert.strictEqual(files['provider-credentials.json'].length, 1);@@@ -87,6 +91,8 @@ t.test('an export carries the person\'s sessions and credential hints, and a delconst d = await deliver(ev('evt_01JZ0000000000000000000D01', 'network.account.deleted', { deletion_id: DEL, subject: A, aliases: [] }));assert.deepStrictEqual([d.status, d.json.outcome], [200, 'erased']);++ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM exchange_compat_presets WHERE owner = $1', [A])), 0, 'saved prompts are erased with the owner');++ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM exchange_compat_presets WHERE owner = $1', [B])), 1, 'another owner retains their preset');assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM console_sessions WHERE subject = $1', [A])), 0);assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM exchange_key_requests WHERE owner = $1', [A])), 0, 'request claims are erased with their owner');assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM exchange_key_requests WHERE owner = $1', [B])), 1, 'another owner\'s claims remain');nnew file mode 100644@@@ -0,0 +1,140 @@++'use strict';++const assert = require('node:assert/strict');++const http = require('node:http');++const { boot, request, token, ALL } = require('./helpers');++const { createKeys } = require('../server/platform/keys');++(async () => {++ const seen = [];++ const upstream = http.createServer((req, res) => {++ let raw = '';++ req.on('data', c => raw += c);++ req.on('end', () => {++ const b = JSON.parse(raw); seen.push(b);++ res.setHeader('Content-Type', 'application/json');++ if (b.model === 'broken') { res.statusCode = 503; return res.end('{"error":{"message":"fixture failure"}}'); }++ if (req.url.endsWith('/messages')) return res.end(JSON.stringify({ id: 'msg_fixture', type: 'message', role: 'assistant', model: b.model, content: [{ type: 'text', text: 'fixture message' }], stop_reason: 'end_turn', usage: { input_tokens: 10, output_tokens: 3 } }));++ if (req.url.endsWith('/embeddings')) return res.end(JSON.stringify({ data: b.input.map((_, index) => ({ index, embedding: [1, 2] })), usage: { prompt_tokens: 2, total_tokens: 2 } }));++ if (req.url.endsWith('/images/generations')) return res.end(JSON.stringify({ data: [{ b64_json: 'Zml4dHVyZQ==' }] }));++ res.end(JSON.stringify({ id: 'fixture', model: b.model, choices: [{ index: 0, message: { role: 'assistant', content: 'fixture answer' }, finish_reason: 'stop' }], usage: { prompt_tokens: 10, completion_tokens: 3, total_tokens: 13 } }));++ });++ });++ await new Promise(resolve => upstream.listen(0, '127.0.0.1', resolve));++ const h = await boot({ env: { AI_IMAGE_MODEL: 'image', AI_STUB_FALLBACK: 'false', AI_PROVIDER_RETRIES: '0', AI_FREE_PROVIDERS: 'fixture,anthfixture', AI_FREE_MAX_OUTPUT_PRICE_USD_PER_MTOK: '5' } });++ try {++ const svc = token('exchange', ALL);++ const added = await request(h.base, 'POST', '/api/v1/providers', { tok: svc, body: {++ key: 'fixture', kind: 'openai', base_url: `http://127.0.0.1:${upstream.address().port}/v1`, auth_mode: 'none',++ capabilities: ['chat', 'complete', 'embed', 'image'], default_model: 'good', provider_access: 'OWN_DEPLOYMENT',++ metadata: { paid: false, data_policy: { training: false, retention: 'none' } },++ } });++ assert.equal(added.status, 201, added.text);++ for (const [model, type] of [['broken', 'chat'], ['good', 'chat'], ['embed', 'embedding'], ['image', 'image']]) {++ const out = await request(h.base, 'POST', '/api/v1/models', { tok: svc, body: {++ provider_key: 'fixture', model_key: model, type, status: 'active', context_window: 8192, max_output: 4096,++ supports: { streaming: true }, cost: { in_per_mtok: 0, out_per_mtok: 0, cached_per_mtok: 0 },++ metadata: { org: 'fixture', model },++ } });++ assert.equal(out.status, 201, out.text);++ }++ assert.equal((await request(h.base, 'POST', '/api/v1/providers', { tok: svc, body: {++ key: 'anthfixture', kind: 'anthropic', base_url: `http://127.0.0.1:${upstream.address().port}`, auth_mode: 'none',++ capabilities: ['chat', 'messages'], default_model: 'claude', provider_access: 'OWN_DEPLOYMENT',++ } })).status, 201);++ assert.equal((await request(h.base, 'POST', '/api/v1/models', { tok: svc, body: {++ provider_key: 'anthfixture', model_key: 'claude', type: 'chat', status: 'active', context_window: 8192,++ supports: {}, cost: { in_per_mtok: 0, out_per_mtok: 0, cached_per_mtok: 0 }, metadata: { org: 'fixture', model: 'claude' },++ } })).status, 201);++ assert.equal((await request(h.base, 'POST', '/api/v1/providers', { tok: svc, body: {++ key: 'paid-image', kind: 'openai', base_url: `http://127.0.0.1:${upstream.address().port}/v1`, auth_mode: 'none',++ capabilities: ['image'], default_model: 'paid-image', provider_access: 'OWN_DEPLOYMENT',++ } })).status, 201);++ assert.equal((await request(h.base, 'POST', '/api/v1/models', { tok: svc, body: {++ provider_key: 'paid-image', model_key: 'paid-image', type: 'image', status: 'active', supports: {},++ cost: { in_per_mtok: 1, out_per_mtok: 1 }, metadata: { org: 'fixture', model: 'paid-image' },++ } })).status, 201);++ const owner = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';++ const keys = createKeys({ db: h.db });++ const key = await keys.create(owner, { name: 'fixture', monthly_cap_usd: 2, allowed_models: [] });++ const other = await keys.create('usr_01JAB2C3D4E5F6G7H8J9K0MNP1', { name: 'other', monthly_cap_usd: 2, allowed_models: [] });++ const post = (path, body, id) => request(h.base, 'POST', path, { tok: key.secret, body, headers: id ? { 'idempotency-key': id } : {} });++ const body = { model: 'fixture/broken', models: ['fixture/good'], provider: { only: ['fixture'], data_collection: 'deny', zdr: true }, usage: { include: true }, messages: [{ role: 'user', content: 'hello' }] };++ const result = await post('/v1/chat/completions', body, 'fallback-request-001');++ assert.equal(result.status, 200, result.text);++ assert.equal(result.body.model, 'good');++ assert.equal(result.body.usage.provider, 'fixture');++ assert.equal(result.body.usage.cost, 0);++ assert.equal(seen.length, 2, 'one failed attempt followed by one model fallback');++ const replay = await post('/v1/chat/completions', body, 'fallback-request-001');++ assert.equal(replay.status, 200, replay.text);++ assert.equal(seen.length, 2, 'replay never calls a provider');++ const generation = await request(h.base, 'GET', '/v1/generation?id=' + result.body.id, { tok: key.secret });++ assert.equal(generation.status, 200, generation.text);++ assert.equal(generation.body.data.provider, 'fixture');++ assert.equal((await request(h.base, 'GET', '/v1/generation?id=' + result.body.id, { tok: other.secret })).status, 404);++ const before = seen.length;++ assert.equal((await post('/v1/chat/completions', { ...body, model: 'fixture/good', models: undefined, provider: { ignore: ['fixture'] } })).status, 503);++ assert.equal(seen.length, before, 'hard routing refusal never calls upstream');++ const quality = { model: 'fixture/good', provider: { sort: 'quality' }, messages: body.messages };++ assert.equal((await post('/v1/chat/completions', quality)).status, 503, 'missing Report/Bench evidence refuses quality routing');++ await h.db.prepare(`INSERT INTO bench_evidence(kind_id,category,model,provider,rating,ci_lower,ci_upper,games,provisional,updated_at)++ VALUES('chat.reply','all','good','fixture',1400,1350,1450,20,0,?)`).run(new Date().toISOString());++ assert.equal((await post('/v1/chat/completions', quality)).status, 200, 'the event-fed rating used by Report enables quality routing');++ const presets = require('../server/exchange/compat-presets').createCompatPresets(h.db);++ await presets.put(owner, { name: 'writer', model: 'fixture/good:free', system: 'fixture system', temperature: .2 });++ const preset = await post('/v1/chat/completions', { model: '@preset/writer', messages: body.messages });++ assert.equal(preset.status, 200, preset.text);++ assert.equal(seen.at(-1).messages[0].content, 'fixture system');++ assert.equal(await presets.get('another-owner', 'writer'), null);++ const legacy = await post('/v1/completions', { model: '@preset/writer', prompt: 'hello' }, 'legacy-request-001');++ assert.equal(legacy.status, 200, legacy.text);++ assert.equal(legacy.body.object, 'text_completion');++ assert.equal(legacy.body.choices[0].text, 'fixture answer');++ assert.equal(seen.at(-1).messages[0].content, 'fixture system', 'legacy conversion preserves the saved system prompt');++ const embeddings = await post('/v1/embeddings', { model: 'fixture/embed', input: ['hello', 'world'] }, 'embedding-request-001');++ assert.equal(embeddings.status, 200, embeddings.text);++ assert.deepEqual(embeddings.body.data[0].embedding, [1, 2]);++ const images = await post('/v1/images/generations', { model: 'fixture/image', prompt: 'fixture' }, 'image-request-001');++ assert.equal(images.status, 200, images.text);++ assert.equal(images.body.data[0].b64_json, 'Zml4dHVyZQ==');++ const beforePaidImage = seen.length;++ const paidImage = await post('/v1/images/generations', { model: 'fixture/paid-image', prompt: 'must not run' });++ assert.equal(paidImage.status, 503, paidImage.text);++ assert.equal(seen.length, beforePaidImage, 'paid image capacity cannot bypass missing per-image accounting');++ await h.db.prepare(`INSERT INTO bench_evidence(kind_id,category,model,provider,rating,ci_lower,ci_upper,games,provisional,updated_at)++ SELECT kind_id,category,'claude','anthfixture',rating,ci_lower,ci_upper,games,provisional,updated_at++ FROM bench_evidence WHERE model = 'good' AND provider = 'fixture'`).run();++ const msgBody = { model: 'fixture/claude', provider: { only: ['anthfixture'], sort: 'quality' }, max_tokens: 32, messages: body.messages, usage: { include: true } };++ const message = await post('/v1/messages', msgBody, 'messages-request-001');++ assert.equal(message.status, 200, message.text);++ assert.equal(message.body.usage.provider, 'anthfixture');++ assert.equal((await request(h.base, 'GET', '/v1/generation?id=' + message.body.id, { tok: key.secret })).status, 200);++ const attempts = seen.length;++ for (const [path, input, idem] of [++ ['/v1/messages', msgBody, 'messages-request-001'],++ ['/v1/embeddings', { model: 'fixture/embed', input: ['hello', 'world'] }, 'embedding-request-001'],++ ['/v1/images/generations', { model: 'fixture/image', prompt: 'fixture' }, 'image-request-001'],++ ['/v1/completions', { model: '@preset/writer', prompt: 'hello' }, 'legacy-request-001'],++ ]) {++ const replayed = await post(path, input, idem);++ assert.equal(replayed.status, 200, replayed.text);++ assert.equal(replayed.headers.get('x-openvibe-replayed'), '1');++ }++ assert.equal(seen.length, attempts, 'every new endpoint replays without another provider call');++ const restricted = await keys.create(owner, { name: 'restricted', monthly_cap_usd: 1, allowed_models: ['fixture/good'] });++ const denied = await request(h.base, 'POST', '/v1/chat/completions', { tok: restricted.secret, body });++ assert.equal(denied.status, 403);++ assert.equal((await h.db.prepare('SELECT id FROM exchange_key_usage WHERE key_id = ?').all(restricted.key.id)).length, 0, 'a disallowed fallback is rejected before reserve');++ const failedKey = await keys.create(owner, { name: 'failed attempt', monthly_cap_usd: 1, allowed_models: [] });++ const failed = await request(h.base, 'POST', '/v1/chat/completions', { tok: failedKey.secret,++ body: { model: 'fixture/broken', messages: body.messages } });++ assert.equal(failed.status, 503, failed.text);++ await new Promise(resolve => setTimeout(resolve, 150));++ const failedRows = await h.db.prepare('SELECT * FROM exchange_key_usage WHERE key_id = ?').all(failedKey.key.id);++ assert.equal(failedRows[0].status, 'unresolved', 'unknown failed upstream usage retains conservative settlement');++ const rows = await h.db.prepare('SELECT * FROM exchange_key_usage WHERE key_id = ?').all(key.key.id);++ assert.equal(rows.length, 10, 'fallback and replay share one hold; other calls each have one');++ assert.ok(rows.every(row => row.status === 'settled'));++ assert.equal((await h.db.prepare('SELECT reserved FROM exchange_key_months WHERE key_id = ?').get(key.key.id)).reserved, 0);++ console.log('exchange-c2-execution: ok');++ } finally { await h.close(); upstream.closeAllConnections(); await new Promise(resolve => upstream.close(resolve)); }++})().catch(err => { console.error(err); process.exitCode = 1; });@@@ -105,7 +105,7 @@ t.test('a message answers Anthropic\'s shape, from a run of ai.messages that keeconst res = await post({ model: 'claude-sonnet-4-5', max_tokens: 64, system: 'Be brief.', messages: [{ role: 'user', content: 'Say hello' }] });assert.strictEqual(res.status, 200);const body = await res.json();-- assert.deepStrictEqual([body.type, body.role, body.id], ['message', 'assistant', 'msg_test_1']);++ assert.deepStrictEqual([body.type, body.role, body.id], ['message', 'assistant', 'msg_' + res.headers.get('x-openvibe-run-id')]);assert.deepStrictEqual(body.content, [{ type: 'text', text: 'Hello there' }]);assert.deepStrictEqual([body.stop_reason, body.usage.input_tokens, body.usage.output_tokens], ['end_turn', 11, 2]);const runId = res.headers.get('x-openvibe-run-id');@@@ -136,16 +136,18 @@ t.test('tools and tool_choice pass through; a tool_use comes back as Anthropic s});t.test('stream: Anthropic\'s events in order, and the run ends with the whole message', async () => {-- const res = await post({ max_tokens: 64, stream: true, messages: [{ role: 'user', content: 'Stream please' }] });++ const res = await post({ max_tokens: 64, stream: true, usage: { include: true }, messages: [{ role: 'user', content: 'Stream please' }] });assert.strictEqual(res.status, 200);assert.match(res.headers.get('content-type'), /^text\/event-stream/);const runId = res.headers.get('x-openvibe-run-id');const events = await sse(res);assert.deepStrictEqual(events.map((e) => e.event), FRAMES.map(([name]) => name));-- assert.deepStrictEqual(events[0].data.message.id, 'msg_stream_1');++ assert.deepStrictEqual(events[0].data.message.id, 'msg_' + runId);const text = events.filter((e) => e.data.type === 'content_block_delta').map((e) => e.data.delta.text).join('');assert.strictEqual(text, 'Hello there');assert.strictEqual(events[events.length - 1].data.type, 'message_stop');++ assert.equal(events.at(-1).data.usage.provider, 'anthropic-fake');++ assert.equal(typeof events.at(-1).data.usage.cost, 'number');const run = await request(h.base, 'GET', `/api/v1/runs/${runId}`, { tok: service });assert.strictEqual(run.body.run.output.content[0].text, text, 'the run kept the whole answer the stream sent');assert.strictEqual(run.body.run.output.stop_reason, 'end_turn');@@@ -76,7 +76,7 @@ const excluded = (key) => {const documented = Object.entries(doc.paths).flatMap(([p, item]) => Object.keys(item).map((m) => `${m.toUpperCase()} ${p}`)).sort();assert.ok(documented.length > 0, 'paths is not empty');for (const [p, item] of Object.entries(doc.paths)) {-- assert.ok(p.startsWith('/api/') || ['/v1/search', '/v1/search/images', '/v1/search/icons', '/v1/fetch'].includes(p), `${p} is outside /api`);++ assert.ok(p.startsWith('/api/') || ['/v1/search', '/v1/search/images', '/v1/search/icons', '/v1/fetch', ...Object.keys(require('../server/exchange/compat-openapi').paths)].includes(p), `${p} is outside /api`);for (const [method, op] of Object.entries(item)) {assert.ok(op.operationId, `${method.toUpperCase()} ${p} has an operationId`);assert.ok(op.responses && Object.keys(op.responses).length, `${method.toUpperCase()} ${p} describes its responses`);@@@ -88,7 +88,7 @@ const excluded = (key) => {assert.ok(!text.includes(`"${field}"`), `the document must not name ${field}`);}-- const live = [...mounted(h.app)].filter((key) => (key.split(' ')[1].startsWith(`${MOUNT}/`) || /^POST \/v1\/(search(?:\/images|\/icons)?|fetch)$/.test(key)) && !excluded(key)).sort();++ const live = [...mounted(h.app)].filter((key) => (key.split(' ')[1].startsWith(`${MOUNT}/`) || Object.keys(require('../server/exchange/compat-openapi').paths).includes(key.split(' ')[1]) || /^POST \/v1\/(search(?:\/images|\/icons)?|fetch)$/.test(key)) && !excluded(key)).sort();assert.deepStrictEqual(documented, live, 'every mounted public /api/v1 route is documented and vice versa');assert.deepStrictEqual(routes(), documented, 'server/openapi.js routes() and the document agree');@@@ -165,7 +165,7 @@ function assertOpenAiStream(parsed, expected) {function assertAnthropicJson(body, expected) {assert.deepStrictEqual([body.type, body.role], ['message', 'assistant']);-- assert.strictEqual(body.id, expected.response.id);++ assert.strictEqual(body.id, 'msg_' + body.x_openvibe.run_id, 'gateway ids support generation lookup');assert.strictEqual(body.model, expected.response.model);assert.deepStrictEqual(body.content, expected.response.content, 'the upstream content blocks are passed through untranslated');assert.strictEqual(body.stop_reason, expected.response.stop_reason);@@@ -186,7 +186,9 @@ function assertAnthropicStream(parsed, expected) {const last = parsed.frames[parsed.frames.length - 1];assert.ok(parsed.frames.length >= 2, 'message_start through message_stop');assert.deepStrictEqual(parsed.frames.map((f) => f.event), expected.stream.map((f) => f.event), 'the named events, in protocol order');-- assert.deepStrictEqual(parsed.frames.slice(0, -1).map((f) => f.data), expected.stream.slice(0, -1).map((f) => f.data), 'every event before message_stop is passed through');++ const expectedEvents = expected.stream.slice(0, -1).map(f => f.event === 'message_start'++ ? { ...f.data, message: { ...f.data.message, id: 'msg_' + last.data.x_openvibe.run_id } } : f.data);++ assert.deepStrictEqual(parsed.frames.slice(0, -1).map((f) => f.data), expectedEvents, 'events pass through with the gateway generation id');assert.ok(parsed.frames.every((f) => f.event === f.data.type), 'the event name matches the data type');assert.deepStrictEqual([last.event, last.data.type], ['message_stop', 'message_stop']);assert.ok(last.data.x_openvibe, 'message_stop carries x_openvibe');
new file mode 100644
new file mode 100644
@@ -0,0 +1,45 @@
@@ -0,0 +1,45 @@
+# Exchange compatible requests
+
+POST `/v1/chat/completions` and `/v1/messages` accept `provider` with `order`,
+`only`, `ignore`, `allow_fallbacks`, `sort`, `max_price`, `data_collection`, and `zdr`.
+`models` is an ordered list of model fallbacks (at most eight distinct models,
+including `model`). Each model's eligible providers are tried before the next
+model. A streamed answer cannot be followed by another model after output starts.
+All attempts remain one run and one Exchange key hold. Idempotent replays do not
+reserve or call a provider again. Every requested fallback must be allowed by the key.
+
+Sorts are `price`, `throughput`, `latency`, `quality`, `value`. Quality reads the
+Bench board ratings shown by Report, through AI's existing `bench.rating.changed`
+evidence store. Value divides that rating by the sum of input and output USD per
+million tokens. Provisional ratings and ratings without confidence intervals are excluded. Throughput and latency use measured decode tokens/second and TTFT. Missing
+quality or speed evidence excludes the offer. Price and privacy limits fail closed.
+`max_price.prompt` and `max_price.completion` are USD per million tokens.
+`data_collection: "deny"` requires declared no-training; `zdr: true` requires no retention.
+`:free` requires both prices to be zero, `:floor` sorts by price, and `:nitro` by throughput.
+Explicit credentials and only-my-keys mode cannot currently combine with this routing;
+they return a validation error. Network routing does not silently bypass those policies.
+
+`usage: { "include": true }` adds `cost` (USD), `latency` (milliseconds), and `provider`
+to token usage, including the final chat stream usage. `GET /v1/generation?id=<id>`
+returns `{ data: { id, model, provider, cost, total_cost, latency, tokens_prompt,
+tokens_completion, created_at } }` only to the original requester. It accepts the run
+id or the chat/message response id; another owner's id returns 404.
+
+A person token can save via `POST /v1/presets/import` or
+`POST /api/v1/exchange/presets`, list via `GET /api/v1/exchange/presets`, and delete
+via `DELETE /api/v1/exchange/presets/:name`. Exchange inference keys never authorize
+provisioning. Names are per person and invoked as `model: "@preset/name"`.
+The saved model, fallbacks, system prompt, provider policy, and sampling defaults
+are applied before key model checks and billing admission. Request fields override
+saved sampling/routing fields. Preset edits change the idempotent request fingerprint.
+`POST /v1/presets/import/preview` remains write-free and redacts submitted values.
+
+`POST /v1/completions` supports one string prompt and streaming through chat completions.
+`POST /v1/embeddings` supports strings/string arrays with float vectors (no token-id
+arrays or dimension override). `POST /v1/images/generations` uses the existing image
+adapter and requires a configured non-metered image route; paid image calls are
+refused until per-image accounting is wired; no provider is enabled by this change.
+`GET /v1/models` adds pricing as USD per token strings, context length, architecture,
+and top-provider limits to catalogue model entries. Unknown prices remain null.
+
+Tests use loopback fixture servers only. No production data or paid provider calls.
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
+-- phase: expand
+-- OpenRouter request presets belong to a person, independently of routing-only presets.
+CREATE TABLE IF NOT EXISTS exchange_compat_presets (
+ owner TEXT NOT NULL,
+ name TEXT NOT NULL,
+ definition JSONB NOT NULL,
+ PRIMARY KEY (owner, name)
+);
@@ -13,7 +13,7 @@
@@ -13,7 +13,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.375.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.380.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.179.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.179.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -1543,9 +1543,9 @@
@@ -1543,9 +1543,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.375.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.375.0",
- "integrity": "sha512-+HI3XAkqkDW1ANR0SyGSXAYTko/z0SsDB9uIdswQCiYixoS/9M3RYGqPOFr+VevvZH9CG9hXvW0Tp3ecc0qaXw==",
+ "version": "0.380.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.380.0",
+ "integrity": "sha512-uPvVxDKkWxu69dhn3sX/eRcX9CaGEmqHZHPT1HXRpIW9XgoKIBeCMFmvtYoQ6JTSx6cpgCQXSdMf6b2c3XS1Kg==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -29,7 +29,7 @@
@@ -29,7 +29,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.375.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.380.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.179.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.179.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -26,6 +26,7 @@ const PERSON_RUN = `on_behalf_of IS NOT NULL AND on_behalf_of::jsonb ->> 'type'
@@ -26,6 +26,7 @@ const PERSON_RUN = `on_behalf_of IS NOT NULL AND on_behalf_of::jsonb ->> 'type'
const RUN_COLUMNS = 'id, workflow_key, workflow_version, template_key, template_version, route_key, status, model_key, provider_key, input, output, citations_count, error_code, error_detail, tokens_in, tokens_out, cost_usd, created_at, started_at, finished_at';
const RUN_COLUMNS = 'id, workflow_key, workflow_version, template_key, template_version, route_key, status, model_key, provider_key, input, output, citations_count, error_code, error_detail, tokens_in, tokens_out, cost_usd, created_at, started_at, finished_at';
const TABLES = [
const TABLES = [
+ { table: 'exchange_compat_presets', subject: 'owner', file: 'exchange-request-presets.json', columns: ['name', 'definition'], erase: 'delete' },
{ table: 'exchange_api_keys', subject: 'owner', file: 'exchange-keys.json', columns: ['id','name','prefix','monthly_cap_usd','allowed_models','created_at','revoked_at','expires_at'], erase: { keep: 'extraErase deletes key usage and cap rows before deleting keys' } },
{ table: 'exchange_api_keys', subject: 'owner', file: 'exchange-keys.json', columns: ['id','name','prefix','monthly_cap_usd','allowed_models','created_at','revoked_at','expires_at'], erase: { keep: 'extraErase deletes key usage and cap rows before deleting keys' } },
{ table: 'exchange_key_requests', subject: 'owner', file: 'exchange-key-requests.json', columns: ['idempotency_key','request_hash','max_cost_usd'], erase: 'delete' },
{ table: 'exchange_key_requests', subject: 'owner', file: 'exchange-key-requests.json', columns: ['idempotency_key','request_hash','max_cost_usd'], erase: 'delete' },
{ table: 'exchange_supply_settings', subject: 'actor', file: null, erase: { keep: 'owner pricing decisions are the operator audit trail' } },
{ table: 'exchange_supply_settings', subject: 'actor', file: null, erase: { keep: 'owner pricing decisions are the operator audit trail' } },
new file mode 100644
new file mode 100644
@@ -0,0 +1,81 @@
@@ -0,0 +1,81 @@
+'use strict';
+const express = require('express');
+const { CAPS } = require('../auth');
+const { AiError, sendError } = require('../util');
+function accounting(run) {
+ return { cost: Number(run.usage.cost_usd || 0), latency: Math.max(0, Date.parse(run.finished_at) - Date.parse(run.started_at || run.created_at)) || 0,
+ provider: run.provenance?.provider || null };
+}
+function compatExtra({ runs, auth, config, log }) {
+ const r = express.Router();
+ const allow = auth.appOrCap(CAPS.appRun, CAPS.runCreate);
+ const wrap = fn => async (req, res) => { try { await fn(req, res); } catch (err) { sendError(res, err, req.ov, log); } };
+ r.get('/v1/generation', allow, wrap(async (req, res) => {
+ const id = String(req.query.id || '').replace(/^(chatcmpl-|msg_)/, '');
+ const run = await runs.get(id);
+ const p = req.principal;
+ // Never use an on_behalf_of claim to widen access to another requester's history.
+ const owner = p.subject?.id;
+ const type = p.subject?.type;
+ if (!run || run.requester.type !== type || run.requester.id !== owner)
+ throw new AiError(404, 'generation.not_found', 'Generation not found');
+ res.set('Cache-Control', 'no-store').json({ data: { id: run.id, model: run.provenance.model,
+ ...accounting(run), total_cost: Number(run.usage.cost_usd || 0),
+ tokens_prompt: run.usage.tokens_in, tokens_completion: run.usage.tokens_out, created_at: run.created_at } });
+ }));
+ r.post('/v1/embeddings', allow, wrap(async (req, res) => {
+ const b = req.body || {};
+ if (b.encoding_format && b.encoding_format !== 'float') throw new AiError(400, 'input.invalid', 'Only float embeddings are supported');
+ if (b.dimensions !== undefined) throw new AiError(400, 'input.invalid', 'Custom dimensions are not supported');
+ const input = { input: b.input, max_cost_usd: b.max_cost_usd };
+ if (b.model || b.provider || b.models) input.compat_routing = require('../exchange/compat-routing').request(b);
+ const created = await runs.create({ workflow: 'ai.embed', input, idempotency_key: req.get('idempotency-key') || undefined }, req.principal, { trace: req.ov?.traceId });
+ res.locals.inferenceAdmitted = true;
+ if (!created.created) res.set('x-openvibe-replayed', '1');
+ const run = await runs.wait(created, Math.max(config.runs.maxWaitMs, 120000));
+ if (['succeeded', 'cached'].includes(run.status) || (['failed', 'cancelled'].includes(run.status) && run.usage.attempts === 0)) res.locals.inferenceCharge = Number(run.usage.cost_usd || 0);
+ if (!['succeeded', 'cached'].includes(run.status)) throw new AiError(503, run.error?.code || 'run.failed', run.error?.detail || 'Embedding unavailable');
+ res.json({ id: run.id, object: 'list', model: run.provenance.model,
+ data: run.output.vectors.map((embedding, index) => ({ object: 'embedding', index, embedding })),
+ usage: { prompt_tokens: run.usage.tokens_in, total_tokens: run.usage.tokens_in, ...(b.usage?.include ? accounting(run) : {}) },
+ x_openvibe: { run_id: run.id, cost_usd: run.usage.cost_usd, provider: run.provenance.provider } });
+ }));
+ r.post('/v1/images/generations', allow, wrap(async (req, res) => {
+ const b = req.body || {};
+ if (b.response_format && !['url', 'b64_json'].includes(b.response_format)) throw new AiError(400, 'input.invalid', 'Unknown image response format');
+ const input = { prompt: b.prompt, ...(b.size ? { size: b.size } : {}), ...(b.n ? { n: b.n } : {}), max_cost_usd: b.max_cost_usd };
+ input.compat_routing = require('../exchange/compat-routing').request(b);
+ const created = await runs.create({ workflow: 'ai.image', input, idempotency_key: req.get('idempotency-key') || undefined }, req.principal, { trace: req.ov?.traceId });
+ res.locals.inferenceAdmitted = true;
+ if (!created.created) res.set('x-openvibe-replayed', '1');
+ const run = await runs.wait(created, Math.max(config.runs.maxWaitMs, 120000));
+ if (['succeeded', 'cached'].includes(run.status) || (['failed', 'cancelled'].includes(run.status) && run.usage.attempts === 0)) res.locals.inferenceCharge = Number(run.usage.cost_usd || 0);
+ if (!['succeeded', 'cached'].includes(run.status)) throw new AiError(503, run.error?.code || 'run.failed', run.error?.detail || 'Image provider unavailable');
+ res.json({ id: run.id, created: Math.floor(Date.parse(run.created_at) / 1000),
+ data: run.output.images.map(i => i.b64 ? { b64_json: i.b64 } : { url: i.url }),
+ usage: { prompt_tokens: run.usage.tokens_in, completion_tokens: run.usage.tokens_out, ...(b.usage?.include ? accounting(run) : {}) },
+ x_openvibe: { run_id: run.id, cost_usd: run.usage.cost_usd, provider: run.provenance.provider } });
+ }));
+ // Reuse the complete workflow and its single hold, including streaming and idempotency.
+ r.post('/v1/completions', (req, res, next) => {
+ if (typeof req.body?.prompt !== 'string' || !req.body.prompt.length)
+ return res.status(400).json({ error: { code: 'input.invalid', message: 'prompt must be a non-empty string' } });
+ const convert = v => v?.choices ? { ...v, object: 'text_completion', choices: v.choices.map(c => ({ index: c.index, text: c.message?.content || c.delta?.content || '', logprobs: c.logprobs || null, finish_reason: c.finish_reason })) } : v;
+ const json = res.json.bind(res);
+ res.json = v => json(convert(v));
+ const write = res.write.bind(res);
+ res.write = (chunk, ...args) => {
+ const value = String(chunk);
+ if (value.startsWith('data: ') && !value.startsWith('data: [DONE]')) {
+ try { chunk = 'data: ' + JSON.stringify(convert(JSON.parse(value.slice(6)))) + '\n\n'; } catch {}
+ }
+ return write(chunk, ...args);
+ };
+ req.body = { ...req.body, messages: [...(req.body.messages || []), { role: 'user', content: req.body.prompt }] };
+ delete req.body.prompt;
+ req.url = '/v1/chat/completions';
+ next();
+ });
+ return r;
+}
+module.exports = { compatExtra, accounting };
@@ -69,6 +69,7 @@ function createEventStream(res) {
@@ -69,6 +69,7 @@ function createEventStream(res) {
let routeInfo = null;
let routeInfo = null;
let commented = false;
let commented = false;
let finalEvent = null;
let finalEvent = null;
+ let responseId = null;
const waiting = [];
const waiting = [];
const alive = () => !res.writableEnded && !res.destroyed;
const alive = () => !res.writableEnded && !res.destroyed;
const comment = () => {
const comment = () => {
@@ -81,6 +82,7 @@ function createEventStream(res) {
@@ -81,6 +82,7 @@ function createEventStream(res) {
if (!data) return;
if (!data) return;
// message_stop is held until the run has settled: the final chunk carries the whole x_openvibe object (run_id,
// message_stop is held until the run has settled: the final chunk carries the whole x_openvibe object (run_id,
// provider, cost_usd and the route disclosure), and the cost is only known once the provider's usage is recorded.
// provider, cost_usd and the route disclosure), and the cost is only known once the provider's usage is recorded.
+ if (event === 'message_start' && data.message && responseId) data = { ...data, message: { ...data.message, id: responseId } };
if (event === 'message_stop') { finalEvent = data; return; }
if (event === 'message_stop') { finalEvent = data; return; }
write(event, data);
write(event, data);
};
};
@@ -94,6 +96,7 @@ function createEventStream(res) {
@@ -94,6 +96,7 @@ function createEventStream(res) {
emit(e.event, e.data);
emit(e.event, e.data);
},
},
open(r) {
open(r) {
+ responseId = 'msg_' + r.id;
res.status(200);
res.status(200);
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Cache-Control', 'no-store');
@@ -109,10 +112,10 @@ function createEventStream(res) {
@@ -109,10 +112,10 @@ function createEventStream(res) {
emit,
emit,
sentAny: () => any,
sentAny: () => any,
/** The held message_stop with the final x_openvibe object (and the receipt), then end the stream. */
/** The held message_stop with the final x_openvibe object (and the receipt), then end the stream. */
- finish(xOpenVibe, receipt) {
+ finish(xOpenVibe, receipt, usage) {
const data = finalEvent || { type: 'message_stop' };
const data = finalEvent || { type: 'message_stop' };
finalEvent = null;
finalEvent = null;
- write('message_stop', xOpenVibe ? { ...data, x_openvibe: xOpenVibe, ...(receipt ? { receipt } : {}) } : data);
+ write('message_stop', xOpenVibe ? { ...data, x_openvibe: xOpenVibe, ...(receipt ? { receipt } : {}), ...(usage ? { usage } : {}) } : data);
return out.done();
return out.done();
},
},
fail(message, code) {
fail(message, code) {
@@ -142,7 +145,7 @@ function replay(res, message, run) {
@@ -142,7 +145,7 @@ function replay(res, message, run) {
res.emit('message_stop', { type: 'message_stop' });
res.emit('message_stop', { type: 'message_stop' });
}
}
-function messagesRouter({ runs, auth, config, registry, presets = null, receipts = null, log = console }) {
+function messagesRouter({ runs, auth, config, registry, presets = null, receipts = null, db = null, log = console }) {
const r = express.Router();
const r = express.Router();
// Developer apps call with ai.app.run, the public capability they already hold (ADR-014; AI Exchange D8').
// Developer apps call with ai.app.run, the public capability they already hold (ADR-014; AI Exchange D8').
const allow = auth.appOrCap(CAPS.appRun, CAPS.runCreate);
const allow = auth.appOrCap(CAPS.appRun, CAPS.runCreate);
@@ -169,7 +172,10 @@ function messagesRouter({ runs, auth, config, registry, presets = null, receipts
@@ -169,7 +172,10 @@ function messagesRouter({ runs, auth, config, registry, presets = null, receipts
};
};
r.post('/v1/messages', asAnthropicErrors, allow, async (req, res) => {
r.post('/v1/messages', asAnthropicErrors, allow, async (req, res) => {
- const b = req.body && typeof req.body === 'object' ? req.body : {};
+ let b = req.body && typeof req.body === 'object' ? req.body : {};
+ if (db && req.principal.kind === 'user') {
+ try { b = await require('../exchange/compat-presets').createCompatPresets(db).expand(req.principal.subject.id, b, 'messages'); } catch (err) { return res.status(err.status || 400).json({ error: { message: err.message, code: err.code } }); }
+ }
if (!Array.isArray(b.messages) || !b.messages.length) return anthropicError(res, 400, 'messages: must be a non-empty array');
if (!Array.isArray(b.messages) || !b.messages.length) return anthropicError(res, 400, 'messages: must be a non-empty array');
if (!Number.isInteger(b.max_tokens) || b.max_tokens < 1) return anthropicError(res, 400, 'max_tokens: must be a positive integer');
if (!Number.isInteger(b.max_tokens) || b.max_tokens < 1) return anthropicError(res, 400, 'max_tokens: must be a positive integer');
const input = { messages: b.messages, max_tokens: Math.min(b.max_tokens, MAX_TOKENS) };
const input = { messages: b.messages, max_tokens: Math.min(b.max_tokens, MAX_TOKENS) };
@@ -212,6 +218,12 @@ function messagesRouter({ runs, auth, config, registry, presets = null, receipts
@@ -212,6 +218,12 @@ function messagesRouter({ runs, auth, config, registry, presets = null, receipts
}
}
}
}
}
}
+ try {
+ if (b.provider !== undefined || b.models !== undefined || (typeof b.model === 'string' && /:(free|floor|nitro)$/.test(b.model)) || (typeof b.model === 'string' && b.model.includes('/') && !b.model.startsWith('@preset/') && !b.model.startsWith('openvibe') && !EXACT_ID.test(b.model))) {
+ input.compat_routing = require('../exchange/compat-routing').request(b);
+ delete input.model;
+ }
+ } catch (err) { return anthropicError(res, err.status || 400, err.message, null, err.code); }
let body = { workflow: 'ai.messages', input, idempotency_key: req.get('idempotency-key') || undefined };
let body = { workflow: 'ai.messages', input, idempotency_key: req.get('idempotency-key') || undefined };
// The preset's rules ride the run into the router.
// The preset's rules ride the run into the router.
if (preset) body = { ...body, preset: { name: preset.name, scope: preset.scope, policy: preset.policy } };
if (preset) body = { ...body, preset: { name: preset.name, scope: preset.scope, policy: preset.policy } };
@@ -235,6 +247,7 @@ function messagesRouter({ runs, auth, config, registry, presets = null, receipts
@@ -235,6 +247,7 @@ function messagesRouter({ runs, auth, config, registry, presets = null, receipts
const created = await runs.create(body, req.principal, { trace: req.ov && req.ov.traceId, requestId: req.ov && req.ov.requestId, abuse: req.abuse,
const created = await runs.create(body, req.principal, { trace: req.ov && req.ov.traceId, requestId: req.ov && req.ov.requestId, abuse: req.abuse,
...(live ? { onDelta: live.event } : {}),
...(live ? { onDelta: live.event } : {}),
...(live && config.modelCatalog && config.modelCatalog.enabled ? { onRoute: (info) => live.route(info) } : {}) });
...(live && config.modelCatalog && config.modelCatalog.enabled ? { onRoute: (info) => live.route(info) } : {}) });
+ res.locals.inferenceAdmitted = true;
if (created.created === false) res.setHeader('x-openvibe-replayed', '1');
if (created.created === false) res.setHeader('x-openvibe-replayed', '1');
if (live) live.open(created.run);
if (live) live.open(created.run);
run = await runs.explained(await runs.wait(created, Math.max(config.runs.maxWaitMs, 120000)));
run = await runs.explained(await runs.wait(created, Math.max(config.runs.maxWaitMs, 120000)));
@@ -243,6 +256,7 @@ function messagesRouter({ runs, auth, config, registry, presets = null, receipts
@@ -243,6 +256,7 @@ function messagesRouter({ runs, auth, config, registry, presets = null, receipts
if ((err && err.status) >= 500) log.warn(`[ai] /v1/messages: ${err && err.message}`);
if ((err && err.status) >= 500) log.warn(`[ai] /v1/messages: ${err && err.message}`);
return anthropicError(res, (err && err.status) || 500, err && (err.detail || err.message), err && err.code === 'credential.unsupported' ? 'invalid_request_error' : null);
return anthropicError(res, (err && err.status) || 500, err && (err.detail || err.message), err && err.code === 'credential.unsupported' ? 'invalid_request_error' : null);
}
}
+ if (['succeeded', 'cached'].includes(run.status) || (['failed', 'cancelled'].includes(run.status) && run.usage.attempts === 0)) res.locals.inferenceCharge = Number(run.usage.cost_usd || 0);
if (live && (run.status === 'failed' || run.status === 'cancelled' || !run.output || !Array.isArray(run.output.content))) {
if (live && (run.status === 'failed' || run.status === 'cancelled' || !run.output || !Array.isArray(run.output.content))) {
const code = (run.error && run.error.code) || (['failed', 'cancelled'].includes(run.status) ? 'run.failed' : 'run.timeout');
const code = (run.error && run.error.code) || (['failed', 'cancelled'].includes(run.status) ? 'run.failed' : 'run.timeout');
return live.fail((run.error && run.error.detail) || (code === 'run.timeout' ? `the message is still running; GET /api/v1/runs/${run.id}` : code), code);
return live.fail((run.error && run.error.detail) || (code === 'run.timeout' ? `the message is still running; GET /api/v1/runs/${run.id}` : code), code);
@@ -262,7 +276,7 @@ function messagesRouter({ runs, auth, config, registry, presets = null, receipts
@@ -262,7 +276,7 @@ function messagesRouter({ runs, auth, config, registry, presets = null, receipts
}
}
const out = run.output;
const out = run.output;
const message = {
const message = {
- id: out.id || `msg_${run.id}`, type: 'message', role: 'assistant',
+ id: `msg_${run.id}`, type: 'message', role: 'assistant',
model: out.model || (run.provenance && run.provenance.model) || 'openvibe',
model: out.model || (run.provenance && run.provenance.model) || 'openvibe',
content: out.content, stop_reason: out.stop_reason || 'end_turn', stop_sequence: out.stop_sequence || null,
content: out.content, stop_reason: out.stop_reason || 'end_turn', stop_sequence: out.stop_sequence || null,
usage: {
usage: {
@@ -272,6 +286,7 @@ function messagesRouter({ runs, auth, config, registry, presets = null, receipts
@@ -272,6 +286,7 @@ function messagesRouter({ runs, auth, config, registry, presets = null, receipts
...(out.usage && out.usage.cache_creation_input_tokens != null ? { cache_creation_input_tokens: out.usage.cache_creation_input_tokens } : {}),
...(out.usage && out.usage.cache_creation_input_tokens != null ? { cache_creation_input_tokens: out.usage.cache_creation_input_tokens } : {}),
},
},
};
};
+ if (b.usage?.include === true) Object.assign(message.usage, require('./compat-extra').accounting(run));
const disclosure = await models.disclosureOf({ registry, config, run });
const disclosure = await models.disclosureOf({ registry, config, run });
const community = (disclosure && disclosure.community) || (run.explain && run.explain.community) || null;
const community = (disclosure && disclosure.community) || (run.explain && run.explain.community) || null;
if (live) live.route(disclosure || (community ? { community } : null));
if (live) live.route(disclosure || (community ? { community } : null));
@@ -288,13 +303,13 @@ function messagesRouter({ runs, auth, config, registry, presets = null, receipts
@@ -288,13 +303,13 @@ function messagesRouter({ runs, auth, config, registry, presets = null, receipts
message.policy = run.output.policy || 'uncensored';
message.policy = run.output.policy || 'uncensored';
res.setHeader('X-OpenVibe-Content-Policy', message.policy);
res.setHeader('X-OpenVibe-Content-Policy', message.policy);
}
}
- const xOpenVibe = (disclosure || community || run.output.label || receipt) ? { ...x, ...(disclosure || {}) } : null;
+ const xOpenVibe = { ...x, ...(disclosure || {}) };
if (live) {
if (live) {
// The events already went out as they arrived; an answer that came in one piece (an upstream that cannot
// The events already went out as they arrived; an answer that came in one piece (an upstream that cannot
// stream, a replayed run) is sent as the events it is. finish() sends the held message_stop with the whole
// stream, a replayed run) is sent as the events it is. finish() sends the held message_stop with the whole
// x_openvibe object, then ends the stream.
// x_openvibe object, then ends the stream.
if (!live.sentAny()) replay(live, message, run);
if (!live.sentAny()) replay(live, message, run);
- return live.finish(xOpenVibe, receiptView);
+ return live.finish(xOpenVibe, receiptView, b.usage?.include ? message.usage : null);
}
}
res.setHeader('X-OpenVibe-Run-Id', run.id);
res.setHeader('X-OpenVibe-Run-Id', run.id);
if (receipt) {
if (receipt) {
@@ -42,6 +42,12 @@ const catalogOpenAi = (x) => ({
@@ -42,6 +42,12 @@ const catalogOpenAi = (x) => ({
created: epochOf(x.entry.created_at),
created: epochOf(x.entry.created_at),
owned_by: x.entry.provider_key,
owned_by: x.entry.provider_key,
dynamic: false,
dynamic: false,
+ name: x.entry.display_name || x.entry.model_key,
+ context_length: x.entry.context_window,
+ pricing: { prompt: x.entry.cost.in_per_mtok == null ? null : String(x.entry.cost.in_per_mtok / 1e6),
+ completion: x.entry.cost.out_per_mtok == null ? null : String(x.entry.cost.out_per_mtok / 1e6) },
+ architecture: { input_modalities: x.entry.modalities || ['text'], output_modalities: [x.entry.type === 'image' ? 'image' : 'text'] },
+ top_provider: { context_length: x.entry.context_window, max_completion_tokens: x.entry.max_output },
});
});
const catalogAnthropic = (x) => ({
const catalogAnthropic = (x) => ({
...x.entry,
...x.entry,
@@ -124,7 +124,7 @@ function openaiError(res, status, message, code, type) {
@@ -124,7 +124,7 @@ function openaiError(res, status, message, code, type) {
res.status(status).json({ error: { message: String(message || 'error').slice(0, 1000), type: type || (status === 401 || status === 403 ? 'authentication_error' : status === 429 ? 'rate_limit_error' : status === 402 ? 'insufficient_quota' : status >= 500 ? 'api_error' : 'invalid_request_error'), code: code || null, param: null } });
res.status(status).json({ error: { message: String(message || 'error').slice(0, 1000), type: type || (status === 401 || status === 403 ? 'authentication_error' : status === 429 ? 'rate_limit_error' : status === 402 ? 'insufficient_quota' : status >= 500 ? 'api_error' : 'invalid_request_error'), code: code || null, param: null } });
}
}
-function openaiRouter({ runs, auth, config, registry, presets = null, receipts = null, log = console }) {
+function openaiRouter({ runs, auth, config, registry, presets = null, receipts = null, db = null, log = console }) {
const r = express.Router();
const r = express.Router();
// Developer apps call with ai.app.run, the public capability they already hold (ADR-014; AI Exchange D8').
// Developer apps call with ai.app.run, the public capability they already hold (ADR-014; AI Exchange D8').
const allow = auth.appOrCap(CAPS.appRun, CAPS.runCreate);
const allow = auth.appOrCap(CAPS.appRun, CAPS.runCreate);
@@ -155,7 +155,10 @@ function openaiRouter({ runs, auth, config, registry, presets = null, receipts =
@@ -155,7 +155,10 @@ function openaiRouter({ runs, auth, config, registry, presets = null, receipts =
};
};
r.post('/v1/chat/completions', asOpenAiErrors, allow, async (req, res) => {
r.post('/v1/chat/completions', asOpenAiErrors, allow, async (req, res) => {
- const b = req.body && typeof req.body === 'object' ? req.body : {};
+ let b = req.body && typeof req.body === 'object' ? req.body : {};
+ if (db && req.principal.kind === 'user') {
+ try { b = await require('../exchange/compat-presets').createCompatPresets(db).expand(req.principal.subject.id, b, 'openai'); } catch (err) { return res.status(err.status || 400).json({ error: { message: err.message, code: err.code } }); }
+ }
if (!Array.isArray(b.messages) || !b.messages.length) return openaiError(res, 400, 'messages must be a non-empty array', 'input.invalid');
if (!Array.isArray(b.messages) || !b.messages.length) return openaiError(res, 400, 'messages must be a non-empty array', 'input.invalid');
if (b.n != null && Number(b.n) !== 1) return openaiError(res, 400, 'n must be 1', 'input.invalid');
if (b.n != null && Number(b.n) !== 1) return openaiError(res, 400, 'n must be 1', 'input.invalid');
const input = {};
const input = {};
@@ -197,6 +200,12 @@ function openaiRouter({ runs, auth, config, registry, presets = null, receipts =
@@ -197,6 +200,12 @@ function openaiRouter({ runs, auth, config, registry, presets = null, receipts =
}
}
}
}
}
}
+ try {
+ if (b.provider !== undefined || b.models !== undefined || (typeof b.model === 'string' && /:(free|floor|nitro)$/.test(b.model)) || (typeof b.model === 'string' && b.model.includes('/') && !b.model.startsWith('@preset/') && !b.model.startsWith('openvibe') && !EXACT_ID.test(b.model))) {
+ input.compat_routing = require('../exchange/compat-routing').request(b);
+ delete input.model;
+ }
+ } catch (err) { return openaiError(res, err.status || 400, err.message, err.code); }
const maxTokens = b.max_completion_tokens != null ? b.max_completion_tokens : b.max_tokens;
const maxTokens = b.max_completion_tokens != null ? b.max_completion_tokens : b.max_tokens;
if (maxTokens != null) input.max_tokens = Math.max(1, Math.min(Number(maxTokens) || 1, 16000));
if (maxTokens != null) input.max_tokens = Math.max(1, Math.min(Number(maxTokens) || 1, 16000));
// AI Exchange F: the caller's liability cap for the Balance hold (an OpenVibe extension, like x_openvibe).
// AI Exchange F: the caller's liability cap for the Balance hold (an OpenVibe extension, like x_openvibe).
@@ -238,6 +247,7 @@ function openaiRouter({ runs, auth, config, registry, presets = null, receipts =
@@ -238,6 +247,7 @@ function openaiRouter({ runs, auth, config, registry, presets = null, receipts =
const created = await runs.create(body, req.principal, { trace: req.ov && req.ov.traceId, requestId: req.ov && req.ov.requestId, abuse: req.abuse,
const created = await runs.create(body, req.principal, { trace: req.ov && req.ov.traceId, requestId: req.ov && req.ov.requestId, abuse: req.abuse,
...(live ? { onDelta: live.delta } : {}),
...(live ? { onDelta: live.delta } : {}),
...(live && config.modelCatalog && config.modelCatalog.enabled ? { onRoute: (info) => live.route(info) } : {}) });
...(live && config.modelCatalog && config.modelCatalog.enabled ? { onRoute: (info) => live.route(info) } : {}) });
+ res.locals.inferenceAdmitted = true;
if (created.created === false) res.setHeader('x-openvibe-replayed', '1');
if (created.created === false) res.setHeader('x-openvibe-replayed', '1');
if (live) live.open(created.run);
if (live) live.open(created.run);
run = await runs.explained(await runs.wait(created, Math.max(config.runs.maxWaitMs, 120000)));
run = await runs.explained(await runs.wait(created, Math.max(config.runs.maxWaitMs, 120000)));
@@ -248,6 +258,7 @@ function openaiRouter({ runs, auth, config, registry, presets = null, receipts =
@@ -248,6 +258,7 @@ function openaiRouter({ runs, auth, config, registry, presets = null, receipts =
if (status >= 500) log.warn(`[ai] /v1/chat/completions: ${err && err.message}`);
if (status >= 500) log.warn(`[ai] /v1/chat/completions: ${err && err.message}`);
return openaiError(res, status, err && (err.detail || err.message), err && err.code);
return openaiError(res, status, err && (err.detail || err.message), err && err.code);
}
}
+ if (['succeeded', 'cached'].includes(run.status) || (['failed', 'cancelled'].includes(run.status) && run.usage.attempts === 0)) res.locals.inferenceCharge = Number(run.usage.cost_usd || 0);
if (live && (run.status === 'failed' || run.status === 'cancelled' || !run.output || !Array.isArray(run.output.choices))) {
if (live && (run.status === 'failed' || run.status === 'cancelled' || !run.output || !Array.isArray(run.output.choices))) {
const code = (run.error && run.error.code) || (['failed', 'cancelled'].includes(run.status) ? 'run.failed' : 'run.timeout');
const code = (run.error && run.error.code) || (['failed', 'cancelled'].includes(run.status) ? 'run.failed' : 'run.timeout');
return live.fail((run.error && run.error.detail) || (code === 'run.timeout' ? `the completion is still running; GET /api/v1/runs/${run.id}` : code), code);
return live.fail((run.error && run.error.detail) || (code === 'run.timeout' ? `the completion is still running; GET /api/v1/runs/${run.id}` : code), code);
@@ -275,6 +286,7 @@ function openaiRouter({ runs, auth, config, registry, presets = null, receipts =
@@ -275,6 +286,7 @@ function openaiRouter({ runs, auth, config, registry, presets = null, receipts =
const id = `chatcmpl-${run.id}`;
const id = `chatcmpl-${run.id}`;
const model = run.output.model || (run.provenance && run.provenance.model) || 'openvibe';
const model = run.output.model || (run.provenance && run.provenance.model) || 'openvibe';
const usage = { prompt_tokens: run.usage.tokens_in || 0, completion_tokens: run.usage.tokens_out || 0, total_tokens: (run.usage.tokens_in || 0) + (run.usage.tokens_out || 0) };
const usage = { prompt_tokens: run.usage.tokens_in || 0, completion_tokens: run.usage.tokens_out || 0, total_tokens: (run.usage.tokens_in || 0) + (run.usage.tokens_out || 0) };
+ if (b.usage?.include === true) Object.assign(usage, require('./compat-extra').accounting(run));
const x = {
const x = {
run_id: run.id, provider: run.provenance && run.provenance.provider,
run_id: run.id, provider: run.provenance && run.provenance.provider,
fallback_used: Boolean(run.provenance && run.provenance.fallback_used), cost_usd: run.usage.cost_usd,
fallback_used: Boolean(run.provenance && run.provenance.fallback_used), cost_usd: run.usage.cost_usd,
@@ -304,7 +316,7 @@ function openaiRouter({ runs, auth, config, registry, presets = null, receipts =
@@ -304,7 +316,7 @@ function openaiRouter({ runs, auth, config, registry, presets = null, receipts =
send({ ...base, choices: [{ index: c.index || 0, delta, finish_reason: null }] });
send({ ...base, choices: [{ index: c.index || 0, delta, finish_reason: null }] });
send({ ...base, choices: [{ index: c.index || 0, delta: {}, finish_reason: c.finish_reason || 'stop' }] });
send({ ...base, choices: [{ index: c.index || 0, delta: {}, finish_reason: c.finish_reason || 'stop' }] });
}
}
- if (b.stream_options && b.stream_options.include_usage) send({ ...base, choices: [], usage });
+ if ((b.stream_options && b.stream_options.include_usage) || b.usage?.include === true) send({ ...base, choices: [], usage });
// A stream's headers went out before the answer was known, so the receipt travels in the last chunk
// A stream's headers went out before the answer was known, so the receipt travels in the last chunk
// (its id was in the headers, and the full receipt is one GET away).
// (its id was in the headers, and the full receipt is one GET away).
send({ ...base, choices: [], x_openvibe: disclosure ? { ...x, ...disclosure } : x, receipt: receiptView });
send({ ...base, choices: [], x_openvibe: disclosure ? { ...x, ...disclosure } : x, receipt: receiptView });
@@ -216,11 +216,12 @@ function createApp({ choices = null, searchFetch = null, config, db, registry, p
@@ -216,11 +216,12 @@ function createApp({ choices = null, searchFetch = null, config, db, registry, p
const ops = createOps({ db, registry, pool, quotas, cache, runs });
const ops = createOps({ db, registry, pool, quotas, cache, runs });
app.use(runsRouter({ choices, runs, registry, auth, config, log }));
app.use(runsRouter({ choices, runs, registry, auth, config, log }));
app.use(actionsRouter({ runs, registry, auth, log }));
app.use(actionsRouter({ runs, registry, auth, log }));
+ app.use(require('./api/compat-extra').compatExtra({ runs, auth, config, log }));
// The OpenAI-compatible endpoint (POST /v1/chat/completions, GET /v1/models): runs of ai.complete.
// The OpenAI-compatible endpoint (POST /v1/chat/completions, GET /v1/models): runs of ai.complete.
app.use(require('./api/routing-preview').routingPreview({ auth, registry, log }));
app.use(require('./api/routing-preview').routingPreview({ auth, registry, log }));
- app.use(require('./api/openai').openaiRouter({ runs, auth, config, registry, presets, receipts, log }));
+ app.use(require('./api/openai').openaiRouter({ runs, auth, config, registry, presets, receipts, db, log }));
// The Anthropic-compatible endpoint (POST /v1/messages, GET /v1/models): runs of ai.messages (Codes C3).
// The Anthropic-compatible endpoint (POST /v1/messages, GET /v1/models): runs of ai.messages (Codes C3).
- app.use(require('./api/messages').messagesRouter({ runs, auth, config, registry, presets, receipts, log }));
+ app.use(require('./api/messages').messagesRouter({ runs, auth, config, registry, presets, receipts, db, log }));
app.use(require('./api/attribution-quotas').attributionQuotasRouter({ db, quotas, auth, clock, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
app.use(require('./api/attribution-quotas').attributionQuotasRouter({ db, quotas, auth, clock, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
if (memory) app.use(require('./memory').memoryRouter({ memory, memoryImport, services: memoryServices, sharing: memorySharing, auth, config, clock, log, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
if (memory) app.use(require('./memory').memoryRouter({ memory, memoryImport, services: memoryServices, sharing: memorySharing, auth, config, clock, log, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
// Memory M5: the uniform share routes for kind memory-store (and /api/v1/share/people, /api/v1/shared-with-me).
// Memory M5: the uniform share routes for kind memory-store (and /api/v1/share/people, /api/v1/shared-with-me).
new file mode 100644
new file mode 100644
@@ -0,0 +1,27 @@
@@ -0,0 +1,27 @@
+'use strict';
+const provider = { type: 'object', additionalProperties: false, properties: {
+ ...Object.fromEntries(['order', 'only', 'ignore'].map(k => [k, { type: 'array', maxItems: 64, items: { type: 'string' } }])),
+ allow_fallbacks: { type: 'boolean' }, sort: { enum: ['price', 'throughput', 'latency', 'quality', 'value'] },
+ max_price: { type: 'object', additionalProperties: false, description: 'USD per million tokens; unknown prices fail the cap.', properties: { prompt: { type: 'number', minimum: 0 }, completion: { type: 'number', minimum: 0 } } },
+ data_collection: { enum: ['allow', 'deny'] }, zdr: { type: 'boolean' },
+} };
+const properties = { model: { type: 'string', description: 'maker/model, @preset/name, or model suffix :free, :floor, :nitro.' },
+ models: { type: 'array', minItems: 1, maxItems: 8, items: { type: 'string' }, description: 'Ordered model fallbacks in the same run and hold.' },
+ provider, usage: { type: 'object', properties: { include: { type: 'boolean' } } },
+};
+const operation = (operationId, summary, props, required = []) => ({ operationId, summary, tags: ['exchange'], security: [{ bearer: [] }],
+ parameters: [{ in: 'header', name: 'Idempotency-Key', schema: { type: 'string' }, description: 'Owner-scoped replay; changed input or endpoint conflicts.' }],
+ requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', properties: props, required } } } },
+ responses: { 200: { description: 'Provider result, token usage and x_openvibe accounting. usage.include adds cost (USD), latency (milliseconds), and provider.' }, 400: { description: 'Invalid request' }, 401: { description: 'Invalid or expired key' }, 403: { description: 'Model outside key scope' }, 409: { description: 'Idempotency conflict' }, 429: { description: 'Key cap reached' }, 503: { description: 'No eligible provider' } },
+});
+const paths = {
+ '/v1/models': { get: { operationId: 'compatModels', summary: 'Catalogue models with OpenRouter pricing and context fields', tags: ['exchange'], security: [{ bearer: [] }], responses: { 200: { description: 'data model entries: id, name, pricing (USD per token strings), context_length, architecture, top_provider. Anthropic header selects Anthropic format. Catalogue flag off retains the virtual model.' } } } },
+ '/v1/chat/completions': { post: operation('compatChatCompletions', 'Chat completion with provider routing', { ...properties, messages: { type: 'array', items: { type: 'object' } }, stream: { type: 'boolean' } }, ['messages']) },
+ '/v1/messages': { post: operation('compatMessages', 'Anthropic Messages with provider routing', { ...properties, messages: { type: 'array', items: { type: 'object' } }, max_tokens: { type: 'integer', minimum: 1 }, stream: { type: 'boolean' } }, ['messages', 'max_tokens']) },
+ '/v1/completions': { post: operation('compatCompletions', 'Legacy string-prompt completion', { ...properties, prompt: { type: 'string', minLength: 1 }, stream: { type: 'boolean' } }, ['prompt']) },
+ '/v1/embeddings': { post: operation('compatEmbeddings', 'Float embeddings from a registered provider', { ...properties, input: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }] }, encoding_format: { const: 'float' } }, ['input']) },
+ '/v1/images/generations': { post: operation('compatImageGeneration', 'Image generation on configured non-metered capacity; paid image accounting is not yet supported', { ...properties, prompt: { type: 'string' }, n: { type: 'integer', minimum: 1, maximum: 4 }, size: { type: 'string' } }, ['prompt']) },
+ '/v1/generation': { get: { operationId: 'compatGeneration', summary: 'Read accounting for your own generation', tags: ['exchange'], security: [{ bearer: [] }], parameters: [{ in: 'query', name: 'id', required: true, schema: { type: 'string' } }], responses: { 200: { description: 'data: id, model, provider, cost, total_cost (USD), latency (ms), tokens_prompt, tokens_completion, created_at. No prompt or output.' }, 404: { description: 'Missing or owned by another requester' } } } },
+ '/v1/presets/import': { post: operation('compatPresetImport', 'Save a validated request preset for the signed-in person', { ...properties, name: { type: 'string' }, system: { type: 'string' }, temperature: { type: 'number' }, top_p: { type: 'number' }, max_tokens: { type: 'integer' } }, ['name']) },
+};
+module.exports = { paths, provider };
new file mode 100644
new file mode 100644
@@ -0,0 +1,37 @@
@@ -0,0 +1,37 @@
+'use strict';
+const { normalizePreset } = require('./import-preset');
+const { AiError } = require('../util');
+function createCompatPresets(db) {
+ const decode = row => row ? (typeof row.definition === 'string' ? JSON.parse(row.definition) : row.definition) : null;
+ async function get(owner, name) {
+ return decode(await db.prepare('SELECT definition FROM exchange_compat_presets WHERE owner = ? AND name = ?').get(owner, name));
+ }
+ async function expand(owner, body, protocol) {
+ if (typeof body.model !== 'string' || !body.model.startsWith('@preset/')) return body;
+ const saved = await get(owner, body.model.slice(8));
+ if (!saved) return body; // Existing routing-only presets retain their namespace.
+ const { name, system, ...defaults } = saved;
+ const out = { ...defaults, ...body, model: defaults.model || defaults.models[0] };
+ if (system && protocol === 'messages') out.system = body.system ?? system;
+ if (system && protocol !== 'messages') out.messages = [{ role: 'system', content: system }, ...(body.messages || [])];
+ return out;
+ }
+ return {
+ get, expand,
+ async list(owner) {
+ return (await db.prepare('SELECT definition FROM exchange_compat_presets WHERE owner = ? ORDER BY name').all(owner)).map(decode);
+ },
+ async put(owner, body) {
+ const value = normalizePreset(body);
+ await db.prepare(`INSERT INTO exchange_compat_presets(owner,name,definition) VALUES(?,?,?)
+ ON CONFLICT(owner,name) DO UPDATE SET definition = excluded.definition`).run(owner, value.name, JSON.stringify(value));
+ return value;
+ },
+ async remove(owner, name) {
+ if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(name)) throw new AiError(400, 'input.invalid', 'Invalid preset name');
+ const result = await db.prepare('DELETE FROM exchange_compat_presets WHERE owner = ? AND name = ?').run(owner, name);
+ return { name, deleted: result.changes > 0 };
+ },
+ };
+}
+module.exports = { createCompatPresets };
@@ -126,6 +126,7 @@ async function select(list, policy, facts) {
@@ -126,6 +126,7 @@ async function select(list, policy, facts) {
}
}
if (policy.sort === 'quality' || policy.sort === 'value') {
if (policy.sort === 'quality' || policy.sort === 'value') {
if (!finite(f.quality)) reason = 'provider.rating_unavailable';
if (!finite(f.quality)) reason = 'provider.rating_unavailable';
+ if (policy.sort === 'value' && cost === null) reason = 'provider.price_unavailable';
score =
score =
policy.sort === 'quality'
policy.sort === 'quality'
? -f.quality
? -f.quality
@@ -155,4 +156,14 @@ async function select(list, policy, facts) {
@@ -155,4 +156,14 @@ async function select(list, policy, facts) {
excluded,
excluded,
};
};
}
}
-module.exports = { normalize, policyFor, modelName, select };
+// Validated once at admission, retained in the run input and never forwarded upstream.
+function request(body) {
+ if (body.models !== undefined && (!Array.isArray(body.models) || !body.models.length || body.models.length > 8))
+ bad('models must contain one to eight model ids');
+ const names = [...new Set([...(body.model ? [body.model] : []), ...(body.models || [])])];
+ if (names.length > 8) bad('At most eight distinct models are supported');
+ const entries = (names.length ? names : ['openvibe/auto']).map(modelName);
+ const policy = normalize(body.provider);
+ return { entries, policy };
+}
+module.exports = { normalize, policyFor, modelName, select, request };
@@ -14,7 +14,7 @@ const accepted = [
@@ -14,7 +14,7 @@ const accepted = [
'response_format',
'response_format',
];
];
// Import preview is intentionally pure: no keys, URLs, provider calls or writes.
// Import preview is intentionally pure: no keys, URLs, provider calls or writes.
-function importPreset(source) {
+function normalizePreset(source) {
const bad = (detail) => {
const bad = (detail) => {
throw new AiError(400, 'input.invalid', detail);
throw new AiError(400, 'input.invalid', detail);
};
};
@@ -92,6 +92,10 @@ function importPreset(source) {
@@ -92,6 +92,10 @@ function importPreset(source) {
JSON.stringify(source.response_format),
JSON.stringify(source.response_format),
);
);
}
}
+ return out;
+}
+function importPreset(source) {
+ const out = normalizePreset(source);
return {
return {
valid: true,
valid: true,
fields: Object.keys(out),
fields: Object.keys(out),
@@ -101,4 +105,4 @@ function importPreset(source) {
@@ -101,4 +105,4 @@ function importPreset(source) {
],
],
};
};
}
}
-module.exports = { importPreset };
+module.exports = { importPreset, normalizePreset };
@@ -21,7 +21,7 @@
@@ -21,7 +21,7 @@
* server/runs.js decode(). Nothing is inferred.
* server/runs.js decode(). Nothing is inferred.
*/
*/
const pkg = require('../package.json');
const pkg = require('../package.json');
-const { paths: searchPaths } = require('./search/openapi');
+const searchPaths = { ...require('./search/openapi').paths, ...require('./exchange/compat-openapi').paths };
const MOUNT = '/api/v1';
const MOUNT = '/api/v1';
// The public base of this API (BASE_URL in production).
// The public base of this API (BASE_URL in production).
@@ -556,6 +556,9 @@ route('get', '/exchange/key-usage', {
@@ -556,6 +556,9 @@ route('get', '/exchange/key-usage', {
description: 'Only a person. One row per day, key, model and status (settled, reserved, unresolved): requests, tokens and dollars. Private to the owner.',
description: 'Only a person. One row per day, key, model and status (settled, reserved, unresolved): requests, tokens and dollars. Private to the owner.',
responses: { 200: json('The usage rows', { type: 'object', required: ['rows'], properties: { rows: arrayOf({ type: 'object' }) } }), ...errors([401, 403]) },
responses: { 200: json('The usage rows', { type: 'object', required: ['rows'], properties: { rows: arrayOf({ type: 'object' }) } }), ...errors([401, 403]) },
});
});
+route('get', '/exchange/presets', { operationId: 'exchangeRequestPresets', summary: 'List your saved request presets', tags: ['exchange'], security: [{ bearer: [] }], responses: { 200: json('Owner presets', { type: 'object', properties: { data: arrayOf({ type: 'object' }) } }), ...errors([401, 403]) } });
+route('post', '/exchange/presets', { operationId: 'exchangeRequestPresetSave', summary: 'Save your request preset', tags: ['exchange'], security: [{ bearer: [] }], requestBody: { required: true, content: { 'application/json': { schema: { type: 'object' } } } }, responses: { 200: json('Saved preset', { type: 'object' }), ...errors([400, 401, 403]) } });
+route('delete', '/exchange/presets/{name}', { operationId: 'exchangeRequestPresetDelete', summary: 'Delete your request preset', tags: ['exchange'], security: [{ bearer: [] }], parameters: [{ in: 'path', name: 'name', required: true, schema: str() }], responses: { 200: json('Deletion result', { type: 'object' }), ...errors([400, 401, 403]) } });
route('get', '/exchange/search/engines', {
route('get', '/exchange/search/engines', {
operationId: 'exchangeSearchEngines', summary: 'The search engines behind web, image and icon search', tags: ['exchange'], security: [],
operationId: 'exchangeSearchEngines', summary: 'The search engines behind web, image and icon search', tags: ['exchange'], security: [],
description: 'Public. Engine IDs, kinds (web, images, icons, fetch), availability of shared keys, BYOK support, health, supported freshness filters, price_per_1000 in USD (null when variable), pricing_note, source URL and checked_on date. Personal key availability is private. Tariffs exclude free allowances and volume discounts.',
description: 'Public. Engine IDs, kinds (web, images, icons, fetch), availability of shared keys, BYOK support, health, supported freshness filters, price_per_1000 in USD (null when variable), pricing_note, source URL and checked_on date. Personal key availability is private. Tariffs exclude free allowances and volume discounts.',
@@ -7,6 +7,7 @@ const { claimRequest } = require('./idempotency');
@@ -7,6 +7,7 @@ const { claimRequest } = require('./idempotency');
const { createSearch, CHARGE } = require('../search');
const { createSearch, CHARGE } = require('../search');
function platform({ db, auth, clock, log, searchFetch, credentials, env = process.env }) {
function platform({ db, auth, clock, log, searchFetch, credentials, env = process.env }) {
const keys = createKeys({ db, clock });
const keys = createKeys({ db, clock });
+ const presets = require('../exchange/compat-presets').createCompatPresets(db);
const search = createSearch({
const search = createSearch({
...(searchFetch ? { fetchImpl: searchFetch } : {}),
...(searchFetch ? { fetchImpl: searchFetch } : {}),
now: () => clock.now(), credentials, env,
now: () => clock.now(), credentials, env,
@@ -55,6 +56,10 @@ function platform({ db, auth, clock, log, searchFetch, credentials, env = proces
@@ -55,6 +56,10 @@ function platform({ db, auth, clock, log, searchFetch, credentials, env = proces
person,
person,
wrap((req) => keys.usage(owner(req))),
wrap((req) => keys.usage(owner(req))),
);
);
+ r.get('/api/v1/exchange/presets', person, wrap(async req => ({ data: await presets.list(owner(req)) })));
+ r.post('/api/v1/exchange/presets', person, wrap(req => presets.put(owner(req), req.body)));
+ r.post('/v1/presets/import', person, wrap(req => presets.put(owner(req), req.body)));
+ r.delete('/api/v1/exchange/presets/:name', person, wrap(req => presets.remove(owner(req), req.params.name)));
const searchContext = req => ({
const searchContext = req => ({
subject: req.principal?.kind === 'user' ? req.principal.subject.id : null,
subject: req.principal?.kind === 'user' ? req.principal.subject.id : null,
plan: req.searchPlan, budget: req.searchBudget,
plan: req.searchPlan, budget: req.searchBudget,
@@ -111,8 +116,8 @@ function platform({ db, auth, clock, log, searchFetch, credentials, env = proces
@@ -111,8 +116,8 @@ function platform({ db, auth, clock, log, searchFetch, credentials, env = proces
'/v1/fetch',
'/v1/fetch',
].includes(req.path);
].includes(req.path);
const inference =
const inference =
- req.method === 'POST' && req.path === '/v1/chat/completions';
- const models = req.method === 'GET' && req.path === '/v1/models';
+ req.method === 'POST' && ['/v1/chat/completions', '/v1/messages', '/v1/completions', '/v1/embeddings', '/v1/images/generations'].includes(req.path);
+ const models = req.method === 'GET' && ['/v1/models', '/v1/generation'].includes(req.path);
if (!isSearch && !inference && !models)
if (!isSearch && !inference && !models)
throw new AiError(
throw new AiError(
403,
403,
@@ -126,18 +131,20 @@ function platform({ db, auth, clock, log, searchFetch, credentials, env = proces
@@ -126,18 +131,20 @@ function platform({ db, auth, clock, log, searchFetch, credentials, env = proces
'token.invalid',
'token.invalid',
'Invalid, expired or revoked Exchange key',
'Invalid, expired or revoked Exchange key',
);
);
+ if (inference) req.body = await presets.expand(key.owner, req.body || {}, req.path === '/v1/messages' ? 'messages' : 'openai');
+ if (inference) require('../exchange/compat-routing').request(req.body || {});
const allowed = JSON.parse(key.allowed_models);
const allowed = JSON.parse(key.allowed_models);
- if (inference && allowed.length && !allowed.includes(req.body?.model))
+ if (inference && allowed.length && [req.body?.model || req.body?.models?.[0] || 'openvibe/auto', ...(req.body?.models || [])].some(model => !allowed.includes(require('../exchange/compat-routing').modelName(model).model)))
throw new AiError(403, 'exchange.model_denied', 'This model is not allowed by this key');
throw new AiError(403, 'exchange.model_denied', 'This model is not allowed by this key');
if (inference && req.get('idempotency-key'))
if (inference && req.get('idempotency-key'))
- claim = await claimRequest(db, key.owner, req.get('idempotency-key'), req.body);
+ claim = await claimRequest(db, key.owner, req.get('idempotency-key'), req.path === '/v1/chat/completions' ? req.body : { path: req.path, body: req.body });
if (isSearch) {
if (isSearch) {
const kind = req.path === '/v1/fetch' ? 'fetch' : req.path.split('/')[3] || 'web';
const kind = req.path === '/v1/fetch' ? 'fetch' : req.path.split('/')[3] || 'web';
req.searchPlan = await search.prepare(kind, req.body || {}, { subject: key.owner });
req.searchPlan = await search.prepare(kind, req.body || {}, { subject: key.owner });
}
}
const searchCeiling = req.searchPlan?.ceiling || 0;
const searchCeiling = req.searchPlan?.ceiling || 0;
const hold = models || claim?.replay ? null : await keys.reserve(
const hold = models || claim?.replay ? null : await keys.reserve(
- key, isSearch ? 'search:' + req.path : String(req.body?.model || ''),
+ key, isSearch ? 'search:' + req.path : require('../exchange/compat-routing').modelName(req.body?.model || req.body?.models?.[0] || 'openvibe/auto').model,
isSearch && searchCeiling === 0,
isSearch && searchCeiling === 0,
isSearch ? searchCeiling : Number.isFinite(req.body?.max_cost_usd) ? req.body.max_cost_usd : null,
isSearch ? searchCeiling : Number.isFinite(req.body?.max_cost_usd) ? req.body.max_cost_usd : null,
isSearch,
isSearch,
@@ -171,6 +178,7 @@ function platform({ db, auth, clock, log, searchFetch, credentials, env = proces
@@ -171,6 +178,7 @@ function platform({ db, auth, clock, log, searchFetch, credentials, env = proces
exchangeKeyId: key.id,
exchangeKeyId: key.id,
};
};
if (hold) {
if (hold) {
+ let completed = false;
let result = null,
let result = null,
tail = '';
tail = '';
const json = res.json.bind(res);
const json = res.json.bind(res);
@@ -186,6 +194,8 @@ function platform({ db, auth, clock, log, searchFetch, credentials, env = proces
@@ -186,6 +194,8 @@ function platform({ db, auth, clock, log, searchFetch, credentials, env = proces
// 'close' without 'finish' is a client that hung up: the hold still settles (at the hold, when
// 'close' without 'finish' is a client that hung up: the hold still settles (at the hold, when
// the cost is unknown), or the key would keep it until the month ends.
// the cost is unknown), or the key would keep it until the month ends.
const done = () => {
const done = () => {
+ if (completed) return;
+ completed = true;
if (!result && tail)
if (!result && tail)
for (const line of tail
for (const line of tail
.split('\n')
.split('\n')
@@ -201,14 +211,14 @@ function platform({ db, auth, clock, log, searchFetch, credentials, env = proces
@@ -201,14 +211,14 @@ function platform({ db, auth, clock, log, searchFetch, credentials, env = proces
const cost =
const cost =
isSearch ? result?.[CHARGE] ?? res.locals.searchCharge
isSearch ? result?.[CHARGE] ?? res.locals.searchCharge
: res.getHeader('x-openvibe-replayed') === '1' && res.statusCode < 400
: res.getHeader('x-openvibe-replayed') === '1' && res.statusCode < 400
- ? 0 : result?.x_openvibe?.cost_usd;
+ ? 0 : result?.x_openvibe?.cost_usd ?? res.locals.inferenceCharge ?? (!res.locals.inferenceAdmitted && res.writableFinished && res.statusCode < 500 && res.statusCode >= 400 ? 0 : undefined);
// Search uses only its own charge, never inference metadata.
// Search uses only its own charge, never inference metadata.
// An early disconnect can precede run insertion. Keep its claim
// An early disconnect can precede run insertion. Keep its claim
// until a response finishes so a retry cannot take another hold.
// until a response finishes so a retry cannot take another hold.
hold.settle({
hold.settle({
cost_usd: cost,
cost_usd: cost,
- tokens_in: usage.prompt_tokens || 0,
- tokens_out: usage.completion_tokens || 0,
+ tokens_in: usage.prompt_tokens || usage.input_tokens || 0,
+ tokens_out: usage.completion_tokens || usage.output_tokens || 0,
}).then(() => res.writableFinished ? claim?.abandon?.() : undefined).catch(() =>
}).then(() => res.writableFinished ? claim?.abandon?.() : undefined).catch(() =>
log.error('Exchange usage settlement failed'),
log.error('Exchange usage settlement failed'),
);
);
@@ -1054,6 +1054,8 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -1054,6 +1054,8 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
}
}
async function execute(route, operation, req, ctx = {}) {
async function execute(route, operation, req, ctx = {}) {
+ if (ctx.compatRouting && (ctx.credential || ctx.byok || ctx.selfPreferences?.only_my_keys))
+ throw new AiError(400, 'input.invalid', 'Provider routing cannot be combined with an explicit credential or only_my_keys');
if (ctx.taskChoice) return require('../choices/execute').executeChoice({ execute, registry, poolRates: rateCardsFor, maxAttempts: config.providerRetries + 1, route, operation, req, ctx });
if (ctx.taskChoice) return require('../choices/execute').executeChoice({ execute, registry, poolRates: rateCardsFor, maxAttempts: config.providerRetries + 1, route, operation, req, ctx });
const contentPolicy = ctx.contentPolicy || 'standard';
const contentPolicy = ctx.contentPolicy || 'standard';
const refuseFloor = (r) => new AiError(403, 'policy.floor_refused', r.detail, { rule: r.rule, policy: contentPolicy });
const refuseFloor = (r) => new AiError(403, 'policy.floor_refused', r.detail, { rule: r.rule, policy: contentPolicy });
@@ -1122,7 +1124,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -1122,7 +1124,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
const selfAttempts = [];
const selfAttempts = [];
// The adult tier is network supply only: a person's own provider key is not an offer marked uncensored, and
// The adult tier is network supply only: a person's own provider key is not an offer marked uncensored, and
// only such an offer may answer an uncensored request. Their key is still used for every standard request.
// only such an offer may answer an uncensored request. Their key is still used for every standard request.
- const selfList = uncensored ? [] : (Array.isArray(ctx.selfCredentials) ? ctx.selfCredentials : []);
+ const selfList = uncensored || ctx.compatRouting ? [] : (Array.isArray(ctx.selfCredentials) ? ctx.selfCredentials : []);
if (!uncensored && (selfList.length || (ctx.selfSubject && prefs.only_my_keys))) {
if (!uncensored && (selfList.length || (ctx.selfSubject && prefs.only_my_keys))) {
const order = [...selfList].sort((a, b) => {
const order = [...selfList].sort((a, b) => {
const rank = (x) => (x.status === 'failing' ? 2 : x.status === 'untested' ? 1 : 0);
const rank = (x) => (x.status === 'failing' ? 2 : x.status === 'untested' ? 1 : 0);
@@ -1185,7 +1187,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -1185,7 +1187,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
// Community placement runs before the catalog pool. A preset with price, privacy, region, or quality
// Community placement runs before the catalog pool. A preset with price, privacy, region, or quality
// rules must use the catalog path so those rules are checked before any offer is called.
// rules must use the catalog path so those rules are checked before any offer is called.
const communityPresetSafe = !ctx.presetPolicy || Object.keys(ctx.presetPolicy).every((key) => key === 'community');
const communityPresetSafe = !ctx.presetPolicy || Object.keys(ctx.presetPolicy).every((key) => key === 'community');
- if (community && ctx.communityOptIn && communityPresetSafe && !ctx.credential && !ctx.byok && !ctx.exact) {
+ if (!ctx.compatRouting && community && ctx.communityOptIn && communityPresetSafe && !ctx.credential && !ctx.byok && !ctx.exact) {
// Community capacity is a free-tier source only when the allowlist names it: without that, the
// Community capacity is a free-tier source only when the allowlist names it: without that, the
// ordinary network answers and the skip is named in the explain, never silently used.
// ordinary network answers and the skip is named in the explain, never silently used.
if (freeSources && !allowsCommunity(freeSources)) {
if (freeSources && !allowsCommunity(freeSources)) {
@@ -1224,6 +1226,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -1224,6 +1226,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
const card = row ? modelCatalog.retailRate(row) : null;
const card = row ? modelCatalog.retailRate(row) : null;
const policyOk = p ? await policyAllows(registry, p, offer.model, ctx.contentPolicy || 'standard') : false;
const policyOk = p ? await policyAllows(registry, p, offer.model, ctx.contentPolicy || 'standard') : false;
if (!p) why.push('unknown_provider');
if (!p) why.push('unknown_provider');
+ else if (!appOffered(ctx, p)) why.push('app_provider_not_allowed');
else if (!policyOk) why.push('content_policy');
else if (!policyOk) why.push('content_policy');
else if (ctx.creditsRequired && !isFreeProvider(p) && card && card.version) { why.push('payer_required'); payerSkipped = true; }
else if (ctx.creditsRequired && !isFreeProvider(p) && card && card.version) { why.push('payer_required'); payerSkipped = true; }
else if (freeSources && !allowsProvider(p.key, p, freeSources)) {
else if (freeSources && !allowsProvider(p.key, p, freeSources)) {
@@ -1365,6 +1368,45 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -1365,6 +1368,45 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
explain.reasons = [picked.view.reason, ...(explain.reasons || [])];
explain.reasons = [picked.view.reason, ...(explain.reasons || [])];
}
}
}
}
+ if (ctx.compatRouting) {
+ const { select, policyFor } = require('../exchange/compat-routing');
+ const ordered = [];
+ const ratingEvidence = require('../bench/evidence');
+ let ratings = [];
+ if (bench && ['quality', 'value'].includes(ctx.compatRouting.policy.sort)) {
+ // Messages shares completion ratings; this is a provider method, not a public capability.
+ const ratingOperation = operation === 'messages' ? 'complete' : operation;
+ const ratingRoute = operation === 'messages' ? { ...route, capability: ratingOperation } : route;
+ for (const kind of bench.kindCandidates(ratingRoute, ratingOperation)) {
+ ratings = await bench.list(kind, bench.categoryFor(route));
+ if (ratings.length) break;
+ }
+ }
+ for (const group of [...new Set(ctx.compatRouting.offers.map(o => o.group))]) {
+ const offers = ctx.compatRouting.offers.filter(o => o.group === group);
+ const variant = offers[0]?.variant;
+ const { policy: requestedPolicy } = policyFor({ model: 'openvibe/auto' + (variant ? ':' + variant : ''), provider: ctx.compatRouting.policy });
+ const groupList = list.filter(c => offers.some(o => o.provider === c.provider && o.model === c.model));
+ const result = await select(groupList, requestedPolicy, async c => {
+ const d = await describeCandidate(c);
+ const privacy = exchangePrivacy.dataPolicyOf(d.record) || {};
+ const metrics = await createProviderMetrics({ db, clock }).get(c.provider, c.model);
+ // Report's board pages read Bench. Use the same event-fed ratings, never a declared tier.
+ const rating = ratings.find(row => ratingEvidence.usable(row) && (
+ ratingEvidence.matchesOffer(row, c) || ratingEvidence.matchesOffer(row, {
+ ...c, model: require('../exchange/catalog').canonicalOf(d.row, d.record),
+ })
+ ));
+ return { priceIn: d.row?.cost?.in_per_mtok ?? null, priceOut: d.row?.cost?.out_per_mtok ?? null, training: privacy.training,
+ retention: privacy.retention, latency: metrics?.ttft_ms, throughput: metrics?.decode_tps,
+ quality: rating ? Number(rating.rating) : null };
+ });
+ ordered.push(...result.allowed);
+ for (const x of result.excluded) explain.candidates.push({ ...x, eligible: false, excluded_reason: x.reason });
+ }
+ list = ordered;
+ if (!list.length) throw new AiError(503, 'ai.routing_unavailable', 'No provider satisfies the requested routing policy', { explain });
+ }
const evidenceReason = explain.evidence ? explain.evidence.reason : null;
const evidenceReason = explain.evidence ? explain.evidence.reason : null;
explain.content_policy = contentPolicy;
explain.content_policy = contentPolicy;
if (uncensored) explain.label = tier.LABEL;
if (uncensored) explain.label = tier.LABEL;
@@ -82,7 +82,7 @@ const workflows = [
@@ -82,7 +82,7 @@ const workflows = [
},
},
{
{
key: 'ai.embed', name: 'Embed text', namespace: 'ai',
key: 'ai.embed', name: 'Embed text', namespace: 'ai',
- input_schema: OBJ({ input: { anyOf: [STR(32000), { type: 'array', minItems: 1, maxItems: 64, items: STR(32000) }] } }, ['input']),
+ input_schema: OBJ({ compat_routing: { type: 'object' }, max_cost_usd: COMMON.max_cost_usd, input: { anyOf: [STR(32000), { type: 'array', minItems: 1, maxItems: 64, items: STR(32000) }] } }, ['input']),
output_schema: OBJ({ vectors: { type: 'array', items: { type: 'array', items: { type: 'number' } } }, dimensions: { type: 'integer' } }, ['vectors', 'dimensions']),
output_schema: OBJ({ vectors: { type: 'array', items: { type: 'array', items: { type: 'number' } } }, dimensions: { type: 'integer' } }, ['vectors', 'dimensions']),
steps: [{ kind: 'embed', route: 'default.embedding' }], cache_mode: 'private',
steps: [{ kind: 'embed', route: 'default.embedding' }], cache_mode: 'private',
},
},
@@ -94,6 +94,7 @@ const workflows = [
@@ -94,6 +94,7 @@ const workflows = [
input_schema: OBJ({
input_schema: OBJ({
// CHECKPOINT D3: an exact id or a dynamic alias, when AI_MODEL_CATALOG is on (server/api/openai.js).
// CHECKPOINT D3: an exact id or a dynamic alias, when AI_MODEL_CATALOG is on (server/api/openai.js).
model: STR(256),
model: STR(256),
+ compat_routing: { type: 'object' },
messages: { type: 'array', minItems: 1, maxItems: 500, items: { type: 'object', required: ['role'], properties: {
messages: { type: 'array', minItems: 1, maxItems: 500, items: { type: 'object', required: ['role'], properties: {
role: { enum: ['system', 'developer', 'user', 'assistant', 'tool'] },
role: { enum: ['system', 'developer', 'user', 'assistant', 'tool'] },
content: { anyOf: [{ type: 'string', maxLength: 400000 }, { type: 'array', maxItems: 100 }, { type: 'null' }] },
content: { anyOf: [{ type: 'string', maxLength: 400000 }, { type: 'array', maxItems: 100 }, { type: 'null' }] },
@@ -122,6 +123,7 @@ const workflows = [
@@ -122,6 +123,7 @@ const workflows = [
input_schema: OBJ({
input_schema: OBJ({
// CHECKPOINT D3: an exact id or a dynamic alias, when AI_MODEL_CATALOG is on (server/api/messages.js).
// CHECKPOINT D3: an exact id or a dynamic alias, when AI_MODEL_CATALOG is on (server/api/messages.js).
model: STR(256),
model: STR(256),
+ compat_routing: { type: 'object' },
messages: { type: 'array', minItems: 1, maxItems: 500, items: { type: 'object', required: ['role'], properties: {
messages: { type: 'array', minItems: 1, maxItems: 500, items: { type: 'object', required: ['role'], properties: {
role: { enum: ['user', 'assistant'] },
role: { enum: ['user', 'assistant'] },
content: { anyOf: [STR(400000), { type: 'array', maxItems: 500 }, { type: 'null' }] },
content: { anyOf: [STR(400000), { type: 'array', maxItems: 500 }, { type: 'null' }] },
@@ -148,7 +150,7 @@ const workflows = [
@@ -148,7 +150,7 @@ const workflows = [
{
{
// Off until AI_IMAGE_MODEL seeds route default.image: without it a run answers 503 route.unavailable.
// Off until AI_IMAGE_MODEL seeds route default.image: without it a run answers 503 route.unavailable.
key: 'ai.image', name: 'Generate an image', namespace: 'ai',
key: 'ai.image', name: 'Generate an image', namespace: 'ai',
- input_schema: OBJ({ prompt: STR(4000), size: { type: 'string', pattern: '^[0-9]{3,4}x[0-9]{3,4}$' }, n: { type: 'integer', minimum: 1, maximum: 4 } }, ['prompt']),
+ input_schema: OBJ({ compat_routing: { type: 'object' }, max_cost_usd: COMMON.max_cost_usd, prompt: STR(4000), size: { type: 'string', pattern: '^[0-9]{3,4}x[0-9]{3,4}$' }, n: { type: 'integer', minimum: 1, maximum: 4 } }, ['prompt']),
output_schema: OBJ({ images: { type: 'array', minItems: 1, items: OBJ({ b64: { type: ['string', 'null'] }, url: { type: ['string', 'null'] } }) }, count: { type: 'integer', minimum: 1 } }, ['images', 'count']),
output_schema: OBJ({ images: { type: 'array', minItems: 1, items: OBJ({ b64: { type: ['string', 'null'] }, url: { type: ['string', 'null'] } }) }, count: { type: 'integer', minimum: 1 } }, ['images', 'count']),
// No cache: several MB of base64 per run, and a fresh image is what a caller asks for.
// No cache: several MB of base64 per run, and a fresh image is what a caller asks for.
steps: [{ kind: 'image', route: 'default.image' }], cache_mode: 'none',
steps: [{ kind: 'image', route: 'default.image' }], cache_mode: 'none',
@@ -285,6 +285,33 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
@@ -285,6 +285,33 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
* never called, so today's route selection is unchanged.
* never called, so today's route selection is unchanged.
*/
*/
async function selectedRoute(base, input) {
async function selectedRoute(base, input) {
+ if (input.compat_routing) {
+ const routing = require('../exchange/compat-routing');
+ const source = input.compat_routing;
+ if (!Array.isArray(source.entries) || !source.entries.length || source.entries.length > 8)
+ throw new AiError(400, 'input.invalid', 'One to eight routing models are required');
+ const entries = source.entries.map(e => routing.modelName(e.model + (e.variant ? ':' + e.variant : '')));
+ const policy = routing.normalize(source.policy);
+ const providers = new Map((await registry.listProviders()).map(p => [p.key, p]));
+ const rows = await registry.listModels();
+ const offers = [];
+ for (const [group, entry] of entries.entries()) {
+ const dynamic = ['openvibe', 'openvibe/auto'].includes(entry.model);
+ for (const row of rows) {
+ const provider = providers.get(row.provider_key);
+ if (!provider || row.status !== 'active' || provider.status !== 'active') continue;
+ const wantedType = base.capability === 'embed' ? 'embedding' : base.capability === 'image' ? 'image' : 'chat';
+ if (row.type !== wantedType && !(wantedType === 'chat' && row.type === 'vision')) continue;
+ if (!modelCatalog.managedAccessAllowed(provider) || provider.metadata?.hidden || provider.metadata?.synthetic) continue;
+ if (!dynamic && require('../exchange/catalog').canonicalOf(row, provider) !== entry.model && row.metadata?.id !== entry.model) continue;
+ if (row.metadata?.hidden || row.metadata?.synthetic || row.metadata?.restricted || row.metadata?.byok) continue;
+ if (['CUSTOMER_BYOK', 'RESTRICTED'].includes(row.provider_access || row.metadata?.provider_access)) continue;
+ offers.push({ provider: row.provider_key, model: row.model_key, group, variant: entry.variant });
+ }
+ }
+ if (!offers.length) throw new AiError(404, 'ai.model_not_found', 'No requested model is available');
+ return { route: base, selection: { kind: 'compat', offers, policy } };
+ }
if (!(config.modelCatalog && config.modelCatalog.enabled) || typeof input.model !== 'string' || !input.model.trim()) return { route: base, selection: null };
if (!(config.modelCatalog && config.modelCatalog.enabled) || typeof input.model !== 'string' || !input.model.trim()) return { route: base, selection: null };
const selection = await modelCatalog.select({ registry, model: input.model.trim(), route: base });
const selection = await modelCatalog.select({ registry, model: input.model.trim(), route: base });
return { route: selection.route, selection };
return { route: selection.route, selection };
@@ -292,6 +319,8 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
@@ -292,6 +319,8 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
function selectionCtx(selection) {
function selectionCtx(selection) {
if (!selection) return {};
if (!selection) return {};
+ if (selection.kind === 'compat') return { compatRouting: selection,
+ exact: { id: 'requested model fallbacks', offers: selection.offers } };
return selection.kind === 'exact'
return selection.kind === 'exact'
? { policy: 'exact', exact: { id: selection.id, offers: selection.offers.map((o) => ({ provider: o.provider, model: o.model })) } }
? { policy: 'exact', exact: { id: selection.id, offers: selection.offers.map((o) => ({ provider: o.provider, model: o.model })) } }
: { policy: selection.policy, aliasPolicy: selection.policy };
: { policy: selection.policy, aliasPolicy: selection.policy };
@@ -418,20 +447,20 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
@@ -418,20 +447,20 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
}
}
async function embedStep(step, input, wf, run, ctx) {
async function embedStep(step, input, wf, run, ctx) {
- const route = await routeFor(step.route || 'default.embedding');
+ const { route, selection } = await selectedRoute(await routeFor(step.route || 'default.embedding'), input);
const inputs = Array.isArray(input.input) ? input.input : [input.input];
const inputs = Array.isArray(input.input) ? input.input : [input.input];
- const exec = await pool.execute(route, 'embed', { input: inputs, timeoutMs: route.timeout_ms || 30000 }, { ...ctx, routeKey: route.key, routeVersion: route.version, promptHash: null });
+ const exec = await pool.execute(route, 'embed', { input: inputs, timeoutMs: route.timeout_ms || 30000 }, { ...ctx, ...selectionCtx(selection), routeKey: route.key, routeVersion: route.version, promptHash: null });
const vectors = exec.result.vectors || [];
const vectors = exec.result.vectors || [];
if (vectors.length !== inputs.length) throw new AiError(502, 'output.invalid', 'embedding count does not match the input');
if (vectors.length !== inputs.length) throw new AiError(502, 'output.invalid', 'embedding count does not match the input');
return { output: { vectors, dimensions: vectors[0] ? vectors[0].length : 0 }, exec, route };
return { output: { vectors, dimensions: vectors[0] ? vectors[0].length : 0 }, exec, route };
}
}
async function imageStep(step, input, wf, run, ctx) {
async function imageStep(step, input, wf, run, ctx) {
- const route = await routeFor(step.route || 'default.image');
+ const { route, selection } = await selectedRoute(await routeFor(step.route || 'default.image'), input);
const prompt = String(input.prompt || '').trim();
const prompt = String(input.prompt || '').trim();
if (!prompt) throw new AiError(422, 'input.invalid', 'image needs a prompt');
if (!prompt) throw new AiError(422, 'input.invalid', 'image needs a prompt');
const req = { prompt, size: input.size || (step.params && step.params.size) || (config.image && config.image.size) || null, n: Math.max(1, Math.min(Number(input.n) || 1, 4)), timeoutMs: route.timeout_ms || 120000 };
const req = { prompt, size: input.size || (step.params && step.params.size) || (config.image && config.image.size) || null, n: Math.max(1, Math.min(Number(input.n) || 1, 4)), timeoutMs: route.timeout_ms || 120000 };
- const exec = await pool.execute(route, 'image', req, { ...ctx, routeKey: route.key, routeVersion: route.version, promptHash: sha256({ p: prompt }) });
+ const exec = await pool.execute(route, 'image', req, { ...ctx, ...(input.compat_routing ? { freeOnly: true } : {}), ...selectionCtx(selection), routeKey: route.key, routeVersion: route.version, promptHash: sha256({ p: prompt }) });
const images = (exec.result.images || []).filter(i => i && (i.b64 || i.url));
const images = (exec.result.images || []).filter(i => i && (i.b64 || i.url));
if (!images.length) throw new AiError(502, 'output.invalid', 'no image was returned');
if (!images.length) throw new AiError(502, 'output.invalid', 'no image was returned');
return { output: { images: images.map(i => ({ b64: i.b64 || null, url: i.url || null })), count: images.length }, exec, route };
return { output: { images: images.map(i => ({ b64: i.b64 || null, url: i.url || null })), count: images.length }, exec, route };
@@ -51,6 +51,7 @@ t.test('an export carries the person\'s sessions and credential hints, and a del
@@ -51,6 +51,7 @@ t.test('an export carries the person\'s sessions and credential hints, and a del
const h = await boot({ env: { AI_EVENTS_SECRET: SECRET, AI_EVENTS_SUBSCRIBE: '0', OV_NETWORK_INTERNAL_URL: stub.url, OV_OAUTH_CLIENT_SECRET: 'ai-secret' } });
const h = await boot({ env: { AI_EVENTS_SECRET: SECRET, AI_EVENTS_SUBSCRIBE: '0', OV_NETWORK_INTERNAL_URL: stub.url, OV_OAUTH_CLIENT_SECRET: 'ai-secret' } });
try {
try {
for (const [subject, name] of [[A, 'ada'], [B, 'bo']]) {
for (const [subject, name] of [[A, 'ada'], [B, 'bo']]) {
+ await h.db.exec('INSERT INTO exchange_compat_presets(owner,name,definition) VALUES ($1,$2,$3)', [subject, 'writer-' + name, JSON.stringify({ name: 'writer-' + name, model: 'openvibe/auto', system: 'Prompt for ' + name })]);
await h.db.exec('INSERT INTO exchange_key_requests(owner,idempotency_key,request_hash,max_cost_usd) VALUES ($1,$2,$3,1)', [subject, `request-${name}`, `hash-${name}`]);
await h.db.exec('INSERT INTO exchange_key_requests(owner,idempotency_key,request_hash,max_cost_usd) VALUES ($1,$2,$3,1)', [subject, `request-${name}`, `hash-${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 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]);
@@ -74,6 +75,9 @@ t.test('an export carries the person\'s sessions and credential hints, and a del
@@ -74,6 +75,9 @@ t.test('an export carries the person\'s sessions and credential hints, and a del
const part = stub.calls.find((c) => c.url === `/internal/account-exports/${EXP}/parts`);
const part = stub.calls.find((c) => c.url === `/internal/account-exports/${EXP}/parts`);
assert.ok(part, 'the part was pushed to Network');
assert.ok(part, 'the part was pushed to Network');
const files = Object.fromEntries(part.body.files.map((f) => [f.name, f.content]));
const files = Object.fromEntries(part.body.files.map((f) => [f.name, f.content]));
+ assert.strictEqual(files['exchange-request-presets.json'].length, 1);
+ assert.strictEqual(files['exchange-request-presets.json'][0].name, 'writer-ada');
+ assert.ok(JSON.stringify(files['exchange-request-presets.json']).includes('Prompt for ada'));
assert.strictEqual(files['console-sessions.json'].length, 1);
assert.strictEqual(files['console-sessions.json'].length, 1);
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);
@@ -87,6 +91,8 @@ t.test('an export carries the person\'s sessions and credential hints, and a del
@@ -87,6 +91,8 @@ t.test('an export carries the person\'s sessions and credential hints, and a del
const d = await deliver(ev('evt_01JZ0000000000000000000D01', 'network.account.deleted', { deletion_id: DEL, subject: A, aliases: [] }));
const d = await deliver(ev('evt_01JZ0000000000000000000D01', 'network.account.deleted', { deletion_id: DEL, subject: A, aliases: [] }));
assert.deepStrictEqual([d.status, d.json.outcome], [200, 'erased']);
assert.deepStrictEqual([d.status, d.json.outcome], [200, 'erased']);
+ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM exchange_compat_presets WHERE owner = $1', [A])), 0, 'saved prompts are erased with the owner');
+ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM exchange_compat_presets WHERE owner = $1', [B])), 1, 'another owner retains their preset');
assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM console_sessions WHERE subject = $1', [A])), 0);
assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM console_sessions WHERE subject = $1', [A])), 0);
assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM exchange_key_requests WHERE owner = $1', [A])), 0, 'request claims are erased with their owner');
assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM exchange_key_requests WHERE owner = $1', [A])), 0, 'request claims are erased with their owner');
assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM exchange_key_requests WHERE owner = $1', [B])), 1, 'another owner\'s claims remain');
assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM exchange_key_requests WHERE owner = $1', [B])), 1, 'another owner\'s claims remain');
new file mode 100644
new file mode 100644
@@ -0,0 +1,140 @@
@@ -0,0 +1,140 @@
+'use strict';
+const assert = require('node:assert/strict');
+const http = require('node:http');
+const { boot, request, token, ALL } = require('./helpers');
+const { createKeys } = require('../server/platform/keys');
+(async () => {
+ const seen = [];
+ const upstream = http.createServer((req, res) => {
+ let raw = '';
+ req.on('data', c => raw += c);
+ req.on('end', () => {
+ const b = JSON.parse(raw); seen.push(b);
+ res.setHeader('Content-Type', 'application/json');
+ if (b.model === 'broken') { res.statusCode = 503; return res.end('{"error":{"message":"fixture failure"}}'); }
+ if (req.url.endsWith('/messages')) return res.end(JSON.stringify({ id: 'msg_fixture', type: 'message', role: 'assistant', model: b.model, content: [{ type: 'text', text: 'fixture message' }], stop_reason: 'end_turn', usage: { input_tokens: 10, output_tokens: 3 } }));
+ if (req.url.endsWith('/embeddings')) return res.end(JSON.stringify({ data: b.input.map((_, index) => ({ index, embedding: [1, 2] })), usage: { prompt_tokens: 2, total_tokens: 2 } }));
+ if (req.url.endsWith('/images/generations')) return res.end(JSON.stringify({ data: [{ b64_json: 'Zml4dHVyZQ==' }] }));
+ res.end(JSON.stringify({ id: 'fixture', model: b.model, choices: [{ index: 0, message: { role: 'assistant', content: 'fixture answer' }, finish_reason: 'stop' }], usage: { prompt_tokens: 10, completion_tokens: 3, total_tokens: 13 } }));
+ });
+ });
+ await new Promise(resolve => upstream.listen(0, '127.0.0.1', resolve));
+ const h = await boot({ env: { AI_IMAGE_MODEL: 'image', AI_STUB_FALLBACK: 'false', AI_PROVIDER_RETRIES: '0', AI_FREE_PROVIDERS: 'fixture,anthfixture', AI_FREE_MAX_OUTPUT_PRICE_USD_PER_MTOK: '5' } });
+ try {
+ const svc = token('exchange', ALL);
+ const added = await request(h.base, 'POST', '/api/v1/providers', { tok: svc, body: {
+ key: 'fixture', kind: 'openai', base_url: `http://127.0.0.1:${upstream.address().port}/v1`, auth_mode: 'none',
+ capabilities: ['chat', 'complete', 'embed', 'image'], default_model: 'good', provider_access: 'OWN_DEPLOYMENT',
+ metadata: { paid: false, data_policy: { training: false, retention: 'none' } },
+ } });
+ assert.equal(added.status, 201, added.text);
+ for (const [model, type] of [['broken', 'chat'], ['good', 'chat'], ['embed', 'embedding'], ['image', 'image']]) {
+ const out = await request(h.base, 'POST', '/api/v1/models', { tok: svc, body: {
+ provider_key: 'fixture', model_key: model, type, status: 'active', context_window: 8192, max_output: 4096,
+ supports: { streaming: true }, cost: { in_per_mtok: 0, out_per_mtok: 0, cached_per_mtok: 0 },
+ metadata: { org: 'fixture', model },
+ } });
+ assert.equal(out.status, 201, out.text);
+ }
+ assert.equal((await request(h.base, 'POST', '/api/v1/providers', { tok: svc, body: {
+ key: 'anthfixture', kind: 'anthropic', base_url: `http://127.0.0.1:${upstream.address().port}`, auth_mode: 'none',
+ capabilities: ['chat', 'messages'], default_model: 'claude', provider_access: 'OWN_DEPLOYMENT',
+ } })).status, 201);
+ assert.equal((await request(h.base, 'POST', '/api/v1/models', { tok: svc, body: {
+ provider_key: 'anthfixture', model_key: 'claude', type: 'chat', status: 'active', context_window: 8192,
+ supports: {}, cost: { in_per_mtok: 0, out_per_mtok: 0, cached_per_mtok: 0 }, metadata: { org: 'fixture', model: 'claude' },
+ } })).status, 201);
+ assert.equal((await request(h.base, 'POST', '/api/v1/providers', { tok: svc, body: {
+ key: 'paid-image', kind: 'openai', base_url: `http://127.0.0.1:${upstream.address().port}/v1`, auth_mode: 'none',
+ capabilities: ['image'], default_model: 'paid-image', provider_access: 'OWN_DEPLOYMENT',
+ } })).status, 201);
+ assert.equal((await request(h.base, 'POST', '/api/v1/models', { tok: svc, body: {
+ provider_key: 'paid-image', model_key: 'paid-image', type: 'image', status: 'active', supports: {},
+ cost: { in_per_mtok: 1, out_per_mtok: 1 }, metadata: { org: 'fixture', model: 'paid-image' },
+ } })).status, 201);
+ const owner = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+ const keys = createKeys({ db: h.db });
+ const key = await keys.create(owner, { name: 'fixture', monthly_cap_usd: 2, allowed_models: [] });
+ const other = await keys.create('usr_01JAB2C3D4E5F6G7H8J9K0MNP1', { name: 'other', monthly_cap_usd: 2, allowed_models: [] });
+ const post = (path, body, id) => request(h.base, 'POST', path, { tok: key.secret, body, headers: id ? { 'idempotency-key': id } : {} });
+ const body = { model: 'fixture/broken', models: ['fixture/good'], provider: { only: ['fixture'], data_collection: 'deny', zdr: true }, usage: { include: true }, messages: [{ role: 'user', content: 'hello' }] };
+ const result = await post('/v1/chat/completions', body, 'fallback-request-001');
+ assert.equal(result.status, 200, result.text);
+ assert.equal(result.body.model, 'good');
+ assert.equal(result.body.usage.provider, 'fixture');
+ assert.equal(result.body.usage.cost, 0);
+ assert.equal(seen.length, 2, 'one failed attempt followed by one model fallback');
+ const replay = await post('/v1/chat/completions', body, 'fallback-request-001');
+ assert.equal(replay.status, 200, replay.text);
+ assert.equal(seen.length, 2, 'replay never calls a provider');
+ const generation = await request(h.base, 'GET', '/v1/generation?id=' + result.body.id, { tok: key.secret });
+ assert.equal(generation.status, 200, generation.text);
+ assert.equal(generation.body.data.provider, 'fixture');
+ assert.equal((await request(h.base, 'GET', '/v1/generation?id=' + result.body.id, { tok: other.secret })).status, 404);
+ const before = seen.length;
+ assert.equal((await post('/v1/chat/completions', { ...body, model: 'fixture/good', models: undefined, provider: { ignore: ['fixture'] } })).status, 503);
+ assert.equal(seen.length, before, 'hard routing refusal never calls upstream');
+ const quality = { model: 'fixture/good', provider: { sort: 'quality' }, messages: body.messages };
+ assert.equal((await post('/v1/chat/completions', quality)).status, 503, 'missing Report/Bench evidence refuses quality routing');
+ await h.db.prepare(`INSERT INTO bench_evidence(kind_id,category,model,provider,rating,ci_lower,ci_upper,games,provisional,updated_at)
+ VALUES('chat.reply','all','good','fixture',1400,1350,1450,20,0,?)`).run(new Date().toISOString());
+ assert.equal((await post('/v1/chat/completions', quality)).status, 200, 'the event-fed rating used by Report enables quality routing');
+ const presets = require('../server/exchange/compat-presets').createCompatPresets(h.db);
+ await presets.put(owner, { name: 'writer', model: 'fixture/good:free', system: 'fixture system', temperature: .2 });
+ const preset = await post('/v1/chat/completions', { model: '@preset/writer', messages: body.messages });
+ assert.equal(preset.status, 200, preset.text);
+ assert.equal(seen.at(-1).messages[0].content, 'fixture system');
+ assert.equal(await presets.get('another-owner', 'writer'), null);
+ const legacy = await post('/v1/completions', { model: '@preset/writer', prompt: 'hello' }, 'legacy-request-001');
+ assert.equal(legacy.status, 200, legacy.text);
+ assert.equal(legacy.body.object, 'text_completion');
+ assert.equal(legacy.body.choices[0].text, 'fixture answer');
+ assert.equal(seen.at(-1).messages[0].content, 'fixture system', 'legacy conversion preserves the saved system prompt');
+ const embeddings = await post('/v1/embeddings', { model: 'fixture/embed', input: ['hello', 'world'] }, 'embedding-request-001');
+ assert.equal(embeddings.status, 200, embeddings.text);
+ assert.deepEqual(embeddings.body.data[0].embedding, [1, 2]);
+ const images = await post('/v1/images/generations', { model: 'fixture/image', prompt: 'fixture' }, 'image-request-001');
+ assert.equal(images.status, 200, images.text);
+ assert.equal(images.body.data[0].b64_json, 'Zml4dHVyZQ==');
+ const beforePaidImage = seen.length;
+ const paidImage = await post('/v1/images/generations', { model: 'fixture/paid-image', prompt: 'must not run' });
+ assert.equal(paidImage.status, 503, paidImage.text);
+ assert.equal(seen.length, beforePaidImage, 'paid image capacity cannot bypass missing per-image accounting');
+ await h.db.prepare(`INSERT INTO bench_evidence(kind_id,category,model,provider,rating,ci_lower,ci_upper,games,provisional,updated_at)
+ SELECT kind_id,category,'claude','anthfixture',rating,ci_lower,ci_upper,games,provisional,updated_at
+ FROM bench_evidence WHERE model = 'good' AND provider = 'fixture'`).run();
+ const msgBody = { model: 'fixture/claude', provider: { only: ['anthfixture'], sort: 'quality' }, max_tokens: 32, messages: body.messages, usage: { include: true } };
+ const message = await post('/v1/messages', msgBody, 'messages-request-001');
+ assert.equal(message.status, 200, message.text);
+ assert.equal(message.body.usage.provider, 'anthfixture');
+ assert.equal((await request(h.base, 'GET', '/v1/generation?id=' + message.body.id, { tok: key.secret })).status, 200);
+ const attempts = seen.length;
+ for (const [path, input, idem] of [
+ ['/v1/messages', msgBody, 'messages-request-001'],
+ ['/v1/embeddings', { model: 'fixture/embed', input: ['hello', 'world'] }, 'embedding-request-001'],
+ ['/v1/images/generations', { model: 'fixture/image', prompt: 'fixture' }, 'image-request-001'],
+ ['/v1/completions', { model: '@preset/writer', prompt: 'hello' }, 'legacy-request-001'],
+ ]) {
+ const replayed = await post(path, input, idem);
+ assert.equal(replayed.status, 200, replayed.text);
+ assert.equal(replayed.headers.get('x-openvibe-replayed'), '1');
+ }
+ assert.equal(seen.length, attempts, 'every new endpoint replays without another provider call');
+ const restricted = await keys.create(owner, { name: 'restricted', monthly_cap_usd: 1, allowed_models: ['fixture/good'] });
+ const denied = await request(h.base, 'POST', '/v1/chat/completions', { tok: restricted.secret, body });
+ assert.equal(denied.status, 403);
+ assert.equal((await h.db.prepare('SELECT id FROM exchange_key_usage WHERE key_id = ?').all(restricted.key.id)).length, 0, 'a disallowed fallback is rejected before reserve');
+ const failedKey = await keys.create(owner, { name: 'failed attempt', monthly_cap_usd: 1, allowed_models: [] });
+ const failed = await request(h.base, 'POST', '/v1/chat/completions', { tok: failedKey.secret,
+ body: { model: 'fixture/broken', messages: body.messages } });
+ assert.equal(failed.status, 503, failed.text);
+ await new Promise(resolve => setTimeout(resolve, 150));
+ const failedRows = await h.db.prepare('SELECT * FROM exchange_key_usage WHERE key_id = ?').all(failedKey.key.id);
+ assert.equal(failedRows[0].status, 'unresolved', 'unknown failed upstream usage retains conservative settlement');
+ const rows = await h.db.prepare('SELECT * FROM exchange_key_usage WHERE key_id = ?').all(key.key.id);
+ assert.equal(rows.length, 10, 'fallback and replay share one hold; other calls each have one');
+ assert.ok(rows.every(row => row.status === 'settled'));
+ assert.equal((await h.db.prepare('SELECT reserved FROM exchange_key_months WHERE key_id = ?').get(key.key.id)).reserved, 0);
+ console.log('exchange-c2-execution: ok');
+ } finally { await h.close(); upstream.closeAllConnections(); await new Promise(resolve => upstream.close(resolve)); }
+})().catch(err => { console.error(err); process.exitCode = 1; });
@@ -105,7 +105,7 @@ t.test('a message answers Anthropic\'s shape, from a run of ai.messages that kee
@@ -105,7 +105,7 @@ t.test('a message answers Anthropic\'s shape, from a run of ai.messages that kee
const res = await post({ model: 'claude-sonnet-4-5', max_tokens: 64, system: 'Be brief.', messages: [{ role: 'user', content: 'Say hello' }] });
const res = await post({ model: 'claude-sonnet-4-5', max_tokens: 64, system: 'Be brief.', messages: [{ role: 'user', content: 'Say hello' }] });
assert.strictEqual(res.status, 200);
assert.strictEqual(res.status, 200);
const body = await res.json();
const body = await res.json();
- assert.deepStrictEqual([body.type, body.role, body.id], ['message', 'assistant', 'msg_test_1']);
+ assert.deepStrictEqual([body.type, body.role, body.id], ['message', 'assistant', 'msg_' + res.headers.get('x-openvibe-run-id')]);
assert.deepStrictEqual(body.content, [{ type: 'text', text: 'Hello there' }]);
assert.deepStrictEqual(body.content, [{ type: 'text', text: 'Hello there' }]);
assert.deepStrictEqual([body.stop_reason, body.usage.input_tokens, body.usage.output_tokens], ['end_turn', 11, 2]);
assert.deepStrictEqual([body.stop_reason, body.usage.input_tokens, body.usage.output_tokens], ['end_turn', 11, 2]);
const runId = res.headers.get('x-openvibe-run-id');
const runId = res.headers.get('x-openvibe-run-id');
@@ -136,16 +136,18 @@ t.test('tools and tool_choice pass through; a tool_use comes back as Anthropic s
@@ -136,16 +136,18 @@ t.test('tools and tool_choice pass through; a tool_use comes back as Anthropic s
});
});
t.test('stream: Anthropic\'s events in order, and the run ends with the whole message', async () => {
t.test('stream: Anthropic\'s events in order, and the run ends with the whole message', async () => {
- const res = await post({ max_tokens: 64, stream: true, messages: [{ role: 'user', content: 'Stream please' }] });
+ const res = await post({ max_tokens: 64, stream: true, usage: { include: true }, messages: [{ role: 'user', content: 'Stream please' }] });
assert.strictEqual(res.status, 200);
assert.strictEqual(res.status, 200);
assert.match(res.headers.get('content-type'), /^text\/event-stream/);
assert.match(res.headers.get('content-type'), /^text\/event-stream/);
const runId = res.headers.get('x-openvibe-run-id');
const runId = res.headers.get('x-openvibe-run-id');
const events = await sse(res);
const events = await sse(res);
assert.deepStrictEqual(events.map((e) => e.event), FRAMES.map(([name]) => name));
assert.deepStrictEqual(events.map((e) => e.event), FRAMES.map(([name]) => name));
- assert.deepStrictEqual(events[0].data.message.id, 'msg_stream_1');
+ assert.deepStrictEqual(events[0].data.message.id, 'msg_' + runId);
const text = events.filter((e) => e.data.type === 'content_block_delta').map((e) => e.data.delta.text).join('');
const text = events.filter((e) => e.data.type === 'content_block_delta').map((e) => e.data.delta.text).join('');
assert.strictEqual(text, 'Hello there');
assert.strictEqual(text, 'Hello there');
assert.strictEqual(events[events.length - 1].data.type, 'message_stop');
assert.strictEqual(events[events.length - 1].data.type, 'message_stop');
+ assert.equal(events.at(-1).data.usage.provider, 'anthropic-fake');
+ assert.equal(typeof events.at(-1).data.usage.cost, 'number');
const run = await request(h.base, 'GET', `/api/v1/runs/${runId}`, { tok: service });
const run = await request(h.base, 'GET', `/api/v1/runs/${runId}`, { tok: service });
assert.strictEqual(run.body.run.output.content[0].text, text, 'the run kept the whole answer the stream sent');
assert.strictEqual(run.body.run.output.content[0].text, text, 'the run kept the whole answer the stream sent');
assert.strictEqual(run.body.run.output.stop_reason, 'end_turn');
assert.strictEqual(run.body.run.output.stop_reason, 'end_turn');
@@ -76,7 +76,7 @@ const excluded = (key) => {
@@ -76,7 +76,7 @@ const excluded = (key) => {
const documented = Object.entries(doc.paths).flatMap(([p, item]) => Object.keys(item).map((m) => `${m.toUpperCase()} ${p}`)).sort();
const documented = Object.entries(doc.paths).flatMap(([p, item]) => Object.keys(item).map((m) => `${m.toUpperCase()} ${p}`)).sort();
assert.ok(documented.length > 0, 'paths is not empty');
assert.ok(documented.length > 0, 'paths is not empty');
for (const [p, item] of Object.entries(doc.paths)) {
for (const [p, item] of Object.entries(doc.paths)) {
- assert.ok(p.startsWith('/api/') || ['/v1/search', '/v1/search/images', '/v1/search/icons', '/v1/fetch'].includes(p), `${p} is outside /api`);
+ assert.ok(p.startsWith('/api/') || ['/v1/search', '/v1/search/images', '/v1/search/icons', '/v1/fetch', ...Object.keys(require('../server/exchange/compat-openapi').paths)].includes(p), `${p} is outside /api`);
for (const [method, op] of Object.entries(item)) {
for (const [method, op] of Object.entries(item)) {
assert.ok(op.operationId, `${method.toUpperCase()} ${p} has an operationId`);
assert.ok(op.operationId, `${method.toUpperCase()} ${p} has an operationId`);
assert.ok(op.responses && Object.keys(op.responses).length, `${method.toUpperCase()} ${p} describes its responses`);
assert.ok(op.responses && Object.keys(op.responses).length, `${method.toUpperCase()} ${p} describes its responses`);
@@ -88,7 +88,7 @@ const excluded = (key) => {
@@ -88,7 +88,7 @@ const excluded = (key) => {
assert.ok(!text.includes(`"${field}"`), `the document must not name ${field}`);
assert.ok(!text.includes(`"${field}"`), `the document must not name ${field}`);
}
}
- const live = [...mounted(h.app)].filter((key) => (key.split(' ')[1].startsWith(`${MOUNT}/`) || /^POST \/v1\/(search(?:\/images|\/icons)?|fetch)$/.test(key)) && !excluded(key)).sort();
+ const live = [...mounted(h.app)].filter((key) => (key.split(' ')[1].startsWith(`${MOUNT}/`) || Object.keys(require('../server/exchange/compat-openapi').paths).includes(key.split(' ')[1]) || /^POST \/v1\/(search(?:\/images|\/icons)?|fetch)$/.test(key)) && !excluded(key)).sort();
assert.deepStrictEqual(documented, live, 'every mounted public /api/v1 route is documented and vice versa');
assert.deepStrictEqual(documented, live, 'every mounted public /api/v1 route is documented and vice versa');
assert.deepStrictEqual(routes(), documented, 'server/openapi.js routes() and the document agree');
assert.deepStrictEqual(routes(), documented, 'server/openapi.js routes() and the document agree');
@@ -165,7 +165,7 @@ function assertOpenAiStream(parsed, expected) {
@@ -165,7 +165,7 @@ function assertOpenAiStream(parsed, expected) {
function assertAnthropicJson(body, expected) {
function assertAnthropicJson(body, expected) {
assert.deepStrictEqual([body.type, body.role], ['message', 'assistant']);
assert.deepStrictEqual([body.type, body.role], ['message', 'assistant']);
- assert.strictEqual(body.id, expected.response.id);
+ assert.strictEqual(body.id, 'msg_' + body.x_openvibe.run_id, 'gateway ids support generation lookup');
assert.strictEqual(body.model, expected.response.model);
assert.strictEqual(body.model, expected.response.model);
assert.deepStrictEqual(body.content, expected.response.content, 'the upstream content blocks are passed through untranslated');
assert.deepStrictEqual(body.content, expected.response.content, 'the upstream content blocks are passed through untranslated');
assert.strictEqual(body.stop_reason, expected.response.stop_reason);
assert.strictEqual(body.stop_reason, expected.response.stop_reason);
@@ -186,7 +186,9 @@ function assertAnthropicStream(parsed, expected) {
@@ -186,7 +186,9 @@ function assertAnthropicStream(parsed, expected) {
const last = parsed.frames[parsed.frames.length - 1];
const last = parsed.frames[parsed.frames.length - 1];
assert.ok(parsed.frames.length >= 2, 'message_start through message_stop');
assert.ok(parsed.frames.length >= 2, 'message_start through message_stop');
assert.deepStrictEqual(parsed.frames.map((f) => f.event), expected.stream.map((f) => f.event), 'the named events, in protocol order');
assert.deepStrictEqual(parsed.frames.map((f) => f.event), expected.stream.map((f) => f.event), 'the named events, in protocol order');
- assert.deepStrictEqual(parsed.frames.slice(0, -1).map((f) => f.data), expected.stream.slice(0, -1).map((f) => f.data), 'every event before message_stop is passed through');
+ const expectedEvents = expected.stream.slice(0, -1).map(f => f.event === 'message_start'
+ ? { ...f.data, message: { ...f.data.message, id: 'msg_' + last.data.x_openvibe.run_id } } : f.data);
+ assert.deepStrictEqual(parsed.frames.slice(0, -1).map((f) => f.data), expectedEvents, 'events pass through with the gateway generation id');
assert.ok(parsed.frames.every((f) => f.event === f.data.type), 'the event name matches the data type');
assert.ok(parsed.frames.every((f) => f.event === f.data.type), 'the event name matches the data type');
assert.deepStrictEqual([last.event, last.data.type], ['message_stop', 'message_stop']);
assert.deepStrictEqual([last.event, last.data.type], ['message_stop', 'message_stop']);
assert.ok(last.data.x_openvibe, 'message_stop carries x_openvibe');
assert.ok(last.data.x_openvibe, 'message_stop carries x_openvibe');