openvibers/openvibe.ai Commits

AI: transcription, moderation and rerank compatible routes, plus model features (#518)

OP OpenVibers committed 11 h ago · 9920a49b62 · 11 files, +512 −8

* AI: transcription, moderation and rerank compatible routes, and model features

* workflows test: example inputs for ai.transcribe and ai.moderate

Changes

ddiff --git a/server/api/compat-extra.js b/server/api/compat-extra.js
iindex e98abd0..0ea2afe 100644
---- a/server/api/compat-extra.js
++++ b/server/api/compat-extra.js
@@@ -1,15 +1,172 @@
'use strict';
const express = require('express');
const { CAPS } = require('../auth');
++const { appAttribution } = require('../apps');
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 };
}
++
++// POST /v1/audio/transcriptions: a run of ai.transcribe carries the audio as a data: URL, and a run's
++// input is capped (AI_MAX_INPUT_BYTES, 6 MB by default), so the file is 3 MB at most.
++const MAX_MULTIPART_BYTES = 5 * 1024 * 1024;
++const MAX_AUDIO_BYTES = 3 * 1024 * 1024;
++const AUDIO_TYPES = new Set(['audio/flac', 'audio/mpeg', 'audio/mp3', 'audio/mp4', 'audio/mpga', 'audio/m4a', 'audio/x-m4a',
++ 'audio/ogg', 'audio/wav', 'audio/x-wav', 'audio/webm', 'audio/aac', 'audio/x-aac', 'video/mp4', 'video/mpeg']);
++const AUDIO_EXTENSIONS = { flac: 'audio/flac', mp3: 'audio/mpeg', mp4: 'audio/mp4', mpeg: 'audio/mpeg', mpga: 'audio/mpga',
++ m4a: 'audio/mp4', ogg: 'audio/ogg', wav: 'audio/wav', webm: 'audio/webm', aac: 'audio/aac' };
++const TRANSCRIBE_FORMATS = new Set(['json', 'text', 'verbose_json']);
++const MODERATION_LABELS = ['threat', 'minors', 'doxxing', 'harassment', 'spam', 'sexual', 'self_harm', 'none'];
++// OpenAI's moderation categories. OpenVibe's labels map onto them where they overlap; a label OpenAI has
++// no category for (doxxing, spam) still flags the result and travels in x_openvibe.classification.
++const MODERATION_CATEGORIES = ['hate', 'hate/threatening', 'harassment', 'harassment/threatening', 'self-harm', 'self-harm/intent',
++ 'self-harm/instructions', 'sexual', 'sexual/minors', 'violence', 'violence/graphic'];
++const SEVERITY_SCORES = { none: 0, low: 0.25, medium: 0.6, high: 0.95 };
++
++const gatewaySafe = (status) => (status === 502 || status === 504 ? 503 : status === 422 ? 400 : status);
++
++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 } });
++}
++
++/** The raw bytes of a request nobody has parsed yet (multipart/form-data bypasses express.json). */
++function readRaw(req, limit) {
++ return new Promise((resolve, reject) => {
++ const chunks = [];
++ let size = 0;
++ let over = false;
++ req.on('data', (chunk) => {
++ size += chunk.length;
++ if (size > limit) { over = true; return; }
++ chunks.push(chunk);
++ });
++ req.on('end', () => over ? reject(new AiError(413, 'input.too_large', `the upload is larger than ${limit} bytes`)) : resolve(Buffer.concat(chunks)));
++ req.on('error', reject);
++ });
++}
++
++/** The one file and the small fields of a multipart form, as OpenAI's audio clients send them. */
++function parseMultipart(buf, contentType) {
++ const m = /boundary=(?:"([^"]+)"|([^;\s]+))/i.exec(String(contentType || ''));
++ if (!m) throw new AiError(400, 'input.invalid', 'the request is not a valid multipart/form-data form');
++ const boundary = '--' + (m[1] || m[2]);
++ const text = buf.toString('latin1');
++ const fields = {};
++ let file = null;
++ let parts = 0;
++ for (const chunk of text.split(boundary).slice(1)) {
++ if (chunk.startsWith('--')) break;
++ parts++;
++ if (parts > 32) throw new AiError(400, 'input.invalid', 'the form has too many parts');
++ const part = chunk.replace(/^\r\n/, '');
++ const split = part.indexOf('\r\n\r\n');
++ if (split < 0) continue;
++ const head = part.slice(0, split);
++ let body = part.slice(split + 4);
++ if (body.endsWith('\r\n')) body = body.slice(0, -2);
++ const name = /name="([^"]+)"/i.exec(head);
++ if (!name) continue;
++ const filename = /filename="([^"]*)"/i.exec(head);
++ if (filename && filename[1]) {
++ const type = /content-type:\s*([^\r\n;]+)/i.exec(head);
++ file = { name: filename[1], type: (type ? type[1] : '').trim().toLowerCase(), data: Buffer.from(body, 'latin1') };
++ } else {
++ fields[name[1]] = Buffer.from(body, 'latin1').toString('utf8');
++ }
++ }
++ return { fields, file };
++}
++
++/** The audio MIME type, from the part's own type or (when the client sent none) its extension. */
++function audioTypeOf(file) {
++ const type = String(file.type || '').split(';')[0].trim().toLowerCase();
++ if (type) {
++ if (!AUDIO_TYPES.has(type)) throw new AiError(400, 'input.invalid', `unsupported audio file type ${type.slice(0, 80)}; send audio/mpeg, audio/wav, audio/ogg, audio/webm or audio/mp4`);
++ return type;
++ }
++ const ext = /\.([a-z0-9]+)$/i.exec(String(file.name || ''));
++ const inferred = ext && AUDIO_EXTENSIONS[ext[1].toLowerCase()];
++ if (!inferred) throw new AiError(400, 'input.invalid', 'the audio file has no content type; send audio/mpeg, audio/wav, audio/ogg, audio/webm or audio/mp4');
++ return inferred;
++}
++
++/** Cosine similarity of two embedding vectors; zero when the vectors are empty or misaligned. */
++function cosine(a, b) {
++ if (!Array.isArray(a) || !Array.isArray(b) || !a.length || a.length !== b.length) return 0;
++ let dot = 0;
++ let na = 0;
++ let nb = 0;
++ for (let i = 0; i < a.length; i++) {
++ const x = Number(a[i]) || 0;
++ const y = Number(b[i]) || 0;
++ dot += x * y;
++ na += x * x;
++ nb += y * y;
++ }
++ if (!na || !nb) return 0;
++ return Math.round(Math.max(-1, Math.min(1, dot / Math.sqrt(na * nb))) * 1e6) / 1e6;
++}
++
++/** OpenAI's moderation result shape from the moderation.classify workflow's answer. */
++function moderationResult(output) {
++ const out = output && typeof output === 'object' ? output : {};
++ const labels = new Set((Array.isArray(out.labels) ? out.labels : []).filter((l) => MODERATION_LABELS.includes(l)));
++ const action = ['allow', 'review', 'block'].includes(out.action) ? out.action : 'review';
++ const severity = Object.hasOwn(SEVERITY_SCORES, out.severity) ? out.severity : 'medium';
++ const score = SEVERITY_SCORES[severity];
++ const categories = Object.fromEntries(MODERATION_CATEGORIES.map((k) => [k, false]));
++ const category_scores = Object.fromEntries(MODERATION_CATEGORIES.map((k) => [k, 0]));
++ const mark = (...keys) => { for (const k of keys) { categories[k] = true; category_scores[k] = Math.max(category_scores[k], score); } };
++ if (labels.has('threat')) mark('violence');
++ if (labels.has('harassment')) mark('harassment');
++ if (labels.has('threat') && labels.has('harassment')) mark('harassment/threatening');
++ if (labels.has('minors')) mark('sexual/minors', 'sexual');
++ if (labels.has('sexual')) mark('sexual');
++ if (labels.has('self_harm')) mark('self-harm', 'self-harm/intent');
++ if (labels.has('doxxing')) mark('harassment');
++ return { flagged: action !== 'allow' || severity !== 'none', categories, category_scores };
++}
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); } };
++ const openaiRoute = (label, fn) => async (req, res) => {
++ try { await fn(req, res); } catch (err) {
++ if (res.headersSent) { try { res.end(); } catch { /* already closed */ } return; }
++ const status = gatewaySafe((err && err.status) || 500);
++ if (status >= 500) log.warn(`[ai] ${label}: ${err && err.message}`);
++ return openaiError(res, status, err && (err.detail || err.message), err && err.code);
++ }
++ };
++ // The run the compatible route waits for, with the same admission and charge bookkeeping as the
++ // chat and speech routes: the run path records the usage and the money.
++ async function createRun(body, req, res) {
++ const created = await runs.create(body, req.principal, { trace: req.ov?.traceId, requestId: req.ov?.requestId, abuse: req.abuse });
++ res.locals.inferenceAdmitted = true;
++ if (created.created === false) res.setHeader('x-openvibe-replayed', '1');
++ const run = await runs.explained(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)) {
++ const code = (run.error && run.error.code) || (run.status === 'failed' ? 'run.failed' : 'run.timeout');
++ const status = code === 'run.timeout' ? 504
++ : code === 'ai.run_budget_reached' || code === 'ai.allowance_spent' || code === 'billing.insufficient_funds' || code === 'billing.payer_required' ? 402
++ : /^(input|validation)\./.test(code) ? 400 : /quota|allowance|budget/.test(code) ? 429
++ : code === 'ai.model_not_found' ? 404 : code === 'ai.model_unavailable' ? 400 : 503;
++ throw new AiError(status, code, (run.error && run.error.detail) || (code === 'run.timeout' ? `the run is still running; GET /api/v1/runs/${run.id}` : code));
++ }
++ return run;
++ }
++ const runBody = (req, workflow, input, extra = {}) => {
++ const body = { workflow, input, ...extra };
++ if (req.principal.kind === 'app') body.attribution = appAttribution(req.principal);
++ return body;
++ };
++ const usageOf = (list) => {
++ const t = list.reduce((a, run) => ({ in: a.in + Number(run.usage.tokens_in || 0), out: a.out + Number(run.usage.tokens_out || 0) }), { in: 0, out: 0 });
++ return { prompt_tokens: t.in, completion_tokens: t.out, total_tokens: t.in + t.out };
++ };
++ const costOf = (list) => Math.round(list.reduce((n, run) => n + Number(run.usage.cost_usd || 0), 0) * 1e6) / 1e6;
r.get('/v1/generation', allow, wrap(async (req, res) => {
const id = String(req.query.id || '').replace(/^(chatcmpl-|msg_)/, '');
const run = await runs.get(id);
@@@ -56,6 +213,138 @@ function compatExtra({ runs, auth, config, log }) {
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 } });
}));
++ // OpenAI's audio/transcriptions: multipart file + model, through the router's transcribe route
++ // (workflow ai.transcribe, route live.stt). language, prompt and temperature go to the provider that
++ // supports them; response_format is json (default), text or verbose_json.
++ r.post('/v1/audio/transcriptions', allow, openaiRoute('/v1/audio/transcriptions', async (req, res) => {
++ const contentType = String(req.get('content-type') || '');
++ if (!/^multipart\/form-data/i.test(contentType)) return openaiError(res, 400, 'send multipart/form-data with a file field', 'input.invalid');
++ const raw = await readRaw(req, MAX_MULTIPART_BYTES);
++ const { fields, file } = parseMultipart(raw, contentType);
++ if (!file) return openaiError(res, 400, 'file is required', 'input.invalid');
++ const mime = audioTypeOf(file);
++ if (!file.data.length) return openaiError(res, 400, 'the audio file is empty', 'input.invalid');
++ if (file.data.length > MAX_AUDIO_BYTES) return openaiError(res, 413, `the audio file is larger than ${MAX_AUDIO_BYTES} bytes`, 'input.too_large');
++ const model = String(fields.model == null ? '' : fields.model).trim();
++ if (!model || model.length > 256) return openaiError(res, 400, 'model must name a transcription model', 'input.invalid');
++ const language = fields.language == null || fields.language === '' ? null : String(fields.language).trim().toLowerCase();
++ if (language && language !== 'auto' && !/^[a-z]{2}$/.test(language)) return openaiError(res, 400, 'language must be an ISO-639-1 code or auto', 'input.invalid');
++ const prompt = fields.prompt == null || fields.prompt === '' ? null : String(fields.prompt);
++ if (prompt && prompt.length > 1000) return openaiError(res, 400, 'prompt is longer than 1000 characters', 'input.invalid');
++ const temperature = fields.temperature == null || fields.temperature === '' ? null : Number(fields.temperature);
++ if (temperature != null && (!Number.isFinite(temperature) || temperature < 0 || temperature > 1)) return openaiError(res, 400, 'temperature must be between 0 and 1', 'input.invalid');
++ const format = fields.response_format == null || fields.response_format === '' ? 'json' : String(fields.response_format);
++ if (!TRANSCRIBE_FORMATS.has(format)) return openaiError(res, 400, 'response_format must be json, text or verbose_json', 'input.invalid');
++
++ const input = { audio: { data_url: `data:${mime};base64,${file.data.toString('base64')}`, mime }, language: language || 'auto' };
++ if (prompt) input.prompt = prompt;
++ if (temperature != null) input.temperature = temperature;
++ if (fields.max_cost_usd != null && fields.max_cost_usd !== '') input.max_cost_usd = fields.max_cost_usd;
++ const run = await createRun(runBody(req, 'ai.transcribe', input, { idempotency_key: req.get('idempotency-key') || undefined }), req, res);
++
++ const output = run.output || {};
++ const text = String(output.text == null ? '' : output.text);
++ const detected = output.language || language || 'en';
++ const segments = Array.isArray(output.segments) ? output.segments : [];
++ const usage = usageOf([run]);
++ const x = { run_id: run.id, provider: run.provenance?.provider || null, model: run.provenance?.model || null,
++ requested_model: model, response_format: format, cost_usd: costOf([run]), synthetic: Boolean(run.synthetic) };
++ res.setHeader('X-OpenVibe-Run-Id', run.id);
++ if (format === 'text') {
++ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
++ return res.status(200).send(text);
++ }
++ if (format === 'verbose_json') {
++ const mapped = segments.map((s, i) => ({ id: i, seek: 0, start: Number(s.start) || 0, end: Number(s.end) || 0, text: String(s.text || ''),
++ tokens: [], temperature: temperature == null ? 0 : temperature, avg_logprob: null, compression_ratio: null, no_speech_prob: null }));
++ return res.json({ task: 'transcribe', language: detected, duration: mapped.length ? mapped[mapped.length - 1].end : 0,
++ text, segments: mapped, usage, x_openvibe: x });
++ }
++ return res.json({ text, usage, x_openvibe: x });
++ }));
++
++ // OpenAI's moderations: no provider here offers a moderation endpoint, so a structured model answers
++ // the fixed moderation.classify prompt (workflow ai.moderate) and the answer says so in x_openvibe.
++ r.post('/v1/moderations', allow, openaiRoute('/v1/moderations', async (req, res) => {
++ const b = req.body && typeof req.body === 'object' ? req.body : {};
++ const requested = b.model == null ? 'omni-moderation-latest' : String(b.model).trim();
++ if (!requested || requested.length > 256) return openaiError(res, 400, 'model must name a moderation model', 'input.invalid');
++ const inputs = Array.isArray(b.input) ? b.input : [b.input];
++ if (!inputs.length || inputs.length > 16) return openaiError(res, 400, 'input must be a string or an array of at most 16 strings', 'input.invalid');
++ const texts = [];
++ for (const value of inputs) {
++ if (typeof value !== 'string' || !value.trim()) return openaiError(res, 400, 'input must be a string or an array of non-empty strings', 'input.invalid');
++ if (value.length > 8000) return openaiError(res, 400, 'each input is at most 8000 characters', 'input.invalid');
++ texts.push(value);
++ }
++ const list = [];
++ for (const text of texts) list.push(await createRun(runBody(req, 'ai.moderate', { text }), req, res));
++ // The charge is every run's, not the last one's.
++ res.locals.inferenceCharge = costOf(list);
++ const first = list[0];
++ return res.json({
++ id: `modr-${first.id}`,
++ model: first.provenance?.model || requested,
++ results: list.map((run) => moderationResult(run.output)),
++ usage: usageOf(list),
++ x_openvibe: {
++ method: 'structured_model', route: 'moderation.classify',
++ note: 'no provider moderation endpoint is configured; a structured model answered the fixed moderation.classify prompt',
++ run_ids: list.map((run) => run.id),
++ classification: list.map((run) => ({ labels: (run.output && run.output.labels) || [], action: (run.output && run.output.action) || null, severity: (run.output && run.output.severity) || null })),
++ requested_model: requested, provider: first.provenance?.provider || null, cost_usd: costOf(list), synthetic: list.some((run) => run.synthetic),
++ },
++ });
++ }));
++
++ // Cohere/Jina-shaped rerank: no reranker adapter exists here, so the answer is the cosine similarity
++ // of ai.embed's vectors and x_openvibe.method says exactly that.
++ r.post('/v1/rerank', allow, openaiRoute('/v1/rerank', async (req, res) => {
++ const b = req.body && typeof req.body === 'object' ? req.body : {};
++ const requested = b.model == null ? null : String(b.model).trim();
++ if (b.model != null && (!requested || requested.length > 256)) return openaiError(res, 400, 'model must be a model id', 'input.invalid');
++ if (typeof b.query !== 'string' || !b.query.trim()) return openaiError(res, 400, 'query must be a non-empty string', 'input.invalid');
++ if (b.query.length > 32000) return openaiError(res, 400, 'query is longer than 32000 characters', 'input.invalid');
++ if (!Array.isArray(b.documents) || !b.documents.length) return openaiError(res, 400, 'documents must be a non-empty array', 'input.invalid');
++ if (b.documents.length > 63) return openaiError(res, 400, 'documents is at most 63 (the embedding workflow carries 64 inputs including the query)', 'input.invalid');
++ const docs = b.documents.map((d, i) => {
++ if (typeof d === 'string') {
++ if (!d.trim()) throw new AiError(400, 'input.invalid', `document ${i} is empty`);
++ if (d.length > 32000) throw new AiError(400, 'input.invalid', `document ${i} is longer than 32000 characters`);
++ return { text: d, original: d };
++ }
++ if (d && typeof d === 'object' && typeof d.text === 'string') {
++ if (!d.text.trim()) throw new AiError(400, 'input.invalid', `document ${i} is empty`);
++ if (d.text.length > 32000) throw new AiError(400, 'input.invalid', `document ${i} is longer than 32000 characters`);
++ return { text: d.text, original: d };
++ }
++ throw new AiError(400, 'input.invalid', `document ${i} must be a string or { text }`);
++ });
++ const topN = b.top_n == null ? docs.length : Number(b.top_n);
++ if (!Number.isInteger(topN) || topN < 1 || topN > docs.length) return openaiError(res, 400, `top_n must be an integer between 1 and ${docs.length}`, 'input.invalid');
++ const input = { input: [b.query, ...docs.map((d) => d.text)] };
++ if (b.max_cost_usd != null) input.max_cost_usd = b.max_cost_usd;
++ const run = await createRun(runBody(req, 'ai.embed', input, { idempotency_key: req.get('idempotency-key') || undefined }), req, res);
++ const vectors = run.output && Array.isArray(run.output.vectors) ? run.output.vectors : [];
++ if (vectors.length !== docs.length + 1) return openaiError(res, 502, 'the embedding provider did not answer for every document', 'provider.output');
++ const results = docs.map((d, i) => ({ index: i, relevance_score: cosine(vectors[0], vectors[i + 1]),
++ ...(b.return_documents === true ? { document: d.original } : {}) }));
++ results.sort((a, z) => z.relevance_score - a.relevance_score || a.index - z.index);
++ const model = run.provenance?.model || requested || 'openvibe';
++ res.setHeader('X-OpenVibe-Run-Id', run.id);
++ return res.json({
++ id: `rerank-${run.id}`,
++ model,
++ results: results.slice(0, topN),
++ meta: { billed_units: { search_units: 1 }, method: 'embeddings_cosine', model, provider: run.provenance?.provider || null,
++ tokens: { input_tokens: Number(run.usage.tokens_in || 0) } },
++ usage: { prompt_tokens: Number(run.usage.tokens_in || 0), total_tokens: Number(run.usage.tokens_in || 0) },
++ x_openvibe: { run_id: run.id, method: 'embeddings_cosine',
++ note: 'no reranker adapter is configured; relevance_score is the cosine similarity of the query and document embeddings',
++ requested_model: requested, provider: run.provenance?.provider || null, cost_usd: costOf([run]), synthetic: Boolean(run.synthetic) },
++ });
++ }));
++
// 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)
ddiff --git a/server/api/models.js b/server/api/models.js
iindex 209e8d3..13a7831 100644
---- a/server/api/models.js
++++ b/server/api/models.js
@@@ -26,6 +26,9 @@ const epochOf = (iso) => {
const ms = Date.parse(iso || '');
return Number.isFinite(ms) ? Math.floor(ms / 1000) : LEGACY_CREATED;
};
++// A dynamic alias is a routing policy, not one artifact: which of these it can do depends on the model
++// the router picks, so every feature is unknown (null) until the answer says which model ran.
++const emptyFeatures = () => Object.fromEntries(modelCatalog.FEATURE_KEYS.map((k) => [k, null]));
const aliasEntry = (alias) => ({
...alias,
object: 'model',
@@@ -34,6 +37,7 @@ const aliasEntry = (alias) => ({
created: LEGACY_CREATED,
created_at: LEGACY_CREATED_AT,
display_name: alias.name,
++ features: emptyFeatures(),
});
const catalogOpenAi = (x) => ({
...x.entry,
@@@ -65,14 +69,15 @@ const anthropicAlias = (alias) => ({
object: undefined,
owned_by: undefined,
created: undefined,
++ features: emptyFeatures(),
});
/** The catalog list in one protocol. Flag off: today's single virtual model. */
async function list({ registry, config, protocol }) {
const enabled = Boolean(config.modelCatalog && config.modelCatalog.enabled);
if (!enabled) {
-- if (protocol === 'anthropic') return { data: [{ type: 'model', id: LEGACY_MODEL, display_name: 'OpenVibe AI', created_at: LEGACY_CREATED_AT }], has_more: false, first_id: LEGACY_MODEL, last_id: LEGACY_MODEL };
-- return { object: 'list', data: [{ id: LEGACY_MODEL, object: 'model', created: LEGACY_CREATED, owned_by: 'openvibe' }] };
++ if (protocol === 'anthropic') return { data: [{ type: 'model', id: LEGACY_MODEL, display_name: 'OpenVibe AI', created_at: LEGACY_CREATED_AT, features: emptyFeatures() }], has_more: false, first_id: LEGACY_MODEL, last_id: LEGACY_MODEL };
++ return { object: 'list', data: [{ id: LEGACY_MODEL, object: 'model', created: LEGACY_CREATED, owned_by: 'openvibe', features: emptyFeatures() }] };
}
const aliases = modelCatalog.ALIASES.map((a) => (protocol === 'anthropic' ? anthropicAlias(a) : aliasEntry(a)));
const models = (await modelCatalog.entries({ registry })).map((x) => (protocol === 'anthropic' ? catalogAnthropic(x) : catalogOpenAi(x)));
ddiff --git a/server/exchange/compat-openapi.js b/server/exchange/compat-openapi.js
iindex d179934..2de09f0 100644
---- a/server/exchange/compat-openapi.js
++++ b/server/exchange/compat-openapi.js
@@@ -15,12 +15,27 @@ const operation = (operationId, summary, props, required = []) => ({ operationId
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/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, and features: { tools, structured_output, vision, audio_in, audio_out, reasoning_effort, prompt_caching, logprobs, streaming, batch, realtime, files }. A feature is true or false only when the registry model and provider metadata say so; unknown is null. 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/audio/transcriptions': { post: { operationId: 'compatTranscriptions', summary: 'Speech-to-text through the router\'s transcribe route', tags: ['exchange'], security: [{ bearer: [] }],
++ parameters: [{ in: 'header', name: 'Idempotency-Key', schema: { type: 'string' } }],
++ requestBody: { required: true, content: { 'multipart/form-data': { schema: { type: 'object', required: ['file', 'model'], properties: {
++ file: { type: 'string', format: 'binary', description: 'Audio to transcribe: mp3, mp4, mpeg, mpga, m4a, wav, webm, ogg or flac, at most 3 MB.' },
++ model: { type: 'string', description: 'A transcription model id. The router\'s live.stt route picks the provider and model that run; the answer names it.' },
++ language: { type: 'string', description: 'ISO-639-1 language, or auto.' }, prompt: { type: 'string', maxLength: 1000, description: 'An initial prompt for the transcriber.' },
++ response_format: { enum: ['json', 'text', 'verbose_json'], default: 'json' }, temperature: { type: 'number', minimum: 0, maximum: 1 },
++ max_cost_usd: { type: 'number', minimum: 0, description: 'The caller liability cap for the Balance hold.' },
++ } } } } },
++ responses: { 200: { description: 'json: { text, usage, x_openvibe }; text: the plain text; verbose_json: { task, language, duration, text, segments, usage, x_openvibe }.' }, 400: { description: 'Invalid request or unsupported file type' }, 401: { description: 'Invalid or expired key' }, 413: { description: 'Audio too large' }, 429: { description: 'Key cap reached' }, 503: { description: 'No eligible provider' } } } },
++ '/v1/moderations': { post: operation('compatModerations', 'Moderation through a structured model with the fixed moderation.classify prompt (no provider moderation endpoint is configured)', { ...properties,
++ input: { anyOf: [{ type: 'string' }, { type: 'array', maxItems: 16, items: { type: 'string' } }] } }, ['input']) },
++ '/v1/rerank': { post: operation('compatRerank', 'Cohere/Jina-shaped rerank by embeddings cosine similarity (no reranker adapter is configured)', { ...properties,
++ query: { type: 'string', maxLength: 32000 }, documents: { type: 'array', minItems: 1, maxItems: 63, items: { anyOf: [{ type: 'string' }, { type: 'object', required: ['text'], properties: { text: { type: 'string' } } }] } },
++ top_n: { type: 'integer', minimum: 1 }, return_documents: { type: 'boolean' } }, ['query', 'documents']) },
'/v1/audio/speech': { post: { operationId: 'compatSpeech', summary: 'Speech synthesis in a Chat voice, metered per character', tags: ['exchange'], security: [{ bearer: [] }],
requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', additionalProperties: false, required: ['model', 'input', 'voice'],
properties: { model: { type: 'string', description: 'Any model id (the voice pins the offer).' }, input: { type: 'string', maxLength: 4096, description: 'The text to speak.' },
ddiff --git a/server/providers/model-catalog.js b/server/providers/model-catalog.js
iindex e746d8b..902840d 100644
---- a/server/providers/model-catalog.js
++++ b/server/providers/model-catalog.js
@@@ -115,6 +115,39 @@ function availabilityOf(row, provider) {
return 'available';
}
++/**
++ * The feature matrix of one model, from what the registry and its provider declare. A feature is true or
++ * false only when a registry field says so; an absent field is null (unknown), never a guess from the
++ * model's name or another model on the same provider. The twelve keys are the OpenAI-compatible
++ * `features` object of GET /v1/models (server/api/models.js).
++ */
++const FEATURE_KEYS = ['tools', 'structured_output', 'vision', 'audio_in', 'audio_out', 'reasoning_effort',
++ 'prompt_caching', 'logprobs', 'streaming', 'batch', 'realtime', 'files'];
++function featuresOf(row, provider) {
++ const support = (row && row.supports) || {};
++ const meta = (row && row.metadata) || {};
++ const caps = new Set((provider && provider.capabilities) || []);
++ const bool = (v) => (typeof v === 'boolean' ? v : null);
++ const type = (row && row.type) || 'chat';
++ const cost = (row && row.cost) || {};
++ return {
++ tools: bool(support.tools),
++ structured_output: bool(support.json),
++ vision: type === 'vision' ? true : bool(support.vision),
++ // The registry's type is the only fact about audio: an stt model takes audio in, a speech model
++ // gives it out. A chat model's audio is unknown unless its row says so.
++ audio_in: type === 'stt' ? true : bool(meta.audio_in),
++ audio_out: type === 'speech' ? true : bool(meta.audio_out),
++ reasoning_effort: bool(meta.reasoning_effort) ?? (meta.reasoning === true ? true : null),
++ prompt_caching: bool(meta.prompt_caching) ?? (cost.cached_per_mtok == null ? null : true),
++ logprobs: bool(meta.logprobs) ?? (caps.has('logprobs') ? true : null),
++ streaming: bool(support.streaming),
++ batch: bool(meta.batch) ?? (caps.has('batch') || (provider && provider.metadata && provider.metadata.tier_rates && provider.metadata.tier_rates.batch != null) ? true : null),
++ realtime: bool(meta.realtime) ?? (caps.has('realtime') ? true : null),
++ files: bool(meta.files) ?? (caps.has('files') ? true : null),
++ };
++}
++
/** Owner price override and provider markup are the final retail rate, while the stored row remains the accepted base price. */
function effectiveCost(row, provider) {
const override = row && row.metadata && row.metadata.owner_override && row.metadata.owner_override.cost;
@@@ -147,6 +180,7 @@ function entryOf(row, provider) {
},
supports: { ...row.supports },
metadata: meta,
++ features: featuresOf(row, provider),
created_at: row.created_at || null,
updated_at: row.updated_at || null,
availability: availabilityOf(row, provider),
@@@ -256,6 +290,6 @@ async function disclosureOf({ registry, run }) {
}
module.exports = {
-- ACCESS, MANAGED_ACCESS, ALIASES, EXACT_ID,
-- accessOf, managedAccessAllowed, artifactOf, entryOf, entries, exactOffers, select, facts, disclosureOf, retailRate, kindOf,
++ ACCESS, MANAGED_ACCESS, ALIASES, EXACT_ID, FEATURE_KEYS,
++ accessOf, managedAccessAllowed, artifactOf, entryOf, entries, exactOffers, select, facts, disclosureOf, retailRate, kindOf, featuresOf,
};
ddiff --git a/server/providers/openai.js b/server/providers/openai.js
iindex 55a6a0d..62dd653 100644
---- a/server/providers/openai.js
++++ b/server/providers/openai.js
@@@ -146,6 +146,8 @@ function createOpenAiProvider(record, { apiKey = '', fetchImpl = globalThis.fetc
form.append('model', req.model);
form.append('response_format', 'verbose_json');
if (req.language && req.language !== 'auto') form.append('language', req.language);
++ if (req.prompt) form.append('prompt', String(req.prompt).slice(0, 1000));
++ if (req.temperature != null) form.append('temperature', String(req.temperature));
let res;
try {
res = await fetchImpl(`${baseUrl}/audio/transcriptions`, { method: 'POST', headers: headers(), body: form, signal: deadline(req.signal, req.timeoutMs) });
ddiff --git a/server/providers/whisper.js b/server/providers/whisper.js
iindex 9866be1..c643fde 100644
---- a/server/providers/whisper.js
++++ b/server/providers/whisper.js
@@@ -210,7 +210,7 @@ function createWhisperProvider(record, { config }) {
});
}
-- function decode(wavPath, { language = 'en', live = false, lowPower = false, offsetSec = 0, words = false, signal, timeoutMs }) {
++ function decode(wavPath, { language = 'en', live = false, lowPower = false, offsetSec = 0, words = false, prompt = null, temperature = null, signal, timeoutMs }) {
return new Promise((resolve, reject) => {
const lang = language && language !== 'en' && multi() ? String(language) : 'en';
const model = lang !== 'en' ? multi() : (live && exists(MODEL_LIVE) ? MODEL_LIVE : MODEL);
@@@ -221,6 +221,9 @@ function createWhisperProvider(record, { config }) {
// -ojf adds every token with its offsets (word timing, C12); only when asked, since it is a far larger file.
const args = ['-m', model, '-f', wavPath, words ? '-ojf' : '-oj', '-of', outBase, '-t', String(threads), '-l', lang];
if (w.beam > 1) args.push('-bs', String(w.beam));
++ // The OpenAI-compatible transcription fields: an initial prompt and the decoding temperature.
++ if (prompt) args.push('--prompt', String(prompt).slice(0, 1000));
++ if (temperature != null) args.push('--temperature', String(temperature));
const vm = vadModel();
if (vm) args.push('--vad', '-vm', vm);
let ch;
@@@ -304,7 +307,8 @@ function createWhisperProvider(record, { config }) {
try {
await acquire(lane);
try {
-- const opts = { language: req.language, live: req.live, lowPower: req.lowPower === true, offsetSec: req.offsetSec || 0, words: req.words === true, signal: req.signal, timeoutMs };
++ const opts = { language: req.language, live: req.live, lowPower: req.lowPower === true, offsetSec: req.offsetSec || 0, words: req.words === true,
++ prompt: req.prompt || null, temperature: req.temperature != null ? req.temperature : null, signal: req.signal, timeoutMs };
let r = null;
if (engineFor(req.language) === 'parakeet') {
try { r = await decodeParakeet(wav, opts); }
ddiff --git a/server/workflows/core.js b/server/workflows/core.js
iindex c2bc0a5..75d9ad7 100644
---- a/server/workflows/core.js
++++ b/server/workflows/core.js
@@@ -5,6 +5,7 @@
* and attribution as any product workflow — the convenience routes are thin, not a second engine.
*/
const { PASSTHROUGH_OUTPUT } = require('./live');
++const { CITES } = require('./common-schemas');
const STR = (n) => ({ type: 'string', maxLength: n });
const OBJ = (props, required = []) => ({ type: 'object', additionalProperties: false, required, properties: props });
@@@ -118,6 +119,43 @@ const workflows = [
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',
},
++ {
++ // POST /v1/audio/transcriptions (server/api/compat-extra.js): the caller's audio (multipart, a base64
++ // data: URL here) spoken to text by the router's transcribe route. The prompt and temperature are the
++ // caller's, passed to the provider that supports them; the audio is never kept (retain_input false).
++ key: 'ai.transcribe', name: 'Speech-to-text (OpenAI-compatible)', namespace: 'ai',
++ input_schema: OBJ({
++ audio: OBJ({ data_url: STR(6 * 1024 * 1024), mime: STR(80) }, ['data_url']),
++ language: STR(8),
++ prompt: STR(1000),
++ temperature: { type: ['number', 'null'], minimum: 0, maximum: 1 },
++ max_cost_usd: COMMON.max_cost_usd,
++ }, ['audio']),
++ output_schema: OBJ({
++ text: STR(400000),
++ language: STR(8),
++ segments: { type: 'array', maxItems: 10000, items: OBJ({ start: { type: 'number' }, end: { type: 'number' }, text: STR(10000) }, ['start', 'end', 'text']) },
++ }, ['text']),
++ steps: [{ kind: 'transcribe', route: 'live.stt' }],
++ cache_mode: 'none', metadata: { seed: true, retain_input: false },
++ },
++ {
++ // POST /v1/moderations (server/api/compat-extra.js): the moderation.classify template's fixed
++ // classification prompt, under its own key so the caller's text is not kept (retain_input false).
++ key: 'ai.moderate', name: 'Moderation (OpenAI-compatible)', namespace: 'ai',
++ input_schema: OBJ({ text: STR(8000), context: STR(1000) }, ['text']),
++ output_schema: OBJ({
++ labels: { type: 'array', maxItems: 8, items: { enum: ['threat', 'minors', 'doxxing', 'harassment', 'spam', 'sexual', 'self_harm', 'none'] } },
++ action: { enum: ['allow', 'review', 'block'] },
++ severity: { enum: ['none', 'low', 'medium', 'high'] },
++ rationale: STR(1000),
++ spans: { type: 'array', maxItems: 20, items: OBJ({ quote: STR(500), label: STR(40) }, ['quote', 'label']) },
++ citations: CITES,
++ }, ['labels', 'action', 'severity']),
++ default_route: 'moderation.classify',
++ steps: [{ kind: 'llm', template: 'moderation.classify', operation: 'classify', output: 'json', prepare: 'sources', cite: true, params: { max_tokens: 2400, temperature: 0.3 } }],
++ cache_mode: 'none', metadata: { seed: true, internal: true, retain_input: false },
++ },
{
// The OpenAI-compatible endpoint (POST /v1/chat/completions, server/api/openai.js): the caller's Chat Completions
// request, tools included, passed to an upstream that speaks it; the run keeps its usage, cost and explain but not
ddiff --git a/server/workflows/engine.js b/server/workflows/engine.js
iindex aa6a188..d4181da 100644
---- a/server/workflows/engine.js
++++ b/server/workflows/engine.js
@@@ -506,7 +506,10 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
try {
const startSec = Number(input.start_sec) || 0;
const offsetSec = input.offset_sec != null ? Number(input.offset_sec) : startSec;
-- const exec = await pool.execute(route, 'transcribe', { filePath: file, language: input.language || 'en', seconds: input.seconds || 0, startSec, offsetSec, lowPower: input.low_power === true, words: input.words === true, timeoutMs: 3600000 }, { ...ctx, routeKey: route.key, routeVersion: route.version, promptHash: null });
++ const exec = await pool.execute(route, 'transcribe', { filePath: file, language: input.language || 'en', seconds: input.seconds || 0, startSec, offsetSec, lowPower: input.low_power === true, words: input.words === true,
++ ...(typeof input.prompt === 'string' && input.prompt ? { prompt: input.prompt } : {}),
++ ...(input.temperature != null ? { temperature: Number(input.temperature) } : {}),
++ timeoutMs: 3600000 }, { ...ctx, routeKey: route.key, routeVersion: route.version, promptHash: null });
const r = exec.result;
return { output: { text: r.text || '', language: r.language || input.language || 'en', segments: r.segments || [] }, exec, route };
} finally {
ddiff --git a/test/openai-compat.test.js b/test/openai-compat.test.js
iindex ab6da8a..67dc457 100644
---- a/test/openai-compat.test.js
++++ b/test/openai-compat.test.js
@@@ -16,6 +16,17 @@ const app = token('app', ['ai.app.run'], { sub: `app:app_${PRJ.slice(4)}`, actor
const WEATHER = { type: 'function', function: { name: 'get_weather', description: 'Weather for a city', parameters: { type: 'object', required: ['city'], properties: { city: { type: 'string' } } } } };
let h;
const post = (body, tok = service) => request(h.base, 'POST', '/v1/chat/completions', { tok, body });
++const postJson = (path, body, tok = service) => request(h.base, 'POST', path, { tok, body });
++const transcribe = async (fields = {}, file = { name: 'sample.wav', type: 'audio/wav', data: Buffer.from('RIFF....WAVEfmt ') }, tok = service) => {
++ const form = new FormData();
++ for (const [key, value] of Object.entries(fields)) form.append(key, String(value));
++ form.append('file', new Blob([file.data], { type: file.type }), file.name);
++ const res = await fetch(`${h.base}/v1/audio/transcriptions`, { method: 'POST', headers: { Authorization: `Bearer ${tok}` }, body: form });
++ const text = await res.text();
++ let body = null;
++ try { body = text ? JSON.parse(text) : null; } catch { body = null; }
++ return { status: res.status, body, text, headers: res.headers };
++};
// The free allowance (a service acting for a person) would be gated by AI_FREE_PROVIDERS; in tests the
// deterministic stub is the network's stand-in, so it is the named free-tier source.
@@@ -222,5 +233,88 @@ t.test('production\'s shape: an OpenAI-format provider declaring chat (not compl
}
});
++t.test('POST /v1/audio/transcriptions: multipart audio through the router, in OpenAI\'s shape', async () => {
++ const r = await transcribe({ model: 'whisper-1', language: 'en', prompt: 'OpenVibe', temperature: '0', response_format: 'json' });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(typeof r.body.text, 'string');
++ assert.ok(r.body.text.length > 0);
++ assert.ok(r.headers.get('x-openvibe-run-id'), 'the run is named in the header');
++ assert.strictEqual(r.body.x_openvibe.run_id, r.headers.get('x-openvibe-run-id'));
++ assert.strictEqual(r.body.x_openvibe.requested_model, 'whisper-1');
++ assert.strictEqual(r.body.x_openvibe.response_format, 'json');
++ const run = await request(h.base, 'GET', `/api/v1/runs/${r.body.x_openvibe.run_id}`, { tok: service });
++ assert.strictEqual(run.body.run.workflow.key, 'ai.transcribe');
++ assert.ok(run.body.run.usage && typeof run.body.run.usage.cost_usd === 'number', 'the run path recorded its usage');
++ assert.ok(!JSON.stringify(run.body.run.input || {}).includes('data:audio'), 'the audio is not kept');
++});
++
++t.test('POST /v1/audio/transcriptions: verbose_json segments and the plain text format', async () => {
++ const v = await transcribe({ model: 'whisper-1', language: 'en', response_format: 'verbose_json' });
++ assert.strictEqual(v.status, 200, v.text);
++ assert.deepStrictEqual([v.body.task, v.body.language], ['transcribe', 'en']);
++ assert.ok(Array.isArray(v.body.segments) && v.body.segments.length > 0);
++ assert.strictEqual(typeof v.body.segments[0].start, 'number');
++ assert.strictEqual(typeof v.body.segments[0].end, 'number');
++ assert.strictEqual(typeof v.body.segments[0].text, 'string');
++ assert.ok(v.body.duration > 0);
++
++ const t = await transcribe({ model: 'whisper-1', language: 'en', response_format: 'text' });
++ assert.strictEqual(t.status, 200, t.text);
++ assert.match(t.headers.get('content-type'), /^text\/plain/);
++ assert.ok(t.text.trim().length > 0);
++ assert.ok(t.headers.get('x-openvibe-run-id'));
++});
++
++t.test('POST /v1/audio/transcriptions: a file that is not audio is refused, and so is an unknown format', async () => {
++ const bad = await transcribe({ model: 'whisper-1' }, { name: 'notes.txt', type: 'text/plain', data: Buffer.from('not audio') });
++ assert.deepStrictEqual([bad.status, bad.body.error.type, bad.body.error.code], [400, 'invalid_request_error', 'input.invalid'], bad.text);
++ const format = await transcribe({ model: 'whisper-1', response_format: 'srt' });
++ assert.deepStrictEqual([format.status, format.body.error.code], [400, 'input.invalid']);
++ const model = await transcribe({});
++ assert.deepStrictEqual([model.status, model.body.error.code], [400, 'input.invalid'], 'model is required as OpenAI requires it');
++});
++
++t.test('POST /v1/moderations: a structured classification in OpenAI\'s shape, labelled as one', async () => {
++ const r = await postJson('/v1/moderations', { model: 'omni-moderation-latest', input: ['a friendly hello', 'I will find where you live'] });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.body.results.length, 2);
++ const categories = ['hate', 'hate/threatening', 'harassment', 'harassment/threatening', 'self-harm', 'self-harm/intent', 'self-harm/instructions', 'sexual', 'sexual/minors', 'violence', 'violence/graphic'];
++ for (const result of r.body.results) {
++ assert.strictEqual(typeof result.flagged, 'boolean');
++ for (const key of categories) {
++ assert.strictEqual(typeof result.categories[key], 'boolean', key);
++ assert.strictEqual(typeof result.category_scores[key], 'number', key);
++ }
++ }
++ assert.deepStrictEqual([r.body.x_openvibe.method, r.body.x_openvibe.route], ['structured_model', 'moderation.classify']);
++ assert.match(r.body.x_openvibe.note, /no provider moderation endpoint/);
++ assert.strictEqual(r.body.x_openvibe.run_ids.length, 2);
++ assert.strictEqual(r.body.x_openvibe.classification.length, 2);
++ const first = await request(h.base, 'GET', `/api/v1/runs/${r.body.x_openvibe.run_ids[0]}`, { tok: service });
++ assert.deepStrictEqual([first.body.run.workflow.key, typeof first.body.run.usage.cost_usd], ['ai.moderate', 'number'], 'the run path recorded its usage');
++});
++
++t.test('POST /v1/rerank: Cohere/Jina shape from embeddings cosine similarity, labelled', async () => {
++ const r = await postJson('/v1/rerank', { model: 'rerank-v3.5', query: 'a cat', documents: ['a cat on a mat', 'a dog barking', { text: 'a bird in a tree' }], top_n: 2 });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.body.results.length, 2);
++ for (const hit of r.body.results) {
++ assert.ok(Number.isInteger(hit.index) && hit.index >= 0 && hit.index < 3);
++ assert.strictEqual(typeof hit.relevance_score, 'number');
++ }
++ assert.ok(r.body.results[0].relevance_score >= r.body.results[1].relevance_score, 'sorted by score');
++ assert.deepStrictEqual([r.body.meta.method, r.body.x_openvibe.method], ['embeddings_cosine', 'embeddings_cosine']);
++ assert.match(r.body.x_openvibe.note, /cosine similarity/);
++ assert.strictEqual(r.body.x_openvibe.requested_model, 'rerank-v3.5');
++ const run = await request(h.base, 'GET', `/api/v1/runs/${r.body.x_openvibe.run_id}`, { tok: service });
++ assert.deepStrictEqual([run.body.run.workflow.key, typeof run.body.run.usage.tokens_in], ['ai.embed', 'number'], 'the run path recorded its usage');
++
++ const kept = await postJson('/v1/rerank', { query: 'x', documents: [{ text: 'one' }, { text: 'two' }], return_documents: true });
++ assert.strictEqual(kept.status, 200, kept.text);
++ assert.ok(kept.body.results.every((hit) => hit.document && typeof hit.document.text === 'string'), 'documents return when asked');
++ const bad = await postJson('/v1/rerank', { query: 'x', documents: ['one'], top_n: 2 });
++ assert.deepStrictEqual([bad.status, bad.body.error.code], [400, 'input.invalid']);
++});
++
t.test('close', async () => { await h.stop(); });
t.run();
ddiff --git a/test/v1-models.test.js b/test/v1-models.test.js
iindex f2c26d3..864ed0b 100644
---- a/test/v1-models.test.js
++++ b/test/v1-models.test.js
@@@ -166,6 +166,23 @@ t.test('both protocols list the real models and the dynamic aliases', async () =
assert.deepStrictEqual([one.status, one.body.id, one.body.display_name], [200, ALPHA_ID, 'alpha-1']);
});
++t.test('every model lists the twelve features from the registry; unknown is null, never guessed', async () => {
++ const r = await request(h.base, 'GET', '/v1/models', { tok: service });
++ assert.strictEqual(r.status, 200, r.text);
++ const keys = ['audio_in', 'audio_out', 'batch', 'files', 'logprobs', 'prompt_caching', 'realtime', 'reasoning_effort', 'streaming', 'structured_output', 'tools', 'vision'];
++ for (const m of r.body.data) {
++ assert.deepStrictEqual(Object.keys(m.features || {}).sort(), [...keys].sort(), `${m.id} carries every feature`);
++ for (const [key, value] of Object.entries(m.features)) assert.ok(value === null || typeof value === 'boolean', `${m.id}.${key}`);
++ }
++ const alpha = r.body.data.find((m) => m.id === ALPHA_ID);
++ assert.deepStrictEqual([alpha.features.tools, alpha.features.structured_output, alpha.features.vision, alpha.features.streaming, alpha.features.prompt_caching],
++ [true, true, false, true, true], 'the registry support flags and the cache price');
++ assert.deepStrictEqual([alpha.features.audio_in, alpha.features.audio_out, alpha.features.reasoning_effort, alpha.features.logprobs, alpha.features.batch, alpha.features.realtime, alpha.features.files],
++ [null, null, null, null, null, null, null], 'nothing said is null');
++ const alias = r.body.data.find((m) => m.id === 'openvibe/auto');
++ assert.ok(Object.values(alias.features).every((value) => value === null), 'a dynamic alias is not one model, so every feature is unknown');
++});
++
t.test('an exact id runs only that artifact; an alias is dynamic and says which model answered', async () => {
alpha.calls.length = 0; beta.calls.length = 0; gamma.calls.length = 0;
const exact = await chat(ALPHA_ID);
@@@ -270,6 +287,7 @@ t.test('flag off: the legacy single model and no disclosure, exactly as before',
try {
const list = await request(off.base, 'GET', '/v1/models', { tok: service });
assert.deepStrictEqual(list.body.data.map((m) => m.id), ['openvibe']);
++ assert.ok(list.body.data[0].features && Object.values(list.body.data[0].features).every((value) => value === null), 'the legacy virtual model has the feature matrix, all unknown');
const anthropic = await request(off.base, 'GET', '/v1/models', { tok: service, headers: { 'anthropic-version': '2023-06-01' } });
assert.deepStrictEqual(anthropic.body.data.map((m) => m.id), ['openvibe']);
const res = await request(off.base, 'POST', '/v1/chat/completions', { tok: service, body: { model: 'not-a-catalog-id', messages: [{ role: 'user', content: 'hi' }] } });
ddiff --git a/test/workflows.test.js b/test/workflows.test.js
iindex 9265b30..501601a 100644
---- a/test/workflows.test.js
++++ b/test/workflows.test.js
@@@ -61,6 +61,8 @@ const EXAMPLES = {
'codes.generate_docs': { project: 'demo', sources: [{ source_type: 'code.file', title: 'index.js', content: 'module.exports = 1;' }] },
'games.generate_lore': { world: 'Hobo Quest', prompt: 'The origin of the tin-can knights', sources: SRC(1) },
'moderation.classify': { text: 'you absolute potato, gg', context: 'arena chat' },
++ 'ai.moderate': { text: 'you absolute potato, gg', context: 'arena chat' },
++ 'ai.transcribe': { audio: { data_url: 'data:audio/ogg;base64,T2dnUw==', mime: 'audio/ogg' }, language: 'en' },
'rent.screen_listing': { listing: { title: 'Bright room near the river', kind: 'room', price: 650, currency: 'EUR', period: 'month', city: 'Porto', country: 'PT', description: 'I am abroad for work; send the deposit by wire and I will mail the keys.' } },
'food.suggest_recipes': { pantry: ['rice', 'onions', 'canned tomatoes'], people: 2, sources: [{ source_type: 'food.food', source_id: 'lentils', title: 'Lentils, dried', content: '{"id":"lentils","unit":"kg"}' }] },
'chat.vibe.listen': { audio: { data_url: 'data:audio/ogg;base64,T2dnUw==', mime: 'audio/ogg' }, language: 'en' },