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)

OP OpenVibers committed 29 days ago · c8b1400e79 · 8 files, +225 −13

**An app can name its project's own provider on `/v1/chat/completions`: `x_openvibe.provider { base_url, model, secret }`, keyed by an ADR-056 project secret that Network reveals per run.**

**Why:** the production proof of #70–#72 answered plain and streamed completions, but refused the tool calls a coding agent's loop makes. App runs that use the router are limited to free and local capacity, and production's local model has no tools. The paid route waits on Billing charging project subjects and on Vibes purchases (O1). The plan's item for Codes is "BYO keys from AI's credential store instead of the machine's env". With project secrets (ADR-056, Network#197), a project can keep its own key in Network and let AI use it, with the key on no machine.

**The request (`server/api/openai.js`):** `x_openvibe.provider` with:
- `base_url`: https only, without credentials, query or fragment;
- `model`: up to 200 characters;
- `secret`: the ADR-056 name of a project secret.

It becomes the run's `project_provider`. Thrown 422 input errors now answer OpenAI's 400. A failed run answers 403 for `project_secret.refused` and 422 for `credential.endpoint_refused`.

**The run (`server/runs.js`):**
- Only for a developer app, and only on `ai.complete`. Otherwise it answers 403 `capability.denied` or 422. Without service credentials it answers 503 `project_secret.unavailable`.
- The options keep the provider's base URL, model and the secret's name, never the value. The run is not cached, and no tier budget holds it, because the project pays its provider. The app's quotas still count it.
- At execution, `projectCredential()` asks Network (`server/project-secrets.js`: `POST /internal/secrets/use`, purpose `reveal`, AI's service token with `network.secret.use`) for the key in the app's environment, for the base URL's origin. The project's grant decides; 404 becomes `project_secret.refused`.
- The key is used for that one run and is not stored, cached or logged. Explain says "the project's own provider at <origin> (project secret <name>, <environment>)". The provider key is `project:<prj>`.

**The provider path (`server/providers/index.js`):** `executeWithCredential` (a person's BYO key, which already goes through the public-address `guardedFetch`) now also passes `complete` through for an OpenAI-format credential. That covers tools, tool results and every other field, with the credential's declared capabilities.

**Needs:** Network grants `ai` the capability `network.secret.use` (Network PR alongside). Each project still decides, per secret, whether `ai` may use it, and for which origins.

**Tests (`project-provider.test.js`, new):** the stub fallback is off, and a fake Network reveals `llm-key` to `https://llm.example.org` only.
- A production app's request with tools reaches `https://llm.example.org/v1/chat/completions` with `Bearer <the key>` and model `gpt-own-1`, and the tool call comes back.
- Network was asked once with AI's token, the project, `production`, `llm-key`, `reveal` and the origin. The run's explain names the project provider.
- The key is in neither the run nor its stored options; the secret's name is kept.
- A sandbox app reveals from `sandbox`.
- An ungranted secret answers 403, an http base URL 400, a malformed name 400, and a service token 403. None of them reached a provider.

`openai-compat`, `app-access`, `credentials`, `runs` and `home` pass.

**Docs:** README ("An app's own provider") and the home's llms text.

Changes

ddiff --git a/README.md b/README.md
iindex 143c974..fb19a92 100644
---- a/README.md
++++ b/README.md
@@@ -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 } }`).
- **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.
++- **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
ddiff --git a/server/api/openai.js b/server/api/openai.js
iindex de4899d..e8482c2 100644
---- a/server/api/openai.js
++++ b/server/api/openai.js
@@@ -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)) {
ddiff --git a/server/home.js b/server/home.js
iindex 78dca15..7e69800 100644
---- a/server/home.js
++++ b/server/home.js
@@@ -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.',
'',
ddiff --git a/server/index.js b/server/index.js
iindex 6a3dd3b..8671aa6 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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 });
// 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`);
ddiff --git a/server/project-secrets.js b/server/project-secrets.js
nnew file mode 100644
iindex 0000000..247267c
---- /dev/null
++++ b/server/project-secrets.js
@@@ -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 };
ddiff --git a/server/providers/index.js b/server/providers/index.js
iindex 63437f2..7a29593 100644
---- a/server/providers/index.js
++++ b/server/providers/index.js
@@@ -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.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,
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');
ddiff --git a/server/runs.js b/server/runs.js
iindex b499d42..b71d5c7 100644
---- a/server/runs.js
++++ b/server/runs.js
@@@ -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 worker
let active = 0;
@@@ -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');
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 = { now
source_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 = { now
admit(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 = { now
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);
}
-- 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 its
ddiff --git a/test/project-provider.test.js b/test/project-provider.test.js
nnew file mode 100644
iindex 0000000..2c78a39
---- /dev/null
++++ b/test/project-provider.test.js
@@@ -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();