openvibers/openvibe.ai Commits
Add an OpenAI Responses API provider kind (#18)
Changes
@@@ -15,3 +15,9 @@ description = "Made-up provider key in test/credentials.test.js (a person's owncondition = "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$''']@@@ -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.cppand 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` |@@@ -22,6 +22,7 @@"providers": ["stub","openai-compatible",++ "openai-responses","anthropic","http-seam","whisper.cpp",@@@ -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.noswitch (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 });nnew file mode 100644@@@ -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 };@@@ -13,7 +13,7 @@ const { sha256, parseJson, iso, AiError, secretRefValid, resolveSecret } = requiconst 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_-]+)*$/;nnew file mode 100644@@@ -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();
@@ -15,3 +15,9 @@ description = "Made-up provider key in test/credentials.test.js (a person's own
@@ -15,3 +15,9 @@ description = "Made-up provider key in test/credentials.test.js (a person's own
condition = "AND"
condition = "AND"
paths = ['''^test/credentials\.test\.js$''']
paths = ['''^test/credentials\.test\.js$''']
regexes = ['''sk-test-dana-0123456789WXYZ''']
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$''']
@@ -41,7 +41,7 @@ owns publication truth: Wiki, Blog, News, Live, … decide what to publish.
@@ -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.Network (JWKS, service tokens, SSO for the operator console, user modules)
- OpenVibe.Events (the `ai.run.*` outbox relay; off without `EVENTS_URL`)
- 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)
- 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
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-contracts` v0.86.0, `openvibe-sdk` v0.26.0 (service tokens, events outbox),
`openvibe-shared` v2.3.1, pinned by release tarball
`openvibe-shared` v2.3.1, pinned by release tarball
@@ -204,6 +204,7 @@ definition, but never over an admin's version.
@@ -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 |
| `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 |
| `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` |
| `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 |
| `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 |
| `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
@@ -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` |
| 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` |
| 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` |
| 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` |
| 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` |
| `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` |
| Import from a Live snapshot: dry run, holds, idempotent re-run | `test/import.test.js` |
@@ -22,6 +22,7 @@
@@ -22,6 +22,7 @@
"providers": [
"providers": [
"stub",
"stub",
"openai-compatible",
"openai-compatible",
+ "openai-responses",
"anthropic",
"anthropic",
"http-seam",
"http-seam",
"whisper.cpp",
"whisper.cpp",
@@ -26,6 +26,7 @@ const { AiError, resolveSecret, sha256 } = require('../util');
@@ -26,6 +26,7 @@ const { AiError, resolveSecret, sha256 } = require('../util');
const { retryable, sleep, ProviderError } = require('./common');
const { retryable, sleep, ProviderError } = require('./common');
const { createStubProvider } = require('./stub');
const { createStubProvider } = require('./stub');
const { createOpenAiProvider } = require('./openai');
const { createOpenAiProvider } = require('./openai');
+const { createResponsesProvider } = require('./responses');
const { createAnthropicProvider } = require('./anthropic');
const { createAnthropicProvider } = require('./anthropic');
const { createHttpSeamProvider } = require('./http');
const { createHttpSeamProvider } = require('./http');
const { createWhisperProvider } = require('./whisper');
const { createWhisperProvider } = require('./whisper');
@@ -53,6 +54,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -53,6 +54,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
switch (p.kind) {
switch (p.kind) {
case 'stub': return createStubProvider(p, { stats: statFor(p.key) });
case 'stub': return createStubProvider(p, { stats: statFor(p.key) });
case 'openai': return createOpenAiProvider(p, { apiKey, fetchImpl });
case 'openai': return createOpenAiProvider(p, { apiKey, fetchImpl });
+ case 'responses': return createResponsesProvider(p, { apiKey, fetchImpl });
case 'anthropic': return createAnthropicProvider(p, { apiKey, fetchImpl });
case 'anthropic': return createAnthropicProvider(p, { apiKey, fetchImpl });
case 'http': return createHttpSeamProvider(p, { apiKey, fetchImpl });
case 'http': return createHttpSeamProvider(p, { apiKey, fetchImpl });
case 'whisper': return createWhisperProvider(p, { config });
case 'whisper': return createWhisperProvider(p, { config });
new file mode 100644
new file mode 100644
@@ -0,0 +1,109 @@
@@ -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 };
@@ -13,7 +13,7 @@ const { sha256, parseJson, iso, AiError, secretRefValid, resolveSecret } = requi
@@ -13,7 +13,7 @@ const { sha256, parseJson, iso, AiError, secretRefValid, resolveSecret } = requi
const schemas = require('./schemas');
const schemas = require('./schemas');
const { provenance } = require('./providers/rate-cards');
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 FEATURES = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'embed', 'vision', 'json', 'transcribe'];
const LIFECYCLE = ['draft', 'active', 'deprecated', 'archived'];
const LIFECYCLE = ['draft', 'active', 'deprecated', 'archived'];
const KEY_RE = /^[a-z][a-z0-9_-]*(\.[a-z0-9_-]+)*$/;
const KEY_RE = /^[a-z][a-z0-9_-]*(\.[a-z0-9_-]+)*$/;
new file mode 100644
new file mode 100644
@@ -0,0 +1,116 @@
@@ -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();