openvibers/openvibe.ai Commits
AI: an app names its project's own provider on /v1/chat/completions (x_openvibe.provider, an ADR-056 secret revealed per run) (#74)
Changes
@@@ -160,6 +160,19 @@ coding agents, Codes' harnesses) points its base URL at `https://ai.openvibe.seraudit and explain apply, and the prompt is not kept. Errors are OpenAI's shape (`{ error: { message, type, code } }`).- **Upstreams:** OpenAI-format providers (`kind: openai`) pass the request through (`complete()`; a reasoning modeltakes `max_completion_tokens` and its default sampling). Anthropic is not used for it yet.++- **An app's own provider** (`x_openvibe.provider`, ADR-056). An app may name its project's own OpenAI-format API++ instead of the router: `{ "base_url": "https://api.openai.com/v1", "model": "gpt-5-mini", "secret": "openai-key" }`.++ - `secret` is the name of a project secret (OpenVibe.Services → project → Secrets) that the project granted to `ai`++ for reveal to that base URL's origin.++ - OpenVibe.Network reveals the key for that one run, from the app's environment (sandbox or production). The key is++ sent to that origin only, through the public-address guard, and is never stored, cached or logged. The run keeps++ the secret's name.++ - The project pays its provider directly, so no tier budget holds the run (the app's quotas still count it). Tools,++ tool results and every other field pass through.++ - Refusals: a secret that does not exist or is not granted answers 403 `project_secret.refused`, a base URL that is++ not https answers 400, and a first-party service token naming a provider answers 403.++ - Production app runs that use the router are limited to free and local capacity (see Developer apps). Today that is++ a small local model without tools, so a coding agent's loop needs its project's own provider.## Developer apps@@@ -13,6 +13,11 @@* (tools need a provider that declares them); the answer names the* model that ran*++ * 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++ * that one run, and the project pays its provider directly++ ** Each request is a run of workflow ai.complete, so it has the run's quota, free allowance, cost, audit and explain* (x_openvibe in the answer); the prompt is not kept. stream: true answers server-sent events in the Chat Completions* chunk format, emulated: the whole answer arrives as one delta, then finish_reason, then [DONE]. Errors are OpenAI's@@@ -25,8 +30,9 @@ const { appAttribution } = require('../apps');const FIELDS = ['messages', 'tools', 'tool_choice', 'parallel_tool_calls', 'response_format', 'stop', 'seed', 'top_p', 'presence_penalty', 'frequency_penalty', 'temperature'];// Cloudflare answers a 502 or 504 from the origin with its own HTML page, so an OpenAI client would get no JSON error:--// an upstream that failed or could not be reached is 503 (OpenAI's own "unavailable, retry"), the body kept.--const gatewaySafe = (status) => (status === 502 || status === 504 ? 503 : status);++// an upstream that failed or could not be reached is 503 (OpenAI's own "unavailable, retry"), the body kept. An invalid++// request is OpenAI's 400, not 422.++const gatewaySafe = (status) => (status === 502 || status === 504 ? 503 : status === 422 ? 400 : status);function openaiError(res, status, message, code, type) {res.status(status).json({ error: { message: String(message || 'error').slice(0, 1000), type: type || (status === 401 || status === 403 ? 'authentication_error' : status === 429 ? 'rate_limit_error' : status >= 500 ? 'api_error' : 'invalid_request_error'), code: code || null, param: null } });@@@ -68,6 +74,8 @@ 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) };++ 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;try {const created = await runs.create(body, req.principal, { trace: req.ov && req.ov.traceId, requestId: req.ov && req.ov.requestId });@@@ -80,7 +88,8 @@ function openaiRouter({ runs, auth, config, log = console }) {}if (run.status === 'failed' || run.status === 'cancelled') {const code = (run.error && run.error.code) || 'run.failed';-- const status = /^(input|validation)\./.test(code) ? 400 : /quota|allowance|budget/.test(code) ? 429 : 503;++ const status = /^(input|validation)\./.test(code) ? 400 : /quota|allowance|budget/.test(code) ? 429++ : code === 'project_secret.refused' ? 403 : code === 'credential.endpoint_refused' ? 422 : 503;return openaiError(res, status, (run.error && run.error.detail) || code, code);}if (!['succeeded', 'cached'].includes(run.status) || !run.output || !Array.isArray(run.output.choices)) {@@@ -155,6 +155,7 @@ function llmsTxt({ siteUrl }) {'- Token: POST https://openvibe.network/oauth/token with grant_type=client_credentials, your app\'s client id and secret, audience=openvibe.ai (five minutes).',`- Operations: POST ${siteUrl}/api/v1/chat, /generate, /summarize, /classify, /extract, /embed; GET ${siteUrl}/api/v1/runs/:id for your own runs.`,`- OpenAI-compatible: POST ${siteUrl}/v1/chat/completions (Chat Completions with tools; stream is emulated) and GET ${siteUrl}/v1/models, with the same token. Point an OpenAI client's base URL at ${siteUrl}/v1.`,++ `- An app's own provider: add x_openvibe.provider { base_url, model, secret } to name the project's own OpenAI-format API; secret is a project secret granted to ai for reveal to that origin (ADR-056), revealed per run and never stored.`,'- Every run response carries explain: the objective, the model selected and every candidate with its estimated cost, latency and excluded reason.','- Sandbox apps run on free and local capacity only; runs, usage and the free allowance are per project; a person\'s own provider key is never used for an app.','',@@@ -49,7 +49,8 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.nowconst userModules = require('./user-modules').createUserModules({ db, config, env, fetchImpl, clock, log });// A person's own provider keys (WS-O task 2): stored by the service holding their consent, used by their runs only.const credentials = require('./credentials').createCredentials({ db, config, clock });-- const runs = createRuns({ db, registry, engine, cache, quotas, config, clock, log, userModules, credentials, tiers });++ const projectSecrets = require('./project-secrets').createProjectSecrets({ config, env, fetchImpl });++ const runs = createRuns({ db, registry, engine, cache, quotas, config, clock, log, userModules, credentials, tiers, projectSecrets });await seed({ registry, quotas, config, env, db });const interrupted = await runs.recoverInterrupted();if (interrupted) log.warn(`[ai] marked ${interrupted} interrupted run(s) failed (run.interrupted); callers can retry them`);nnew file mode 100644@@@ -0,0 +1,53 @@++'use strict';++/**++ * Project secrets (ADR-056) through OpenVibe.Network's POST /internal/secrets/use, with AI's own service token++ * (network.secret.use, audience openvibe.network). A developer app's run may name its project's own provider key:++ *++ * reveal({ project_id, environment, name, origin }) → { value, version }++ *++ * The project's grant of that secret to `ai` for reveal to the provider's origin decides. Network answers 404 for a++ * secret that does not exist or is not granted (SecretRefused, final for the run); anything else (Network unreachable,++ * no service credentials here) is thrown as it is. A revealed value is used for the one run it is for and never kept,++ * logged or stored.++ */++const { AiError } = require('./util');++++class SecretRefused extends AiError {++ constructor(detail) { super(403, 'project_secret.refused', detail); }++}++++function createProjectSecrets({ config, env = process.env, fetchImpl = globalThis.fetch }) {++ const base = String(config.networkInternalUrl || 'http://127.0.0.1:4000').replace(/\/+$/, '');++ const secret = env.OV_OAUTH_CLIENT_SECRET || '';++ let tokens = null;++ const tokenClient = () => {++ if (!tokens) {++ const { createServiceTokenClient } = require('openvibe-sdk/auth');++ tokens = createServiceTokenClient({ tokenUrl: `${base}/oauth/token`, clientId: env.OV_OAUTH_CLIENT_ID || 'ai', clientSecret: secret, fetch: fetchImpl });++ }++ return tokens;++ };++ return {++ available: () => Boolean(secret),++ async reveal({ project_id, environment, name, origin }) {++ if (!secret) throw new AiError(503, 'project_secret.unavailable', 'project secrets are not configured on this service');++ const t = await tokenClient().getToken({ audience: 'openvibe.network' });++ let res;++ try {++ res = await fetchImpl(`${base}/internal/secrets/use`, {++ method: 'POST', signal: AbortSignal.timeout(5000),++ headers: { Authorization: `Bearer ${t}`, 'Content-Type': 'application/json', Accept: 'application/json' },++ body: JSON.stringify({ project_id, environment, name, purpose: 'reveal', origin }),++ });++ } catch (err) {++ throw new AiError(503, 'project_secret.unavailable', `OpenVibe.Network did not answer: ${err.message}`);++ }++ const body = await res.json().catch(() => null);++ if (res.status === 404) throw new SecretRefused(`project secret ${name} (${environment}) does not exist, or is not granted to ai for reveal to ${origin}`);++ if (!res.ok || !body || typeof body.value !== 'string' || !body.value) throw new AiError(503, 'project_secret.unavailable', `OpenVibe.Network answered ${res.status}`);++ return { value: body.value, version: body.version };++ },++ };++}++++module.exports = { createProjectSecrets, SecretRefused };@@@ -370,22 +370,25 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no*/async function executeWithCredential(cred, operation, req, ctx = {}) {// Text operations are all chat completions on the person's provider (templated workflows use generate).-- if (!['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich'].includes(operation)) throw new AiError(422, 'credential.unsupported', `a person's own key is used for text only, not ${operation}`);++ // 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';++ if (!completing && !['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich'].includes(operation)) throw new AiError(422, 'credential.unsupported', `a person's own key is used for text only, not ${operation}`);const guardedFetch = credentialFetch || require('./guarded-fetch').guardedFetch;// The person's model for this role: the run's input.role, else the Live role its route names (live.director).const wanted = ctx.role || ((/^live\.(chat|vision|director|summary|legacy)$/.exec(ctx.routeKey || '') || [])[1]) || null;const role = wanted && cred.models[wanted] ? wanted : null;const model = (req.image && cred.models.vision) || (role && cred.models[role]) || cred.models.chat || (cred.provider === 'anthropic' ? 'claude-haiku-4-5-20251001' : 'gpt-4o-mini');-- const record = { key: cred.providerKey, kind: cred.provider, base_url: cred.base_url, capabilities: ['chat', 'json', 'vision'], timeout_ms: 60000 };++ const record = { key: cred.providerKey, kind: cred.provider, base_url: cred.base_url, capabilities: cred.capabilities || ['chat', 'json', 'vision'], timeout_ms: 60000 };const a = cred.provider === 'anthropic' ? createAnthropicProvider(record, { apiKey: cred.apiKey, fetchImpl: guardedFetch }) : createOpenAiProvider(record, { apiKey: cred.apiKey, fetchImpl: guardedFetch });const base = { operation, provider_key: cred.providerKey, model_key: model, route_key: ctx.routeKey || null, route_version: ctx.routeVersion || null, fallback: 0, prompt_hash: ctx.promptHash || null };const timeoutMs = Math.max(1000, Math.min(req.timeoutMs || 60000, 120000));++ const call = completing ? a.complete : a.chat;let lastErr = null;for (let attempt = 0; attempt <= config.providerRetries; attempt++) {if (ctx.signal && ctx.signal.aborted) throw new AiError(409, 'run.cancelled', 'run was cancelled');const t0 = Date.now();try {-- const result = await a.chat({ ...req, model, signal: ctx.signal, timeoutMs });++ const result = await call({ ...req, model, signal: ctx.signal, timeoutMs });const usage = result.usage || { input: 0, output: 0, cached: 0 };const cost = rates.costOfUsage(rates.buildCards({ providerKey: cred.providerKey, model: result.model || model, pricing: config.pricing, flat: false }), usage);const latency = Date.now() - t0;@@@ -393,7 +396,8 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noctx.logRequest({ ...base, model_key: result.model || model, status: 'ok', output_hash: sha256(result.json || result.text || ''), tokens_in: usage.input || 0, tokens_out: usage.output || 0,tokens_cached: usage.cached || 0, tokens_estimated: usage.estimated ? 1 : 0, cost_usd: cost, latency_ms: latency });}-- return { result, provider: cred.providerKey, providerKind: cred.provider, model: result.model || model, fallbackUsed: false, usage, cost, latencyMs: latency, synthetic: false, tried: [], startedAt: t0 };++ return { result, provider: cred.providerKey, providerKind: cred.provider, model: result.model || model, fallbackUsed: false, usage, cost, latencyMs: latency, synthetic: false, tried: [], startedAt: t0,++ ...(cred.explain ? { explain: cred.explain } : {}) };} catch (err) {lastErr = err;if (ctx.signal && ctx.signal.aborted) throw new AiError(409, 'run.cancelled', 'run was cancelled');@@@ -59,7 +59,21 @@ function retainedInput(input, wf, debugRaw) {return { value, full: !redacted };}--function createRuns({ db, registry, engine, cache, quotas, config, clock = { now: () => Date.now() }, log = console, userModules = null, credentials = null, tiers = null }) {++// An app's own provider for ai.complete (POST /v1/chat/completions, x_openvibe.provider): an OpenAI-format API at an++// https base URL, its model, and the name of the project secret that holds its key (ADR-056 names).++const SECRET_NAME_RE = /^[a-z][a-z0-9_-]{0,63}$/;++const MODEL_RE = /^[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,199}$/;++function projectProviderOf(spec) {++ if (!spec || typeof spec !== 'object' || Array.isArray(spec)) throw new AiError(422, 'input.invalid', 'provider must be { base_url, model, secret }');++ let u;++ try { u = new URL(String(spec.base_url || '')); } catch { throw new AiError(422, 'input.invalid', 'provider.base_url must be a URL'); }++ if (u.protocol !== 'https:' || u.username || u.password || u.search || u.hash) throw new AiError(422, 'input.invalid', 'provider.base_url must be https, without credentials, query or fragment');++ if (!MODEL_RE.test(String(spec.model || ''))) throw new AiError(422, 'input.invalid', 'provider.model must name the model (letters, digits and . _ : / @ + -, at most 200)');++ if (!SECRET_NAME_RE.test(String(spec.secret || ''))) throw new AiError(422, 'input.invalid', 'provider.secret must be the name of a project secret (lower case, at most 64)');++ return { base_url: `${u.origin}${u.pathname.replace(/\/+$/, '')}`, model: String(spec.model), secret: String(spec.secret) };++}++++function createRuns({ db, registry, engine, cache, quotas, config, clock = { now: () => Date.now() }, log = console, userModules = null, credentials = null, tiers = null, projectSecrets = null }) {const inflight = new Map(); // run id -> { controller, promise, release, caller }const queue = []; // run ids waiting for the workerlet active = 0;@@@ -180,6 +194,14 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { nowif (requester.type !== 'service') throw new AiError(403, 'capability.denied', 'only the service that stored a credential runs with it');await credentials.admit(requester.id, credentialSubject);}++ // An app's own provider (ADR-056 project secret): only on ai.complete, only for an app; its key spends the++ // project's money at its provider, so no tier budget holds it (the app's quotas still count it).++ const projectProvider = body.project_provider == null ? null : projectProviderOf(body.project_provider);++ if (projectProvider) {++ if (!app) throw new AiError(403, 'capability.denied', 'only a developer app names its project\'s own provider');++ if (wf.key !== 'ai.complete') throw new AiError(422, 'input.invalid', 'a project provider is used for ai.complete only');++ if (!projectSecrets || !projectSecrets.available()) throw new AiError(503, 'project_secret.unavailable', 'project secrets are not configured on this service');++ }const ctx = {requesterType: requester.type, requesterId: requester.id,actorKey: subjectKey(body.on_behalf_of), targetKey: entityKey(body.target), attributionKey: entityKey(body.attribution), workflowKey: wf.key,@@@ -197,12 +219,13 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { nowsource_service: requester.type === 'service' ? requester.id : null,target: body.target ? JSON.stringify(body.target) : null, target_key: ctx.targetKey,input: null, input_hash: inputHash, idempotency_key: idem, trace_id: trace, request_id: requestId, retry_of: retryOf,-- options: JSON.stringify({ cache: options.cache !== false && credentialSubject === null, debug: Boolean(options.debug), input_retained: kept.full, ...(credentialSubject !== null ? { credential_subject: credentialSubject } : {}) }), created_at: now,++ options: JSON.stringify({ cache: options.cache !== false && credentialSubject === null && !projectProvider, debug: Boolean(options.debug), input_retained: kept.full,++ ...(credentialSubject !== null ? { credential_subject: credentialSubject } : {}), ...(projectProvider ? { project_provider: projectProvider } : {}) }), created_at: now,};base.input = stableStringify(kept.value);// Cache (scoped; never crosses requester/actor/target/attribution)-- const scope = options.cache === false || credentialSubject !== null ? null : cache.scopeFor(wf.cache_mode, ctx);++ const scope = options.cache === false || credentialSubject !== null || projectProvider ? null : cache.scopeFor(wf.cache_mode, ctx);let cacheKey = null;if (scope) {cacheKey = cache.keyFor({ scope, workflowKey: wf.key, workflowVersion: wf.version, templateVersion: base.template_version, routeKey: base.route_key, routeVersion: base.route_version, inputHash });@@@ -234,7 +257,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { nowadmit(caller);// A run with a person's own key spends their money, not the shared quotas (its own budget was checked above).// The tier budgets (server/govern.js, AI_GOVERN_TIERS) hold first, so a run they refuse is not counted by reserve().-- const hold = credentialSubject !== null || !tiers ? null : await tiers.admit(ctx, id);++ const hold = credentialSubject !== null || projectProvider || !tiers ? null : await tiers.admit(ctx, id);let reserved;try {reserved = credentialSubject !== null ? null : await quotas.reserve(ctx);@@@ -284,6 +307,20 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now}}++ /**++ * The project's own provider as a run credential: the key is revealed by OpenVibe.Network for this one run, to the++ * provider's origin only (the project's grant of the secret to ai decides), and is never stored.++ */++ async function projectCredential(spec, ctx) {++ const environment = ctx.sandbox ? 'sandbox' : 'production';++ const { value } = await projectSecrets.reveal({ project_id: ctx.projectId, environment, name: spec.secret, origin: new URL(spec.base_url).origin });++ return {++ providerKey: `project:${ctx.projectId}`, provider: 'openai', base_url: spec.base_url, apiKey: value, models: { chat: spec.model },++ capabilities: ['chat', 'json', 'vision', 'tools'],++ explain: { objective: null, reasons: [`the project's own provider at ${new URL(spec.base_url).origin} (project secret ${spec.secret}, ${environment})`], selected: `project:${ctx.projectId}`, candidates: [] },++ };++ }++async function execute(id, { controller, reserved, scope, ctx, input, hold = null }) {const row = await getRow(id);if (!row || row.status !== 'queued') { inflight.delete(id); return; }@@@ -312,7 +349,8 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { nowif (preferences.history === false) {await db.prepare("UPDATE runs SET input = 'null', cache_key = NULL, options = (COALESCE(options, '{}')::jsonb || jsonb_build_object('input_retained', false, 'history', false))::text WHERE id = ?").run(id);}-- const credential = opts.credential_subject ? await credentials.forRun(row.requester_id, opts.credential_subject) : null;++ const credential = opts.credential_subject ? await credentials.forRun(row.requester_id, opts.credential_subject)++ : opts.project_provider ? await projectCredential(opts.project_provider, ctx) : null;const r = await engine.execute(wf, row, input, { signal: controller.signal, logRequest, debugRaw: Boolean(config.debugRawLog && opts.debug), inputHash: row.input_hash, preferences,// A developer app's run (ADR-014): the provider pool excludes a person's byok key, and the run is// limited to free and local capacity when it is a sandbox run or when no tier budget governs itsnnew file mode 100644@@@ -0,0 +1,93 @@++'use strict';++// An app's own provider on the OpenAI-compatible endpoint (x_openvibe.provider, ADR-056): the project secret is revealed++// by Network for that run, to the provider's origin, in the app's environment; the request (tools included) goes to the++// project's provider with that key and the model it named, and the run is the project's. Refused: an unknown or++// ungranted secret (403), a non-https base URL (400), a service token naming a provider (403). The key is never stored.++const assert = require('assert');++const { boot, request, token, suite } = require('./helpers');++++const t = suite('project-provider');++const PRJ = 'prj_01JAB2C3D4E5F6G7H8J9K0MNPQ';++const appToken = (env) => token('app', ['ai.app.run'], { sub: `app:app_${PRJ.slice(4)}`, actorType: 'app', ns: [PRJ, `app.${PRJ}.*`], extra: { project_id: PRJ, env } });++const service = token('live', ['ai.run.create', 'ai.run.read']);++const KEY = `sk-own-${'k'.repeat(32)}`;++const PROVIDER = { base_url: 'https://llm.example.org/v1/', model: 'gpt-own-1', secret: 'llm-key' };++const WEATHER = { type: 'function', function: { name: 'get_weather', parameters: { type: 'object', properties: { city: { type: 'string' } } } } };++const NETWORK = 'http://network.test';++let h;++const reveals = [];++const upstream = [];++++const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });++// OpenVibe.Network: a client-credentials token, and /internal/secrets/use revealing llm-key to https://llm.example.org only.++async function network(url, init = {}) {++ const u = new URL(url);++ if (u.pathname === '/oauth/token') return json(200, { access_token: 'svc-token', token_type: 'Bearer', expires_in: 300 });++ if (u.pathname === '/internal/secrets/use') {++ const b = JSON.parse(init.body);++ reveals.push({ auth: init.headers.Authorization, ...b });++ if (b.name === 'llm-key' && b.purpose === 'reveal' && b.origin === 'https://llm.example.org') return json(200, { value: KEY, version: 3 });++ return json(404, { code: 'secret.not_found' });++ }++ return json(404, {});++}++// The project's provider (through the credential fetch, as guardedFetch would reach it): a tool call when given tools.++async function provider(url, init = {}) {++ const body = JSON.parse(init.body);++ upstream.push({ url, auth: init.headers.Authorization || init.headers.authorization, body });++ const message = body.tools ? { role: 'assistant', content: null, tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'get_weather', arguments: '{"city":"Oslo"}' } }] } : { role: 'assistant', content: 'from the project provider' };++ return json(200, { id: 'chatcmpl-own', object: 'chat.completion', model: body.model, choices: [{ index: 0, message, finish_reason: body.tools ? 'tool_calls' : 'stop' }], usage: { prompt_tokens: 7, completion_tokens: 3, total_tokens: 10 } });++}++const post = (tok, extra) => request(h.base, 'POST', '/v1/chat/completions', { tok, body: { model: 'x', messages: [{ role: 'user', content: 'Weather in Oslo?' }], ...extra } });++++t.test('boot (stub fallback off: only the project provider can answer)', async () => {++ h = await boot({++ env: { AI_STUB_FALLBACK: 'false', OV_OAUTH_CLIENT_SECRET: 's'.repeat(40), OV_NETWORK_INTERNAL_URL: NETWORK },++ fetchImpl: (url, init) => (String(url).startsWith(NETWORK) ? network(url, init) : globalThis.fetch(url, init)),++ credentialFetch: provider,++ });++});++++t.test('the project secret is revealed for the run and the request (tools too) goes to the project provider', async () => {++ const r = await post(appToken('production'), { tools: [WEATHER], x_openvibe: { provider: PROVIDER } });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.body.choices[0].finish_reason, 'tool_calls');++ assert.strictEqual(r.body.choices[0].message.tool_calls[0].function.name, 'get_weather');++ assert.strictEqual(r.body.x_openvibe.provider, `project:${PRJ}`);++ assert.deepStrictEqual(reveals.map((x) => [x.auth, x.project_id, x.environment, x.name, x.purpose, x.origin]),++ [['Bearer svc-token', PRJ, 'production', 'llm-key', 'reveal', 'https://llm.example.org']]);++ assert.deepStrictEqual([upstream[0].url, upstream[0].auth, upstream[0].body.model, upstream[0].body.tools.length],++ ['https://llm.example.org/v1/chat/completions', `Bearer ${KEY}`, 'gpt-own-1', 1]);++ const run = await request(h.base, 'GET', `/api/v1/runs/${r.body.x_openvibe.run_id}`, { tok: appToken('production') });++ assert.strictEqual(run.status, 200, run.text);++ assert.match(run.body.run.explain.reasons.join(' '), /project's own provider at https:\/\/llm\.example\.org \(project secret llm-key, production\)/);++ assert.ok(!JSON.stringify(run.body).includes(KEY), 'the key is not in the run');++ const row = await h.testdb.db.prepare('SELECT options FROM runs WHERE id = ?').get(r.body.x_openvibe.run_id);++ assert.ok(!String(row.options).includes(KEY) && String(row.options).includes('llm-key'), 'the run keeps the secret\'s name, never its value');++});++++t.test('a sandbox app reveals from the sandbox environment', async () => {++ const r = await post(appToken('sandbox'), { x_openvibe: { provider: PROVIDER } });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.body.choices[0].message.content, 'from the project provider');++ assert.strictEqual(reveals[reveals.length - 1].environment, 'sandbox');++});++++t.test('refused: a secret not granted (403), a base URL that is not https (400), a service naming a provider (403)', async () => {++ const calls = upstream.length;++ const missing = await post(appToken('production'), { x_openvibe: { provider: { ...PROVIDER, secret: 'other-key' } } });++ assert.strictEqual(missing.status, 403, missing.text);++ assert.strictEqual(missing.body.error.code, 'project_secret.refused');++ const plain = await post(appToken('production'), { x_openvibe: { provider: { ...PROVIDER, base_url: 'http://llm.example.org/v1' } } });++ assert.strictEqual(plain.status, 400, plain.text);++ assert.strictEqual(plain.body.error.code, 'input.invalid');++ const bad = await post(appToken('production'), { x_openvibe: { provider: { ...PROVIDER, secret: 'Not A Name' } } });++ assert.strictEqual(bad.status, 400, bad.text);++ const svc = await post(service, { x_openvibe: { provider: PROVIDER } });++ assert.strictEqual(svc.status, 403, svc.text);++ assert.strictEqual(svc.body.error.code, 'capability.denied');++ assert.strictEqual(upstream.length, calls, 'nothing refused reached a provider');++});++++t.test('close', async () => { await h.stop(); });++t.run();
@@ -160,6 +160,19 @@ coding agents, Codes' harnesses) points its base URL at `https://ai.openvibe.ser
@@ -160,6 +160,19 @@ coding agents, Codes' harnesses) points its base URL at `https://ai.openvibe.ser
audit and explain apply, and the prompt is not kept. Errors are OpenAI's shape (`{ error: { message, type, code } }`).
audit and explain apply, and the prompt is not kept. Errors are OpenAI's shape (`{ error: { message, type, code } }`).
- **Upstreams:** OpenAI-format providers (`kind: openai`) pass the request through (`complete()`; a reasoning model
- **Upstreams:** OpenAI-format providers (`kind: openai`) pass the request through (`complete()`; a reasoning model
takes `max_completion_tokens` and its default sampling). Anthropic is not used for it yet.
takes `max_completion_tokens` and its default sampling). Anthropic is not used for it yet.
+- **An app's own provider** (`x_openvibe.provider`, ADR-056). An app may name its project's own OpenAI-format API
+ instead of the router: `{ "base_url": "https://api.openai.com/v1", "model": "gpt-5-mini", "secret": "openai-key" }`.
+ - `secret` is the name of a project secret (OpenVibe.Services → project → Secrets) that the project granted to `ai`
+ for reveal to that base URL's origin.
+ - OpenVibe.Network reveals the key for that one run, from the app's environment (sandbox or production). The key is
+ sent to that origin only, through the public-address guard, and is never stored, cached or logged. The run keeps
+ the secret's name.
+ - The project pays its provider directly, so no tier budget holds the run (the app's quotas still count it). Tools,
+ tool results and every other field pass through.
+ - Refusals: a secret that does not exist or is not granted answers 403 `project_secret.refused`, a base URL that is
+ not https answers 400, and a first-party service token naming a provider answers 403.
+ - Production app runs that use the router are limited to free and local capacity (see Developer apps). Today that is
+ a small local model without tools, so a coding agent's loop needs its project's own provider.
## Developer apps
## Developer apps
@@ -13,6 +13,11 @@
@@ -13,6 +13,11 @@
* (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.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
+ * that one run, and the project pays its provider directly
+ *
* Each request is a run of workflow ai.complete, so it has the run's quota, free allowance, cost, audit and explain
* Each request is a run of workflow ai.complete, so it has the run's quota, free allowance, cost, audit and explain
* (x_openvibe in the answer); the prompt is not kept. stream: true answers server-sent events in the Chat Completions
* (x_openvibe in the answer); the prompt is not kept. stream: true answers server-sent events in the Chat Completions
* chunk format, emulated: the whole answer arrives as one delta, then finish_reason, then [DONE]. Errors are OpenAI's
* chunk format, emulated: the whole answer arrives as one delta, then finish_reason, then [DONE]. Errors are OpenAI's
@@ -25,8 +30,9 @@ const { appAttribution } = require('../apps');
@@ -25,8 +30,9 @@ const { appAttribution } = require('../apps');
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'];
// Cloudflare answers a 502 or 504 from the origin with its own HTML page, so an OpenAI client would get no JSON error:
// Cloudflare answers a 502 or 504 from the origin with its own HTML page, so an OpenAI client would get no JSON error:
-// an upstream that failed or could not be reached is 503 (OpenAI's own "unavailable, retry"), the body kept.
-const gatewaySafe = (status) => (status === 502 || status === 504 ? 503 : status);
+// an upstream that failed or could not be reached is 503 (OpenAI's own "unavailable, retry"), the body kept. An invalid
+// request is OpenAI's 400, not 422.
+const gatewaySafe = (status) => (status === 502 || status === 504 ? 503 : status === 422 ? 400 : status);
function openaiError(res, status, message, code, type) {
function openaiError(res, status, message, code, type) {
res.status(status).json({ error: { message: String(message || 'error').slice(0, 1000), type: type || (status === 401 || status === 403 ? 'authentication_error' : status === 429 ? 'rate_limit_error' : status >= 500 ? 'api_error' : 'invalid_request_error'), code: code || null, param: null } });
res.status(status).json({ error: { message: String(message || 'error').slice(0, 1000), type: type || (status === 401 || status === 403 ? 'authentication_error' : status === 429 ? 'rate_limit_error' : status >= 500 ? 'api_error' : 'invalid_request_error'), code: code || null, param: null } });
@@ -68,6 +74,8 @@ function openaiRouter({ runs, auth, config, log = console }) {
@@ -68,6 +74,8 @@ 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) };
+ 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;
let run;
try {
try {
const created = await runs.create(body, req.principal, { trace: req.ov && req.ov.traceId, requestId: req.ov && req.ov.requestId });
const created = await runs.create(body, req.principal, { trace: req.ov && req.ov.traceId, requestId: req.ov && req.ov.requestId });
@@ -80,7 +88,8 @@ function openaiRouter({ runs, auth, config, log = console }) {
@@ -80,7 +88,8 @@ function openaiRouter({ runs, auth, config, log = console }) {
}
}
if (run.status === 'failed' || run.status === 'cancelled') {
if (run.status === 'failed' || run.status === 'cancelled') {
const code = (run.error && run.error.code) || 'run.failed';
const code = (run.error && run.error.code) || 'run.failed';
- const status = /^(input|validation)\./.test(code) ? 400 : /quota|allowance|budget/.test(code) ? 429 : 503;
+ const status = /^(input|validation)\./.test(code) ? 400 : /quota|allowance|budget/.test(code) ? 429
+ : code === 'project_secret.refused' ? 403 : code === 'credential.endpoint_refused' ? 422 : 503;
return openaiError(res, status, (run.error && run.error.detail) || code, code);
return openaiError(res, status, (run.error && run.error.detail) || code, code);
}
}
if (!['succeeded', 'cached'].includes(run.status) || !run.output || !Array.isArray(run.output.choices)) {
if (!['succeeded', 'cached'].includes(run.status) || !run.output || !Array.isArray(run.output.choices)) {
@@ -155,6 +155,7 @@ function llmsTxt({ siteUrl }) {
@@ -155,6 +155,7 @@ function llmsTxt({ siteUrl }) {
'- Token: POST https://openvibe.network/oauth/token with grant_type=client_credentials, your app\'s client id and secret, audience=openvibe.ai (five minutes).',
'- Token: POST https://openvibe.network/oauth/token with grant_type=client_credentials, your app\'s client id and secret, audience=openvibe.ai (five minutes).',
`- Operations: POST ${siteUrl}/api/v1/chat, /generate, /summarize, /classify, /extract, /embed; GET ${siteUrl}/api/v1/runs/:id for your own runs.`,
`- Operations: POST ${siteUrl}/api/v1/chat, /generate, /summarize, /classify, /extract, /embed; GET ${siteUrl}/api/v1/runs/:id for your own runs.`,
`- OpenAI-compatible: POST ${siteUrl}/v1/chat/completions (Chat Completions with tools; stream is emulated) and GET ${siteUrl}/v1/models, with the same token. Point an OpenAI client's base URL at ${siteUrl}/v1.`,
`- OpenAI-compatible: POST ${siteUrl}/v1/chat/completions (Chat Completions with tools; stream is emulated) and GET ${siteUrl}/v1/models, with the same token. Point an OpenAI client's base URL at ${siteUrl}/v1.`,
+ `- An app's own provider: add x_openvibe.provider { base_url, model, secret } to name the project's own OpenAI-format API; secret is a project secret granted to ai for reveal to that origin (ADR-056), revealed per run and never stored.`,
'- Every run response carries explain: the objective, the model selected and every candidate with its estimated cost, latency and excluded reason.',
'- Every run response carries explain: the objective, the model selected and every candidate with its estimated cost, latency and excluded reason.',
'- Sandbox apps run on free and local capacity only; runs, usage and the free allowance are per project; a person\'s own provider key is never used for an app.',
'- Sandbox apps run on free and local capacity only; runs, usage and the free allowance are per project; a person\'s own provider key is never used for an app.',
'',
'',
@@ -49,7 +49,8 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
@@ -49,7 +49,8 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
const userModules = require('./user-modules').createUserModules({ db, config, env, fetchImpl, clock, log });
const userModules = require('./user-modules').createUserModules({ db, config, env, fetchImpl, clock, log });
// A person's own provider keys (WS-O task 2): stored by the service holding their consent, used by their runs only.
// A person's own provider keys (WS-O task 2): stored by the service holding their consent, used by their runs only.
const credentials = require('./credentials').createCredentials({ db, config, clock });
const credentials = require('./credentials').createCredentials({ db, config, clock });
- const runs = createRuns({ db, registry, engine, cache, quotas, config, clock, log, userModules, credentials, tiers });
+ const projectSecrets = require('./project-secrets').createProjectSecrets({ config, env, fetchImpl });
+ const runs = createRuns({ db, registry, engine, cache, quotas, config, clock, log, userModules, credentials, tiers, projectSecrets });
await seed({ registry, quotas, config, env, db });
await seed({ registry, quotas, config, env, db });
const interrupted = await runs.recoverInterrupted();
const interrupted = await runs.recoverInterrupted();
if (interrupted) log.warn(`[ai] marked ${interrupted} interrupted run(s) failed (run.interrupted); callers can retry them`);
if (interrupted) log.warn(`[ai] marked ${interrupted} interrupted run(s) failed (run.interrupted); callers can retry them`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,53 @@
@@ -0,0 +1,53 @@
+'use strict';
+/**
+ * Project secrets (ADR-056) through OpenVibe.Network's POST /internal/secrets/use, with AI's own service token
+ * (network.secret.use, audience openvibe.network). A developer app's run may name its project's own provider key:
+ *
+ * reveal({ project_id, environment, name, origin }) → { value, version }
+ *
+ * The project's grant of that secret to `ai` for reveal to the provider's origin decides. Network answers 404 for a
+ * secret that does not exist or is not granted (SecretRefused, final for the run); anything else (Network unreachable,
+ * no service credentials here) is thrown as it is. A revealed value is used for the one run it is for and never kept,
+ * logged or stored.
+ */
+const { AiError } = require('./util');
+
+class SecretRefused extends AiError {
+ constructor(detail) { super(403, 'project_secret.refused', detail); }
+}
+
+function createProjectSecrets({ config, env = process.env, fetchImpl = globalThis.fetch }) {
+ const base = String(config.networkInternalUrl || 'http://127.0.0.1:4000').replace(/\/+$/, '');
+ const secret = env.OV_OAUTH_CLIENT_SECRET || '';
+ let tokens = null;
+ const tokenClient = () => {
+ if (!tokens) {
+ const { createServiceTokenClient } = require('openvibe-sdk/auth');
+ tokens = createServiceTokenClient({ tokenUrl: `${base}/oauth/token`, clientId: env.OV_OAUTH_CLIENT_ID || 'ai', clientSecret: secret, fetch: fetchImpl });
+ }
+ return tokens;
+ };
+ return {
+ available: () => Boolean(secret),
+ async reveal({ project_id, environment, name, origin }) {
+ if (!secret) throw new AiError(503, 'project_secret.unavailable', 'project secrets are not configured on this service');
+ const t = await tokenClient().getToken({ audience: 'openvibe.network' });
+ let res;
+ try {
+ res = await fetchImpl(`${base}/internal/secrets/use`, {
+ method: 'POST', signal: AbortSignal.timeout(5000),
+ headers: { Authorization: `Bearer ${t}`, 'Content-Type': 'application/json', Accept: 'application/json' },
+ body: JSON.stringify({ project_id, environment, name, purpose: 'reveal', origin }),
+ });
+ } catch (err) {
+ throw new AiError(503, 'project_secret.unavailable', `OpenVibe.Network did not answer: ${err.message}`);
+ }
+ const body = await res.json().catch(() => null);
+ if (res.status === 404) throw new SecretRefused(`project secret ${name} (${environment}) does not exist, or is not granted to ai for reveal to ${origin}`);
+ if (!res.ok || !body || typeof body.value !== 'string' || !body.value) throw new AiError(503, 'project_secret.unavailable', `OpenVibe.Network answered ${res.status}`);
+ return { value: body.value, version: body.version };
+ },
+ };
+}
+
+module.exports = { createProjectSecrets, SecretRefused };
@@ -370,22 +370,25 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -370,22 +370,25 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
*/
*/
async function executeWithCredential(cred, operation, req, ctx = {}) {
async function executeWithCredential(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).
- if (!['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich'].includes(operation)) throw new AiError(422, 'credential.unsupported', `a person's own key is used for text only, not ${operation}`);
+ // 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';
+ if (!completing && !['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich'].includes(operation)) throw new AiError(422, 'credential.unsupported', `a person's own key is used for text only, not ${operation}`);
const guardedFetch = credentialFetch || require('./guarded-fetch').guardedFetch;
const guardedFetch = credentialFetch || require('./guarded-fetch').guardedFetch;
// The person's model for this role: the run's input.role, else the Live role its route names (live.director).
// The person's model for this role: the run's input.role, else the Live role its route names (live.director).
const wanted = ctx.role || ((/^live\.(chat|vision|director|summary|legacy)$/.exec(ctx.routeKey || '') || [])[1]) || null;
const wanted = ctx.role || ((/^live\.(chat|vision|director|summary|legacy)$/.exec(ctx.routeKey || '') || [])[1]) || null;
const role = wanted && cred.models[wanted] ? wanted : null;
const role = wanted && cred.models[wanted] ? wanted : null;
const model = (req.image && cred.models.vision) || (role && cred.models[role]) || cred.models.chat || (cred.provider === 'anthropic' ? 'claude-haiku-4-5-20251001' : 'gpt-4o-mini');
const model = (req.image && cred.models.vision) || (role && cred.models[role]) || cred.models.chat || (cred.provider === 'anthropic' ? 'claude-haiku-4-5-20251001' : 'gpt-4o-mini');
- const record = { key: cred.providerKey, kind: cred.provider, base_url: cred.base_url, capabilities: ['chat', 'json', 'vision'], timeout_ms: 60000 };
+ const record = { key: cred.providerKey, kind: cred.provider, base_url: cred.base_url, capabilities: cred.capabilities || ['chat', 'json', 'vision'], timeout_ms: 60000 };
const a = cred.provider === 'anthropic' ? createAnthropicProvider(record, { apiKey: cred.apiKey, fetchImpl: guardedFetch }) : createOpenAiProvider(record, { apiKey: cred.apiKey, fetchImpl: guardedFetch });
const a = cred.provider === 'anthropic' ? createAnthropicProvider(record, { apiKey: cred.apiKey, fetchImpl: guardedFetch }) : createOpenAiProvider(record, { apiKey: cred.apiKey, fetchImpl: guardedFetch });
const base = { operation, provider_key: cred.providerKey, model_key: model, route_key: ctx.routeKey || null, route_version: ctx.routeVersion || null, fallback: 0, prompt_hash: ctx.promptHash || null };
const base = { operation, provider_key: cred.providerKey, model_key: model, route_key: ctx.routeKey || null, route_version: ctx.routeVersion || null, fallback: 0, prompt_hash: ctx.promptHash || null };
const timeoutMs = Math.max(1000, Math.min(req.timeoutMs || 60000, 120000));
const timeoutMs = Math.max(1000, Math.min(req.timeoutMs || 60000, 120000));
+ const call = completing ? a.complete : a.chat;
let lastErr = null;
let lastErr = null;
for (let attempt = 0; attempt <= config.providerRetries; attempt++) {
for (let attempt = 0; attempt <= config.providerRetries; attempt++) {
if (ctx.signal && ctx.signal.aborted) throw new AiError(409, 'run.cancelled', 'run was cancelled');
if (ctx.signal && ctx.signal.aborted) throw new AiError(409, 'run.cancelled', 'run was cancelled');
const t0 = Date.now();
const t0 = Date.now();
try {
try {
- const result = await a.chat({ ...req, model, signal: ctx.signal, timeoutMs });
+ const result = await call({ ...req, model, signal: ctx.signal, timeoutMs });
const usage = result.usage || { input: 0, output: 0, cached: 0 };
const usage = result.usage || { input: 0, output: 0, cached: 0 };
const cost = rates.costOfUsage(rates.buildCards({ providerKey: cred.providerKey, model: result.model || model, pricing: config.pricing, flat: false }), usage);
const cost = rates.costOfUsage(rates.buildCards({ providerKey: cred.providerKey, model: result.model || model, pricing: config.pricing, flat: false }), usage);
const latency = Date.now() - t0;
const latency = Date.now() - t0;
@@ -393,7 +396,8 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -393,7 +396,8 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
ctx.logRequest({ ...base, model_key: result.model || model, status: 'ok', output_hash: sha256(result.json || result.text || ''), tokens_in: usage.input || 0, tokens_out: usage.output || 0,
ctx.logRequest({ ...base, model_key: result.model || model, status: 'ok', output_hash: sha256(result.json || result.text || ''), tokens_in: usage.input || 0, tokens_out: usage.output || 0,
tokens_cached: usage.cached || 0, tokens_estimated: usage.estimated ? 1 : 0, cost_usd: cost, latency_ms: latency });
tokens_cached: usage.cached || 0, tokens_estimated: usage.estimated ? 1 : 0, cost_usd: cost, latency_ms: latency });
}
}
- return { result, provider: cred.providerKey, providerKind: cred.provider, model: result.model || model, fallbackUsed: false, usage, cost, latencyMs: latency, synthetic: false, tried: [], startedAt: t0 };
+ return { result, provider: cred.providerKey, providerKind: cred.provider, model: result.model || model, fallbackUsed: false, usage, cost, latencyMs: latency, synthetic: false, tried: [], startedAt: t0,
+ ...(cred.explain ? { explain: cred.explain } : {}) };
} catch (err) {
} catch (err) {
lastErr = err;
lastErr = err;
if (ctx.signal && ctx.signal.aborted) throw new AiError(409, 'run.cancelled', 'run was cancelled');
if (ctx.signal && ctx.signal.aborted) throw new AiError(409, 'run.cancelled', 'run was cancelled');
@@ -59,7 +59,21 @@ function retainedInput(input, wf, debugRaw) {
@@ -59,7 +59,21 @@ function retainedInput(input, wf, debugRaw) {
return { value, full: !redacted };
return { value, full: !redacted };
}
}
-function createRuns({ db, registry, engine, cache, quotas, config, clock = { now: () => Date.now() }, log = console, userModules = null, credentials = null, tiers = null }) {
+// An app's own provider for ai.complete (POST /v1/chat/completions, x_openvibe.provider): an OpenAI-format API at an
+// https base URL, its model, and the name of the project secret that holds its key (ADR-056 names).
+const SECRET_NAME_RE = /^[a-z][a-z0-9_-]{0,63}$/;
+const MODEL_RE = /^[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,199}$/;
+function projectProviderOf(spec) {
+ if (!spec || typeof spec !== 'object' || Array.isArray(spec)) throw new AiError(422, 'input.invalid', 'provider must be { base_url, model, secret }');
+ let u;
+ try { u = new URL(String(spec.base_url || '')); } catch { throw new AiError(422, 'input.invalid', 'provider.base_url must be a URL'); }
+ if (u.protocol !== 'https:' || u.username || u.password || u.search || u.hash) throw new AiError(422, 'input.invalid', 'provider.base_url must be https, without credentials, query or fragment');
+ if (!MODEL_RE.test(String(spec.model || ''))) throw new AiError(422, 'input.invalid', 'provider.model must name the model (letters, digits and . _ : / @ + -, at most 200)');
+ if (!SECRET_NAME_RE.test(String(spec.secret || ''))) throw new AiError(422, 'input.invalid', 'provider.secret must be the name of a project secret (lower case, at most 64)');
+ return { base_url: `${u.origin}${u.pathname.replace(/\/+$/, '')}`, model: String(spec.model), secret: String(spec.secret) };
+}
+
+function createRuns({ db, registry, engine, cache, quotas, config, clock = { now: () => Date.now() }, log = console, userModules = null, credentials = null, tiers = null, projectSecrets = null }) {
const inflight = new Map(); // run id -> { controller, promise, release, caller }
const inflight = new Map(); // run id -> { controller, promise, release, caller }
const queue = []; // run ids waiting for the worker
const queue = []; // run ids waiting for the worker
let active = 0;
let active = 0;
@@ -180,6 +194,14 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -180,6 +194,14 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
if (requester.type !== 'service') throw new AiError(403, 'capability.denied', 'only the service that stored a credential runs with it');
if (requester.type !== 'service') throw new AiError(403, 'capability.denied', 'only the service that stored a credential runs with it');
await credentials.admit(requester.id, credentialSubject);
await credentials.admit(requester.id, credentialSubject);
}
}
+ // An app's own provider (ADR-056 project secret): only on ai.complete, only for an app; its key spends the
+ // project's money at its provider, so no tier budget holds it (the app's quotas still count it).
+ const projectProvider = body.project_provider == null ? null : projectProviderOf(body.project_provider);
+ if (projectProvider) {
+ if (!app) throw new AiError(403, 'capability.denied', 'only a developer app names its project\'s own provider');
+ if (wf.key !== 'ai.complete') throw new AiError(422, 'input.invalid', 'a project provider is used for ai.complete only');
+ if (!projectSecrets || !projectSecrets.available()) throw new AiError(503, 'project_secret.unavailable', 'project secrets are not configured on this service');
+ }
const ctx = {
const ctx = {
requesterType: requester.type, requesterId: requester.id,
requesterType: requester.type, requesterId: requester.id,
actorKey: subjectKey(body.on_behalf_of), targetKey: entityKey(body.target), attributionKey: entityKey(body.attribution), workflowKey: wf.key,
actorKey: subjectKey(body.on_behalf_of), targetKey: entityKey(body.target), attributionKey: entityKey(body.attribution), workflowKey: wf.key,
@@ -197,12 +219,13 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -197,12 +219,13 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
source_service: requester.type === 'service' ? requester.id : null,
source_service: requester.type === 'service' ? requester.id : null,
target: body.target ? JSON.stringify(body.target) : null, target_key: ctx.targetKey,
target: body.target ? JSON.stringify(body.target) : null, target_key: ctx.targetKey,
input: null, input_hash: inputHash, idempotency_key: idem, trace_id: trace, request_id: requestId, retry_of: retryOf,
input: null, input_hash: inputHash, idempotency_key: idem, trace_id: trace, request_id: requestId, retry_of: retryOf,
- options: JSON.stringify({ cache: options.cache !== false && credentialSubject === null, debug: Boolean(options.debug), input_retained: kept.full, ...(credentialSubject !== null ? { credential_subject: credentialSubject } : {}) }), created_at: now,
+ options: JSON.stringify({ cache: options.cache !== false && credentialSubject === null && !projectProvider, debug: Boolean(options.debug), input_retained: kept.full,
+ ...(credentialSubject !== null ? { credential_subject: credentialSubject } : {}), ...(projectProvider ? { project_provider: projectProvider } : {}) }), created_at: now,
};
};
base.input = stableStringify(kept.value);
base.input = stableStringify(kept.value);
// Cache (scoped; never crosses requester/actor/target/attribution)
// Cache (scoped; never crosses requester/actor/target/attribution)
- const scope = options.cache === false || credentialSubject !== null ? null : cache.scopeFor(wf.cache_mode, ctx);
+ const scope = options.cache === false || credentialSubject !== null || projectProvider ? null : cache.scopeFor(wf.cache_mode, ctx);
let cacheKey = null;
let cacheKey = null;
if (scope) {
if (scope) {
cacheKey = cache.keyFor({ scope, workflowKey: wf.key, workflowVersion: wf.version, templateVersion: base.template_version, routeKey: base.route_key, routeVersion: base.route_version, inputHash });
cacheKey = cache.keyFor({ scope, workflowKey: wf.key, workflowVersion: wf.version, templateVersion: base.template_version, routeKey: base.route_key, routeVersion: base.route_version, inputHash });
@@ -234,7 +257,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -234,7 +257,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
admit(caller);
admit(caller);
// A run with a person's own key spends their money, not the shared quotas (its own budget was checked above).
// A run with a person's own key spends their money, not the shared quotas (its own budget was checked above).
// The tier budgets (server/govern.js, AI_GOVERN_TIERS) hold first, so a run they refuse is not counted by reserve().
// The tier budgets (server/govern.js, AI_GOVERN_TIERS) hold first, so a run they refuse is not counted by reserve().
- const hold = credentialSubject !== null || !tiers ? null : await tiers.admit(ctx, id);
+ const hold = credentialSubject !== null || projectProvider || !tiers ? null : await tiers.admit(ctx, id);
let reserved;
let reserved;
try {
try {
reserved = credentialSubject !== null ? null : await quotas.reserve(ctx);
reserved = credentialSubject !== null ? null : await quotas.reserve(ctx);
@@ -284,6 +307,20 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -284,6 +307,20 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
}
}
}
}
+ /**
+ * The project's own provider as a run credential: the key is revealed by OpenVibe.Network for this one run, to the
+ * provider's origin only (the project's grant of the secret to ai decides), and is never stored.
+ */
+ async function projectCredential(spec, ctx) {
+ const environment = ctx.sandbox ? 'sandbox' : 'production';
+ const { value } = await projectSecrets.reveal({ project_id: ctx.projectId, environment, name: spec.secret, origin: new URL(spec.base_url).origin });
+ return {
+ providerKey: `project:${ctx.projectId}`, provider: 'openai', base_url: spec.base_url, apiKey: value, models: { chat: spec.model },
+ capabilities: ['chat', 'json', 'vision', 'tools'],
+ explain: { objective: null, reasons: [`the project's own provider at ${new URL(spec.base_url).origin} (project secret ${spec.secret}, ${environment})`], selected: `project:${ctx.projectId}`, candidates: [] },
+ };
+ }
+
async function execute(id, { controller, reserved, scope, ctx, input, hold = null }) {
async function execute(id, { controller, reserved, scope, ctx, input, hold = null }) {
const row = await getRow(id);
const row = await getRow(id);
if (!row || row.status !== 'queued') { inflight.delete(id); return; }
if (!row || row.status !== 'queued') { inflight.delete(id); return; }
@@ -312,7 +349,8 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -312,7 +349,8 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
if (preferences.history === false) {
if (preferences.history === false) {
await db.prepare("UPDATE runs SET input = 'null', cache_key = NULL, options = (COALESCE(options, '{}')::jsonb || jsonb_build_object('input_retained', false, 'history', false))::text WHERE id = ?").run(id);
await db.prepare("UPDATE runs SET input = 'null', cache_key = NULL, options = (COALESCE(options, '{}')::jsonb || jsonb_build_object('input_retained', false, 'history', false))::text WHERE id = ?").run(id);
}
}
- const credential = opts.credential_subject ? await credentials.forRun(row.requester_id, opts.credential_subject) : null;
+ const credential = opts.credential_subject ? await credentials.forRun(row.requester_id, opts.credential_subject)
+ : opts.project_provider ? await projectCredential(opts.project_provider, ctx) : null;
const r = await engine.execute(wf, row, input, { signal: controller.signal, logRequest, debugRaw: Boolean(config.debugRawLog && opts.debug), inputHash: row.input_hash, preferences,
const r = await engine.execute(wf, row, input, { signal: controller.signal, logRequest, debugRaw: Boolean(config.debugRawLog && opts.debug), inputHash: row.input_hash, preferences,
// A developer app's run (ADR-014): the provider pool excludes a person's byok key, and the run is
// A developer app's run (ADR-014): the provider pool excludes a person's byok key, and the run is
// limited to free and local capacity when it is a sandbox run or when no tier budget governs its
// limited to free and local capacity when it is a sandbox run or when no tier budget governs its
new file mode 100644
new file mode 100644
@@ -0,0 +1,93 @@
@@ -0,0 +1,93 @@
+'use strict';
+// An app's own provider on the OpenAI-compatible endpoint (x_openvibe.provider, ADR-056): the project secret is revealed
+// by Network for that run, to the provider's origin, in the app's environment; the request (tools included) goes to the
+// project's provider with that key and the model it named, and the run is the project's. Refused: an unknown or
+// ungranted secret (403), a non-https base URL (400), a service token naming a provider (403). The key is never stored.
+const assert = require('assert');
+const { boot, request, token, suite } = require('./helpers');
+
+const t = suite('project-provider');
+const PRJ = 'prj_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+const appToken = (env) => token('app', ['ai.app.run'], { sub: `app:app_${PRJ.slice(4)}`, actorType: 'app', ns: [PRJ, `app.${PRJ}.*`], extra: { project_id: PRJ, env } });
+const service = token('live', ['ai.run.create', 'ai.run.read']);
+const KEY = `sk-own-${'k'.repeat(32)}`;
+const PROVIDER = { base_url: 'https://llm.example.org/v1/', model: 'gpt-own-1', secret: 'llm-key' };
+const WEATHER = { type: 'function', function: { name: 'get_weather', parameters: { type: 'object', properties: { city: { type: 'string' } } } } };
+const NETWORK = 'http://network.test';
+let h;
+const reveals = [];
+const upstream = [];
+
+const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
+// OpenVibe.Network: a client-credentials token, and /internal/secrets/use revealing llm-key to https://llm.example.org only.
+async function network(url, init = {}) {
+ const u = new URL(url);
+ if (u.pathname === '/oauth/token') return json(200, { access_token: 'svc-token', token_type: 'Bearer', expires_in: 300 });
+ if (u.pathname === '/internal/secrets/use') {
+ const b = JSON.parse(init.body);
+ reveals.push({ auth: init.headers.Authorization, ...b });
+ if (b.name === 'llm-key' && b.purpose === 'reveal' && b.origin === 'https://llm.example.org') return json(200, { value: KEY, version: 3 });
+ return json(404, { code: 'secret.not_found' });
+ }
+ return json(404, {});
+}
+// The project's provider (through the credential fetch, as guardedFetch would reach it): a tool call when given tools.
+async function provider(url, init = {}) {
+ const body = JSON.parse(init.body);
+ upstream.push({ url, auth: init.headers.Authorization || init.headers.authorization, body });
+ const message = body.tools ? { role: 'assistant', content: null, tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'get_weather', arguments: '{"city":"Oslo"}' } }] } : { role: 'assistant', content: 'from the project provider' };
+ return json(200, { id: 'chatcmpl-own', object: 'chat.completion', model: body.model, choices: [{ index: 0, message, finish_reason: body.tools ? 'tool_calls' : 'stop' }], usage: { prompt_tokens: 7, completion_tokens: 3, total_tokens: 10 } });
+}
+const post = (tok, extra) => request(h.base, 'POST', '/v1/chat/completions', { tok, body: { model: 'x', messages: [{ role: 'user', content: 'Weather in Oslo?' }], ...extra } });
+
+t.test('boot (stub fallback off: only the project provider can answer)', async () => {
+ h = await boot({
+ env: { AI_STUB_FALLBACK: 'false', OV_OAUTH_CLIENT_SECRET: 's'.repeat(40), OV_NETWORK_INTERNAL_URL: NETWORK },
+ fetchImpl: (url, init) => (String(url).startsWith(NETWORK) ? network(url, init) : globalThis.fetch(url, init)),
+ credentialFetch: provider,
+ });
+});
+
+t.test('the project secret is revealed for the run and the request (tools too) goes to the project provider', async () => {
+ const r = await post(appToken('production'), { tools: [WEATHER], x_openvibe: { provider: PROVIDER } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.body.choices[0].finish_reason, 'tool_calls');
+ assert.strictEqual(r.body.choices[0].message.tool_calls[0].function.name, 'get_weather');
+ assert.strictEqual(r.body.x_openvibe.provider, `project:${PRJ}`);
+ assert.deepStrictEqual(reveals.map((x) => [x.auth, x.project_id, x.environment, x.name, x.purpose, x.origin]),
+ [['Bearer svc-token', PRJ, 'production', 'llm-key', 'reveal', 'https://llm.example.org']]);
+ assert.deepStrictEqual([upstream[0].url, upstream[0].auth, upstream[0].body.model, upstream[0].body.tools.length],
+ ['https://llm.example.org/v1/chat/completions', `Bearer ${KEY}`, 'gpt-own-1', 1]);
+ const run = await request(h.base, 'GET', `/api/v1/runs/${r.body.x_openvibe.run_id}`, { tok: appToken('production') });
+ assert.strictEqual(run.status, 200, run.text);
+ assert.match(run.body.run.explain.reasons.join(' '), /project's own provider at https:\/\/llm\.example\.org \(project secret llm-key, production\)/);
+ assert.ok(!JSON.stringify(run.body).includes(KEY), 'the key is not in the run');
+ const row = await h.testdb.db.prepare('SELECT options FROM runs WHERE id = ?').get(r.body.x_openvibe.run_id);
+ assert.ok(!String(row.options).includes(KEY) && String(row.options).includes('llm-key'), 'the run keeps the secret\'s name, never its value');
+});
+
+t.test('a sandbox app reveals from the sandbox environment', async () => {
+ const r = await post(appToken('sandbox'), { x_openvibe: { provider: PROVIDER } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.body.choices[0].message.content, 'from the project provider');
+ assert.strictEqual(reveals[reveals.length - 1].environment, 'sandbox');
+});
+
+t.test('refused: a secret not granted (403), a base URL that is not https (400), a service naming a provider (403)', async () => {
+ const calls = upstream.length;
+ const missing = await post(appToken('production'), { x_openvibe: { provider: { ...PROVIDER, secret: 'other-key' } } });
+ assert.strictEqual(missing.status, 403, missing.text);
+ assert.strictEqual(missing.body.error.code, 'project_secret.refused');
+ const plain = await post(appToken('production'), { x_openvibe: { provider: { ...PROVIDER, base_url: 'http://llm.example.org/v1' } } });
+ assert.strictEqual(plain.status, 400, plain.text);
+ assert.strictEqual(plain.body.error.code, 'input.invalid');
+ const bad = await post(appToken('production'), { x_openvibe: { provider: { ...PROVIDER, secret: 'Not A Name' } } });
+ assert.strictEqual(bad.status, 400, bad.text);
+ const svc = await post(service, { x_openvibe: { provider: PROVIDER } });
+ assert.strictEqual(svc.status, 403, svc.text);
+ assert.strictEqual(svc.body.error.code, 'capability.denied');
+ assert.strictEqual(upstream.length, calls, 'nothing refused reached a provider');
+});
+
+t.test('close', async () => { await h.stop(); });
+t.run();