openvibers/openvibe.ai Commits

Codes C3: x-openvibe-credential on the OpenAI-compatible endpoint (#203)

OP OpenVibers committed 14 days ago · c47954fc36 · 5 files, +107 −13

A harness that speaks OpenAI's format (Codex, any OpenAI-compatible client) now runs with the
person's own key: POST /v1/chat/completions reads the same x-openvibe-credential header that
POST /v1/messages reads, and the run uses that person's sealed key for that provider instead of
the router's own. The header helper moves to server/api/credential.js so both endpoints share it,
and OpenRouter counts as an OpenAI-format provider for a Chat Completions request.

The key itself never leaves the vault: the answer, the error and the log lines carry only the
provider and the person.

Changes

ddiff --git a/server/api/credential.js b/server/api/credential.js
nnew file mode 100644
iindex 0000000..dde08d9
---- /dev/null
++++ b/server/api/credential.js
@@@ -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 };
ddiff --git a/server/api/messages.js b/server/api/messages.js
iindex 32705c6..120693b 100644
---- a/server/api/messages.js
++++ b/server/api/messages.js
@@@ -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);
ddiff --git a/server/api/openai.js b/server/api/openai.js
iindex a582591..39c20fd 100644
---- a/server/api/openai.js
++++ b/server/api/openai.js
@@@ -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;
ddiff --git a/server/providers/index.js b/server/providers/index.js
iindex c1feff2..9beed2f 100644
---- a/server/providers/index.js
++++ b/server/providers/index.js
@@@ -424,7 +424,9 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
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).
// 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';
ddiff --git a/test/chat-credential.test.js b/test/chat-credential.test.js
nnew file mode 100644
iindex 0000000..d37d8f5
---- /dev/null
++++ b/test/chat-credential.test.js
@@@ -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();