openvibers/openvibe.ai Commits
AI: an OpenAI-compatible endpoint — POST /v1/chat/completions with tools, metered as runs of ai.complete (#70)
Changes
@@@ -139,6 +139,28 @@ no `ns` runs nothing. Every caller's Network grant names its namespaces (Live: ``network.site_copy`, `media.analyze`). The fallback for service tokens without `ns` (`AI_NS_FALLBACK`)and its rollback lever (`AI_NS_REQUIRED`) were retired on 2026-09-28 (shims C-22 and C-23).++## OpenAI-compatible endpoint++++`POST /v1/chat/completions` and `GET /v1/models` (`server/api/openai.js`), so a client built for OpenAI's API (the SDKs,++coding agents, Codes' harnesses) points its base URL at `https://ai.openvibe.services/v1`.++++- **Tokens:** the same as the direct operations: an app's Network token (`ai.app.run`; its project pays and owns the++ run) or a first-party service's (`ai.run.create`). Network tokens last five minutes: a client refreshes it with the++ client-credentials grant (the SDKs take a token function).++- **Request:** Chat Completions, as written: `messages` (system, developer, user, assistant with `tool_calls`, and `tool`++ results), `tools`, `tool_choice`, `parallel_tool_calls`, `response_format`, `stop`, `seed`, `temperature`, `top_p`,++ `max_tokens` or `max_completion_tokens`, `stream`. `n` must be 1. `model` may be anything: AI's router picks the++ upstream for `default.chat` by price and health, and a request with `tools` goes only to a provider that declares++ `tools`. The answer's `model` names the model that ran.++- **Answer:** `chat.completion` with the upstream's `choices` and `usage`, plus `x_openvibe` (`run_id`, `provider`,++ `fallback_used`, `cost_usd`) and the `X-OpenVibe-Run-Id` header. `stream: true` answers server-sent events in the chunk++ format, **emulated**: the whole answer arrives as one delta, then the finish reason (and `usage` with++ `stream_options.include_usage`), then `[DONE]`.++- **Each request is a run** of workflow `ai.complete` (step kind `complete`): the run's quota, free allowance, cost,++ audit and explain apply, and the prompt is not kept. Errors are OpenAI's shape (`{ error: { message, type, code } }`).++- **Upstreams:** OpenAI-format providers (`kind: openai`) pass the request through (`complete()`; a reasoning model++ takes `max_completion_tokens` and its default sampling). Anthropic is not used for it yet.++## Developer appsRoadmap Wave 20, ADR-014. A developer app gets a token from OpenVibe.Network (`POST /oauth/token`,nnew file mode 100644@@@ -0,0 +1,120 @@++'use strict';++/**++ * The OpenAI-compatible endpoint: POST /v1/chat/completions on ai.openvibe.services, so a client built for OpenAI's API++ * (the SDKs, coding agents, Codes' harnesses) points its base URL at https://ai.openvibe.services/v1 and works.++ *++ * Authorization: Bearer <a Network token> an app's token (ai.app.run, its project pays) or a first-party service's++ * (ai.run.create), exactly as POST /api/v1/chat takes them++ * body OpenAI Chat Completions: messages (with tool calls and tool results),++ * tools, tool_choice, parallel_tool_calls, response_format, stop, seed,++ * temperature, top_p, max_tokens / max_completion_tokens, stream++ * model "openvibe" (or anything else): AI's router picks the upstream for++ * route default.chat by price, health and what the request needs++ * (tools need a provider that declares them); the answer names the++ * model that ran++ *++ * Each request is a run of workflow ai.complete, so it has the run's quota, free allowance, cost, audit and explain++ * (x_openvibe in the answer); the prompt is not kept. stream: true answers server-sent events in the Chat Completions++ * chunk format, emulated: the whole answer arrives as one delta, then finish_reason, then [DONE]. Errors are OpenAI's++ * shape ({ error: { message, type, code } }) with the HTTP status the run's problem had.++ */++const express = require('express');++const { CAPS } = require('../auth');++const { appAttribution } = require('../apps');++++const FIELDS = ['messages', 'tools', 'tool_choice', 'parallel_tool_calls', 'response_format', 'stop', 'seed', 'top_p', 'presence_penalty', 'frequency_penalty', 'temperature'];++++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 >= 500 ? 'api_error' : 'invalid_request_error'), code: code || null, param: null } });++}++++function openaiRouter({ runs, auth, config, log = console }) {++ const r = express.Router();++ const allow = auth.appOrCap(CAPS.appRun, CAPS.runCreate);++++ // A problem from the auth layer arrives as problem+json; a client built for OpenAI reads { error }: convert it as++ // it is written, whichever way the layer writes it.++ const typeFor = (status) => (status === 401 || status === 403 ? 'authentication_error' : status === 429 ? 'rate_limit_error' : status >= 500 ? 'api_error' : 'invalid_request_error');++ const asOpenAiErrors = (req, res, next) => {++ const end = res.end.bind(res);++ res.end = (chunk, ...rest) => {++ const ct = String(res.getHeader('Content-Type') || '');++ if (res.statusCode >= 400 && /problem\+json/.test(ct) && chunk) {++ let p = null;++ try { p = JSON.parse(Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk)); } catch { p = null; }++ if (p && (p.error == null || typeof p.error !== 'object')) {++ const body = JSON.stringify({ error: { message: String(p.detail || p.title || p.code || 'error'), type: typeFor(res.statusCode), code: p.code || null, param: null } });++ res.setHeader('Content-Type', 'application/json; charset=utf-8');++ res.setHeader('Content-Length', Buffer.byteLength(body));++ return end(body);++ }++ }++ return end(chunk, ...rest);++ };++ next();++ };++++ r.post('/v1/chat/completions', asOpenAiErrors, allow, async (req, res) => {++ const b = req.body && typeof req.body === 'object' ? req.body : {};++ 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 = {};++ for (const k of FIELDS) if (b[k] !== undefined && b[k] !== null) input[k] = b[k];++ 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));++ let body = { workflow: 'ai.complete', input };++ if (req.principal.kind === 'app') body = { ...body, attribution: appAttribution(req.principal) };++ let run;++ try {++ const created = await runs.create(body, req.principal, { trace: req.ov && req.ov.traceId, requestId: req.ov && req.ov.requestId });++ run = await runs.explained(await runs.wait(created, Math.max(config.runs.maxWaitMs, 120000)));++ } catch (err) {++ const status = (err && err.status) || 500;++ if (status === 429 && err.retryAfter) res.setHeader('Retry-After', String(err.retryAfter));++ 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 (run.status === 'failed' || run.status === 'cancelled') {++ const code = (run.error && run.error.code) || 'run.failed';++ const status = /^(input|validation)\./.test(code) ? 400 : /quota|allowance|budget/.test(code) ? 429 : 502;++ return openaiError(res, status, (run.error && run.error.detail) || code, code);++ }++ if (!['succeeded', 'cached'].includes(run.status) || !run.output || !Array.isArray(run.output.choices)) {++ return openaiError(res, 504, `the completion is still running; GET /api/v1/runs/${run.id}`, 'run.timeout');++ }++ const created = Math.floor(Date.parse(run.created_at || new Date().toISOString()) / 1000) || Math.floor(Date.now() / 1000);++ 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) };++ 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, synthetic: Boolean(run.synthetic) };++ res.setHeader('X-OpenVibe-Run-Id', run.id);++ if (b.stream === true) {++ res.status(200);++ res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');++ res.setHeader('Cache-Control', 'no-store');++ const send = (o) => res.write(`data: ${JSON.stringify(o)}\n\n`);++ const base = { id, object: 'chat.completion.chunk', created, model, system_fingerprint: run.output.system_fingerprint || null };++ for (const c of run.output.choices) {++ const m = c.message || {};++ const delta = { role: 'assistant', content: m.content == null ? null : m.content };++ if (Array.isArray(m.tool_calls) && m.tool_calls.length) delta.tool_calls = m.tool_calls.map((t, i) => ({ index: i, ...t }));++ 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 });++ send({ ...base, choices: [], x_openvibe: x });++ res.write('data: [DONE]\n\n');++ return res.end();++ }++ return res.status(200).json({ id, object: 'chat.completion', created, model, choices: run.output.choices, usage, system_fingerprint: run.output.system_fingerprint || null, x_openvibe: x });++ });++++ // The model list a client asks for first: one router model, the upstreams are AI's choice.++ r.get('/v1/models', asOpenAiErrors, allow, (req, res) => {++ res.json({ object: 'list', data: [{ id: 'openvibe', object: 'model', created: 1760486400, owned_by: 'openvibe' }] });++ });++++ return r;++}++++module.exports = { openaiRouter };@@@ -70,7 +70,7 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys// /shared/* URLs from this repo's pinned openvibe-shared; each answer sets its own asset cache policy.app.use('/shared', ovServe.handler());// Inline images arrive as data URLs; the per-run input cap is enforced again in runs.create.-- app.use('/api', express.json({ limit: Math.ceil(config.runs.maxInputBytes * 1.5), type: ['application/json', 'application/*+json'] }));++ app.use(['/api', '/v1'], express.json({ limit: Math.ceil(config.runs.maxInputBytes * 1.5), type: ['application/json', 'application/*+json'] }));app.get('/api/health', (_req, res) => {res.json({ status: 'ok', service: 'openvibe-ai', version: pkg.version });@@@ -86,6 +86,8 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys// Operator actions shared by the admin API and the operator console (one implementation, one audit row).const ops = createOps({ db, registry, pool, quotas, cache, runs });app.use(runsRouter({ runs, registry, auth, config, log }));++ // The OpenAI-compatible endpoint (POST /v1/chat/completions, GET /v1/models): runs of ai.complete.++ app.use(require('./api/openai').openaiRouter({ runs, auth, config, log }));app.use(require('./api/attribution-quotas').attributionQuotasRouter({ db, quotas, auth, clock, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));if (credentials) app.use(require('./credentials').credentialsRouter({ credentials, auth, registry, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));app.use(adminRouter({ db, registry, pool, quotas, cache, runs, auth, ops, log }));@@@ -24,7 +24,7 @@ const APP_SUB_RE = new RegExp(`^app:app_(${ULID})$`);const PROJECT_RE = new RegExp(`^prj_(${ULID})$`);const ENVS = ['sandbox', 'production'];/** The operations ai.app.run grants by route, as workflow keys (POST /api/v1/<op> is a run of ai.<op>). */--const DIRECT_OPS = Object.freeze(['chat', 'generate', 'summarize', 'classify', 'extract', 'embed', 'image'].map((op) => `ai.${op}`));++const DIRECT_OPS = Object.freeze(['chat', 'complete', 'generate', 'summarize', 'classify', 'extract', 'embed', 'image'].map((op) => `ai.${op}`));/** 'prj_01JAB…' -> 'p01jab…' (null for anything that is not a project id). */function projectKey(projectId) {@@@ -154,6 +154,7 @@ function llmsTxt({ siteUrl }) {'','- Token: POST https://openvibe.network/oauth/token with grant_type=client_credentials, your app\'s client id and secret, audience=openvibe.ai (five minutes).',`- Operations: POST ${siteUrl}/api/v1/chat, /generate, /summarize, /classify, /extract, /embed; GET ${siteUrl}/api/v1/runs/:id for your own runs.`,++ `- OpenAI-compatible: POST ${siteUrl}/v1/chat/completions (Chat Completions with tools; stream is emulated) and GET ${siteUrl}/v1/models, with the same token. Point an OpenAI client's base URL at ${siteUrl}/v1.`,'- Every run response carries explain: the objective, the model selected and every candidate with its estimated cost, latency and excluded reason.','- Sandbox apps run on free and local capacity only; runs, usage and the free allowance are per project; a person\'s own provider key is never used for an app.','',@@@ -398,6 +398,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noconst features = [operation];if (req.image) features.push('vision');if (req.json) features.push('json');++ if (req.openai && Array.isArray(req.openai.tools) && req.openai.tools.length) features.push('tools');const tried = [];let unreadable = null; // a candidate could not read the caller's media (whisper's ffmpeg): no other will eitherconst { order: list, explain } = await candidates(route, features, operation, ctx);@@@ -124,11 +124,44 @@ function createOpenAiProvider(record, { apiKey = '', fetchImpl = globalThis.fetcreturn { images: list, model: j.model || model, usage: { input: 0, output: list.length, cached: 0 } };}++ /**++ * complete (the OpenAI-compatible endpoint, /v1/chat/completions): the caller's Chat Completions request as they++ * wrote it (messages with tool calls and tool results, tools, tool_choice, response_format, stop, seed), sent to++ * this upstream with the routed model. A reasoning model takes max_completion_tokens and its default temperature.++ * → { completion, text, model, usage }++ */++ async function complete(req) {++ const o = req.openai || {};++ const body = { model: req.model, messages: o.messages };++ for (const k of ['tools', 'tool_choice', 'parallel_tool_calls', 'response_format', 'stop', 'seed', 'top_p', 'presence_penalty', 'frequency_penalty']) {++ if (o[k] !== undefined && o[k] !== null) body[k] = o[k];++ }++ if (isReasoningModel(req.model)) {++ if (req.maxTokens) body.max_completion_tokens = req.maxTokens;++ for (const k of ['top_p', 'presence_penalty', 'frequency_penalty']) delete body[k];++ } else {++ if (req.maxTokens) body.max_tokens = req.maxTokens;++ if (req.temperature != null) body.temperature = req.temperature;++ }++ const j = await postJson(`${baseUrl}/chat/completions`, headers(), body, { signal: req.signal, timeoutMs: req.timeoutMs, fetchImpl });++ if (!Array.isArray(j.choices) || !j.choices.length) throw new ProviderError('completion has no choices', { code: 'provider.output' });++ const msg = j.choices[0].message || {};++ const u = j.usage || {};++ return {++ completion: { choices: j.choices, system_fingerprint: j.system_fingerprint || null },++ text: typeof msg.content === 'string' ? msg.content : '',++ model: j.model || req.model,++ usage: { input: u.prompt_tokens || 0, output: u.completion_tokens || 0, cached: (u.prompt_tokens_details && u.prompt_tokens_details.cached_tokens) || 0 },++ };++ }++const adapter = {key: record.key,kind: 'openai',-- supports: (feature) => caps.has(feature),++ // complete is the chat endpoint as the caller wrote it: any upstream that chats completes; tools are declared.++ supports: (feature) => caps.has(feature) || (feature === 'complete' && caps.has('chat')),chat,++ complete,embed,transcribe,image: images,@@@ -150,11 +150,29 @@ function createStubProvider(record = { key: 'stub', metadata: {} }, { stats = nureturn { text, json, model, synthetic: true, usage: { input: inTok, output: approxTokens(text), cached: 0, estimated: true } };}++ /** complete: an OpenAI-shaped answer. With tools and no tool result yet it calls the first tool; else it answers. */++ async function complete(req) {++ const o = req.openai || {};++ const msgs = Array.isArray(o.messages) ? o.messages : [];++ const last = msgs[msgs.length - 1] || {};++ const rnd = seeded(`complete:${JSON.stringify(msgs).slice(-2000)}`);++ const inTok = msgs.reduce((n, m) => n + approxTokens(typeof m.content === 'string' ? m.content : JSON.stringify(m.content || '')), 0);++ const tool = Array.isArray(o.tools) && o.tools.length && last.role !== 'tool' && o.tool_choice !== 'none' ? o.tools[0] : null;++ if (tool && tool.function) {++ const args = tool.function.parameters ? synthesize(tool.function.parameters, rnd, 0, 'arguments') : {};++ const call = { id: `call_${crypto.createHash('sha256').update(JSON.stringify(msgs)).digest('hex').slice(0, 24)}`, type: 'function', function: { name: tool.function.name, arguments: JSON.stringify(args) } };++ return { completion: { choices: [{ index: 0, message: { role: 'assistant', content: null, tool_calls: [call] }, finish_reason: 'tool_calls' }] }, text: '', model: req.model || 'stub-1', synthetic: true, usage: { input: inTok, output: 20, cached: 0, estimated: true } };++ }++ const text = last.role === 'tool' ? `(synthetic) the tool answered: ${String(last.content || '').slice(0, 80)}` : `(synthetic) ${phrase(rnd, 8)}`;++ return { completion: { choices: [{ index: 0, message: { role: 'assistant', content: text }, finish_reason: 'stop' }] }, text, model: req.model || 'stub-1', synthetic: true, usage: { input: inTok, output: approxTokens(text), cached: 0, estimated: true } };++ }++const adapter = {key: record.key,kind: 'stub',synthetic: true,supports: () => true,++ complete,transcribe: async (req) => await run('transcribe', req),embed: async (req) => await run('embed', req),image: async (req) => await run('image', req),@@@ -15,7 +15,7 @@ const { provenance } = require('./providers/rate-cards');const PROVIDER_KINDS = ['stub', 'openai', 'responses', 'anthropic', 'http', 'whisper'];const BILLING_PROFILES = ['metered', 'subscription', 'payg', 'free', 'byok'];--const FEATURES = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'embed', 'vision', 'json', 'transcribe', 'image'];++const FEATURES = ['chat', 'complete', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'embed', 'vision', 'json', 'tools', 'transcribe', 'image'];const LIFECYCLE = ['draft', 'active', 'deprecated', 'archived'];const KEY_RE = /^[a-z][a-z0-9_-]*(\.[a-z0-9_-]+)*$/;@@@ -340,7 +340,7 @@ function createRegistry(db, { clock = { now: () => Date.now() }, env = process.eschemas.assertSchema(w.output_schema, 'output_schema');if (!Array.isArray(w.steps) || !w.steps.length) throw new AiError(422, 'ai.invalid', 'workflow needs at least one step');for (const s of w.steps) {-- if (!s || !['llm', 'passthrough', 'transcribe', 'embed', 'image', 'media_analysis'].includes(s.kind)) throw new AiError(422, 'ai.invalid', 'step kind must be llm, passthrough, transcribe, embed, image or media_analysis');++ if (!s || !['llm', 'passthrough', 'complete', 'transcribe', 'embed', 'image', 'media_analysis'].includes(s.kind)) throw new AiError(422, 'ai.invalid', 'step kind must be llm, passthrough, complete, transcribe, embed, image or media_analysis');if ((s.kind === 'llm' || (s.kind === 'media_analysis' && s.template)) && !(s.template && await latestAny('templates', s.template))) throw new AiError(422, 'ai.invalid', `step references unknown template ${s.template}`);}if (!['none', 'private', 'service'].includes(w.cache_mode)) throw new AiError(422, 'ai.invalid', 'cache_mode must be none, private or service');@@@ -84,6 +84,32 @@ 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',},++ {++ // 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++ // the prompt (passthrough). The output is the completion's choices.++ key: 'ai.complete', name: 'Chat completion (OpenAI-compatible)', namespace: 'ai',++ input_schema: OBJ({++ 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' }] },++ name: STR(64), tool_calls: { type: 'array', maxItems: 64 }, tool_call_id: STR(128),++ } } },++ tools: { type: 'array', maxItems: 128, items: { type: 'object', required: ['type', 'function'], properties: { type: { const: 'function' }, function: { type: 'object', required: ['name'], properties: { name: { type: 'string', pattern: '^[a-zA-Z0-9_-]{1,64}$' }, description: STR(4000), parameters: { type: 'object' }, strict: { type: 'boolean' } } } } } },++ tool_choice: { anyOf: [{ enum: ['none', 'auto', 'required'] }, { type: 'object' }] },++ parallel_tool_calls: { type: 'boolean' },++ response_format: { type: 'object' },++ stop: { anyOf: [STR(200), { type: 'array', maxItems: 4, items: STR(200) }, { type: 'null' }] },++ seed: { type: ['integer', 'null'] },++ top_p: { type: ['number', 'null'], minimum: 0, maximum: 1 },++ presence_penalty: { type: ['number', 'null'], minimum: -2, maximum: 2 },++ frequency_penalty: { type: ['number', 'null'], minimum: -2, maximum: 2 },++ ...COMMON,++ }, ['messages']),++ output_schema: OBJ({ choices: { type: 'array', minItems: 1, maxItems: 8, items: { type: 'object' } }, system_fingerprint: { type: ['string', 'null'] }, model: { type: ['string', 'null'] } }, ['choices']),++ metadata: { passthrough: true },++ steps: [{ kind: 'complete', route: 'default.chat' }], cache_mode: 'none',++ },{// 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',@@@ -187,6 +187,25 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spareturn { output: { text: exec.result.text || '', json }, exec, route };}++ /** complete: the OpenAI Chat Completions request as the caller wrote it (ai.complete), to an upstream that speaks it. */++ async function completeStep(step, input, wf, run, ctx) {++ const route = await routeFor(step.route || 'default.chat');++ const openai = {};++ for (const k of ['messages', 'tools', 'tool_choice', 'parallel_tool_calls', 'response_format', 'stop', 'seed', 'top_p', 'presence_penalty', 'frequency_penalty']) {++ if (input[k] !== undefined) openai[k] = input[k];++ }++ const req = {++ openai,++ maxTokens: Math.max(1, Math.round(input.max_tokens || route.max_output_tokens || 1024)),++ temperature: input.temperature == null ? null : input.temperature,++ timeoutMs: Math.max(route.timeout_ms || 0, 120000),++ };++ const exec = await pool.execute(route, 'complete', req, { ...ctx, routeKey: route.key, routeVersion: route.version, promptHash: sha256({ m: openai.messages, t: openai.tools || null }) });++ const c = exec.result.completion;++ if (!c || !Array.isArray(c.choices) || !c.choices.length) throw new AiError(502, 'output.empty', 'the provider answered, but with no choices');++ return { output: { choices: c.choices, system_fingerprint: c.system_fingerprint || null, model: exec.model }, exec, route };++ }++async function transcribeStep(step, input, wf, run, ctx) {const route = await routeFor(step.route || 'live.stt');// Read where it lies (the loopback reader), one window at a time: start_sec seeks with a range request, and@@@ -226,7 +245,7 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spareturn { output: { images: images.map(i => ({ b64: i.b64 || null, url: i.url || null })), count: images.length }, exec, route };}-- const STEP = { llm: llmStep, passthrough: passthroughStep, transcribe: transcribeStep, embed: embedStep, image: imageStep, media_analysis: mediaAnalysis.step };++ const STEP = { llm: llmStep, passthrough: passthroughStep, complete: completeStep, transcribe: transcribeStep, embed: embedStep, image: imageStep, media_analysis: mediaAnalysis.step };/*** Run a workflow. ctx: { signal, logRequest, debugRaw, inputHash }nnew file mode 100644@@@ -0,0 +1,120 @@++'use strict';++// The OpenAI-compatible endpoint (server/api/openai.js): POST /v1/chat/completions answers OpenAI's Chat Completions shape++// from a run of ai.complete — text, tool calls and a tool-result round trip, emulated streaming as chunks then [DONE], an++// app token whose run is its project's, OpenAI-shaped errors (no token, bad body), GET /v1/models; and the OpenAI-format++// adapter's complete() passes tools, tool_choice and the tool messages through as written, mapping max_tokens to++// max_completion_tokens (and dropping sampling knobs) for a reasoning model.++const assert = require('assert');++const { boot, request, token, suite, ALL } = require('./helpers');++const { createOpenAiProvider } = require('../server/providers/openai');++++const t = suite('openai-compat');++const service = token('live', ALL);++const PRJ = 'prj_01JAB2C3D4E5F6G7H8J9K0MNPQ';++const app = token('app', ['ai.app.run'], { sub: `app:app_${PRJ.slice(4)}`, actorType: 'app', ns: [PRJ, `app.${PRJ}.*`], extra: { project_id: PRJ, env: 'production' } });++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 });++++t.test('boot', async () => { h = await boot(); });++++t.test('a plain chat answers the Chat Completions shape, with usage and the run it was', async () => {++ const r = await post({ model: 'openvibe', messages: [{ role: 'system', content: 'Be brief.' }, { role: 'user', content: 'Say hello' }] });++ assert.strictEqual(r.status, 200, r.text);++ const c = r.body;++ assert.strictEqual(c.object, 'chat.completion');++ assert.match(c.id, /^chatcmpl-run_/);++ assert.strictEqual(c.choices[0].message.role, 'assistant');++ assert.strictEqual(typeof c.choices[0].message.content, 'string');++ assert.strictEqual(c.choices[0].finish_reason, 'stop');++ assert.ok(c.usage.prompt_tokens > 0 && c.usage.total_tokens === c.usage.prompt_tokens + c.usage.completion_tokens);++ assert.ok(c.x_openvibe.run_id && c.x_openvibe.provider);++ assert.strictEqual(r.headers.get('x-openvibe-run-id'), c.x_openvibe.run_id);++ const run = await request(h.base, 'GET', `/api/v1/runs/${c.x_openvibe.run_id}`, { tok: service });++ assert.strictEqual(run.body.run.workflow.key, 'ai.complete');++ assert.ok(!JSON.stringify(run.body.run.input || {}).includes('Say hello'), 'the prompt is not kept');++});++++t.test('tools: the model calls one; the tool result goes back and it answers', async () => {++ const msgs = [{ role: 'user', content: 'Weather in Lisbon?' }];++ const first = await post({ model: 'openvibe', messages: msgs, tools: [WEATHER], tool_choice: 'auto' });++ assert.strictEqual(first.status, 200, first.text);++ const choice = first.body.choices[0];++ assert.strictEqual(choice.finish_reason, 'tool_calls');++ const call = choice.message.tool_calls[0];++ assert.strictEqual(call.type, 'function');++ assert.strictEqual(call.function.name, 'get_weather');++ assert.ok(JSON.parse(call.function.arguments).city !== undefined, 'arguments follow the parameters schema');++ const second = await post({ model: 'openvibe', tools: [WEATHER], messages: [...msgs, choice.message, { role: 'tool', tool_call_id: call.id, content: '19 C and sunny' }] });++ assert.strictEqual(second.status, 200, second.text);++ assert.strictEqual(second.body.choices[0].finish_reason, 'stop');++ assert.match(second.body.choices[0].message.content, /19 C and sunny/);++});++++t.test('stream: chunks with the delta, then finish_reason, usage on request, then [DONE]', async () => {++ const res = await fetch(`${h.base}/v1/chat/completions`, { method: 'POST', headers: { Authorization: `Bearer ${service}`, 'Content-Type': 'application/json' },++ body: JSON.stringify({ model: 'openvibe', stream: true, stream_options: { include_usage: true }, messages: [{ role: 'user', content: 'Stream please' }] }) });++ assert.strictEqual(res.status, 200);++ assert.match(res.headers.get('content-type'), /^text\/event-stream/);++ const lines = (await res.text()).split('\n').filter((l) => l.startsWith('data: ')).map((l) => l.slice(6));++ assert.strictEqual(lines[lines.length - 1], '[DONE]');++ const chunks = lines.slice(0, -1).map((l) => JSON.parse(l));++ assert.ok(chunks.every((c) => c.object === 'chat.completion.chunk'));++ assert.strictEqual(chunks[0].choices[0].delta.role, 'assistant');++ assert.strictEqual(typeof chunks[0].choices[0].delta.content, 'string');++ assert.strictEqual(chunks[1].choices[0].finish_reason, 'stop');++ assert.ok(chunks.some((c) => c.usage && c.usage.total_tokens > 0), 'usage when asked');++});++++t.test('an app token works, and its run is its project\'s', async () => {++ const r = await post({ model: 'openvibe', messages: [{ role: 'user', content: 'From an app' }] }, app);++ assert.strictEqual(r.status, 200, r.text);++ const run = await request(h.base, 'GET', `/api/v1/runs/${r.body.x_openvibe.run_id}`, { tok: service });++ assert.match(JSON.stringify(run.body.run.attribution || {}), new RegExp(PRJ));++});++++t.test('errors come in OpenAI\'s shape: no token, no messages, n > 1', async () => {++ const none = await request(h.base, 'POST', '/v1/chat/completions', { body: { messages: [{ role: 'user', content: 'x' }] } });++ assert.strictEqual(none.status, 401);++ assert.strictEqual(typeof none.body.error.message, 'string');++ assert.strictEqual(none.body.error.type, 'authentication_error');++ const empty = await post({ model: 'openvibe', messages: [] });++ assert.deepStrictEqual([empty.status, empty.body.error.type], [400, 'invalid_request_error']);++ assert.strictEqual((await post({ model: 'openvibe', n: 2, messages: [{ role: 'user', content: 'x' }] })).status, 400);++});++++t.test('a provider may declare tools (and complete) in its capabilities', async () => {++ const r = await request(h.base, 'POST', '/api/v1/providers', { tok: service, body: { key: 'toolsup', kind: 'openai', base_url: 'https://upstream.example/v1', auth_mode: 'none', capabilities: ['chat', 'complete', 'tools'], default_model: 'm' } });++ assert.strictEqual(r.status, 201, r.text);++ assert.deepStrictEqual(r.body.provider.capabilities, ['chat', 'complete', 'tools']);++ assert.strictEqual((await request(h.base, 'POST', '/api/v1/providers/toolsup/disable', { tok: service, body: {} })).status, 200, 'and is kept out of the way of the other checks');++});++++t.test('GET /v1/models lists the router model', async () => {++ const r = await request(h.base, 'GET', '/v1/models', { tok: service });++ assert.strictEqual(r.status, 200, r.text);++ assert.deepStrictEqual(r.body.data.map((m) => m.id), ['openvibe']);++});++++t.test('the OpenAI-format adapter passes the request through as written', async () => {++ const seen = [];++ const fetchImpl = async (url, opts) => {++ seen.push({ url, body: JSON.parse(opts.body) });++ return new Response(JSON.stringify({ model: 'up-1', choices: [{ index: 0, message: { role: 'assistant', content: null, tool_calls: [{ id: 'c1', type: 'function', function: { name: 'get_weather', arguments: '{"city":"Oslo"}' } }] }, finish_reason: 'tool_calls' }], usage: { prompt_tokens: 12, completion_tokens: 7 } }), { status: 200, headers: { 'Content-Type': 'application/json' } });++ };++ const p = createOpenAiProvider({ key: 'up', base_url: 'https://upstream.example/v1', capabilities: ['chat', 'tools'] }, { apiKey: 'k'.repeat(40), fetchImpl });++ assert.strictEqual(p.supports('complete'), true, 'complete follows chat');++ assert.strictEqual(p.supports('tools'), true);++ const messages = [{ role: 'user', content: 'Weather in Oslo?' }];++ const out = await p.complete({ model: 'gpt-4.1-mini', maxTokens: 300, temperature: 0.2, openai: { messages, tools: [WEATHER], tool_choice: 'required', parallel_tool_calls: false, top_p: 0.9 } });++ assert.strictEqual(seen[0].url, 'https://upstream.example/v1/chat/completions');++ assert.deepStrictEqual(seen[0].body, { model: 'gpt-4.1-mini', messages, tools: [WEATHER], tool_choice: 'required', parallel_tool_calls: false, top_p: 0.9, max_tokens: 300, temperature: 0.2 });++ assert.deepStrictEqual([out.model, out.usage.input, out.usage.output, out.completion.choices[0].finish_reason], ['up-1', 12, 7, 'tool_calls']);++ await p.complete({ model: 'gpt-5-nano', maxTokens: 300, temperature: 0.2, openai: { messages, top_p: 0.9 } });++ assert.deepStrictEqual(seen[1].body, { model: 'gpt-5-nano', messages, max_completion_tokens: 300 }, 'a reasoning model: max_completion_tokens, no sampling knobs');++ const plain = createOpenAiProvider({ key: 'local', capabilities: ['chat'] }, { fetchImpl });++ assert.strictEqual(plain.supports('tools'), false, 'tools are declared, never assumed');++});++++t.test('close', async () => { await h.stop(); });++t.run();@@@ -26,6 +26,7 @@ const EXAMPLES = {'media.analyze': { media_url: '__MEDIA__/fixture.mp4', language: 'en' },'network.site_copy': { sites: [{ id: 'live', name: 'OpenVibe.Live', what: 'live streaming' }], links: [{ id: 'tools', name: 'Tools', about: 'online tools' }] },'ai.chat': { messages: [{ role: 'user', content: 'hello' }] },++ 'ai.complete': { messages: [{ role: 'user', content: 'hello' }], tools: [{ type: 'function', function: { name: 'lookup', parameters: { type: 'object', properties: { q: { type: 'string' } } } } }] },'ai.generate': { prompt: 'Write a haiku about SQLite.' },'ai.summarize': { text: 'SQLite is an embedded database engine. It is small and fast.', max_words: 20 },'ai.classify': { text: 'I love this!', labels: ['positive', 'negative', 'neutral'] },
@@ -139,6 +139,28 @@ no `ns` runs nothing. Every caller's Network grant names its namespaces (Live: `
@@ -139,6 +139,28 @@ no `ns` runs nothing. Every caller's Network grant names its namespaces (Live: `
`network.site_copy`, `media.analyze`). The fallback for service tokens without `ns` (`AI_NS_FALLBACK`)
`network.site_copy`, `media.analyze`). The fallback for service tokens without `ns` (`AI_NS_FALLBACK`)
and its rollback lever (`AI_NS_REQUIRED`) were retired on 2026-09-28 (shims C-22 and C-23).
and its rollback lever (`AI_NS_REQUIRED`) were retired on 2026-09-28 (shims C-22 and C-23).
+## OpenAI-compatible endpoint
+
+`POST /v1/chat/completions` and `GET /v1/models` (`server/api/openai.js`), so a client built for OpenAI's API (the SDKs,
+coding agents, Codes' harnesses) points its base URL at `https://ai.openvibe.services/v1`.
+
+- **Tokens:** the same as the direct operations: an app's Network token (`ai.app.run`; its project pays and owns the
+ run) or a first-party service's (`ai.run.create`). Network tokens last five minutes: a client refreshes it with the
+ client-credentials grant (the SDKs take a token function).
+- **Request:** Chat Completions, as written: `messages` (system, developer, user, assistant with `tool_calls`, and `tool`
+ results), `tools`, `tool_choice`, `parallel_tool_calls`, `response_format`, `stop`, `seed`, `temperature`, `top_p`,
+ `max_tokens` or `max_completion_tokens`, `stream`. `n` must be 1. `model` may be anything: AI's router picks the
+ upstream for `default.chat` by price and health, and a request with `tools` goes only to a provider that declares
+ `tools`. The answer's `model` names the model that ran.
+- **Answer:** `chat.completion` with the upstream's `choices` and `usage`, plus `x_openvibe` (`run_id`, `provider`,
+ `fallback_used`, `cost_usd`) and the `X-OpenVibe-Run-Id` header. `stream: true` answers server-sent events in the chunk
+ format, **emulated**: the whole answer arrives as one delta, then the finish reason (and `usage` with
+ `stream_options.include_usage`), then `[DONE]`.
+- **Each request is a run** of workflow `ai.complete` (step kind `complete`): the run's quota, free allowance, cost,
+ audit and explain apply, and the prompt is not kept. Errors are OpenAI's shape (`{ error: { message, type, code } }`).
+- **Upstreams:** OpenAI-format providers (`kind: openai`) pass the request through (`complete()`; a reasoning model
+ takes `max_completion_tokens` and its default sampling). Anthropic is not used for it yet.
+
## Developer apps
## Developer apps
Roadmap Wave 20, ADR-014. A developer app gets a token from OpenVibe.Network (`POST /oauth/token`,
Roadmap Wave 20, ADR-014. A developer app gets a token from OpenVibe.Network (`POST /oauth/token`,
new file mode 100644
new file mode 100644
@@ -0,0 +1,120 @@
@@ -0,0 +1,120 @@
+'use strict';
+/**
+ * The OpenAI-compatible endpoint: POST /v1/chat/completions on ai.openvibe.services, so a client built for OpenAI's API
+ * (the SDKs, coding agents, Codes' harnesses) points its base URL at https://ai.openvibe.services/v1 and works.
+ *
+ * Authorization: Bearer <a Network token> an app's token (ai.app.run, its project pays) or a first-party service's
+ * (ai.run.create), exactly as POST /api/v1/chat takes them
+ * body OpenAI Chat Completions: messages (with tool calls and tool results),
+ * tools, tool_choice, parallel_tool_calls, response_format, stop, seed,
+ * temperature, top_p, max_tokens / max_completion_tokens, stream
+ * model "openvibe" (or anything else): AI's router picks the upstream for
+ * route default.chat by price, health and what the request needs
+ * (tools need a provider that declares them); the answer names the
+ * model that ran
+ *
+ * Each request is a run of workflow ai.complete, so it has the run's quota, free allowance, cost, audit and explain
+ * (x_openvibe in the answer); the prompt is not kept. stream: true answers server-sent events in the Chat Completions
+ * chunk format, emulated: the whole answer arrives as one delta, then finish_reason, then [DONE]. Errors are OpenAI's
+ * shape ({ error: { message, type, code } }) with the HTTP status the run's problem had.
+ */
+const express = require('express');
+const { CAPS } = require('../auth');
+const { appAttribution } = require('../apps');
+
+const FIELDS = ['messages', 'tools', 'tool_choice', 'parallel_tool_calls', 'response_format', 'stop', 'seed', 'top_p', 'presence_penalty', 'frequency_penalty', 'temperature'];
+
+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 >= 500 ? 'api_error' : 'invalid_request_error'), code: code || null, param: null } });
+}
+
+function openaiRouter({ runs, auth, config, log = console }) {
+ const r = express.Router();
+ const allow = auth.appOrCap(CAPS.appRun, CAPS.runCreate);
+
+ // A problem from the auth layer arrives as problem+json; a client built for OpenAI reads { error }: convert it as
+ // it is written, whichever way the layer writes it.
+ const typeFor = (status) => (status === 401 || status === 403 ? 'authentication_error' : status === 429 ? 'rate_limit_error' : status >= 500 ? 'api_error' : 'invalid_request_error');
+ const asOpenAiErrors = (req, res, next) => {
+ const end = res.end.bind(res);
+ res.end = (chunk, ...rest) => {
+ const ct = String(res.getHeader('Content-Type') || '');
+ if (res.statusCode >= 400 && /problem\+json/.test(ct) && chunk) {
+ let p = null;
+ try { p = JSON.parse(Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk)); } catch { p = null; }
+ if (p && (p.error == null || typeof p.error !== 'object')) {
+ const body = JSON.stringify({ error: { message: String(p.detail || p.title || p.code || 'error'), type: typeFor(res.statusCode), code: p.code || null, param: null } });
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
+ res.setHeader('Content-Length', Buffer.byteLength(body));
+ return end(body);
+ }
+ }
+ return end(chunk, ...rest);
+ };
+ next();
+ };
+
+ r.post('/v1/chat/completions', asOpenAiErrors, allow, async (req, res) => {
+ const b = req.body && typeof req.body === 'object' ? req.body : {};
+ 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 = {};
+ for (const k of FIELDS) if (b[k] !== undefined && b[k] !== null) input[k] = b[k];
+ 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));
+ let body = { workflow: 'ai.complete', input };
+ if (req.principal.kind === 'app') body = { ...body, attribution: appAttribution(req.principal) };
+ let run;
+ try {
+ const created = await runs.create(body, req.principal, { trace: req.ov && req.ov.traceId, requestId: req.ov && req.ov.requestId });
+ run = await runs.explained(await runs.wait(created, Math.max(config.runs.maxWaitMs, 120000)));
+ } catch (err) {
+ const status = (err && err.status) || 500;
+ if (status === 429 && err.retryAfter) res.setHeader('Retry-After', String(err.retryAfter));
+ 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 (run.status === 'failed' || run.status === 'cancelled') {
+ const code = (run.error && run.error.code) || 'run.failed';
+ const status = /^(input|validation)\./.test(code) ? 400 : /quota|allowance|budget/.test(code) ? 429 : 502;
+ return openaiError(res, status, (run.error && run.error.detail) || code, code);
+ }
+ if (!['succeeded', 'cached'].includes(run.status) || !run.output || !Array.isArray(run.output.choices)) {
+ return openaiError(res, 504, `the completion is still running; GET /api/v1/runs/${run.id}`, 'run.timeout');
+ }
+ const created = Math.floor(Date.parse(run.created_at || new Date().toISOString()) / 1000) || Math.floor(Date.now() / 1000);
+ 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) };
+ 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, synthetic: Boolean(run.synthetic) };
+ res.setHeader('X-OpenVibe-Run-Id', run.id);
+ if (b.stream === true) {
+ res.status(200);
+ res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
+ res.setHeader('Cache-Control', 'no-store');
+ const send = (o) => res.write(`data: ${JSON.stringify(o)}\n\n`);
+ const base = { id, object: 'chat.completion.chunk', created, model, system_fingerprint: run.output.system_fingerprint || null };
+ for (const c of run.output.choices) {
+ const m = c.message || {};
+ const delta = { role: 'assistant', content: m.content == null ? null : m.content };
+ if (Array.isArray(m.tool_calls) && m.tool_calls.length) delta.tool_calls = m.tool_calls.map((t, i) => ({ index: i, ...t }));
+ 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 });
+ send({ ...base, choices: [], x_openvibe: x });
+ res.write('data: [DONE]\n\n');
+ return res.end();
+ }
+ return res.status(200).json({ id, object: 'chat.completion', created, model, choices: run.output.choices, usage, system_fingerprint: run.output.system_fingerprint || null, x_openvibe: x });
+ });
+
+ // The model list a client asks for first: one router model, the upstreams are AI's choice.
+ r.get('/v1/models', asOpenAiErrors, allow, (req, res) => {
+ res.json({ object: 'list', data: [{ id: 'openvibe', object: 'model', created: 1760486400, owned_by: 'openvibe' }] });
+ });
+
+ return r;
+}
+
+module.exports = { openaiRouter };
@@ -70,7 +70,7 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
@@ -70,7 +70,7 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
// /shared/* URLs from this repo's pinned openvibe-shared; each answer sets its own asset cache policy.
// /shared/* URLs from this repo's pinned openvibe-shared; each answer sets its own asset cache policy.
app.use('/shared', ovServe.handler());
app.use('/shared', ovServe.handler());
// Inline images arrive as data URLs; the per-run input cap is enforced again in runs.create.
// Inline images arrive as data URLs; the per-run input cap is enforced again in runs.create.
- app.use('/api', express.json({ limit: Math.ceil(config.runs.maxInputBytes * 1.5), type: ['application/json', 'application/*+json'] }));
+ app.use(['/api', '/v1'], express.json({ limit: Math.ceil(config.runs.maxInputBytes * 1.5), type: ['application/json', 'application/*+json'] }));
app.get('/api/health', (_req, res) => {
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok', service: 'openvibe-ai', version: pkg.version });
res.json({ status: 'ok', service: 'openvibe-ai', version: pkg.version });
@@ -86,6 +86,8 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
@@ -86,6 +86,8 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
// Operator actions shared by the admin API and the operator console (one implementation, one audit row).
// Operator actions shared by the admin API and the operator console (one implementation, one audit row).
const ops = createOps({ db, registry, pool, quotas, cache, runs });
const ops = createOps({ db, registry, pool, quotas, cache, runs });
app.use(runsRouter({ runs, registry, auth, config, log }));
app.use(runsRouter({ runs, registry, auth, config, log }));
+ // The OpenAI-compatible endpoint (POST /v1/chat/completions, GET /v1/models): runs of ai.complete.
+ app.use(require('./api/openai').openaiRouter({ runs, auth, config, log }));
app.use(require('./api/attribution-quotas').attributionQuotasRouter({ db, quotas, auth, clock, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
app.use(require('./api/attribution-quotas').attributionQuotasRouter({ db, quotas, auth, clock, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
if (credentials) app.use(require('./credentials').credentialsRouter({ credentials, auth, registry, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
if (credentials) app.use(require('./credentials').credentialsRouter({ credentials, auth, registry, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
app.use(adminRouter({ db, registry, pool, quotas, cache, runs, auth, ops, log }));
app.use(adminRouter({ db, registry, pool, quotas, cache, runs, auth, ops, log }));
@@ -24,7 +24,7 @@ const APP_SUB_RE = new RegExp(`^app:app_(${ULID})$`);
@@ -24,7 +24,7 @@ const APP_SUB_RE = new RegExp(`^app:app_(${ULID})$`);
const PROJECT_RE = new RegExp(`^prj_(${ULID})$`);
const PROJECT_RE = new RegExp(`^prj_(${ULID})$`);
const ENVS = ['sandbox', 'production'];
const ENVS = ['sandbox', 'production'];
/** The operations ai.app.run grants by route, as workflow keys (POST /api/v1/<op> is a run of ai.<op>). */
/** The operations ai.app.run grants by route, as workflow keys (POST /api/v1/<op> is a run of ai.<op>). */
-const DIRECT_OPS = Object.freeze(['chat', 'generate', 'summarize', 'classify', 'extract', 'embed', 'image'].map((op) => `ai.${op}`));
+const DIRECT_OPS = Object.freeze(['chat', 'complete', 'generate', 'summarize', 'classify', 'extract', 'embed', 'image'].map((op) => `ai.${op}`));
/** 'prj_01JAB…' -> 'p01jab…' (null for anything that is not a project id). */
/** 'prj_01JAB…' -> 'p01jab…' (null for anything that is not a project id). */
function projectKey(projectId) {
function projectKey(projectId) {
@@ -154,6 +154,7 @@ function llmsTxt({ siteUrl }) {
@@ -154,6 +154,7 @@ function llmsTxt({ siteUrl }) {
'',
'',
'- Token: POST https://openvibe.network/oauth/token with grant_type=client_credentials, your app\'s client id and secret, audience=openvibe.ai (five minutes).',
'- Token: POST https://openvibe.network/oauth/token with grant_type=client_credentials, your app\'s client id and secret, audience=openvibe.ai (five minutes).',
`- Operations: POST ${siteUrl}/api/v1/chat, /generate, /summarize, /classify, /extract, /embed; GET ${siteUrl}/api/v1/runs/:id for your own runs.`,
`- Operations: POST ${siteUrl}/api/v1/chat, /generate, /summarize, /classify, /extract, /embed; GET ${siteUrl}/api/v1/runs/:id for your own runs.`,
+ `- OpenAI-compatible: POST ${siteUrl}/v1/chat/completions (Chat Completions with tools; stream is emulated) and GET ${siteUrl}/v1/models, with the same token. Point an OpenAI client's base URL at ${siteUrl}/v1.`,
'- Every run response carries explain: the objective, the model selected and every candidate with its estimated cost, latency and excluded reason.',
'- Every run response carries explain: the objective, the model selected and every candidate with its estimated cost, latency and excluded reason.',
'- Sandbox apps run on free and local capacity only; runs, usage and the free allowance are per project; a person\'s own provider key is never used for an app.',
'- Sandbox apps run on free and local capacity only; runs, usage and the free allowance are per project; a person\'s own provider key is never used for an app.',
'',
'',
@@ -398,6 +398,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -398,6 +398,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
const features = [operation];
const features = [operation];
if (req.image) features.push('vision');
if (req.image) features.push('vision');
if (req.json) features.push('json');
if (req.json) features.push('json');
+ if (req.openai && Array.isArray(req.openai.tools) && req.openai.tools.length) features.push('tools');
const tried = [];
const tried = [];
let unreadable = null; // a candidate could not read the caller's media (whisper's ffmpeg): no other will either
let unreadable = null; // a candidate could not read the caller's media (whisper's ffmpeg): no other will either
const { order: list, explain } = await candidates(route, features, operation, ctx);
const { order: list, explain } = await candidates(route, features, operation, ctx);
@@ -124,11 +124,44 @@ function createOpenAiProvider(record, { apiKey = '', fetchImpl = globalThis.fetc
@@ -124,11 +124,44 @@ function createOpenAiProvider(record, { apiKey = '', fetchImpl = globalThis.fetc
return { images: list, model: j.model || model, usage: { input: 0, output: list.length, cached: 0 } };
return { images: list, model: j.model || model, usage: { input: 0, output: list.length, cached: 0 } };
}
}
+ /**
+ * complete (the OpenAI-compatible endpoint, /v1/chat/completions): the caller's Chat Completions request as they
+ * wrote it (messages with tool calls and tool results, tools, tool_choice, response_format, stop, seed), sent to
+ * this upstream with the routed model. A reasoning model takes max_completion_tokens and its default temperature.
+ * → { completion, text, model, usage }
+ */
+ async function complete(req) {
+ const o = req.openai || {};
+ const body = { model: req.model, messages: o.messages };
+ for (const k of ['tools', 'tool_choice', 'parallel_tool_calls', 'response_format', 'stop', 'seed', 'top_p', 'presence_penalty', 'frequency_penalty']) {
+ if (o[k] !== undefined && o[k] !== null) body[k] = o[k];
+ }
+ if (isReasoningModel(req.model)) {
+ if (req.maxTokens) body.max_completion_tokens = req.maxTokens;
+ for (const k of ['top_p', 'presence_penalty', 'frequency_penalty']) delete body[k];
+ } else {
+ if (req.maxTokens) body.max_tokens = req.maxTokens;
+ if (req.temperature != null) body.temperature = req.temperature;
+ }
+ const j = await postJson(`${baseUrl}/chat/completions`, headers(), body, { signal: req.signal, timeoutMs: req.timeoutMs, fetchImpl });
+ if (!Array.isArray(j.choices) || !j.choices.length) throw new ProviderError('completion has no choices', { code: 'provider.output' });
+ const msg = j.choices[0].message || {};
+ const u = j.usage || {};
+ return {
+ completion: { choices: j.choices, system_fingerprint: j.system_fingerprint || null },
+ text: typeof msg.content === 'string' ? msg.content : '',
+ model: j.model || req.model,
+ usage: { input: u.prompt_tokens || 0, output: u.completion_tokens || 0, cached: (u.prompt_tokens_details && u.prompt_tokens_details.cached_tokens) || 0 },
+ };
+ }
+
const adapter = {
const adapter = {
key: record.key,
key: record.key,
kind: 'openai',
kind: 'openai',
- supports: (feature) => caps.has(feature),
+ // complete is the chat endpoint as the caller wrote it: any upstream that chats completes; tools are declared.
+ supports: (feature) => caps.has(feature) || (feature === 'complete' && caps.has('chat')),
chat,
chat,
+ complete,
embed,
embed,
transcribe,
transcribe,
image: images,
image: images,
@@ -150,11 +150,29 @@ function createStubProvider(record = { key: 'stub', metadata: {} }, { stats = nu
@@ -150,11 +150,29 @@ function createStubProvider(record = { key: 'stub', metadata: {} }, { stats = nu
return { text, json, model, synthetic: true, usage: { input: inTok, output: approxTokens(text), cached: 0, estimated: true } };
return { text, json, model, synthetic: true, usage: { input: inTok, output: approxTokens(text), cached: 0, estimated: true } };
}
}
+ /** complete: an OpenAI-shaped answer. With tools and no tool result yet it calls the first tool; else it answers. */
+ async function complete(req) {
+ const o = req.openai || {};
+ const msgs = Array.isArray(o.messages) ? o.messages : [];
+ const last = msgs[msgs.length - 1] || {};
+ const rnd = seeded(`complete:${JSON.stringify(msgs).slice(-2000)}`);
+ const inTok = msgs.reduce((n, m) => n + approxTokens(typeof m.content === 'string' ? m.content : JSON.stringify(m.content || '')), 0);
+ const tool = Array.isArray(o.tools) && o.tools.length && last.role !== 'tool' && o.tool_choice !== 'none' ? o.tools[0] : null;
+ if (tool && tool.function) {
+ const args = tool.function.parameters ? synthesize(tool.function.parameters, rnd, 0, 'arguments') : {};
+ const call = { id: `call_${crypto.createHash('sha256').update(JSON.stringify(msgs)).digest('hex').slice(0, 24)}`, type: 'function', function: { name: tool.function.name, arguments: JSON.stringify(args) } };
+ return { completion: { choices: [{ index: 0, message: { role: 'assistant', content: null, tool_calls: [call] }, finish_reason: 'tool_calls' }] }, text: '', model: req.model || 'stub-1', synthetic: true, usage: { input: inTok, output: 20, cached: 0, estimated: true } };
+ }
+ const text = last.role === 'tool' ? `(synthetic) the tool answered: ${String(last.content || '').slice(0, 80)}` : `(synthetic) ${phrase(rnd, 8)}`;
+ return { completion: { choices: [{ index: 0, message: { role: 'assistant', content: text }, finish_reason: 'stop' }] }, text, model: req.model || 'stub-1', synthetic: true, usage: { input: inTok, output: approxTokens(text), cached: 0, estimated: true } };
+ }
+
const adapter = {
const adapter = {
key: record.key,
key: record.key,
kind: 'stub',
kind: 'stub',
synthetic: true,
synthetic: true,
supports: () => true,
supports: () => true,
+ complete,
transcribe: async (req) => await run('transcribe', req),
transcribe: async (req) => await run('transcribe', req),
embed: async (req) => await run('embed', req),
embed: async (req) => await run('embed', req),
image: async (req) => await run('image', req),
image: async (req) => await run('image', req),
@@ -15,7 +15,7 @@ const { provenance } = require('./providers/rate-cards');
@@ -15,7 +15,7 @@ const { provenance } = require('./providers/rate-cards');
const PROVIDER_KINDS = ['stub', 'openai', 'responses', 'anthropic', 'http', 'whisper'];
const PROVIDER_KINDS = ['stub', 'openai', 'responses', 'anthropic', 'http', 'whisper'];
const BILLING_PROFILES = ['metered', 'subscription', 'payg', 'free', 'byok'];
const BILLING_PROFILES = ['metered', 'subscription', 'payg', 'free', 'byok'];
-const FEATURES = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'embed', 'vision', 'json', 'transcribe', 'image'];
+const FEATURES = ['chat', 'complete', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'embed', 'vision', 'json', 'tools', 'transcribe', 'image'];
const LIFECYCLE = ['draft', 'active', 'deprecated', 'archived'];
const LIFECYCLE = ['draft', 'active', 'deprecated', 'archived'];
const KEY_RE = /^[a-z][a-z0-9_-]*(\.[a-z0-9_-]+)*$/;
const KEY_RE = /^[a-z][a-z0-9_-]*(\.[a-z0-9_-]+)*$/;
@@ -340,7 +340,7 @@ function createRegistry(db, { clock = { now: () => Date.now() }, env = process.e
@@ -340,7 +340,7 @@ function createRegistry(db, { clock = { now: () => Date.now() }, env = process.e
schemas.assertSchema(w.output_schema, 'output_schema');
schemas.assertSchema(w.output_schema, 'output_schema');
if (!Array.isArray(w.steps) || !w.steps.length) throw new AiError(422, 'ai.invalid', 'workflow needs at least one step');
if (!Array.isArray(w.steps) || !w.steps.length) throw new AiError(422, 'ai.invalid', 'workflow needs at least one step');
for (const s of w.steps) {
for (const s of w.steps) {
- if (!s || !['llm', 'passthrough', 'transcribe', 'embed', 'image', 'media_analysis'].includes(s.kind)) throw new AiError(422, 'ai.invalid', 'step kind must be llm, passthrough, transcribe, embed, image or media_analysis');
+ if (!s || !['llm', 'passthrough', 'complete', 'transcribe', 'embed', 'image', 'media_analysis'].includes(s.kind)) throw new AiError(422, 'ai.invalid', 'step kind must be llm, passthrough, complete, transcribe, embed, image or media_analysis');
if ((s.kind === 'llm' || (s.kind === 'media_analysis' && s.template)) && !(s.template && await latestAny('templates', s.template))) throw new AiError(422, 'ai.invalid', `step references unknown template ${s.template}`);
if ((s.kind === 'llm' || (s.kind === 'media_analysis' && s.template)) && !(s.template && await latestAny('templates', s.template))) throw new AiError(422, 'ai.invalid', `step references unknown template ${s.template}`);
}
}
if (!['none', 'private', 'service'].includes(w.cache_mode)) throw new AiError(422, 'ai.invalid', 'cache_mode must be none, private or service');
if (!['none', 'private', 'service'].includes(w.cache_mode)) throw new AiError(422, 'ai.invalid', 'cache_mode must be none, private or service');
@@ -84,6 +84,32 @@ const workflows = [
@@ -84,6 +84,32 @@ const workflows = [
output_schema: OBJ({ vectors: { type: 'array', items: { type: 'array', items: { type: 'number' } } }, dimensions: { type: 'integer' } }, ['vectors', 'dimensions']),
output_schema: OBJ({ vectors: { type: 'array', items: { type: 'array', items: { type: 'number' } } }, dimensions: { type: 'integer' } }, ['vectors', 'dimensions']),
steps: [{ kind: 'embed', route: 'default.embedding' }], cache_mode: 'private',
steps: [{ kind: 'embed', route: 'default.embedding' }], cache_mode: 'private',
},
},
+ {
+ // 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
+ // the prompt (passthrough). The output is the completion's choices.
+ key: 'ai.complete', name: 'Chat completion (OpenAI-compatible)', namespace: 'ai',
+ input_schema: OBJ({
+ 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' }] },
+ name: STR(64), tool_calls: { type: 'array', maxItems: 64 }, tool_call_id: STR(128),
+ } } },
+ tools: { type: 'array', maxItems: 128, items: { type: 'object', required: ['type', 'function'], properties: { type: { const: 'function' }, function: { type: 'object', required: ['name'], properties: { name: { type: 'string', pattern: '^[a-zA-Z0-9_-]{1,64}$' }, description: STR(4000), parameters: { type: 'object' }, strict: { type: 'boolean' } } } } } },
+ tool_choice: { anyOf: [{ enum: ['none', 'auto', 'required'] }, { type: 'object' }] },
+ parallel_tool_calls: { type: 'boolean' },
+ response_format: { type: 'object' },
+ stop: { anyOf: [STR(200), { type: 'array', maxItems: 4, items: STR(200) }, { type: 'null' }] },
+ seed: { type: ['integer', 'null'] },
+ top_p: { type: ['number', 'null'], minimum: 0, maximum: 1 },
+ presence_penalty: { type: ['number', 'null'], minimum: -2, maximum: 2 },
+ frequency_penalty: { type: ['number', 'null'], minimum: -2, maximum: 2 },
+ ...COMMON,
+ }, ['messages']),
+ output_schema: OBJ({ choices: { type: 'array', minItems: 1, maxItems: 8, items: { type: 'object' } }, system_fingerprint: { type: ['string', 'null'] }, model: { type: ['string', 'null'] } }, ['choices']),
+ metadata: { passthrough: true },
+ steps: [{ kind: 'complete', route: 'default.chat' }], cache_mode: 'none',
+ },
{
{
// Off until AI_IMAGE_MODEL seeds route default.image: without it a run answers 503 route.unavailable.
// Off until AI_IMAGE_MODEL seeds route default.image: without it a run answers 503 route.unavailable.
key: 'ai.image', name: 'Generate an image', namespace: 'ai',
key: 'ai.image', name: 'Generate an image', namespace: 'ai',
@@ -187,6 +187,25 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
@@ -187,6 +187,25 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
return { output: { text: exec.result.text || '', json }, exec, route };
return { output: { text: exec.result.text || '', json }, exec, route };
}
}
+ /** complete: the OpenAI Chat Completions request as the caller wrote it (ai.complete), to an upstream that speaks it. */
+ async function completeStep(step, input, wf, run, ctx) {
+ const route = await routeFor(step.route || 'default.chat');
+ const openai = {};
+ for (const k of ['messages', 'tools', 'tool_choice', 'parallel_tool_calls', 'response_format', 'stop', 'seed', 'top_p', 'presence_penalty', 'frequency_penalty']) {
+ if (input[k] !== undefined) openai[k] = input[k];
+ }
+ const req = {
+ openai,
+ maxTokens: Math.max(1, Math.round(input.max_tokens || route.max_output_tokens || 1024)),
+ temperature: input.temperature == null ? null : input.temperature,
+ timeoutMs: Math.max(route.timeout_ms || 0, 120000),
+ };
+ const exec = await pool.execute(route, 'complete', req, { ...ctx, routeKey: route.key, routeVersion: route.version, promptHash: sha256({ m: openai.messages, t: openai.tools || null }) });
+ const c = exec.result.completion;
+ if (!c || !Array.isArray(c.choices) || !c.choices.length) throw new AiError(502, 'output.empty', 'the provider answered, but with no choices');
+ return { output: { choices: c.choices, system_fingerprint: c.system_fingerprint || null, model: exec.model }, exec, route };
+ }
+
async function transcribeStep(step, input, wf, run, ctx) {
async function transcribeStep(step, input, wf, run, ctx) {
const route = await routeFor(step.route || 'live.stt');
const route = await routeFor(step.route || 'live.stt');
// Read where it lies (the loopback reader), one window at a time: start_sec seeks with a range request, and
// Read where it lies (the loopback reader), one window at a time: start_sec seeks with a range request, and
@@ -226,7 +245,7 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
@@ -226,7 +245,7 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
return { output: { images: images.map(i => ({ b64: i.b64 || null, url: i.url || null })), count: images.length }, exec, route };
return { output: { images: images.map(i => ({ b64: i.b64 || null, url: i.url || null })), count: images.length }, exec, route };
}
}
- const STEP = { llm: llmStep, passthrough: passthroughStep, transcribe: transcribeStep, embed: embedStep, image: imageStep, media_analysis: mediaAnalysis.step };
+ const STEP = { llm: llmStep, passthrough: passthroughStep, complete: completeStep, transcribe: transcribeStep, embed: embedStep, image: imageStep, media_analysis: mediaAnalysis.step };
/**
/**
* Run a workflow. ctx: { signal, logRequest, debugRaw, inputHash }
* Run a workflow. ctx: { signal, logRequest, debugRaw, inputHash }
new file mode 100644
new file mode 100644
@@ -0,0 +1,120 @@
@@ -0,0 +1,120 @@
+'use strict';
+// The OpenAI-compatible endpoint (server/api/openai.js): POST /v1/chat/completions answers OpenAI's Chat Completions shape
+// from a run of ai.complete — text, tool calls and a tool-result round trip, emulated streaming as chunks then [DONE], an
+// app token whose run is its project's, OpenAI-shaped errors (no token, bad body), GET /v1/models; and the OpenAI-format
+// adapter's complete() passes tools, tool_choice and the tool messages through as written, mapping max_tokens to
+// max_completion_tokens (and dropping sampling knobs) for a reasoning model.
+const assert = require('assert');
+const { boot, request, token, suite, ALL } = require('./helpers');
+const { createOpenAiProvider } = require('../server/providers/openai');
+
+const t = suite('openai-compat');
+const service = token('live', ALL);
+const PRJ = 'prj_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+const app = token('app', ['ai.app.run'], { sub: `app:app_${PRJ.slice(4)}`, actorType: 'app', ns: [PRJ, `app.${PRJ}.*`], extra: { project_id: PRJ, env: 'production' } });
+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 });
+
+t.test('boot', async () => { h = await boot(); });
+
+t.test('a plain chat answers the Chat Completions shape, with usage and the run it was', async () => {
+ const r = await post({ model: 'openvibe', messages: [{ role: 'system', content: 'Be brief.' }, { role: 'user', content: 'Say hello' }] });
+ assert.strictEqual(r.status, 200, r.text);
+ const c = r.body;
+ assert.strictEqual(c.object, 'chat.completion');
+ assert.match(c.id, /^chatcmpl-run_/);
+ assert.strictEqual(c.choices[0].message.role, 'assistant');
+ assert.strictEqual(typeof c.choices[0].message.content, 'string');
+ assert.strictEqual(c.choices[0].finish_reason, 'stop');
+ assert.ok(c.usage.prompt_tokens > 0 && c.usage.total_tokens === c.usage.prompt_tokens + c.usage.completion_tokens);
+ assert.ok(c.x_openvibe.run_id && c.x_openvibe.provider);
+ assert.strictEqual(r.headers.get('x-openvibe-run-id'), c.x_openvibe.run_id);
+ const run = await request(h.base, 'GET', `/api/v1/runs/${c.x_openvibe.run_id}`, { tok: service });
+ assert.strictEqual(run.body.run.workflow.key, 'ai.complete');
+ assert.ok(!JSON.stringify(run.body.run.input || {}).includes('Say hello'), 'the prompt is not kept');
+});
+
+t.test('tools: the model calls one; the tool result goes back and it answers', async () => {
+ const msgs = [{ role: 'user', content: 'Weather in Lisbon?' }];
+ const first = await post({ model: 'openvibe', messages: msgs, tools: [WEATHER], tool_choice: 'auto' });
+ assert.strictEqual(first.status, 200, first.text);
+ const choice = first.body.choices[0];
+ assert.strictEqual(choice.finish_reason, 'tool_calls');
+ const call = choice.message.tool_calls[0];
+ assert.strictEqual(call.type, 'function');
+ assert.strictEqual(call.function.name, 'get_weather');
+ assert.ok(JSON.parse(call.function.arguments).city !== undefined, 'arguments follow the parameters schema');
+ const second = await post({ model: 'openvibe', tools: [WEATHER], messages: [...msgs, choice.message, { role: 'tool', tool_call_id: call.id, content: '19 C and sunny' }] });
+ assert.strictEqual(second.status, 200, second.text);
+ assert.strictEqual(second.body.choices[0].finish_reason, 'stop');
+ assert.match(second.body.choices[0].message.content, /19 C and sunny/);
+});
+
+t.test('stream: chunks with the delta, then finish_reason, usage on request, then [DONE]', async () => {
+ const res = await fetch(`${h.base}/v1/chat/completions`, { method: 'POST', headers: { Authorization: `Bearer ${service}`, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ model: 'openvibe', stream: true, stream_options: { include_usage: true }, messages: [{ role: 'user', content: 'Stream please' }] }) });
+ assert.strictEqual(res.status, 200);
+ assert.match(res.headers.get('content-type'), /^text\/event-stream/);
+ const lines = (await res.text()).split('\n').filter((l) => l.startsWith('data: ')).map((l) => l.slice(6));
+ assert.strictEqual(lines[lines.length - 1], '[DONE]');
+ const chunks = lines.slice(0, -1).map((l) => JSON.parse(l));
+ assert.ok(chunks.every((c) => c.object === 'chat.completion.chunk'));
+ assert.strictEqual(chunks[0].choices[0].delta.role, 'assistant');
+ assert.strictEqual(typeof chunks[0].choices[0].delta.content, 'string');
+ assert.strictEqual(chunks[1].choices[0].finish_reason, 'stop');
+ assert.ok(chunks.some((c) => c.usage && c.usage.total_tokens > 0), 'usage when asked');
+});
+
+t.test('an app token works, and its run is its project\'s', async () => {
+ const r = await post({ model: 'openvibe', messages: [{ role: 'user', content: 'From an app' }] }, app);
+ assert.strictEqual(r.status, 200, r.text);
+ const run = await request(h.base, 'GET', `/api/v1/runs/${r.body.x_openvibe.run_id}`, { tok: service });
+ assert.match(JSON.stringify(run.body.run.attribution || {}), new RegExp(PRJ));
+});
+
+t.test('errors come in OpenAI\'s shape: no token, no messages, n > 1', async () => {
+ const none = await request(h.base, 'POST', '/v1/chat/completions', { body: { messages: [{ role: 'user', content: 'x' }] } });
+ assert.strictEqual(none.status, 401);
+ assert.strictEqual(typeof none.body.error.message, 'string');
+ assert.strictEqual(none.body.error.type, 'authentication_error');
+ const empty = await post({ model: 'openvibe', messages: [] });
+ assert.deepStrictEqual([empty.status, empty.body.error.type], [400, 'invalid_request_error']);
+ assert.strictEqual((await post({ model: 'openvibe', n: 2, messages: [{ role: 'user', content: 'x' }] })).status, 400);
+});
+
+t.test('a provider may declare tools (and complete) in its capabilities', async () => {
+ const r = await request(h.base, 'POST', '/api/v1/providers', { tok: service, body: { key: 'toolsup', kind: 'openai', base_url: 'https://upstream.example/v1', auth_mode: 'none', capabilities: ['chat', 'complete', 'tools'], default_model: 'm' } });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.deepStrictEqual(r.body.provider.capabilities, ['chat', 'complete', 'tools']);
+ assert.strictEqual((await request(h.base, 'POST', '/api/v1/providers/toolsup/disable', { tok: service, body: {} })).status, 200, 'and is kept out of the way of the other checks');
+});
+
+t.test('GET /v1/models lists the router model', async () => {
+ const r = await request(h.base, 'GET', '/v1/models', { tok: service });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.deepStrictEqual(r.body.data.map((m) => m.id), ['openvibe']);
+});
+
+t.test('the OpenAI-format adapter passes the request through as written', async () => {
+ const seen = [];
+ const fetchImpl = async (url, opts) => {
+ seen.push({ url, body: JSON.parse(opts.body) });
+ return new Response(JSON.stringify({ model: 'up-1', choices: [{ index: 0, message: { role: 'assistant', content: null, tool_calls: [{ id: 'c1', type: 'function', function: { name: 'get_weather', arguments: '{"city":"Oslo"}' } }] }, finish_reason: 'tool_calls' }], usage: { prompt_tokens: 12, completion_tokens: 7 } }), { status: 200, headers: { 'Content-Type': 'application/json' } });
+ };
+ const p = createOpenAiProvider({ key: 'up', base_url: 'https://upstream.example/v1', capabilities: ['chat', 'tools'] }, { apiKey: 'k'.repeat(40), fetchImpl });
+ assert.strictEqual(p.supports('complete'), true, 'complete follows chat');
+ assert.strictEqual(p.supports('tools'), true);
+ const messages = [{ role: 'user', content: 'Weather in Oslo?' }];
+ const out = await p.complete({ model: 'gpt-4.1-mini', maxTokens: 300, temperature: 0.2, openai: { messages, tools: [WEATHER], tool_choice: 'required', parallel_tool_calls: false, top_p: 0.9 } });
+ assert.strictEqual(seen[0].url, 'https://upstream.example/v1/chat/completions');
+ assert.deepStrictEqual(seen[0].body, { model: 'gpt-4.1-mini', messages, tools: [WEATHER], tool_choice: 'required', parallel_tool_calls: false, top_p: 0.9, max_tokens: 300, temperature: 0.2 });
+ assert.deepStrictEqual([out.model, out.usage.input, out.usage.output, out.completion.choices[0].finish_reason], ['up-1', 12, 7, 'tool_calls']);
+ await p.complete({ model: 'gpt-5-nano', maxTokens: 300, temperature: 0.2, openai: { messages, top_p: 0.9 } });
+ assert.deepStrictEqual(seen[1].body, { model: 'gpt-5-nano', messages, max_completion_tokens: 300 }, 'a reasoning model: max_completion_tokens, no sampling knobs');
+ const plain = createOpenAiProvider({ key: 'local', capabilities: ['chat'] }, { fetchImpl });
+ assert.strictEqual(plain.supports('tools'), false, 'tools are declared, never assumed');
+});
+
+t.test('close', async () => { await h.stop(); });
+t.run();
@@ -26,6 +26,7 @@ const EXAMPLES = {
@@ -26,6 +26,7 @@ const EXAMPLES = {
'media.analyze': { media_url: '__MEDIA__/fixture.mp4', language: 'en' },
'media.analyze': { media_url: '__MEDIA__/fixture.mp4', language: 'en' },
'network.site_copy': { sites: [{ id: 'live', name: 'OpenVibe.Live', what: 'live streaming' }], links: [{ id: 'tools', name: 'Tools', about: 'online tools' }] },
'network.site_copy': { sites: [{ id: 'live', name: 'OpenVibe.Live', what: 'live streaming' }], links: [{ id: 'tools', name: 'Tools', about: 'online tools' }] },
'ai.chat': { messages: [{ role: 'user', content: 'hello' }] },
'ai.chat': { messages: [{ role: 'user', content: 'hello' }] },
+ 'ai.complete': { messages: [{ role: 'user', content: 'hello' }], tools: [{ type: 'function', function: { name: 'lookup', parameters: { type: 'object', properties: { q: { type: 'string' } } } } }] },
'ai.generate': { prompt: 'Write a haiku about SQLite.' },
'ai.generate': { prompt: 'Write a haiku about SQLite.' },
'ai.summarize': { text: 'SQLite is an embedded database engine. It is small and fast.', max_words: 20 },
'ai.summarize': { text: 'SQLite is an embedded database engine. It is small and fast.', max_words: 20 },
'ai.classify': { text: 'I love this!', labels: ['positive', 'negative', 'neutral'] },
'ai.classify': { text: 'I love this!', labels: ['positive', 'negative', 'neutral'] },