openvibers/openvibe.ai Commits
API: publish OpenAPI 3.1 at /openapi.json (#188)
Changes
@@@ -9,6 +9,7 @@ const { createAiReadiness, registerAiGauges } = require('./observability');const { runsRouter } = require('./api/runs');const { actionsRouter } = require('./api/actions');const { sendError } = require('./util');++const openapi = require('./openapi');const { adminRouter } = require('./api/admin');const { createOps } = require('./ops');const { consoleRouter } = require('./console');@@@ -84,6 +85,8 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys// fail; provider configuration is optional and degrades it (see observability.js).const readiness = createAiReadiness({ db, registry, pool, keys, runs, release: release.release });app.get('/api/ready', readiness.handler);++ // GET /openapi.json: the public OpenAPI 3.1 document (server/openapi.js); /api/v1/openapi.json redirects here.++ openapi.mount(app);// GET /release.json (ADR-016) and POST /release-metrics (open tabs' update reports into /metrics).release.mount(app, { registry: metrics.registry });nnew file mode 100644@@@ -0,0 +1,250 @@++'use strict';++/**++ * OpenAPI 3.1 for OpenVibe.AI's run API, from the route table below.++ *++ * ROUTES is the table: every entry is one method on one path under /api/v1. server/app.js mounts++ * exactly these routers, and test/openapi.test.js walks the mounted stack and fails when the table++ * and the app differ in either direction — so the document cannot advertise an address this service++ * does not answer.++ *++ * GET /openapi.json this document (public, Cache-Control 5 minutes)++ * GET /api/v1/openapi.json 308 to /openapi.json++ *++ * /api/health and /api/ready are probes. The operator/admin API and the attribution-quota API are++ * internal (staff/service tokens): they stay out. Credentials are deliberately not documented at all++ * — no credential, provider secret or secret reference field appears in this document. The++ * OpenAI-compatible /v1/chat/completions and /v1/models are outside /api and stay out of it too.++ *++ * Request fields are what server/api/runs.js and server/api/actions.js read; the run properties are++ * server/runs.js decode(). Nothing is inferred.++ */++const pkg = require('../package.json');++++const MOUNT = '/api/v1';++const SITE = 'https://openvibe.ai';++++const ref = (name) => ({ $ref: `#/components/schemas/${name}` });++const str = (extra = {}) => ({ type: 'string', ...extra });++const nullable = (type, extra = {}) => ({ type: [type, 'null'], ...extra });++const arrayOf = (items) => ({ type: 'array', items });++const integer = (extra = {}) => ({ type: 'integer', ...extra });++++const problem = {++ description: 'A problem (RFC 9457, application/problem+json): type, title, status and a stable code',++ content: {++ 'application/problem+json': {++ schema: {++ type: 'object',++ required: ['type', 'title', 'status'],++ properties: { type: str(), title: str(), status: integer(), code: str(), detail: str(), request_id: str() },++ },++ },++ },++};++const errors = (codes = [400, 401, 403, 404, 409, 413, 422, 429]) => ({++ ...Object.fromEntries(codes.map((code) => [code, problem])),++ default: problem,++});++++const path = (name, schema, description) => ({ name, in: 'path', required: true, schema, ...(description && { description }) });++const query = (name, schema, description) => ({ name, in: 'query', required: false, schema, ...(description && { description }) });++++const SERVICE = [{ bearer: [] }];++const SERVICE_OR_APP = [{ bearer: [] }, { app: [] }];++++const json = (description, schema) => ({ description, ...(schema ? { content: { 'application/json': { schema } } } : {}) });++const body = (schema, required = true) => ({ required, content: { 'application/json': { schema } } });++++const runBody = (required = []) => ({++ type: 'object', ...(required.length ? { required } : {}),++ properties: {++ workflow: str({ description: 'The workflow key, e.g. ai.generate' }), version: integer({ minimum: 1 }),++ input: { type: 'object' }, idempotency_key: str({ minLength: 8, maxLength: 128 }),++ target: { type: 'object' }, attribution: { type: 'object' }, on_behalf_of: ref('SubjectRef'),++ options: { type: 'object' }, route: { type: 'object', description: '{ provider, model } — one offer from GET /api/v1/routes?operation=…' },++ },++});++const directBody = {++ type: 'object',++ properties: {++ idempotency_key: str({ minLength: 8, maxLength: 128 }), target: { type: 'object' }, attribution: { type: 'object' },++ on_behalf_of: ref('SubjectRef'), options: { type: 'object' },++ },++};++++const PATHS = {};++function route(method, p, operation) {++ (PATHS[p] = PATHS[p] || {})[method] = operation;++}++++// ── Runs ──────────────────────────────────────────────────────────────────────────────────────++route('post', '/runs', {++ operationId: 'createRun', summary: 'Start a workflow run (201 finished, 202 queued or running, 200 replay)', tags: ['runs'], security: SERVICE,++ description: 'ai.run.create. ?wait=ms waits for the result (bounded by the service maximum); ?stream=1 answers text/event-stream (delta { text } as the answer is written, then run { run }, or error { code, message }). A 202 carries Location: /api/v1/runs/{id}.',++ parameters: [query('wait', integer({ minimum: 0 }), 'Milliseconds to wait for the result'), query('stream', { type: 'string', enum: ['1'] }, 'Answer server-sent events')],++ requestBody: body(runBody(['workflow'])),++ responses: {++ 201: json('Finished (or served from the cache)', ref('RunResponse')),++ 202: { description: 'Still queued or running', headers: { Location: { schema: { type: 'string' } } }, content: { 'application/json': { schema: ref('RunResponse') } } },++ 200: json('An idempotent replay of an earlier run', ref('RunResponse')),++ 429: { ...problem, headers: { 'Retry-After': { schema: { type: 'integer' } } } },++ ...errors([400, 401, 403, 404, 409, 413, 422, 503]),++ },++});++route('get', '/runs', {++ operationId: 'listRuns', summary: 'The caller\'s runs, newest first', tags: ['runs'], security: SERVICE,++ description: 'ai.run.read (or ai.run.create). `all=1` needs ai.usage.read.',++ parameters: [++ query('status', str()), query('workflow', str()), query('target', str()), query('limit', integer({ minimum: 1 })),++ query('before', str(), 'The next page cursor'), query('all', { type: 'string', enum: ['1'] }, 'Every caller\'s runs (ai.usage.read)'),++ ],++ responses: { 200: json('Runs', { type: 'object', required: ['runs'], properties: { runs: arrayOf(ref('Run')) } }), ...errors([400, 401, 403, 429]) },++});++route('get', '/runs/{id}', {++ operationId: 'getRun', summary: 'One run with its citations and request log', tags: ['runs'], security: SERVICE_OR_APP,++ description: 'ai.run.read (or ai.run.create; an app token needs ai.app.run). Another caller\'s run answers 404.',++ parameters: [path('id', str())],++ responses: { 200: json('The run', { type: 'object', required: ['run'], properties: {++ run: ref('Run'), citations: arrayOf(ref('Citation')), requests: arrayOf(ref('RunRequest')),++ } }), ...errors([401, 403, 404, 429]) },++});++route('post', '/runs/{id}/cancel', {++ operationId: 'cancelRun', summary: 'Cancel a queued or running run (owner)', tags: ['runs'], security: SERVICE,++ description: 'ai.run.create.',++ parameters: [path('id', str())],++ responses: { 200: json('The run', { type: 'object', required: ['run'], properties: { run: ref('Run') } }), ...errors([401, 403, 404, 409, 429]) },++});++route('post', '/runs/{id}/retry', {++ operationId: 'retryRun', summary: 'Retry a failed run as a new run (owner)', tags: ['runs'], security: SERVICE,++ description: 'ai.run.create. The new run carries retry_of.',++ parameters: [path('id', str()), query('wait', integer({ minimum: 0 }))],++ responses: { 201: json('The new run', { type: 'object', required: ['run'], properties: { run: ref('Run') } }),++ 202: json('Still running', { type: 'object', required: ['run'], properties: { run: ref('Run') } }), ...errors([401, 403, 404, 409, 429]) },++});++route('get', '/runs/{id}/citations', {++ operationId: 'listRunCitations', summary: 'A run\'s citations', tags: ['runs'], security: SERVICE,++ description: 'ai.run.read.',++ parameters: [path('id', str())],++ responses: { 200: json('Citations', { type: 'object', required: ['citations'], properties: { citations: arrayOf(ref('Citation')) } }), ...errors([401, 403, 404, 429]) },++});++route('post', '/runs/{id}/citations', {++ operationId: 'attachRunCitations', summary: 'Attach sources or citations to a finished run (owner)', tags: ['runs'], security: SERVICE,++ description: 'ai.run.create. 1 to 50 items; every item needs a source_type.',++ parameters: [path('id', str())],++ requestBody: body({ type: 'object', required: ['citations'], properties: { citations: { type: 'array', minItems: 1, maxItems: 50, items: ref('CitationInput') } } }),++ responses: { 201: json('The run\'s citations', { type: 'object', required: ['citations'], properties: { citations: arrayOf(ref('Citation')) } }), ...errors([401, 403, 404, 422, 429]) },++});++++// ── Direct operations ────────────────────────────────────────────────────────────────────────++for (const op of ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'embed', 'image']) {++ const appAllowed = op !== 'enrich' && op !== 'image';++ route('post', `/${op}`, {++ operationId: `${op}Run`, summary: `Run ai.${op} directly and wait for the answer`, tags: ['operations'],++ security: appAllowed ? SERVICE_OR_APP : SERVICE,++ description: `${appAllowed ? 'ai.run.create (or an app token with ai.app.run) for a run of workflow ai.${op}' : `ai.run.create for a first-party run of workflow ai.${op}`}. The operation's own input fields sit at the top level; workflow/version are not accepted here.`,++ parameters: [query('wait', integer({ minimum: 0 }), 'Milliseconds to wait; defaults to the service maximum')],++ requestBody: body(directBody),++ responses: {++ 201: json('Finished', ref('RunResponse')),++ 202: { description: 'Still running', headers: { Location: { schema: { type: 'string' } } }, content: { 'application/json': { schema: ref('RunResponse') } } },++ 200: json('An idempotent replay', ref('RunResponse')),++ 429: { ...problem, headers: { 'Retry-After': { schema: { type: 'integer' } } } },++ ...errors([400, 401, 403, 404, 409, 413, 422, 503]),++ },++ });++}++route('post', '/research', {++ operationId: 'research', summary: 'Gather research sources for a question (workflow research.gather)', tags: ['operations'], security: SERVICE_OR_APP,++ description: 'ai.run.create (or an app token with ai.app.run). An app\'s run leaves DeepSeek\'s paid web search out and reads only the free sources.',++ parameters: [query('wait', integer({ minimum: 0 }))],++ requestBody: body(directBody),++ responses: { 201: json('Finished', ref('RunResponse')), 202: json('Still running', ref('RunResponse')), 200: json('Replay', ref('RunResponse')), ...errors() },++});++++// ── Action Center ────────────────────────────────────────────────────────────────────────────++route('get', '/actions', {++ operationId: 'listActions', summary: 'The Action Center items for one person', tags: ['actions'], security: SERVICE,++ description: 'ai.actions.read: a service token names one usr_ subject and gets only the runs whose on_behalf_of is that person — queued and running, then done and failed from the last 30 days, at most 50.',++ parameters: [query('subject', str({ pattern: '^usr_[0-9A-HJKMNP-TV-Z]{26}$' }), 'Required')],++ responses: { 200: json('common.action-list-result@1', { type: 'object', required: ['items'], properties: { items: arrayOf(ref('ActionItem')) } }), ...errors([400, 401, 403, 429]) },++});++++function openapi() {++ return {++ openapi: '3.1.0',++ info: {++ title: 'OpenVibe.AI API',++ version: pkg.version,++ description: pkg.description,++ license: { name: 'AGPL-3.0-only', identifier: 'AGPL-3.0-only' },++ contact: { name: 'OpenVibers', url: 'https://github.com/OpenVibers/OpenVibe.AI', email: '[email protected]' },++ },++ servers: [{ url: SITE }],++ tags: ['runs', 'operations', 'actions'].map((name) => ({ name })),++ components: {++ securitySchemes: {++ bearer: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT', description: 'A Network service or user token for audience openvibe.ai carrying the route\'s capability (ai.run.create, ai.run.read, ai.actions.read)' },++ app: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT', description: 'A developer app token with ai.app.run: it runs only its own app.<project_key>.* namespace and is metered as its project' },++ },++ schemas: {++ SubjectRef: { type: 'object', required: ['type', 'id'], properties: { type: str(), id: str() } },++ WorkflowRef: { type: 'object', required: ['key', 'version'], properties: { key: str(), version: integer() } },++ Run: { type: 'object', required: ['id', 'status', 'workflow'], properties: {++ id: str(), status: { type: 'string', enum: ['queued', 'running', 'succeeded', 'failed', 'cancelled'] },++ workflow: ref('WorkflowRef'), template: nullable('object'), route: nullable('object'),++ requester: { type: 'object', properties: { type: str(), id: str() } }, on_behalf_of: nullable('object'), attribution: nullable('object'),++ source_service: nullable('string'), target: nullable('object'), input: nullable('object'), output: nullable('object'),++ error: nullable('object', { properties: { code: str(), detail: str() } }), synthetic: { type: 'boolean' },++ provenance: { type: 'object' },++ usage: { type: 'object', properties: { tokens_in: nullable('integer'), tokens_out: nullable('integer'), cost_usd: nullable('number'), attempts: nullable('integer'), usage_sample_ids: nullable(arrayOf(str())) } },++ explain: nullable('object'), citations_count: nullable('integer'), grounding: nullable('object'),++ retry_of: nullable('string'), idempotency_key: nullable('string'), trace_id: nullable('string'),++ created_at: str({ format: 'date-time' }), started_at: nullable('string', { format: 'date-time' }), finished_at: nullable('string', { format: 'date-time' }),++ } },++ RunResponse: { type: 'object', required: ['run'], properties: { run: ref('Run'), replayed: { type: 'boolean' } } },++ RunRequest: { type: 'object', properties: {++ id: str(), seq: integer(), operation: str(), provider_key: nullable('string'), model_key: nullable('string'),++ route_key: nullable('string'), route_version: nullable('integer'), status: str(), skip_reason: nullable('string'),++ fallback: { type: 'boolean' }, prompt_hash: nullable('string'), input_hash: nullable('string'), output_hash: nullable('string'),++ tokens_in: nullable('integer'), tokens_out: nullable('integer'), tokens_cached: nullable('integer'), tokens_estimated: nullable('integer'),++ cost_usd: nullable('number'), latency_ms: nullable('integer'), error: nullable('string'), created_at: str({ format: 'date-time' }),++ } },++ CitationInput: { type: 'object', required: ['source_type'], properties: {++ source_type: str({ pattern: '^[a-z][a-z0-9_.-]{1,63}$' }), source_id: str(), url: str(), title: str(),++ author: str(), snippet: str(), published_at: str(), retrieved_at: str(), provenance: { type: 'object' },++ }, additionalProperties: false },++ Citation: { type: 'object', properties: {++ id: str(), run_id: str(), ordinal: integer(), source_type: str(), source_id: nullable('string'), url: nullable('string'),++ title: nullable('string'), author: nullable('string'), published_at: nullable('string'), retrieved_at: nullable('string'),++ snippet: nullable('string'), content_hash: nullable('string'), trust: { type: 'object' }, provenance: { type: 'object' },++ attached_by: str(), created_at: str({ format: 'date-time' }),++ } },++ ActionItem: { type: 'object', required: ['service', 'id', 'kind', 'title', 'bucket', 'state'], properties: {++ service: { const: 'ai' }, id: str(), kind: { const: 'ai_run' }, title: str(), summary: nullable('string'),++ bucket: { type: 'string', enum: ['active', 'done', 'failed'] }, state: str(), agent: nullable('string'),++ cost_usd: nullable('number'), steps: nullable('integer'), error: nullable('string'),++ created_at: str({ format: 'date-time' }), updated_at: str({ format: 'date-time' }), href: str(),++ actions: arrayOf(str()),++ } },++ },++ },++ paths: { ...Object.fromEntries(Object.entries(PATHS).sort(([a], [b]) => a.localeCompare(b)).map(([p, item]) => [MOUNT + p, item])) },++ };++}++++/** The documented routes as "METHOD /api/v1/path": what the app must mount. */++function routes() {++ return Object.entries(PATHS).flatMap(([p, item]) => Object.keys(item).map((method) => `${method.toUpperCase()} ${MOUNT}${p}`)).sort();++}++++/** Serve GET /openapi.json here; GET /api/v1/openapi.json redirects to it (308). */++function mount(app, document = openapi()) {++ const send = (_req, res) => {++ res.set('Cache-Control', 'public, max-age=300');++ res.type('application/json; charset=utf-8').send(JSON.stringify(document));++ };++ app.get('/openapi.json', send);++ app.get('/api/v1/openapi.json', (_req, res) => res.redirect(308, '/openapi.json'));++}++++module.exports = { openapi, routes, mount, MOUNT };nnew file mode 100644@@@ -0,0 +1,100 @@++'use strict';++/**++ * /openapi.json documents this service's run API, and the document is the API: every route it lists++ * is a route the app mounts, and every mounted public /api/v1 route is listed (the probes, the++ * redirect, the operator/admin API, the attribution-quota API and credentials excepted). The mounted++ * set is read from the real Express stack, so a route added, renamed or removed without the document++ * fails here. Credentials are excluded on purpose: no secret-bearing field may reach the document.++ */++const assert = require('assert');++const H = require('./helpers');++const { routes, MOUNT } = require('../server/openapi');++++/** The path an app.use(...) layer mounts its router at, decoded from Express's compiled regexp. */++function mountOf(layer) {++ const source = String((layer.regexp && layer.regexp.source) || '');++ if (layer.regexp && layer.regexp.fast_slash) return '';++ const m = /^\^(.*?)\\\/\?\(\?=\\\/\|\$\)$/.exec(source);++ assert.ok(m, `cannot read the mount path of a router layer (${source})`);++ return m[1].replace(/\\\//g, '/').replace(/\\\./g, '.');++}++++/** ":param" as OpenAPI writes it, "{param}". */++const templated = (p) => String(p).replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, '{$1}');++++/** Every "METHOD /path" the app answers, routers included. */++function mounted(app) {++ const out = new Set();++ const walk = (stack, prefix) => {++ for (const layer of stack) {++ if (layer.route) {++ for (const [method, on] of Object.entries(layer.route.methods)) {++ if (!on || method === '_all') continue;++ for (const p of [].concat(layer.route.path)) out.add(`${method.toUpperCase()} ${prefix}${templated(p)}`);++ }++ } else if (layer.name === 'router' && layer.handle && layer.handle.stack) {++ walk(layer.handle.stack, prefix + mountOf(layer));++ }++ }++ };++ assert.ok(app._router && app._router.stack, 'the app has a router');++ walk(app._router.stack, '');++ return out;++}++++/**++ * Not public API surface: probes and redirect, the operator/admin API, the attribution-quota API and++ * the credential routes (which must never appear in a public document).++ */++const LEFT_OUT = [++ '/api/health', '/api/ready', '/api/v1/openapi.json',++ '/api/v1/status', '/api/v1/providers', '/api/v1/models', '/api/v1/routes', '/api/v1/templates', '/api/v1/workflows',++ '/api/v1/quotas', '/api/v1/usage', '/api/v1/requests', '/api/v1/audit', '/api/v1/cache',++ '/api/v1/credentials', '/api/v1/attribution-quotas',++];++const excluded = (key) => {++ const p = key.split(' ')[1];++ return LEFT_OUT.some((prefix) => p === prefix || p.startsWith(`${prefix}/`));++};++++(async () => {++ const h = await H.boot();++ try {++ const r = await H.request(h.base, 'GET', '/openapi.json');++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers.get('content-type'), 'application/json; charset=utf-8');++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=300');++ assert.ok(r.body, 'the response is JSON');++++ const doc = r.body;++ assert.strictEqual(doc.openapi, '3.1.0');++ assert.ok(doc.paths && typeof doc.paths === 'object', 'paths is an object');++ assert.strictEqual(doc.info.title, 'OpenVibe.AI API');++ assert.deepStrictEqual(doc.servers, [{ url: 'https://openvibe.ai' }]);++ const documented = Object.entries(doc.paths).flatMap(([p, item]) => Object.keys(item).map((m) => `${m.toUpperCase()} ${p}`)).sort();++ assert.ok(documented.length > 0, 'paths is not empty');++ for (const [p, item] of Object.entries(doc.paths)) {++ assert.ok(p.startsWith('/api/'), `${p} is outside /api`);++ for (const [method, op] of Object.entries(item)) {++ assert.ok(op.operationId, `${method.toUpperCase()} ${p} has an operationId`);++ assert.ok(op.responses && Object.keys(op.responses).length, `${method.toUpperCase()} ${p} describes its responses`);++ }++ }++ // The document never carries a credential or a provider secret field.++ const text = JSON.stringify(doc);++ for (const field of ['credential', 'api_key', 'secret_ref', 'project_provider']) {++ assert.ok(!text.includes(`"${field}"`), `the document must not name ${field}`);++ }++++ const live = [...mounted(h.app)].filter((key) => key.split(' ')[1].startsWith(`${MOUNT}/`) && !excluded(key)).sort();++ assert.deepStrictEqual(documented, live, 'every mounted public /api/v1 route is documented and vice versa');++ assert.deepStrictEqual(routes(), documented, 'server/openapi.js routes() and the document agree');++++ const redirect = await fetch(`${h.base}/api/v1/openapi.json`, { redirect: 'manual' });++ assert.strictEqual(redirect.status, 308);++ assert.strictEqual(redirect.headers.get('location'), '/openapi.json');++ } finally {++ await h.stop();++ }++ console.log('openapi: /openapi.json documents exactly the public /api/v1 routes');++})().catch((err) => { console.error(err); process.exitCode = 1; });
@@ -9,6 +9,7 @@ const { createAiReadiness, registerAiGauges } = require('./observability');
@@ -9,6 +9,7 @@ const { createAiReadiness, registerAiGauges } = require('./observability');
const { runsRouter } = require('./api/runs');
const { runsRouter } = require('./api/runs');
const { actionsRouter } = require('./api/actions');
const { actionsRouter } = require('./api/actions');
const { sendError } = require('./util');
const { sendError } = require('./util');
+const openapi = require('./openapi');
const { adminRouter } = require('./api/admin');
const { adminRouter } = require('./api/admin');
const { createOps } = require('./ops');
const { createOps } = require('./ops');
const { consoleRouter } = require('./console');
const { consoleRouter } = require('./console');
@@ -84,6 +85,8 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
@@ -84,6 +85,8 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
// fail; provider configuration is optional and degrades it (see observability.js).
// fail; provider configuration is optional and degrades it (see observability.js).
const readiness = createAiReadiness({ db, registry, pool, keys, runs, release: release.release });
const readiness = createAiReadiness({ db, registry, pool, keys, runs, release: release.release });
app.get('/api/ready', readiness.handler);
app.get('/api/ready', readiness.handler);
+ // GET /openapi.json: the public OpenAPI 3.1 document (server/openapi.js); /api/v1/openapi.json redirects here.
+ openapi.mount(app);
// GET /release.json (ADR-016) and POST /release-metrics (open tabs' update reports into /metrics).
// GET /release.json (ADR-016) and POST /release-metrics (open tabs' update reports into /metrics).
release.mount(app, { registry: metrics.registry });
release.mount(app, { registry: metrics.registry });
new file mode 100644
new file mode 100644
@@ -0,0 +1,250 @@
@@ -0,0 +1,250 @@
+'use strict';
+/**
+ * OpenAPI 3.1 for OpenVibe.AI's run API, from the route table below.
+ *
+ * ROUTES is the table: every entry is one method on one path under /api/v1. server/app.js mounts
+ * exactly these routers, and test/openapi.test.js walks the mounted stack and fails when the table
+ * and the app differ in either direction — so the document cannot advertise an address this service
+ * does not answer.
+ *
+ * GET /openapi.json this document (public, Cache-Control 5 minutes)
+ * GET /api/v1/openapi.json 308 to /openapi.json
+ *
+ * /api/health and /api/ready are probes. The operator/admin API and the attribution-quota API are
+ * internal (staff/service tokens): they stay out. Credentials are deliberately not documented at all
+ * — no credential, provider secret or secret reference field appears in this document. The
+ * OpenAI-compatible /v1/chat/completions and /v1/models are outside /api and stay out of it too.
+ *
+ * Request fields are what server/api/runs.js and server/api/actions.js read; the run properties are
+ * server/runs.js decode(). Nothing is inferred.
+ */
+const pkg = require('../package.json');
+
+const MOUNT = '/api/v1';
+const SITE = 'https://openvibe.ai';
+
+const ref = (name) => ({ $ref: `#/components/schemas/${name}` });
+const str = (extra = {}) => ({ type: 'string', ...extra });
+const nullable = (type, extra = {}) => ({ type: [type, 'null'], ...extra });
+const arrayOf = (items) => ({ type: 'array', items });
+const integer = (extra = {}) => ({ type: 'integer', ...extra });
+
+const problem = {
+ description: 'A problem (RFC 9457, application/problem+json): type, title, status and a stable code',
+ content: {
+ 'application/problem+json': {
+ schema: {
+ type: 'object',
+ required: ['type', 'title', 'status'],
+ properties: { type: str(), title: str(), status: integer(), code: str(), detail: str(), request_id: str() },
+ },
+ },
+ },
+};
+const errors = (codes = [400, 401, 403, 404, 409, 413, 422, 429]) => ({
+ ...Object.fromEntries(codes.map((code) => [code, problem])),
+ default: problem,
+});
+
+const path = (name, schema, description) => ({ name, in: 'path', required: true, schema, ...(description && { description }) });
+const query = (name, schema, description) => ({ name, in: 'query', required: false, schema, ...(description && { description }) });
+
+const SERVICE = [{ bearer: [] }];
+const SERVICE_OR_APP = [{ bearer: [] }, { app: [] }];
+
+const json = (description, schema) => ({ description, ...(schema ? { content: { 'application/json': { schema } } } : {}) });
+const body = (schema, required = true) => ({ required, content: { 'application/json': { schema } } });
+
+const runBody = (required = []) => ({
+ type: 'object', ...(required.length ? { required } : {}),
+ properties: {
+ workflow: str({ description: 'The workflow key, e.g. ai.generate' }), version: integer({ minimum: 1 }),
+ input: { type: 'object' }, idempotency_key: str({ minLength: 8, maxLength: 128 }),
+ target: { type: 'object' }, attribution: { type: 'object' }, on_behalf_of: ref('SubjectRef'),
+ options: { type: 'object' }, route: { type: 'object', description: '{ provider, model } — one offer from GET /api/v1/routes?operation=…' },
+ },
+});
+const directBody = {
+ type: 'object',
+ properties: {
+ idempotency_key: str({ minLength: 8, maxLength: 128 }), target: { type: 'object' }, attribution: { type: 'object' },
+ on_behalf_of: ref('SubjectRef'), options: { type: 'object' },
+ },
+};
+
+const PATHS = {};
+function route(method, p, operation) {
+ (PATHS[p] = PATHS[p] || {})[method] = operation;
+}
+
+// ── Runs ──────────────────────────────────────────────────────────────────────────────────────
+route('post', '/runs', {
+ operationId: 'createRun', summary: 'Start a workflow run (201 finished, 202 queued or running, 200 replay)', tags: ['runs'], security: SERVICE,
+ description: 'ai.run.create. ?wait=ms waits for the result (bounded by the service maximum); ?stream=1 answers text/event-stream (delta { text } as the answer is written, then run { run }, or error { code, message }). A 202 carries Location: /api/v1/runs/{id}.',
+ parameters: [query('wait', integer({ minimum: 0 }), 'Milliseconds to wait for the result'), query('stream', { type: 'string', enum: ['1'] }, 'Answer server-sent events')],
+ requestBody: body(runBody(['workflow'])),
+ responses: {
+ 201: json('Finished (or served from the cache)', ref('RunResponse')),
+ 202: { description: 'Still queued or running', headers: { Location: { schema: { type: 'string' } } }, content: { 'application/json': { schema: ref('RunResponse') } } },
+ 200: json('An idempotent replay of an earlier run', ref('RunResponse')),
+ 429: { ...problem, headers: { 'Retry-After': { schema: { type: 'integer' } } } },
+ ...errors([400, 401, 403, 404, 409, 413, 422, 503]),
+ },
+});
+route('get', '/runs', {
+ operationId: 'listRuns', summary: 'The caller\'s runs, newest first', tags: ['runs'], security: SERVICE,
+ description: 'ai.run.read (or ai.run.create). `all=1` needs ai.usage.read.',
+ parameters: [
+ query('status', str()), query('workflow', str()), query('target', str()), query('limit', integer({ minimum: 1 })),
+ query('before', str(), 'The next page cursor'), query('all', { type: 'string', enum: ['1'] }, 'Every caller\'s runs (ai.usage.read)'),
+ ],
+ responses: { 200: json('Runs', { type: 'object', required: ['runs'], properties: { runs: arrayOf(ref('Run')) } }), ...errors([400, 401, 403, 429]) },
+});
+route('get', '/runs/{id}', {
+ operationId: 'getRun', summary: 'One run with its citations and request log', tags: ['runs'], security: SERVICE_OR_APP,
+ description: 'ai.run.read (or ai.run.create; an app token needs ai.app.run). Another caller\'s run answers 404.',
+ parameters: [path('id', str())],
+ responses: { 200: json('The run', { type: 'object', required: ['run'], properties: {
+ run: ref('Run'), citations: arrayOf(ref('Citation')), requests: arrayOf(ref('RunRequest')),
+ } }), ...errors([401, 403, 404, 429]) },
+});
+route('post', '/runs/{id}/cancel', {
+ operationId: 'cancelRun', summary: 'Cancel a queued or running run (owner)', tags: ['runs'], security: SERVICE,
+ description: 'ai.run.create.',
+ parameters: [path('id', str())],
+ responses: { 200: json('The run', { type: 'object', required: ['run'], properties: { run: ref('Run') } }), ...errors([401, 403, 404, 409, 429]) },
+});
+route('post', '/runs/{id}/retry', {
+ operationId: 'retryRun', summary: 'Retry a failed run as a new run (owner)', tags: ['runs'], security: SERVICE,
+ description: 'ai.run.create. The new run carries retry_of.',
+ parameters: [path('id', str()), query('wait', integer({ minimum: 0 }))],
+ responses: { 201: json('The new run', { type: 'object', required: ['run'], properties: { run: ref('Run') } }),
+ 202: json('Still running', { type: 'object', required: ['run'], properties: { run: ref('Run') } }), ...errors([401, 403, 404, 409, 429]) },
+});
+route('get', '/runs/{id}/citations', {
+ operationId: 'listRunCitations', summary: 'A run\'s citations', tags: ['runs'], security: SERVICE,
+ description: 'ai.run.read.',
+ parameters: [path('id', str())],
+ responses: { 200: json('Citations', { type: 'object', required: ['citations'], properties: { citations: arrayOf(ref('Citation')) } }), ...errors([401, 403, 404, 429]) },
+});
+route('post', '/runs/{id}/citations', {
+ operationId: 'attachRunCitations', summary: 'Attach sources or citations to a finished run (owner)', tags: ['runs'], security: SERVICE,
+ description: 'ai.run.create. 1 to 50 items; every item needs a source_type.',
+ parameters: [path('id', str())],
+ requestBody: body({ type: 'object', required: ['citations'], properties: { citations: { type: 'array', minItems: 1, maxItems: 50, items: ref('CitationInput') } } }),
+ responses: { 201: json('The run\'s citations', { type: 'object', required: ['citations'], properties: { citations: arrayOf(ref('Citation')) } }), ...errors([401, 403, 404, 422, 429]) },
+});
+
+// ── Direct operations ────────────────────────────────────────────────────────────────────────
+for (const op of ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'embed', 'image']) {
+ const appAllowed = op !== 'enrich' && op !== 'image';
+ route('post', `/${op}`, {
+ operationId: `${op}Run`, summary: `Run ai.${op} directly and wait for the answer`, tags: ['operations'],
+ security: appAllowed ? SERVICE_OR_APP : SERVICE,
+ description: `${appAllowed ? 'ai.run.create (or an app token with ai.app.run) for a run of workflow ai.${op}' : `ai.run.create for a first-party run of workflow ai.${op}`}. The operation's own input fields sit at the top level; workflow/version are not accepted here.`,
+ parameters: [query('wait', integer({ minimum: 0 }), 'Milliseconds to wait; defaults to the service maximum')],
+ requestBody: body(directBody),
+ responses: {
+ 201: json('Finished', ref('RunResponse')),
+ 202: { description: 'Still running', headers: { Location: { schema: { type: 'string' } } }, content: { 'application/json': { schema: ref('RunResponse') } } },
+ 200: json('An idempotent replay', ref('RunResponse')),
+ 429: { ...problem, headers: { 'Retry-After': { schema: { type: 'integer' } } } },
+ ...errors([400, 401, 403, 404, 409, 413, 422, 503]),
+ },
+ });
+}
+route('post', '/research', {
+ operationId: 'research', summary: 'Gather research sources for a question (workflow research.gather)', tags: ['operations'], security: SERVICE_OR_APP,
+ description: 'ai.run.create (or an app token with ai.app.run). An app\'s run leaves DeepSeek\'s paid web search out and reads only the free sources.',
+ parameters: [query('wait', integer({ minimum: 0 }))],
+ requestBody: body(directBody),
+ responses: { 201: json('Finished', ref('RunResponse')), 202: json('Still running', ref('RunResponse')), 200: json('Replay', ref('RunResponse')), ...errors() },
+});
+
+// ── Action Center ────────────────────────────────────────────────────────────────────────────
+route('get', '/actions', {
+ operationId: 'listActions', summary: 'The Action Center items for one person', tags: ['actions'], security: SERVICE,
+ description: 'ai.actions.read: a service token names one usr_ subject and gets only the runs whose on_behalf_of is that person — queued and running, then done and failed from the last 30 days, at most 50.',
+ parameters: [query('subject', str({ pattern: '^usr_[0-9A-HJKMNP-TV-Z]{26}$' }), 'Required')],
+ responses: { 200: json('common.action-list-result@1', { type: 'object', required: ['items'], properties: { items: arrayOf(ref('ActionItem')) } }), ...errors([400, 401, 403, 429]) },
+});
+
+function openapi() {
+ return {
+ openapi: '3.1.0',
+ info: {
+ title: 'OpenVibe.AI API',
+ version: pkg.version,
+ description: pkg.description,
+ license: { name: 'AGPL-3.0-only', identifier: 'AGPL-3.0-only' },
+ contact: { name: 'OpenVibers', url: 'https://github.com/OpenVibers/OpenVibe.AI', email: '[email protected]' },
+ },
+ servers: [{ url: SITE }],
+ tags: ['runs', 'operations', 'actions'].map((name) => ({ name })),
+ components: {
+ securitySchemes: {
+ bearer: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT', description: 'A Network service or user token for audience openvibe.ai carrying the route\'s capability (ai.run.create, ai.run.read, ai.actions.read)' },
+ app: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT', description: 'A developer app token with ai.app.run: it runs only its own app.<project_key>.* namespace and is metered as its project' },
+ },
+ schemas: {
+ SubjectRef: { type: 'object', required: ['type', 'id'], properties: { type: str(), id: str() } },
+ WorkflowRef: { type: 'object', required: ['key', 'version'], properties: { key: str(), version: integer() } },
+ Run: { type: 'object', required: ['id', 'status', 'workflow'], properties: {
+ id: str(), status: { type: 'string', enum: ['queued', 'running', 'succeeded', 'failed', 'cancelled'] },
+ workflow: ref('WorkflowRef'), template: nullable('object'), route: nullable('object'),
+ requester: { type: 'object', properties: { type: str(), id: str() } }, on_behalf_of: nullable('object'), attribution: nullable('object'),
+ source_service: nullable('string'), target: nullable('object'), input: nullable('object'), output: nullable('object'),
+ error: nullable('object', { properties: { code: str(), detail: str() } }), synthetic: { type: 'boolean' },
+ provenance: { type: 'object' },
+ usage: { type: 'object', properties: { tokens_in: nullable('integer'), tokens_out: nullable('integer'), cost_usd: nullable('number'), attempts: nullable('integer'), usage_sample_ids: nullable(arrayOf(str())) } },
+ explain: nullable('object'), citations_count: nullable('integer'), grounding: nullable('object'),
+ retry_of: nullable('string'), idempotency_key: nullable('string'), trace_id: nullable('string'),
+ created_at: str({ format: 'date-time' }), started_at: nullable('string', { format: 'date-time' }), finished_at: nullable('string', { format: 'date-time' }),
+ } },
+ RunResponse: { type: 'object', required: ['run'], properties: { run: ref('Run'), replayed: { type: 'boolean' } } },
+ RunRequest: { type: 'object', properties: {
+ id: str(), seq: integer(), operation: str(), provider_key: nullable('string'), model_key: nullable('string'),
+ route_key: nullable('string'), route_version: nullable('integer'), status: str(), skip_reason: nullable('string'),
+ fallback: { type: 'boolean' }, prompt_hash: nullable('string'), input_hash: nullable('string'), output_hash: nullable('string'),
+ tokens_in: nullable('integer'), tokens_out: nullable('integer'), tokens_cached: nullable('integer'), tokens_estimated: nullable('integer'),
+ cost_usd: nullable('number'), latency_ms: nullable('integer'), error: nullable('string'), created_at: str({ format: 'date-time' }),
+ } },
+ CitationInput: { type: 'object', required: ['source_type'], properties: {
+ source_type: str({ pattern: '^[a-z][a-z0-9_.-]{1,63}$' }), source_id: str(), url: str(), title: str(),
+ author: str(), snippet: str(), published_at: str(), retrieved_at: str(), provenance: { type: 'object' },
+ }, additionalProperties: false },
+ Citation: { type: 'object', properties: {
+ id: str(), run_id: str(), ordinal: integer(), source_type: str(), source_id: nullable('string'), url: nullable('string'),
+ title: nullable('string'), author: nullable('string'), published_at: nullable('string'), retrieved_at: nullable('string'),
+ snippet: nullable('string'), content_hash: nullable('string'), trust: { type: 'object' }, provenance: { type: 'object' },
+ attached_by: str(), created_at: str({ format: 'date-time' }),
+ } },
+ ActionItem: { type: 'object', required: ['service', 'id', 'kind', 'title', 'bucket', 'state'], properties: {
+ service: { const: 'ai' }, id: str(), kind: { const: 'ai_run' }, title: str(), summary: nullable('string'),
+ bucket: { type: 'string', enum: ['active', 'done', 'failed'] }, state: str(), agent: nullable('string'),
+ cost_usd: nullable('number'), steps: nullable('integer'), error: nullable('string'),
+ created_at: str({ format: 'date-time' }), updated_at: str({ format: 'date-time' }), href: str(),
+ actions: arrayOf(str()),
+ } },
+ },
+ },
+ paths: { ...Object.fromEntries(Object.entries(PATHS).sort(([a], [b]) => a.localeCompare(b)).map(([p, item]) => [MOUNT + p, item])) },
+ };
+}
+
+/** The documented routes as "METHOD /api/v1/path": what the app must mount. */
+function routes() {
+ return Object.entries(PATHS).flatMap(([p, item]) => Object.keys(item).map((method) => `${method.toUpperCase()} ${MOUNT}${p}`)).sort();
+}
+
+/** Serve GET /openapi.json here; GET /api/v1/openapi.json redirects to it (308). */
+function mount(app, document = openapi()) {
+ const send = (_req, res) => {
+ res.set('Cache-Control', 'public, max-age=300');
+ res.type('application/json; charset=utf-8').send(JSON.stringify(document));
+ };
+ app.get('/openapi.json', send);
+ app.get('/api/v1/openapi.json', (_req, res) => res.redirect(308, '/openapi.json'));
+}
+
+module.exports = { openapi, routes, mount, MOUNT };
new file mode 100644
new file mode 100644
@@ -0,0 +1,100 @@
@@ -0,0 +1,100 @@
+'use strict';
+/**
+ * /openapi.json documents this service's run API, and the document is the API: every route it lists
+ * is a route the app mounts, and every mounted public /api/v1 route is listed (the probes, the
+ * redirect, the operator/admin API, the attribution-quota API and credentials excepted). The mounted
+ * set is read from the real Express stack, so a route added, renamed or removed without the document
+ * fails here. Credentials are excluded on purpose: no secret-bearing field may reach the document.
+ */
+const assert = require('assert');
+const H = require('./helpers');
+const { routes, MOUNT } = require('../server/openapi');
+
+/** The path an app.use(...) layer mounts its router at, decoded from Express's compiled regexp. */
+function mountOf(layer) {
+ const source = String((layer.regexp && layer.regexp.source) || '');
+ if (layer.regexp && layer.regexp.fast_slash) return '';
+ const m = /^\^(.*?)\\\/\?\(\?=\\\/\|\$\)$/.exec(source);
+ assert.ok(m, `cannot read the mount path of a router layer (${source})`);
+ return m[1].replace(/\\\//g, '/').replace(/\\\./g, '.');
+}
+
+/** ":param" as OpenAPI writes it, "{param}". */
+const templated = (p) => String(p).replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, '{$1}');
+
+/** Every "METHOD /path" the app answers, routers included. */
+function mounted(app) {
+ const out = new Set();
+ const walk = (stack, prefix) => {
+ for (const layer of stack) {
+ if (layer.route) {
+ for (const [method, on] of Object.entries(layer.route.methods)) {
+ if (!on || method === '_all') continue;
+ for (const p of [].concat(layer.route.path)) out.add(`${method.toUpperCase()} ${prefix}${templated(p)}`);
+ }
+ } else if (layer.name === 'router' && layer.handle && layer.handle.stack) {
+ walk(layer.handle.stack, prefix + mountOf(layer));
+ }
+ }
+ };
+ assert.ok(app._router && app._router.stack, 'the app has a router');
+ walk(app._router.stack, '');
+ return out;
+}
+
+/**
+ * Not public API surface: probes and redirect, the operator/admin API, the attribution-quota API and
+ * the credential routes (which must never appear in a public document).
+ */
+const LEFT_OUT = [
+ '/api/health', '/api/ready', '/api/v1/openapi.json',
+ '/api/v1/status', '/api/v1/providers', '/api/v1/models', '/api/v1/routes', '/api/v1/templates', '/api/v1/workflows',
+ '/api/v1/quotas', '/api/v1/usage', '/api/v1/requests', '/api/v1/audit', '/api/v1/cache',
+ '/api/v1/credentials', '/api/v1/attribution-quotas',
+];
+const excluded = (key) => {
+ const p = key.split(' ')[1];
+ return LEFT_OUT.some((prefix) => p === prefix || p.startsWith(`${prefix}/`));
+};
+
+(async () => {
+ const h = await H.boot();
+ try {
+ const r = await H.request(h.base, 'GET', '/openapi.json');
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers.get('content-type'), 'application/json; charset=utf-8');
+ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=300');
+ assert.ok(r.body, 'the response is JSON');
+
+ const doc = r.body;
+ assert.strictEqual(doc.openapi, '3.1.0');
+ assert.ok(doc.paths && typeof doc.paths === 'object', 'paths is an object');
+ assert.strictEqual(doc.info.title, 'OpenVibe.AI API');
+ assert.deepStrictEqual(doc.servers, [{ url: 'https://openvibe.ai' }]);
+ const documented = Object.entries(doc.paths).flatMap(([p, item]) => Object.keys(item).map((m) => `${m.toUpperCase()} ${p}`)).sort();
+ assert.ok(documented.length > 0, 'paths is not empty');
+ for (const [p, item] of Object.entries(doc.paths)) {
+ assert.ok(p.startsWith('/api/'), `${p} is outside /api`);
+ for (const [method, op] of Object.entries(item)) {
+ assert.ok(op.operationId, `${method.toUpperCase()} ${p} has an operationId`);
+ assert.ok(op.responses && Object.keys(op.responses).length, `${method.toUpperCase()} ${p} describes its responses`);
+ }
+ }
+ // The document never carries a credential or a provider secret field.
+ const text = JSON.stringify(doc);
+ for (const field of ['credential', 'api_key', 'secret_ref', 'project_provider']) {
+ assert.ok(!text.includes(`"${field}"`), `the document must not name ${field}`);
+ }
+
+ const live = [...mounted(h.app)].filter((key) => key.split(' ')[1].startsWith(`${MOUNT}/`) && !excluded(key)).sort();
+ assert.deepStrictEqual(documented, live, 'every mounted public /api/v1 route is documented and vice versa');
+ assert.deepStrictEqual(routes(), documented, 'server/openapi.js routes() and the document agree');
+
+ const redirect = await fetch(`${h.base}/api/v1/openapi.json`, { redirect: 'manual' });
+ assert.strictEqual(redirect.status, 308);
+ assert.strictEqual(redirect.headers.get('location'), '/openapi.json');
+ } finally {
+ await h.stop();
+ }
+ console.log('openapi: /openapi.json documents exactly the public /api/v1 routes');
+})().catch((err) => { console.error(err); process.exitCode = 1; });