openvibers/openvibe.ai Commits

Execute Exchange routing, presets and generation accounting (#418)

OP OpenVibers committed 3 days ago · c143f306bd · 24 files, +528 −44

* Execute Exchange routing and owner presets within one billed run

* Use Report's Bench evidence and verify all inference replays

* Keep failed inference settlement conservative and gate unpriced images

* Match routing candidates to operation and share Messages rating evidence

* Preserve saved system prompts in legacy completions

* Include saved request presets in account export and erasure

* Reconcile current main with Exchange compatibility routing

* Distinguish rating operations from public capabilities

* Integrate Exchange execution with Contracts 0.380.0 and current main

* Renumber compatibility presets migration

Changes

ddiff --git a/docs/exchange-c2-execution.md b/docs/exchange-c2-execution.md
nnew file mode 100644
iindex 0000000..a1add9d
---- /dev/null
++++ b/docs/exchange-c2-execution.md
@@@ -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.
ddiff --git a/migrations/0062_exchange_compat_presets.sql b/migrations/0062_exchange_compat_presets.sql
nnew file mode 100644
iindex 0000000..a2718d7
---- /dev/null
++++ b/migrations/0062_exchange_compat_presets.sql
@@@ -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)
++);
ddiff --git a/package-lock.json b/package-lock.json
iindex 74f8eed..83fcad0 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 6d8a218..8c32880 100644
---- a/package.json
++++ b/package.json
@@@ -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"
ddiff --git a/server/account-data.js b/server/account-data.js
iindex b351c80..cbb05c8 100644
---- a/server/account-data.js
++++ b/server/account-data.js
@@@ -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' } },
ddiff --git a/server/api/compat-extra.js b/server/api/compat-extra.js
nnew file mode 100644
iindex 0000000..e98abd0
---- /dev/null
++++ b/server/api/compat-extra.js
@@@ -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 };
ddiff --git a/server/api/messages.js b/server/api/messages.js
iindex d124682..aa6a90c 100644
---- a/server/api/messages.js
++++ b/server/api/messages.js
@@@ -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, 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.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, receipts
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);
}
++ 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, receipts
message.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) {
ddiff --git a/server/api/models.js b/server/api/models.js
iindex ff4a3ea..209e8d3 100644
---- a/server/api/models.js
++++ b/server/api/models.js
@@@ -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,
ddiff --git a/server/api/openai.js b/server/api/openai.js
iindex a303bfe..91dc3c3 100644
---- a/server/api/openai.js
++++ b/server/api/openai.js
@@@ -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 });
ddiff --git a/server/app.js b/server/app.js
iindex b7f1b13..a3fee3e 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -216,11 +216,12 @@ function createApp({ choices = null, searchFetch = null, config, db, registry, p
const 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).
ddiff --git a/server/exchange/compat-openapi.js b/server/exchange/compat-openapi.js
nnew file mode 100644
iindex 0000000..985052d
---- /dev/null
++++ b/server/exchange/compat-openapi.js
@@@ -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 };
ddiff --git a/server/exchange/compat-presets.js b/server/exchange/compat-presets.js
nnew file mode 100644
iindex 0000000..bb1b221
---- /dev/null
++++ b/server/exchange/compat-presets.js
@@@ -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 };
ddiff --git a/server/exchange/compat-routing.js b/server/exchange/compat-routing.js
iindex 4c0d895..e7c1a9f 100644
---- a/server/exchange/compat-routing.js
++++ b/server/exchange/compat-routing.js
@@@ -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 };
ddiff --git a/server/exchange/import-preset.js b/server/exchange/import-preset.js
iindex 05b50b9..88fff82 100644
---- a/server/exchange/import-preset.js
++++ b/server/exchange/import-preset.js
@@@ -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 };
ddiff --git a/server/openapi.js b/server/openapi.js
iindex e647c55..433a8bc 100644
---- a/server/openapi.js
++++ b/server/openapi.js
@@@ -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.',
ddiff --git a/server/platform/router.js b/server/platform/router.js
iindex dd914a2..6532dc1 100644
---- a/server/platform/router.js
++++ b/server/platform/router.js
@@@ -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 = proces
person,
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 = proces
exchangeKeyId: 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 = proces
const 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'),
);
ddiff --git a/server/providers/index.js b/server/providers/index.js
iindex 3a9ddd9..1848378 100644
---- a/server/providers/index.js
++++ b/server/providers/index.js
@@@ -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.no
const 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.no
const 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.no
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;
explain.content_policy = contentPolicy;
if (uncensored) explain.label = tier.LABEL;
ddiff --git a/server/workflows/core.js b/server/workflows/core.js
iindex 710c9c3..d3b6ea7 100644
---- a/server/workflows/core.js
++++ b/server/workflows/core.js
@@@ -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',
ddiff --git a/server/workflows/engine.js b/server/workflows/engine.js
iindex 69d6efe..312f65e 100644
---- a/server/workflows/engine.js
++++ b/server/workflows/engine.js
@@@ -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 = {}, spa
function 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 };
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex a6bdb16..ade7a0f 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -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' } });
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 del
const 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 del
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.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');
ddiff --git a/test/exchange-c2-execution.test.js b/test/exchange-c2-execution.test.js
nnew file mode 100644
iindex 0000000..598c760
---- /dev/null
++++ b/test/exchange-c2-execution.test.js
@@@ -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; });
ddiff --git a/test/messages-api.test.js b/test/messages-api.test.js
iindex 7503983..c55ac50 100644
---- a/test/messages-api.test.js
++++ b/test/messages-api.test.js
@@@ -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' }] });
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');
ddiff --git a/test/openapi.test.js b/test/openapi.test.js
iindex d060582..84fa644 100644
---- a/test/openapi.test.js
++++ b/test/openapi.test.js
@@@ -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');
ddiff --git a/test/protocol-compat.test.js b/test/protocol-compat.test.js
iindex 053cc4a..4492ecd 100644
---- a/test/protocol-compat.test.js
++++ b/test/protocol-compat.test.js
@@@ -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');