openvibers/openvibe.ai Commits
Codes C3: x-openvibe-credential on the OpenAI-compatible endpoint (#203)
Changes
nnew file mode 100644@@@ -0,0 +1,25 @@++'use strict';++/**++ * The credential a request names in its header (Codes C3): `x-openvibe-credential: <provider>` or++ * `<provider>:<subject>`.++ *++ * Without it the router picks an upstream. With it the run uses that person's own key for that provider, which is++ * what a harness that runs on the person's own machine sends (its base URL points here and its token names them).++ * The person is the subject in the header, or the subject the token acts for (on_behalf_of: a run token, Codes C3).++ * The key itself never comes here: it stays sealed in the vault (server/credentials.js).++ *++ * The two model endpoints read the same header: POST /v1/messages (Anthropic's format) and POST++ * /v1/chat/completions (OpenAI's format).++ */++function credentialFor(req) {++ const raw = String(req.get('x-openvibe-credential') || '').trim();++ if (!raw) return {};++ const m = /^([a-z]+)(?::(.+))?$/.exec(raw);++ if (!m) return { status: 400, error: 'x-openvibe-credential must be <provider> or <provider>:<subject>' };++ const provider = m[1];++ const subject = m[2] || (req.principal && req.principal.claims && req.principal.claims.on_behalf_of) || null;++ if (!subject) return { status: 400, error: `x-openvibe-credential ${provider} needs the person whose key it is: send <provider>:<subject>, or use a token that acts for them (on_behalf_of)` };++ return { credential: { subject, provider } };++}++++module.exports = { credentialFor };@@@ -23,6 +23,7 @@const express = require('express');const { CAPS } = require('../auth');const { appAttribution } = require('../apps');++const { credentialFor } = require('./credential');const FIELDS = ['system', 'tools', 'tool_choice', 'stop_sequences', 'temperature', 'top_p', 'top_k', 'metadata'];const MAX_TOKENS = 64000;@@@ -107,18 +108,6 @@ function replay(res, message, run) {res.emit('message_stop', { type: 'message_stop' });}--/** x-openvibe-credential: <provider> or <provider>:<subject>, or nothing (the router picks). */--function credentialFor(req) {-- const raw = String(req.get('x-openvibe-credential') || '').trim();-- if (!raw) return {};-- const m = /^([a-z]+)(?::(.+))?$/.exec(raw);-- if (!m) return { status: 400, error: 'x-openvibe-credential must be <provider> or <provider>:<subject>' };-- const provider = m[1];-- const subject = m[2] || (req.principal && req.principal.claims && req.principal.claims.on_behalf_of) || null;-- if (!subject) return { status: 400, error: `x-openvibe-credential ${provider} needs the person whose key it is: send <provider>:<subject>, or use a token that acts for them (on_behalf_of)` };-- return { credential: { subject, provider } };--}--function messagesRouter({ runs, auth, config, log = console }) {const r = express.Router();const allow = auth.appOrCap(CAPS.appRun, CAPS.runCreate);@@@ -13,6 +13,8 @@* (tools need a provider that declares them); the answer names the* model that ran*++ * x-openvibe-credential the person's own key for a provider instead of the router++ * (Codes C3): <provider> or <provider>:<subject>* x_openvibe.provider an app's own provider instead of the router: { base_url (https, an* OpenAI-format API), model, secret (the name of a project secret granted* to ai for reveal to that origin, ADR-056) }; the key is revealed for@@@ -29,6 +31,7 @@const express = require('express');const { CAPS } = require('../auth');const { appAttribution } = require('../apps');++const { credentialFor } = require('./credential');const FIELDS = ['messages', 'tools', 'tool_choice', 'parallel_tool_calls', 'response_format', 'stop', 'seed', 'top_p', 'presence_penalty', 'frequency_penalty', 'temperature'];@@@ -125,6 +128,10 @@ function openaiRouter({ runs, auth, config, log = console }) {if (maxTokens != null) input.max_tokens = Math.max(1, Math.min(Number(maxTokens) || 1, 16000));let body = { workflow: 'ai.complete', input };if (req.principal.kind === 'app') body = { ...body, attribution: appAttribution(req.principal) };++ // The person's own key (Codes C3): x-openvibe-credential names the provider whose key this request uses.++ const personKey = credentialFor(req);++ if (personKey.error) return openaiError(res, personKey.status, personKey.error, 'invalid_request_error');++ if (personKey.credential) body = { ...body, credential: personKey.credential };const own = b.x_openvibe && typeof b.x_openvibe === 'object' ? b.x_openvibe.provider : undefined;if (own !== undefined) body = { ...body, project_provider: own };let run;@@@ -424,7 +424,9 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noif (cred.provider === 'node') return await executeOnMachine(cred, operation, req, ctx);// Text operations are all chat completions on the person's provider (templated workflows use generate).// A project's own provider (ADR-056 secret, ai.complete) passes a Chat Completions request through as written.-- const completing = operation === 'complete' && cred.provider === 'openai';++ // The OpenAI-format providers: a Chat Completions request is passed through as written (Codes C3: OpenRouter++ // speaks that format too, so a person's OpenRouter key answers it as well).++ const completing = operation === 'complete' && ['openai', 'openrouter'].includes(cred.provider);// Codes C3: POST /v1/messages on the person's own key. Only a provider that speaks Anthropic's Messages API serves// it — Anthropic itself, DeepSeek and MiniMax (their own Anthropic-compatible endpoints) and a custom endpoint.const messaging = operation === 'messages';nnew file mode 100644@@@ -0,0 +1,71 @@++'use strict';++// The person's own key on the OpenAI-compatible endpoint (Codes C3): POST /v1/chat/completions honours++// x-openvibe-credential, so a harness that speaks OpenAI's format (Codex, any OpenAI-compatible client) runs with the++// person's own key instead of the router's provider. The key never appears in a response, a log line or an error.++const assert = require('assert');++const { boot, request, token, suite, ALL } = require('./helpers');++++const t = suite('chat-credential');++const KEY = 'cd'.repeat(32);++const PERSON = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP7';++const PERSON_KEY = 'sk-openai-test-person-0123456789';++const service = token('live', ALL);++const acting = token('codes', ALL, { extra: { on_behalf_of: PERSON } });++const manager = token('codes', [...ALL, 'ai.credential.manage'], { extra: { on_behalf_of: PERSON } });++++// The person's own provider, answered in-process (the guarded fetch is replaced, as in credentials.test.js).++const ownCalls = [];++const credentialFetch = async (url, init) => {++ ownCalls.push({ url, headers: init.headers || {}, body: JSON.parse(init.body) });++ return new Response(JSON.stringify({++ id: 'chatcmpl_person', object: 'chat.completion', model: 'gpt-person',++ choices: [{ index: 0, message: { role: 'assistant', content: 'Hello there' }, finish_reason: 'stop' }],++ usage: { prompt_tokens: 5, completion_tokens: 2 },++ }), { status: 200, headers: { 'content-type': 'application/json' } });++};++++const logs = [];++const capture = { log: (...a) => logs.push(a.join(' ')), warn: (...a) => logs.push(a.join(' ')), error: (...a) => logs.push(a.join(' ')) };++let h;++++const post = (body, { tok = service, headers = {} } = {}) => request(h.base, 'POST', '/v1/chat/completions', { tok, body, headers });++++t.test('boot with the credentials key set', async () => {++ h = await boot({ env: { AI_CREDENTIALS_KEY: KEY, AI_STUB_FALLBACK: 'false' }, credentialFetch, log: capture });++});++++t.test('the person\'s own key is stored and never answered', async () => {++ const r = await request(h.base, 'PUT', `/api/v1/credentials/${PERSON}/openai`, { tok: manager, body: { key: PERSON_KEY, models: { chat: 'gpt-person' } } });++ if (r.status !== 200) console.error('LOGS', logs.join('\n'));++ assert.strictEqual(r.status, 200, r.text);++ assert.ok(!r.text.includes(PERSON_KEY), 'the key is not in the answer');++});++++t.test('x-openvibe-credential uses the person\'s key, not the router\'s provider', async () => {++ const before = ownCalls.length;++ const res = await post({ model: 'openvibe', messages: [{ role: 'user', content: 'Say hello' }] }, { tok: acting, headers: { 'x-openvibe-credential': 'openai' } });++ assert.strictEqual(res.status, 200, res.text);++ assert.strictEqual(res.body.choices[0].message.content, 'Hello there');++ assert.ok(ownCalls.length > before, 'the person\'s provider was called');++ const call = ownCalls[ownCalls.length - 1];++ assert.ok(String(call.headers.Authorization || call.headers.authorization || '').includes(PERSON_KEY), 'the person\'s key was sent to their provider');++ assert.ok(!JSON.stringify(res.body).includes(PERSON_KEY), 'the key is not in the answer');++ const run = await request(h.base, 'GET', `/api/v1/runs/${res.headers.get('x-openvibe-run-id')}`, { tok: acting });++ assert.strictEqual(run.body.run.provenance.provider, `byo:codes:${PERSON}:openai`);++});++++t.test('<provider>:<subject> names the person explicitly, without an on_behalf_of claim', async () => {++ const res = await post({ model: 'openvibe', messages: [{ role: 'user', content: 'hi' }] }, { tok: manager, headers: { 'x-openvibe-credential': `openai:${PERSON}` } });++ assert.strictEqual(res.status, 200, res.text);++});++++t.test('a header without a person is refused, in OpenAI\'s error shape', async () => {++ const res = await post({ model: 'openvibe', messages: [{ role: 'user', content: 'hi' }] }, { tok: service, headers: { 'x-openvibe-credential': 'openai' } });++ assert.strictEqual(res.status, 400);++ assert.strictEqual(res.body.error.type, 'invalid_request_error');++ assert.ok(!logs.join('\n').includes(PERSON_KEY), 'the key is never in a log line');++});++++t.test('stop', async () => { await h.stop(); });++++t.run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,25 @@
@@ -0,0 +1,25 @@
+'use strict';
+/**
+ * The credential a request names in its header (Codes C3): `x-openvibe-credential: <provider>` or
+ * `<provider>:<subject>`.
+ *
+ * Without it the router picks an upstream. With it the run uses that person's own key for that provider, which is
+ * what a harness that runs on the person's own machine sends (its base URL points here and its token names them).
+ * The person is the subject in the header, or the subject the token acts for (on_behalf_of: a run token, Codes C3).
+ * The key itself never comes here: it stays sealed in the vault (server/credentials.js).
+ *
+ * The two model endpoints read the same header: POST /v1/messages (Anthropic's format) and POST
+ * /v1/chat/completions (OpenAI's format).
+ */
+function credentialFor(req) {
+ const raw = String(req.get('x-openvibe-credential') || '').trim();
+ if (!raw) return {};
+ const m = /^([a-z]+)(?::(.+))?$/.exec(raw);
+ if (!m) return { status: 400, error: 'x-openvibe-credential must be <provider> or <provider>:<subject>' };
+ const provider = m[1];
+ const subject = m[2] || (req.principal && req.principal.claims && req.principal.claims.on_behalf_of) || null;
+ if (!subject) return { status: 400, error: `x-openvibe-credential ${provider} needs the person whose key it is: send <provider>:<subject>, or use a token that acts for them (on_behalf_of)` };
+ return { credential: { subject, provider } };
+}
+
+module.exports = { credentialFor };
@@ -23,6 +23,7 @@
@@ -23,6 +23,7 @@
const express = require('express');
const express = require('express');
const { CAPS } = require('../auth');
const { CAPS } = require('../auth');
const { appAttribution } = require('../apps');
const { appAttribution } = require('../apps');
+const { credentialFor } = require('./credential');
const FIELDS = ['system', 'tools', 'tool_choice', 'stop_sequences', 'temperature', 'top_p', 'top_k', 'metadata'];
const FIELDS = ['system', 'tools', 'tool_choice', 'stop_sequences', 'temperature', 'top_p', 'top_k', 'metadata'];
const MAX_TOKENS = 64000;
const MAX_TOKENS = 64000;
@@ -107,18 +108,6 @@ function replay(res, message, run) {
@@ -107,18 +108,6 @@ function replay(res, message, run) {
res.emit('message_stop', { type: 'message_stop' });
res.emit('message_stop', { type: 'message_stop' });
}
}
-/** x-openvibe-credential: <provider> or <provider>:<subject>, or nothing (the router picks). */
-function credentialFor(req) {
- const raw = String(req.get('x-openvibe-credential') || '').trim();
- if (!raw) return {};
- const m = /^([a-z]+)(?::(.+))?$/.exec(raw);
- if (!m) return { status: 400, error: 'x-openvibe-credential must be <provider> or <provider>:<subject>' };
- const provider = m[1];
- const subject = m[2] || (req.principal && req.principal.claims && req.principal.claims.on_behalf_of) || null;
- if (!subject) return { status: 400, error: `x-openvibe-credential ${provider} needs the person whose key it is: send <provider>:<subject>, or use a token that acts for them (on_behalf_of)` };
- return { credential: { subject, provider } };
-}
-
function messagesRouter({ runs, auth, config, log = console }) {
function messagesRouter({ runs, auth, config, log = console }) {
const r = express.Router();
const r = express.Router();
const allow = auth.appOrCap(CAPS.appRun, CAPS.runCreate);
const allow = auth.appOrCap(CAPS.appRun, CAPS.runCreate);
@@ -13,6 +13,8 @@
@@ -13,6 +13,8 @@
* (tools need a provider that declares them); the answer names the
* (tools need a provider that declares them); the answer names the
* model that ran
* model that ran
*
*
+ * x-openvibe-credential the person's own key for a provider instead of the router
+ * (Codes C3): <provider> or <provider>:<subject>
* x_openvibe.provider an app's own provider instead of the router: { base_url (https, an
* x_openvibe.provider an app's own provider instead of the router: { base_url (https, an
* OpenAI-format API), model, secret (the name of a project secret granted
* OpenAI-format API), model, secret (the name of a project secret granted
* to ai for reveal to that origin, ADR-056) }; the key is revealed for
* to ai for reveal to that origin, ADR-056) }; the key is revealed for
@@ -29,6 +31,7 @@
@@ -29,6 +31,7 @@
const express = require('express');
const express = require('express');
const { CAPS } = require('../auth');
const { CAPS } = require('../auth');
const { appAttribution } = require('../apps');
const { appAttribution } = require('../apps');
+const { credentialFor } = require('./credential');
const FIELDS = ['messages', 'tools', 'tool_choice', 'parallel_tool_calls', 'response_format', 'stop', 'seed', 'top_p', 'presence_penalty', 'frequency_penalty', 'temperature'];
const FIELDS = ['messages', 'tools', 'tool_choice', 'parallel_tool_calls', 'response_format', 'stop', 'seed', 'top_p', 'presence_penalty', 'frequency_penalty', 'temperature'];
@@ -125,6 +128,10 @@ function openaiRouter({ runs, auth, config, log = console }) {
@@ -125,6 +128,10 @@ function openaiRouter({ runs, auth, config, log = console }) {
if (maxTokens != null) input.max_tokens = Math.max(1, Math.min(Number(maxTokens) || 1, 16000));
if (maxTokens != null) input.max_tokens = Math.max(1, Math.min(Number(maxTokens) || 1, 16000));
let body = { workflow: 'ai.complete', input };
let body = { workflow: 'ai.complete', input };
if (req.principal.kind === 'app') body = { ...body, attribution: appAttribution(req.principal) };
if (req.principal.kind === 'app') body = { ...body, attribution: appAttribution(req.principal) };
+ // The person's own key (Codes C3): x-openvibe-credential names the provider whose key this request uses.
+ const personKey = credentialFor(req);
+ if (personKey.error) return openaiError(res, personKey.status, personKey.error, 'invalid_request_error');
+ if (personKey.credential) body = { ...body, credential: personKey.credential };
const own = b.x_openvibe && typeof b.x_openvibe === 'object' ? b.x_openvibe.provider : undefined;
const own = b.x_openvibe && typeof b.x_openvibe === 'object' ? b.x_openvibe.provider : undefined;
if (own !== undefined) body = { ...body, project_provider: own };
if (own !== undefined) body = { ...body, project_provider: own };
let run;
let run;
@@ -424,7 +424,9 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -424,7 +424,9 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
if (cred.provider === 'node') return await executeOnMachine(cred, operation, req, ctx);
if (cred.provider === 'node') return await executeOnMachine(cred, operation, req, ctx);
// Text operations are all chat completions on the person's provider (templated workflows use generate).
// Text operations are all chat completions on the person's provider (templated workflows use generate).
// A project's own provider (ADR-056 secret, ai.complete) passes a Chat Completions request through as written.
// A project's own provider (ADR-056 secret, ai.complete) passes a Chat Completions request through as written.
- const completing = operation === 'complete' && cred.provider === 'openai';
+ // The OpenAI-format providers: a Chat Completions request is passed through as written (Codes C3: OpenRouter
+ // speaks that format too, so a person's OpenRouter key answers it as well).
+ const completing = operation === 'complete' && ['openai', 'openrouter'].includes(cred.provider);
// Codes C3: POST /v1/messages on the person's own key. Only a provider that speaks Anthropic's Messages API serves
// Codes C3: POST /v1/messages on the person's own key. Only a provider that speaks Anthropic's Messages API serves
// it — Anthropic itself, DeepSeek and MiniMax (their own Anthropic-compatible endpoints) and a custom endpoint.
// it — Anthropic itself, DeepSeek and MiniMax (their own Anthropic-compatible endpoints) and a custom endpoint.
const messaging = operation === 'messages';
const messaging = operation === 'messages';
new file mode 100644
new file mode 100644
@@ -0,0 +1,71 @@
@@ -0,0 +1,71 @@
+'use strict';
+// The person's own key on the OpenAI-compatible endpoint (Codes C3): POST /v1/chat/completions honours
+// x-openvibe-credential, so a harness that speaks OpenAI's format (Codex, any OpenAI-compatible client) runs with the
+// person's own key instead of the router's provider. The key never appears in a response, a log line or an error.
+const assert = require('assert');
+const { boot, request, token, suite, ALL } = require('./helpers');
+
+const t = suite('chat-credential');
+const KEY = 'cd'.repeat(32);
+const PERSON = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP7';
+const PERSON_KEY = 'sk-openai-test-person-0123456789';
+const service = token('live', ALL);
+const acting = token('codes', ALL, { extra: { on_behalf_of: PERSON } });
+const manager = token('codes', [...ALL, 'ai.credential.manage'], { extra: { on_behalf_of: PERSON } });
+
+// The person's own provider, answered in-process (the guarded fetch is replaced, as in credentials.test.js).
+const ownCalls = [];
+const credentialFetch = async (url, init) => {
+ ownCalls.push({ url, headers: init.headers || {}, body: JSON.parse(init.body) });
+ return new Response(JSON.stringify({
+ id: 'chatcmpl_person', object: 'chat.completion', model: 'gpt-person',
+ choices: [{ index: 0, message: { role: 'assistant', content: 'Hello there' }, finish_reason: 'stop' }],
+ usage: { prompt_tokens: 5, completion_tokens: 2 },
+ }), { status: 200, headers: { 'content-type': 'application/json' } });
+};
+
+const logs = [];
+const capture = { log: (...a) => logs.push(a.join(' ')), warn: (...a) => logs.push(a.join(' ')), error: (...a) => logs.push(a.join(' ')) };
+let h;
+
+const post = (body, { tok = service, headers = {} } = {}) => request(h.base, 'POST', '/v1/chat/completions', { tok, body, headers });
+
+t.test('boot with the credentials key set', async () => {
+ h = await boot({ env: { AI_CREDENTIALS_KEY: KEY, AI_STUB_FALLBACK: 'false' }, credentialFetch, log: capture });
+});
+
+t.test('the person\'s own key is stored and never answered', async () => {
+ const r = await request(h.base, 'PUT', `/api/v1/credentials/${PERSON}/openai`, { tok: manager, body: { key: PERSON_KEY, models: { chat: 'gpt-person' } } });
+ if (r.status !== 200) console.error('LOGS', logs.join('\n'));
+ assert.strictEqual(r.status, 200, r.text);
+ assert.ok(!r.text.includes(PERSON_KEY), 'the key is not in the answer');
+});
+
+t.test('x-openvibe-credential uses the person\'s key, not the router\'s provider', async () => {
+ const before = ownCalls.length;
+ const res = await post({ model: 'openvibe', messages: [{ role: 'user', content: 'Say hello' }] }, { tok: acting, headers: { 'x-openvibe-credential': 'openai' } });
+ assert.strictEqual(res.status, 200, res.text);
+ assert.strictEqual(res.body.choices[0].message.content, 'Hello there');
+ assert.ok(ownCalls.length > before, 'the person\'s provider was called');
+ const call = ownCalls[ownCalls.length - 1];
+ assert.ok(String(call.headers.Authorization || call.headers.authorization || '').includes(PERSON_KEY), 'the person\'s key was sent to their provider');
+ assert.ok(!JSON.stringify(res.body).includes(PERSON_KEY), 'the key is not in the answer');
+ const run = await request(h.base, 'GET', `/api/v1/runs/${res.headers.get('x-openvibe-run-id')}`, { tok: acting });
+ assert.strictEqual(run.body.run.provenance.provider, `byo:codes:${PERSON}:openai`);
+});
+
+t.test('<provider>:<subject> names the person explicitly, without an on_behalf_of claim', async () => {
+ const res = await post({ model: 'openvibe', messages: [{ role: 'user', content: 'hi' }] }, { tok: manager, headers: { 'x-openvibe-credential': `openai:${PERSON}` } });
+ assert.strictEqual(res.status, 200, res.text);
+});
+
+t.test('a header without a person is refused, in OpenAI\'s error shape', async () => {
+ const res = await post({ model: 'openvibe', messages: [{ role: 'user', content: 'hi' }] }, { tok: service, headers: { 'x-openvibe-credential': 'openai' } });
+ assert.strictEqual(res.status, 400);
+ assert.strictEqual(res.body.error.type, 'invalid_request_error');
+ assert.ok(!logs.join('\n').includes(PERSON_KEY), 'the key is never in a log line');
+});
+
+t.test('stop', async () => { await h.stop(); });
+
+t.run();