openvibers/openvibe.ai Commits

Add an OpenAI Responses API provider kind (#18)

OP OpenVibers committed 5 Oct 2026 · 524b9b9165 · 7 files, +238 −2

Asked: OpenVibe.AI: re-land T16 step 9 first half (an OpenAI Responses API provider kind). The work is finished and its tests pass, but delivery was refused by gitleaks over a made-up test key, so it only exists as local commit 17a35e3 on branch agent/ai-t16-step-9-first-half-232832-muugcbcn (base origin/…
Harness job ai-openvibe-ai-re-land-t16-step-9-233430.

Changes

ddiff --git a/.gitleaks.toml b/.gitleaks.toml
iindex b8cf0cb..327e92e 100644
---- a/.gitleaks.toml
++++ b/.gitleaks.toml
@@@ -15,3 +15,9 @@ description = "Made-up provider key in test/credentials.test.js (a person's own
condition = "AND"
paths = ['''^test/credentials\.test\.js$''']
regexes = ['''sk-test-dana-0123456789WXYZ''']
++
++[[allowlists]]
++description = "Made-up provider key in test/provider-responses.test.js (asserts the key never reaches a thrown error; a placeholder, not a secret)"
++condition = "AND"
++paths = ['''^test/provider-responses\.test\.js$''']
++regexes = ['''^sk-resp-secret-0123456789$''']
ddiff --git a/README.md b/README.md
iindex 268588a..cc56348 100644
---- a/README.md
++++ b/README.md
@@@ -41,7 +41,7 @@ owns publication truth: Wiki, Blog, News, Live, … decide what to publish.
- OpenVibe.Network (JWKS, service tokens, SSO for the operator console, user modules)
- OpenVibe.Events (the `ai.run.*` outbox relay; off without `EVENTS_URL`)
- OpenVibe.Media (recordings `media.analyze` reads, over allow-listed https URLs or signed URLs)
--- provider APIs configured by the operator (OpenAI-compatible, Anthropic, the HTTP seam), whisper.cpp
++- provider APIs configured by the operator (OpenAI-compatible, OpenAI Responses, Anthropic, the HTTP seam), whisper.cpp
and the local model server `openvibe-llm.service` on this host
- `openvibe-contracts` v0.86.0, `openvibe-sdk` v0.26.0 (service tokens, events outbox),
`openvibe-shared` v2.3.1, pinned by release tarball
@@@ -204,6 +204,7 @@ definition, but never over an admin's version.
|---|---|---|
| `stub` | Deterministic, no key, realistically shaped output (schema-shaped JSON, sentences, unit-vector embeddings, timed transcript segments), clearly marked synthetic (`(synthetic)` text, `synthetic: true` on the run). Never cached. | always present |
| `openai` | Any OpenAI-compatible API (OpenAI, OpenRouter, Groq, Together, Ollama, LM Studio, llama.cpp): chat with structured-output step-down, embeddings, Whisper-style transcription. Ported from Live's `llm.js` / `ai-provider.js`. | `AI_PROVIDER`, `AI_BASE_URL`, `AI_API_KEY`, `AI_MODEL` — Live's setting names |
++| `responses` | The OpenAI Responses API (`POST {base}/responses`) for GPT-family models that only speak it, at any OpenAI-compatible `base_url` (default `https://api.openai.com/v1`): chat and every text operation, structured output through `text.format` with the same step-down, vision; no embeddings or transcription. | the admin API (`kind: "responses"`, `base_url`, `secret_ref`) |
| `anthropic` | Messages API with cached system blocks and forced-tool JSON. Ported from Live's `llm.js`. | same, with `AI_PROVIDER=anthropic` |
| `http` | The local HTTP seam: `POST {operation, …}` → `{text, json, usage}` | `AI_HTTP_SEAM_URL` or the admin API |
| `whisper` | whisper.cpp on this host, with Live's VAD, hallucination filter, multilingual model and live/batch lanes | `WHISPER_*` — Live's names |
@@@ -333,6 +334,7 @@ file beside it, change `--model`/`--alias` and `AI_LOCAL_LLM_MODEL`, and restart
| Token and capability denial, namespaces, no secret values in any response, runs private to the requester | `test/auth.test.js` |
| Operator console: SSO + PKCE, staff-only (anonymous → sign-in, non-staff 403), the staff → AI capability map, CSRF on every write, no script/secret on any page, noindex/robots, failed-run filters, audit rows from API and console changes | `test/console.test.js` |
| Public price/latency page: 200 without auth, rows from seeded stats and rate cards, the empty state, no caller ids, own-key providers, URLs or secrets | `test/stats-page.test.js` |
++| The OpenAI Responses adapter (`kind: responses`) with a stubbed fetch: request shape, output and usage parsing, `text.format`, error mapping, the key never in an error | `test/provider-responses.test.js` |
| Ported adapters against fake OpenAI/Anthropic servers, stub determinism, whisper filter, templates | `test/providers.test.js` |
| `media.analyze` on a real 28 s fixture: signals, scenes and loud moments measured, highlights with evidence, the local model's overview from the facts only; the local model down → extractive and said; paid only with `allow_paid` and a `media.paid` budget, never past it; too little disk refused before downloading | `test/media-analysis.test.js` |
| Import from a Live snapshot: dry run, holds, idempotent re-run | `test/import.test.js` |
ddiff --git a/STATUS.json b/STATUS.json
iindex 53fcf20..b703d36 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -22,6 +22,7 @@
"providers": [
"stub",
"openai-compatible",
++ "openai-responses",
"anthropic",
"http-seam",
"whisper.cpp",
ddiff --git a/server/providers/index.js b/server/providers/index.js
iindex 74bbca0..3d4adfd 100644
---- a/server/providers/index.js
++++ b/server/providers/index.js
@@@ -26,6 +26,7 @@ const { AiError, resolveSecret, sha256 } = require('../util');
const { retryable, sleep, ProviderError } = require('./common');
const { createStubProvider } = require('./stub');
const { createOpenAiProvider } = require('./openai');
++const { createResponsesProvider } = require('./responses');
const { createAnthropicProvider } = require('./anthropic');
const { createHttpSeamProvider } = require('./http');
const { createWhisperProvider } = require('./whisper');
@@@ -53,6 +54,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
switch (p.kind) {
case 'stub': return createStubProvider(p, { stats: statFor(p.key) });
case 'openai': return createOpenAiProvider(p, { apiKey, fetchImpl });
++ case 'responses': return createResponsesProvider(p, { apiKey, fetchImpl });
case 'anthropic': return createAnthropicProvider(p, { apiKey, fetchImpl });
case 'http': return createHttpSeamProvider(p, { apiKey, fetchImpl });
case 'whisper': return createWhisperProvider(p, { config });
ddiff --git a/server/providers/responses.js b/server/providers/responses.js
nnew file mode 100644
iindex 0000000..1175ce1
---- /dev/null
++++ b/server/providers/responses.js
@@@ -0,0 +1,109 @@
++'use strict';
++/**
++ * OpenAI Responses API adapter, for GPT-family models that only speak Responses and any gateway
++ * with the same shape (any OpenAI-compatible base_url):
++ *
++ * chat and every text operation POST {base}/responses
++ * { model, instructions, input, max_output_tokens, text: { format: { type: 'json_schema' | 'json_object' } } }
++ * -> { output: [{ content: [{ type: 'output_text', text }] }], usage: { input_tokens, output_tokens } }
++ *
++ * Same transport, error mapping and usage shape as openai.js (postJson: deadline, describeAbort,
++ * ProviderError; the pool's one retry on a transient failure), and the same structured-output
++ * step-down json_schema -> json_object -> prose for gateways that lack it. No embeddings or
++ * transcription: those stay with the `openai` kind.
++ */
++const { ProviderError, postJson } = require('./common');
++
++const DEFAULT_BASE_URL = 'https://api.openai.com/v1';
++const TEXT_OPS = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich'];
++
++function isReasoningModel(m) { return /^(gpt-5|o\d)/i.test(m || ''); }
++
++/** Chat-style content (a string or [{type:'text',text}] parts) as Responses input content. */
++function inputContent(content) {
++ if (!Array.isArray(content)) return String(content || '');
++ return content.map(p => (p && p.type === 'text' ? { type: 'input_text', text: String(p.text || '') } : p));
++}
++
++function buildBody({ model, system, messages, image, json, maxTokens, temperature, cacheKey, baseUrl }, jsonMode) {
++ const input = (messages || []).map((m, i) => {
++ const last = i === messages.length - 1;
++ if (last && image && m.role === 'user') {
++ const parts = Array.isArray(m.content) ? inputContent(m.content) : [{ type: 'input_text', text: String(m.content || '') }];
++ return { role: 'user', content: [...parts, { type: 'input_image', image_url: `data:${image.mediaType};base64,${image.base64}` }] };
++ }
++ return { role: m.role, content: inputContent(m.content) };
++ });
++ const body = { model, input };
++ const sysText = (system || []).map(x => x.text).join('\n\n');
++ if (sysText) body.instructions = sysText;
++ if (isReasoningModel(model)) {
++ body.max_output_tokens = Math.max(maxTokens, 256) + 512;
++ body.reasoning = { effort: /^gpt-5/i.test(model) ? 'minimal' : 'low' };
++ } else {
++ body.max_output_tokens = maxTokens;
++ if (temperature != null) body.temperature = temperature;
++ }
++ if (json && jsonMode === 'schema') body.text = { format: { type: 'json_schema', name: json.name || 'result', schema: json.schema, strict: json.strict !== false } };
++ else if (json && jsonMode === 'object') body.text = { format: { type: 'json_object' } };
++ if (cacheKey && /api\.openai\.com/i.test(baseUrl)) body.prompt_cache_key = String(cacheKey).slice(0, 64);
++ return body;
++}
++
++/** The output_text parts of every output item, in order. */
++function outputText(j) {
++ const parts = [];
++ for (const item of Array.isArray(j.output) ? j.output : []) {
++ for (const c of (item && Array.isArray(item.content) ? item.content : [])) {
++ if (c && c.type === 'output_text' && typeof c.text === 'string') parts.push(c.text);
++ }
++ }
++ if (!parts.length && typeof j.output_text === 'string') parts.push(j.output_text);
++ return parts.join('').trim();
++}
++
++/** An upstream that echoes the bearer key back in its error body never gets it into a thrown error. */
++function scrubKey(err, apiKey) {
++ if (!apiKey || !(err instanceof ProviderError)) return err;
++ if (err.message.includes(apiKey)) err.message = err.message.split(apiKey).join('[redacted]');
++ if (typeof err.stack === 'string' && err.stack.includes(apiKey)) err.stack = err.stack.split(apiKey).join('[redacted]');
++ if (err.body && JSON.stringify(err.body).includes(apiKey)) err.body = null;
++ return err;
++}
++
++function createResponsesProvider(record, { apiKey = '', fetchImpl = globalThis.fetch } = {}) {
++ const baseUrl = String(record.base_url || DEFAULT_BASE_URL).replace(/\/+$/, '');
++ const headers = () => (apiKey ? { Authorization: `Bearer ${apiKey}` } : {});
++ const caps = new Set(record.capabilities && record.capabilities.length ? record.capabilities : [...TEXT_OPS, 'json', 'vision']);
++
++ async function chat(req) {
++ let jsonMode = req.json ? 'schema' : null;
++ for (let attempt = 0; attempt < 3; attempt++) {
++ const body = buildBody({ ...req, baseUrl }, jsonMode);
++ try {
++ const j = await postJson(`${baseUrl}/responses`, headers(), body, { signal: req.signal, timeoutMs: req.timeoutMs, fetchImpl });
++ const u = j.usage || {};
++ return { text: outputText(j), json: null, model: j.model || req.model, usage: { input: u.input_tokens || 0, output: u.output_tokens || 0, cached: (u.input_tokens_details && u.input_tokens_details.cached_tokens) || 0 } };
++ } catch (e) {
++ // Gateways without structured-output support: schema -> json_object -> prose.
++ if (e instanceof ProviderError && e.status === 400 && jsonMode && /text\.format|json_schema|json_object|schema|strict/i.test(e.message)) {
++ jsonMode = jsonMode === 'schema' ? 'object' : null;
++ continue;
++ }
++ throw scrubKey(e, apiKey);
++ }
++ }
++ throw new ProviderError('structured output unsupported', { code: 'provider.unsupported' });
++ }
++
++ const adapter = {
++ key: record.key,
++ kind: 'responses',
++ supports: (feature) => caps.has(feature),
++ chat,
++ };
++ for (const op of TEXT_OPS) if (op !== 'chat') adapter[op] = chat; // the engine shapes the prompt per operation
++ return adapter;
++}
++
++module.exports = { createResponsesProvider, buildBody, outputText, DEFAULT_BASE_URL };
ddiff --git a/server/registry.js b/server/registry.js
iindex fc4185e..85cec41 100644
---- a/server/registry.js
++++ b/server/registry.js
@@@ -13,7 +13,7 @@ const { sha256, parseJson, iso, AiError, secretRefValid, resolveSecret } = requi
const schemas = require('./schemas');
const { provenance } = require('./providers/rate-cards');
--const PROVIDER_KINDS = ['stub', 'openai', 'anthropic', 'http', 'whisper'];
++const PROVIDER_KINDS = ['stub', 'openai', 'responses', 'anthropic', 'http', 'whisper'];
const FEATURES = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'embed', 'vision', 'json', 'transcribe'];
const LIFECYCLE = ['draft', 'active', 'deprecated', 'archived'];
const KEY_RE = /^[a-z][a-z0-9_-]*(\.[a-z0-9_-]+)*$/;
ddiff --git a/test/provider-responses.test.js b/test/provider-responses.test.js
nnew file mode 100644
iindex 0000000..89af7b0
---- /dev/null
++++ b/test/provider-responses.test.js
@@@ -0,0 +1,116 @@
++'use strict';
++// The OpenAI Responses adapter (kind 'responses') with a stubbed fetchImpl: the pool builds it,
++// it POSTs {base}/responses with input, instructions and the bearer key, parses output_text and
++// usage, asks for JSON through text.format (with the openai.js step-down), maps a non-2xx exactly
++// like openai.js does, and never lets the key into a thrown error.
++
++const assert = require('assert');
++const { suite } = require('./helpers');
++const { createResponsesProvider, buildBody } = require('../server/providers/responses');
++const { createOpenAiProvider } = require('../server/providers/openai');
++const { createProviderPool } = require('../server/providers');
++const { ProviderError, retryable } = require('../server/providers/common');
++const { PROVIDER_KINDS } = require('../server/registry');
++
++const t = suite('provider-responses');
++const KEY = 'sk-resp-secret-0123456789';
++const req = (extra = {}) => ({ system: [{ text: 'be brief' }], messages: [{ role: 'user', content: 'hi' }], maxTokens: 50, temperature: 0.2, timeoutMs: 3000, model: 'gpt-4.1-mini', ...extra });
++const ok = { model: 'gpt-4.1-mini-2025', output: [{ type: 'reasoning', summary: [] }, { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'hello ' }, { type: 'output_text', text: 'there' }] }], usage: { input_tokens: 11, output_tokens: 4, input_tokens_details: { cached_tokens: 3 } } };
++
++/** A fetchImpl that records each call and answers with respond(body, call). */
++function stubFetch(respond) {
++ const calls = [];
++ const fetchImpl = async (url, init) => {
++ const body = JSON.parse(init.body);
++ calls.push({ url, headers: init.headers, body });
++ const out = await respond(body, calls.length);
++ const status = out && out.status ? out.status : 200;
++ const payload = out && out.status ? out.body : out;
++ return { ok: status >= 200 && status < 300, status, text: async () => JSON.stringify(payload || {}) };
++ };
++ return { calls, fetchImpl };
++}
++
++t.test('the pool builds kind responses with the key from its secret reference', async () => {
++ const record = { key: 'resp', kind: 'responses', base_url: 'https://gw.example/v1/', secret_ref: 'env:RESP_KEY', capabilities: [], updated_at: 1 };
++ const noop = { get: async () => undefined, run: async () => ({}), all: async () => [] };
++ const s = stubFetch(() => ok);
++ const pool = createProviderPool({ db: { prepare: () => noop }, registry: { getProvider: async (k) => (k === 'resp' ? record : null) }, config: { breaker: { failureThreshold: 3, cooldownMs: 1000 } }, fetchImpl: s.fetchImpl, env: { RESP_KEY: KEY } });
++ assert.ok(PROVIDER_KINDS.includes('responses'), 'the registry accepts kind responses');
++ const { adapter } = await pool.adapter('resp');
++ assert.strictEqual(adapter.kind, 'responses');
++ assert.ok(adapter.supports('chat') && adapter.supports('json') && adapter.supports('vision'));
++ assert.ok(!adapter.supports('embed') && !adapter.supports('transcribe'));
++ assert.strictEqual(typeof adapter.summarize, 'function');
++ await adapter.chat(req());
++ assert.strictEqual(s.calls[0].url, 'https://gw.example/v1/responses');
++ assert.strictEqual(s.calls[0].headers.Authorization, `Bearer ${KEY}`);
++});
++
++t.test('POST /responses with input and instructions; output_text and usage are parsed', async () => {
++ const s = stubFetch(() => ok);
++ const a = createResponsesProvider({ key: 'r', capabilities: [] }, { apiKey: KEY, fetchImpl: s.fetchImpl });
++ const r = await a.chat(req());
++ assert.strictEqual(s.calls[0].url, 'https://api.openai.com/v1/responses');
++ assert.strictEqual(s.calls[0].headers.Authorization, `Bearer ${KEY}`);
++ assert.strictEqual(s.calls[0].headers['Content-Type'], 'application/json');
++ const b = s.calls[0].body;
++ assert.strictEqual(b.model, 'gpt-4.1-mini');
++ assert.strictEqual(b.instructions, 'be brief');
++ assert.deepStrictEqual(b.input, [{ role: 'user', content: 'hi' }]);
++ assert.strictEqual(b.max_output_tokens, 50);
++ assert.strictEqual(b.text, undefined);
++ assert.strictEqual(r.text, 'hello there');
++ assert.strictEqual(r.model, 'gpt-4.1-mini-2025');
++ assert.deepStrictEqual(r.usage, { input: 11, output: 4, cached: 3 });
++ const bare = await createResponsesProvider({ key: 'r' }, { apiKey: KEY, fetchImpl: stubFetch(() => ({ output: [] })).fetchImpl }).chat(req());
++ assert.deepStrictEqual([bare.text, bare.model, bare.usage], ['', 'gpt-4.1-mini', { input: 0, output: 0, cached: 0 }]);
++});
++
++t.test('body: reasoning models, image input, no instructions without a system prompt', () => {
++ const r = buildBody({ ...req({ model: 'gpt-5-mini', system: [] }), image: { mediaType: 'image/png', base64: 'AAAA' } }, null);
++ assert.strictEqual(r.instructions, undefined);
++ assert.ok(r.max_output_tokens >= 256 + 512);
++ assert.deepStrictEqual(r.reasoning, { effort: 'minimal' });
++ assert.strictEqual(r.temperature, undefined);
++ assert.deepStrictEqual(r.input[0].content, [{ type: 'input_text', text: 'hi' }, { type: 'input_image', image_url: 'data:image/png;base64,AAAA' }]);
++});
++
++t.test('a JSON request sets text.format and steps down json_schema -> json_object on a gateway that refuses it', async () => {
++ const s = stubFetch((body) => (body.text.format.type === 'json_schema'
++ ? { status: 400, body: { error: { message: "Invalid value for 'text.format': json_schema is not supported" } } }
++ : { output: [{ type: 'message', content: [{ type: 'output_text', text: '{"a":1}' }] }], usage: { input_tokens: 7, output_tokens: 3 } }));
++ const a = createResponsesProvider({ key: 'r', capabilities: [] }, { apiKey: KEY, fetchImpl: s.fetchImpl });
++ const r = await a.chat(req({ json: { name: 'r', schema: { type: 'object' } } }));
++ assert.deepStrictEqual(s.calls[0].body.text, { format: { type: 'json_schema', name: 'r', schema: { type: 'object' }, strict: true } });
++ assert.deepStrictEqual(s.calls[1].body.text, { format: { type: 'json_object' } });
++ assert.strictEqual(r.text, '{"a":1}');
++ assert.deepStrictEqual(r.usage, { input: 7, output: 3, cached: 0 });
++});
++
++t.test('a non-2xx maps to the same ProviderError, code, status and retryable flag as openai.js', async () => {
++ for (const [status, code, retry] of [[500, 'provider.http', true], [429, 'provider.http', true], [401, 'provider.auth', false], [400, 'provider.http', false]]) {
++ const answer = () => ({ status, body: { error: { message: `upstream ${status}` } } });
++ const fromResponses = await createResponsesProvider({ key: 'r' }, { apiKey: KEY, fetchImpl: stubFetch(answer).fetchImpl }).chat(req()).then(() => null, e => e);
++ const fromOpenAi = await createOpenAiProvider({ key: 'o' }, { apiKey: KEY, fetchImpl: stubFetch(answer).fetchImpl }).chat(req()).then(() => null, e => e);
++ for (const e of [fromResponses, fromOpenAi]) {
++ assert.ok(e instanceof ProviderError, `${status}: ProviderError`);
++ assert.deepStrictEqual([e.status, e.code, retryable(e), e.message], [status, code, retry, `upstream ${status}`]);
++ }
++ }
++ const down = await createResponsesProvider({ key: 'r' }, { apiKey: KEY, fetchImpl: async () => { throw new Error('ECONNREFUSED'); } }).chat(req()).then(() => null, e => e);
++ assert.ok(down instanceof ProviderError && down.code === 'provider.unreachable' && retryable(down));
++});
++
++t.test('the API key never appears in a thrown error, even when the upstream echoes it', async () => {
++ const echo = stubFetch(() => ({ status: 401, body: { error: { message: `Incorrect API key provided: ${KEY}.` } } }));
++ const e = await createResponsesProvider({ key: 'r' }, { apiKey: KEY, fetchImpl: echo.fetchImpl }).chat(req()).then(() => null, x => x);
++ assert.ok(e instanceof ProviderError && e.code === 'provider.auth');
++ assert.ok(!e.message.includes(KEY) && !JSON.stringify(e.body || {}).includes(KEY) && !String(e.stack).includes(KEY), e.message);
++ assert.match(e.message, /\[redacted\]/);
++ const raw = stubFetch(() => ({ status: 502, body: `bad gateway for Bearer ${KEY}` }));
++ const e2 = await createResponsesProvider({ key: 'r' }, { apiKey: KEY, fetchImpl: raw.fetchImpl }).chat(req()).then(() => null, x => x);
++ assert.ok(e2 instanceof ProviderError && e2.status === 502 && !e2.message.includes(KEY), e2.message);
++});
++
++t.run();