openvibers/openvibe.codes Commits

Codes: the OpenVibe.AI agent names the project's own provider (OPENVIBE_AI_SECRET → x_openvibe.provider) (#72)

OP OpenVibers committed 29 days ago · 08359ed28b · 5 files, +22 −4

**Codes' OpenVibe.AI agent can run on the project's own provider: `OPENVIBE_AI_SECRET` names a project secret, and every request carries AI's `x_openvibe.provider` (AI#74).**

**Why:** app runs on AI's router are limited to free capacity, and production's free model has no tools. The agent's loop calls tools on every turn, so it was refused (503). AI#74 lets an app name its project's own OpenAI-format provider, with the key kept as an ADR-056 project secret that Network reveals per request. With this, a person runs Codes on their own OpenAI, DeepSeek or OpenRouter key without the key ever being on their machine.

**Change:**
- `harness/adapters/api.js`: `spec.extra(env)` adds fields to every request body.
- `harness/index.js`: when `OPENVIBE_AI_SECRET` is set, the `openvibe-ai` agent sends `x_openvibe.provider { base_url: OPENVIBE_AI_PROVIDER_URL (default https://api.openai.com/v1), model: OPENVIBE_AI_PROVIDER_MODEL (default gpt-5-mini), secret: OPENVIBE_AI_SECRET }`. Without it, nothing changes.

**Docs:** the README's agent table and `/start`'s OpenVibe.AI row now name the three variables and explain that the key stays in OpenVibe.Network.

**Test:** `harness-api.test.js` checks that a run with the variables sends exactly that `x_openvibe.provider`, and that one without them sends no `x_openvibe`. The 7 test files that cover the change pass.

Changes

