openvibers/openvibe.ai Commits

AI: an OpenAI-compatible endpoint — POST /v1/chat/completions with tools, metered as runs of ai.complete (#70)

OP OpenVibers committed 29 days ago · 24f0dfee28 · 13 files, +369 −6

* AI: an OpenAI-compatible endpoint — POST /v1/chat/completions with tools, metered as runs of ai.complete

**AI speaks OpenAI's Chat Completions: `POST /v1/chat/completions`, with tools (plan T6).**

**Why:** clients built for OpenAI's API (the SDKs, coding agents, Codes' harnesses) can now point their base URL at `https://ai.openvibe.services/v1`. The backlog's "Codes: BYO keys from AI" waited on this: AI's `/api/v1/chat` is its own run shape and had no tool calling.

**The endpoint (`server/api/openai.js`):**
- **Tokens:** the same as the direct operations, an app's Network token (`ai.app.run`; its project owns and pays for the run) or a first-party service's (`ai.run.create`).
- **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`. `n` must be 1.
- **Model:** `model` is not used to choose. AI's router picks the upstream for `default.chat`, and a request with tools goes only to a provider that declares `tools`.
- **Answer:** `chat.completion` with the upstream's choices and usage, plus `x_openvibe` (`run_id`, `provider`, `fallback_used`, `cost_usd`) and `X-OpenVibe-Run-Id`.
- **Streaming:** `stream: true` sends SSE chunks, emulated (one delta, the finish reason, usage when asked, `[DONE]`).
- **Errors:** OpenAI's shape. The auth layer's problem+json is converted as it is written.
- **Models:** `GET /v1/models` lists `openvibe`.

**Under it:**
- **A run of a new workflow `ai.complete`** (step kind `complete`). The run's quota, free allowance, cost, audit and explain apply, and the prompt is not kept (passthrough). It has its own output schema (choices) and is allowed for apps (`DIRECT_OPS`).
- **`complete()` on the OpenAI-format adapter** sends the request through with the routed model. A reasoning model (gpt-5, o-series) takes `max_completion_tokens` and its default sampling. `complete` follows `chat`, while `tools` must be declared; production's `local` provider (a 1.5B model) does not declare it.
- **The pool** requires `tools` when a request carries tools. The stub answers deterministically: it calls the first tool, then answers the tool's result.
- `/v1` gets the same JSON parser as `/api`; the registry accepts the `complete` step kind.

**Tests:**
- `openai-compat.test.js` (new, nine checks): the plain chat shape, usage and run, with the prompt not kept; a tool call and the tool-result round trip; the streaming chunks and `[DONE]`; an app token's run belonging to its project; OpenAI-shaped errors (no token, empty messages, `n` = 2); `/v1/models`; and the adapter's exact passthrough body, the reasoning-model mapping and that tools are never assumed.
- `workflows.test.js` has an `ai.complete` example, and every workflow runs end to end on the stub.

**Docs:** README "OpenAI-compatible endpoint", and the home's llms text. The 11 reached test files pass.

After deploy, production's `shared` provider declares `tools` (an operator update), so tool requests have an upstream.

* AI: providers may declare tools and complete (the registry's feature list)

Changes

ddiff --git a/README.md b/README.md
iindex e1ba92e..1b0ea90 100644
---- a/README.md
++++ b/README.md
@@@ -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 apps
Roadmap Wave 20, ADR-014. A developer app gets a token from OpenVibe.Network (`POST /oauth/token`,
ddiff --git a/server/api/openai.js b/server/api/openai.js
nnew file mode 100644
iindex 0000000..48bcd16
---- /dev/null
++++ b/server/api/openai.js
@@@ -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 };
ddiff --git a/server/app.js b/server/app.js
iindex d67880a..84bc169 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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 }));
ddiff --git a/server/apps.js b/server/apps.js
iindex 0f3c9d0..0fe9d73 100644
---- a/server/apps.js
++++ b/server/apps.js
@@@ -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) {
ddiff --git a/server/home.js b/server/home.js
iindex d583307..78dca15 100644
---- a/server/home.js
++++ b/server/home.js
@@@ -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.',
'',
ddiff --git a/server/providers/index.js b/server/providers/index.js
iindex c0f8f61..71961ae 100644
---- a/server/providers/index.js
++++ b/server/providers/index.js
@@@ -398,6 +398,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
const 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 either
const { order: list, explain } = await candidates(route, features, operation, ctx);
ddiff --git a/server/providers/openai.js b/server/providers/openai.js
iindex d11db39..2823b19 100644
---- a/server/providers/openai.js
++++ b/server/providers/openai.js
@@@ -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 } };
}
++ /**
++ * 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,
ddiff --git a/server/providers/stub.js b/server/providers/stub.js
iindex d2bc80c..15a2232 100644
---- a/server/providers/stub.js
++++ b/server/providers/stub.js
@@@ -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 } };
}
++ /** 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),
ddiff --git a/server/registry.js b/server/registry.js
iindex dfca965..0923842 100644
---- a/server/registry.js
++++ b/server/registry.js
@@@ -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.e
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');
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');
ddiff --git a/server/workflows/core.js b/server/workflows/core.js
iindex 9f6d421..49f960f 100644
---- a/server/workflows/core.js
++++ b/server/workflows/core.js
@@@ -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',
ddiff --git a/server/workflows/engine.js b/server/workflows/engine.js
iindex 7be90af..f613850 100644
---- a/server/workflows/engine.js
++++ b/server/workflows/engine.js
@@@ -187,6 +187,25 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
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) {
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 = {}, spa
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 }
ddiff --git a/test/openai-compat.test.js b/test/openai-compat.test.js
nnew file mode 100644
iindex 0000000..04e5490
---- /dev/null
++++ b/test/openai-compat.test.js
@@@ -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();
ddiff --git a/test/workflows.test.js b/test/workflows.test.js
iindex 5e0db41..eae5bcb 100644
---- a/test/workflows.test.js
++++ b/test/workflows.test.js
@@@ -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'] },