ddiff --git a/README.md b/README.md
iindex 4875c0d..07a83e1 100644
---- a/README.md
++++ b/README.md
@@@ -49,7 +49,7 @@ openvibe-codes resume <run id> "now add a test"
| Command Code | `cmd --print --output-format json` | `cmd` on PATH |
| Aider | `aider --message` | `aider` on PATH |
| DeepSeek | the harness's own agent loop ([harness/adapters/api.js](harness/adapters/api.js)) | `DEEPSEEK_API_KEY` |
--| OpenVibe.AI | the same loop against AI's `/v1/chat/completions`; its router picks the model and each answer reports its cost | `OPENVIBE_APP_ID` + `OPENVIBE_APP_SECRET` (an app with `ai.app.run`; tokens are fetched and refreshed), or `OPENVIBE_AI_TOKEN` |
++| OpenVibe.AI | the same loop against AI's `/v1/chat/completions`; each answer reports its cost | `OPENVIBE_APP_ID` + `OPENVIBE_APP_SECRET` (an app with `ai.app.run`; tokens are fetched and refreshed), or `OPENVIBE_AI_TOKEN`. App runs on AI's router use free capacity without tools, so name your project's own provider: `OPENVIBE_AI_SECRET` (a project secret granted to `ai` for reveal to the provider's origin), `OPENVIBE_AI_PROVIDER_URL` (default `https://api.openai.com/v1`), `OPENVIBE_AI_PROVIDER_MODEL`. The key stays in OpenVibe.Network, never on the machine |
| Your own model | the same loop against any OpenAI-compatible server | `OPENVIBE_CODES_BASE_URL`, `OPENVIBE_CODES_MODEL`, optional `OPENVIBE_CODES_API_KEY` |
- **Events.** Every adapter turns its agent's output into one stream in Claude Code's `stream-json` shape (system/init, assistant text and tool_use, user tool_result, system warning/error/handoff, exactly one result), [harness/events.js](harness/events.js). Keys, tokens and private keys are redacted before an event is printed or kept.
ddiff --git a/harness/adapters/api.js b/harness/adapters/api.js
iindex b9d4591..772d254 100644
---- a/harness/adapters/api.js
++++ b/harness/adapters/api.js
@@@ -142,6 +142,7 @@ function runCommand(command, cwd, signal) {
/**
* apiAgent({ id, name, baseUrl, keyEnv, model, priceIn, priceOut, token }) → an adapter.
++ * extra(env) → fields added to every request body (OpenVibe.AI's x_openvibe.provider: the project's own provider).
* token(env, fetch) → a bearer, asked before every request (OpenVibe.AI takes a five-minute Network token, refreshed by
* the function); without it the key is keyEnv's value. When the API reports each answer's cost (OpenVibe.AI's
* x_openvibe.cost_usd), the run's cost is the sum of those, not a price list.
@@@ -183,7 +184,7 @@ function apiAgent(spec) {
const res = await doFetch(`${base}/chat/completions`, {
method: 'POST',
headers: { 'content-type': 'application/json', ...(bearer ? { authorization: `Bearer ${bearer}` } : {}) },
-- body: JSON.stringify({ model, messages, tools, tool_choice: 'auto' }),
++ body: JSON.stringify({ ...(spec.extra ? spec.extra(env) : {}), model, messages, tools, tool_choice: 'auto' }),
signal: controller.signal,
});
const body = await res.json().catch(() => null);
ddiff --git a/harness/index.js b/harness/index.js
iindex 87eda75..25860dc 100644
---- a/harness/index.js
++++ b/harness/index.js
@@@ -47,6 +47,12 @@ function networkToken() {
};
}
++/** x_openvibe.provider for OpenVibe.AI when OPENVIBE_AI_SECRET names the project's own provider key, else nothing. */
++function projectProvider(env) {
++ if (!env.OPENVIBE_AI_SECRET) return {};
++ return { x_openvibe: { provider: { base_url: env.OPENVIBE_AI_PROVIDER_URL || 'https://api.openai.com/v1', model: env.OPENVIBE_AI_PROVIDER_MODEL || 'gpt-5-mini', secret: env.OPENVIBE_AI_SECRET } } };
++}
++
/** The adapter for each catalog harness (the catalog row's id). */
function adaptersFor(env) {
return {
@@@ -57,7 +63,9 @@ function adaptersFor(env) {
aider: cli.aider,
deepseek: apiAgent({ id: 'deepseek', name: 'DeepSeek', baseUrl: env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com', keyEnv: 'DEEPSEEK_API_KEY', model: 'deepseek-chat', priceIn: 0.00027, priceOut: 0.0011 }),
// OpenVibe.AI's OpenAI-compatible endpoint: its router picks the model, each answer reports its cost.
-- 'openvibe-ai': apiAgent({ id: 'openvibe-ai', name: 'OpenVibe.AI', baseUrl: env.OPENVIBE_AI_URL || 'https://ai.openvibe.services/v1', token: networkToken(), model: 'openvibe' }),
++ // OPENVIBE_AI_SECRET names a project secret granted to ai (ADR-056): AI sends each request to the project's own
++ // provider (OPENVIBE_AI_PROVIDER_URL, OPENVIBE_AI_PROVIDER_MODEL) with that key, revealed per request by Network.
++ 'openvibe-ai': apiAgent({ id: 'openvibe-ai', name: 'OpenVibe.AI', baseUrl: env.OPENVIBE_AI_URL || 'https://ai.openvibe.services/v1', token: networkToken(), model: 'openvibe', extra: projectProvider }),
'openai-compatible': apiAgent({ id: 'openai-compatible', name: 'Your own model', baseUrl: env.OPENVIBE_CODES_BASE_URL || 'http://127.0.0.1:8080/v1', keyEnv: 'OPENVIBE_CODES_API_KEY', model: env.OPENVIBE_CODES_MODEL || 'local' }),
};
}
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex b9bd253..74826f9 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -176,7 +176,7 @@ ${table(['Agent', 'What it needs'], [
[html`<a href="/harnesses#command-code">Command Code</a>`, html`<code>cmd</code> on PATH`],
[html`<a href="/harnesses#aider">Aider</a>`, html`<code>aider</code> on PATH`],
[html`<a href="/harnesses#deepseek">DeepSeek</a>`, html`<code>DEEPSEEK_API_KEY</code>; the harness runs its own agent loop against the API`],
-- [html`<a href="/harnesses#openvibe-ai">OpenVibe.AI</a>`, html`<code>OPENVIBE_APP_ID</code> and <code>OPENVIBE_APP_SECRET</code> (an app of your project on openvibe.services with <code>ai.app.run</code>; AI's router picks the model and your project pays), or a Network token in <code>OPENVIBE_AI_TOKEN</code>`],
++ [html`<a href="/harnesses#openvibe-ai">OpenVibe.AI</a>`, html`<code>OPENVIBE_APP_ID</code> and <code>OPENVIBE_APP_SECRET</code> (an app of your project on openvibe.services with <code>ai.app.run</code>), or a Network token in <code>OPENVIBE_AI_TOKEN</code>. For tool-capable models, keep your provider's key as a project secret granted to <code>ai</code> and name it in <code>OPENVIBE_AI_SECRET</code>, with <code>OPENVIBE_AI_PROVIDER_URL</code> and <code>OPENVIBE_AI_PROVIDER_MODEL</code>: the key never sits on this machine`],
[html`<a href="/harnesses#openai-compatible">Your own model</a>`, html`<code>OPENVIBE_CODES_BASE_URL</code> (an OpenAI-compatible server: llama.cpp, vLLM, Ollama, LM Studio…), <code>OPENVIBE_CODES_MODEL</code>, and <code>OPENVIBE_CODES_API_KEY</code> if it wants one`],
])}
<h2 id="use">Use it</h2>
ddiff --git a/test/harness-api.test.js b/test/harness-api.test.js
iindex a5ccde0..923dbd2 100644
---- a/test/harness-api.test.js
++++ b/test/harness-api.test.js
@@@ -104,6 +104,15 @@ const toolResults = (events) => events.filter((e) => e.type === 'user').flatMap(
assert.strictEqual(tokenCalls.length, 1, 'one token for both turns');
assert.deepStrictEqual([tokenCalls[0].path, tokenCalls[0].body.grant_type, tokenCalls[0].body.audience, tokenCalls[0].body.client_id], ['/oauth/token', 'client_credentials', 'openvibe.ai', 'app_01JAB2C3D4E5F6G7H8J9K0MNPQ']);
assert.ok(Math.abs(r.total_cost_usd - 0.003) < 1e-9, `cost ${r.total_cost_usd}`);
++ assert.ok(!('x_openvibe' in api.requests[0].body), 'the router, when no project provider is named');
++ // OPENVIBE_AI_SECRET names the project's own provider: every request carries it for AI to use.
++ const ownEnv = { ...aiEnv, OPENVIBE_AI_SECRET: 'deepseek-key', OPENVIBE_AI_PROVIDER_URL: 'https://api.deepseek.com/v1', OPENVIBE_AI_PROVIDER_MODEL: 'deepseek-chat' };
++ const own = createHarness({ env: ownEnv, store: createStore({ env: ownEnv }) });
++ api.set([{ content: 'done' }]);
++ const before = api.requests.length;
++ const done = (await collect(own.run({ prompt: 'hi', agent: 'openvibe-ai', cwd: work, permission: 'read', handoff: false }))).find((e) => e.type === 'result');
++ assert.strictEqual(done.is_error, false, done.result);
++ assert.deepStrictEqual(api.requests[before].body.x_openvibe, { provider: { base_url: 'https://api.deepseek.com/v1', model: 'deepseek-chat', secret: 'deepseek-key' } });
} finally { await new Promise((r) => network.close(r)); }
});