openvibers/openvibe.codes Commits

D11 owner settings: codes.ai (#274)

OP OpenVibers committed 8 days ago · 735ed08418 · 26 files, +898 −52

* AI Exchange P9: OpenVibe.AI's catalog in the pickers (CODES_AI_CATALOG)

* Review fixes: the AI catalog is on the SSRF ratchet; the fleet test resets ahead of now

* D11 owner settings: codes.ai (shared 4.110.0, contracts 0.288.0)

* D11: keep CODES_AI_CATALOG's yes/on spellings as the env pin

Changes

ddiff --git a/.env.example b/.env.example
iindex ef7e692..81e23d0 100644
---- a/.env.example
++++ b/.env.example
@@@ -30,6 +30,15 @@ OV_OAUTH_CLIENT_SECRET=
# optional: defaults to true in production
# COOKIE_SECURE=true
++# OpenVibe.AI (Codes C3). CODES_AI_URL is what a harness on a person's machine calls; without a values file it is
++# https://ai.openvibe.services.
++#CODES_AI_URL=https://ai.openvibe.services
++# AI Exchange P9: read OpenVibe.AI's real model catalog (GET /v1/models) into the pickers, behind this flag (off by
++# default). AI's own AI_MODEL_CATALOG must be on for the answer to be more than the legacy `openvibe` model; with
++# either off, the pickers are exactly as before. The answer is cached five minutes.
++#CODES_AI_CATALOG=0
++#CODES_AI_CATALOG_TTL_MS=300000
++
# Know (the shared lessons store): on a Node, the portal's know/lessons.js. Its path is configuration, never a
# hard-coded home; without it a job gets no lessons. Never a secret.
#CODES_KNOW_LESSONS=/home/openvibe/agents/know/lessons.js
ddiff --git a/README.md b/README.md
iindex 43a9c14..6a10ba1 100644
---- a/README.md
++++ b/README.md
@@@ -129,11 +129,16 @@ No path in Codes sends or accepts a shared loopback key (tested by grep and at r
- [test/start-page.test.js](test/start-page.test.js) — `/start` names every adapter and its needs, and the command line prints the same version.
- [test/discovery.test.js](test/discovery.test.js) — robots.txt, sitemap.xml and llms.txt are served with real public entries and a lastmod from the site's own data.
- [test/perf-budget.test.js](test/perf-budget.test.js) — the home page's size budgets, measured on the server as it runs.
++- [test/ai-catalog.test.js](test/ai-catalog.test.js) — AI's catalog parsed exactly (aliases before exact ids, nano-USD prices), and today's picker while either catalog flag is off.
## Configuration
See [.env.example](.env.example). Required in production: `OV_OAUTH_CLIENT_SECRET`, `BASE_URL`, `DATABASE_URL` (and `DATABASE_DIRECT_URL` for the boot migrations, run as the owner). Optional: `VALKEY_URL`, `CODES_LIMITS_MINUTE` / `CODES_LIMITS_HOUR`.
++### OpenVibe.AI's model catalog
++
++Behind `CODES_AI_CATALOG` (off by default), the model pickers read OpenVibe.AI's `GET /v1/models` with Codes' own Network token and cache the answer five minutes ([server/domain/ai-catalog.js](server/domain/ai-catalog.js)). While AI's own `AI_MODEL_CATALOG` is on, the OpenVibe.AI choices are the dynamic aliases (`openvibe/auto`, `economy`, `balanced`, `fast`, `private`) and then its exact ids (`<org>/<model>[:<quant>]@<revision>`), each with its retail output price per million tokens and its availability on the `<option>`. The chosen id is sent unchanged as `model`; an exact id AI refuses (`ai.model_not_found`, `ai.model_unavailable`) is shown as that error, never swapped for another model. With either flag off, the pickers are exactly as before. A chosen model's answer carries AI's `x_openvibe` disclosure; `resolved_model` is recorded as the attempt's model and shown where Codes already shows it.
++
### Per-actor limits
`/api/v1` limits who calls it, once the caller is known and before any work: `server/http/actor-limits.js`, openvibe-sdk/limits, roadmap WS-R task 4. A signed-in person counts as `user:usr_…`, anyone else by address. Past a limit: `429` problem+json `rate_limited` with `Retry-After`, one `[Limits]` log line and `codes_rate_limited_total{limit,window}`. Reads: `CODES_LIMITS_MINUTE` / `CODES_LIMITS_HOUR` (120 / 3000); the router: 30 a minute, 600 an hour. Never limited per actor: `/api/health`, `/api/ready`, `/release.json`, `/metrics`, sign-in and the public pages (the per-address limit bounds them).
ddiff --git a/engine/review.js b/engine/review.js
iindex 9ac051d..f756b52 100644
---- a/engine/review.js
++++ b/engine/review.js
@@@ -130,7 +130,7 @@ function readVerdict(text) {
* @param {string} [what.patch] the diff
* @param {string} [what.summary] what the change was for
* @param {function} [what.fetchImpl] fetch, for a test
-- * @returns {Promise<{ family: string, verdict: string, summary: string }>} the verdict
++ * @returns {Promise<{ family: string, model: string, verdict: string, summary: string, resolved_model?: string, disclosure?: object }>} the verdict
*/
async function reviewWithAi({
baseUrl = 'https://ai.openvibe.services/v1',
@@@ -150,19 +150,40 @@ async function reviewWithAi({
let res = await fetchImpl(`${root}/chat/completions`, {
method: 'POST', headers, body: JSON.stringify({ model, messages: [{ role: 'user', content: prompt }] }),
});
-- if (res && res.status === 404) {
-- // A provider that speaks the messages dialect instead.
++ let doc = res ? await res.json().catch(() => null) : null;
++ const codeOf = (body) => (body && body.error && typeof body.error === 'object' ? body.error.code : null) || (body && body.code) || null;
++ // A 404 for a model AI refused is final; only a provider that does not speak chat completions gets the other
++ // dialect, and it is asked for the same model.
++ if (res && res.status === 404 && !/^ai\.model_/.test(String(codeOf(doc) || ''))) {
res = await fetchImpl(`${root}/messages`, {
method: 'POST', headers, body: JSON.stringify({ model, max_tokens: 1200, messages: [{ role: 'user', content: prompt }] }),
});
++ doc = await res.json().catch(() => null);
}
-- if (!res || !res.ok) throw new Error(`OpenVibe.AI refused the review (HTTP ${res && res.status})`);
-- const body = await res.json().catch(() => null);
++ if (!res || !res.ok) {
++ const err = (doc && doc.error && typeof doc.error === 'object') ? doc.error : null;
++ const code = String((err && err.code) || (doc && doc.code) || '') || null;
++ const detail = String((err && (err.message || err.detail)) || (doc && (doc.detail || doc.title)) || '').slice(0, 300);
++ throw new Error(`OpenVibe.AI refused the review (HTTP ${res && res.status}${code ? `, ${code}` : ''})${detail ? `: ${detail}` : ''}`);
++ }
++ const body = doc;
const text = body && body.choices && body.choices[0] && body.choices[0].message && body.choices[0].message.content
? body.choices[0].message.content
: (body && body.content && Array.isArray(body.content) ? body.content.map((c) => c && c.text).filter(Boolean).join(' ') : '');
const verdict = readVerdict(text);
-- return { family: family || familyOf(model), model, verdict: verdict.verdict, summary: verdict.summary };
++ // The route disclosure (D3'): the model that actually ran, the deployment and the cost, inside x_openvibe.
++ const x = body && body.x_openvibe && typeof body.x_openvibe === 'object' ? body.x_openvibe : null;
++ const resolved = x && x.resolved_model ? String(x.resolved_model) : (body && body.model ? String(body.model) : model);
++ return {
++ family: familyOf(resolved) || family || familyOf(model) || null,
++ model: resolved,
++ requested_model: model,
++ ...(x && x.resolved_model ? { resolved_model: String(x.resolved_model) } : {}),
++ ...(x ? { disclosure: x } : {}),
++ ...(x && Number.isFinite(+x.cost_usd) ? { cost_usd: +x.cost_usd } : {}),
++ verdict: verdict.verdict,
++ summary: verdict.summary,
++ };
}
module.exports = {
ddiff --git a/harness/adapters/api.js b/harness/adapters/api.js
iindex 2f0f5b1..b1df86c 100644
---- a/harness/adapters/api.js
++++ b/harness/adapters/api.js
@@@ -170,6 +170,20 @@ function extraHeaders(env) {
return out;
}
++/**
++ * The API's refusal in the person's words. An exact id AI refused keeps its own code (ai.model_not_found,
++ * ai.model_unavailable): the reason a chosen model did not run is shown as that, never as a different model's answer.
++ */
++function refusalOf(name, status, body, model) {
++ const err = body && typeof body.error === 'object' && body.error ? body.error : null;
++ const code = String((err && err.code) || (body && body.code) || '') || null;
++ const detail = String((err && (err.message || err.detail)) || (body && (body.detail || body.title)) || (typeof (body && body.error) === 'string' ? body.error : '') || '').slice(0, 300);
++ if (code === 'ai.model_not_found' || code === 'ai.model_unavailable') {
++ return `${name} refused the model ${model}: HTTP ${status} ${code}${detail ? `: ${detail}` : ''}`;
++ }
++ return `${name} answered HTTP ${status}${code ? ` (${code})` : ''}${detail ? `: ${detail}` : ''}`;
++}
++
/**
* 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).
@@@ -207,6 +221,9 @@ function apiAgent(spec) {
let lastText = '';
let turns = 0;
let failure = null;
++ // The model AI says ran, and its route disclosure (x_openvibe), recorded on the run's result.
++ let resolvedModel = null;
++ let disclosure = null;
try {
while (turns < maxTurns && !cancelled) {
turns++;
@@@ -218,25 +235,32 @@ function apiAgent(spec) {
signal: controller.signal,
});
const body = await res.json().catch(() => null);
++ const x = body && body.x_openvibe && typeof body.x_openvibe === 'object' ? body.x_openvibe : null;
++ if (x) {
++ if (x.resolved_model) resolvedModel = String(x.resolved_model);
++ disclosure = { ...(disclosure || {}), ...x };
++ }
if (!res.ok || !body || !Array.isArray(body.choices) || !body.choices[0]) {
-- failure = `${spec.name} answered HTTP ${res.status}${body && body.error ? `: ${String(body.error.message || body.error).slice(0, 300)}` : ''}`;
++ // An exact id AI refused (ai.model_not_found / ai.model_unavailable) is its own answer:
++ // it is reported as it is, and this loop never asks for a different model instead.
++ failure = refusalOf(spec.name, res.status, body, model);
yield ev.error(failure);
break;
}
const usage = body.usage || {};
-- if (body.x_openvibe && Number.isFinite(+body.x_openvibe.cost_usd)) { reported.usd += +body.x_openvibe.cost_usd; reported.any = true; }
++ if (x && Number.isFinite(+x.cost_usd)) { reported.usd += +x.cost_usd; reported.any = true; }
u.input += +usage.prompt_tokens || 0; u.output += +usage.completion_tokens || 0;
u.cacheRead += +(usage.prompt_cache_hit_tokens || (usage.prompt_tokens_details && usage.prompt_tokens_details.cached_tokens)) || 0;
const msg = body.choices[0].message || {};
messages.push({ role: 'assistant', content: msg.content || '', ...(msg.tool_calls ? { tool_calls: msg.tool_calls } : {}) });
-- if (String(msg.content || '').trim()) { lastText = msg.content; yield ev.text(msg.content, model); }
++ if (String(msg.content || '').trim()) { lastText = msg.content; yield ev.text(msg.content, resolvedModel || model); }
const calls = Array.isArray(msg.tool_calls) ? msg.tool_calls : [];
if (!calls.length) break;
for (const call of calls) {
const name = call.function && call.function.name;
let args = {};
try { args = JSON.parse((call.function && call.function.arguments) || '{}'); } catch { /* answered as an error below */ }
-- yield ev.toolUse(call.id, SHOWN_AS[name] || name, shownInput(name, args), model);
++ yield ev.toolUse(call.id, SHOWN_AS[name] || name, shownInput(name, args), resolvedModel || model);
let out;
const def = TOOL_DEFS[name];
if (!def) out = [`unknown tool ${name}`, true];
@@@ -265,9 +289,11 @@ function apiAgent(spec) {
failure = cancelled ? `${spec.name} was stopped` : `${spec.name} could not be reached: ${err.message}`;
yield ev.error(failure);
}
-- if (ctx.sessions) ctx.sessions.save(sessionId, { agent: spec.id, model, messages: messages.slice(-200) });
++ if (ctx.sessions) ctx.sessions.save(sessionId, { agent: spec.id, model: resolvedModel || model, messages: messages.slice(-200) });
const cost = reported.any ? reported.usd : (u.input * (spec.priceIn || 0) + u.output * (spec.priceOut || 0)) / 1000;
-- yield ev.result({ ok: !failure && !cancelled, text: failure || lastText, sessionId, model, startedAt, turns, costUsd: cost,
++ yield ev.result({ ok: !failure && !cancelled, text: failure || lastText, sessionId, model: resolvedModel || model, startedAt, turns, costUsd: cost,
++ ...(resolvedModel ? { resolved_model: resolvedModel } : {}),
++ ...(disclosure ? { disclosure } : {}),
usage: ev.usage({ input: u.input - u.cacheRead, output: u.output, cacheRead: u.cacheRead }) });
}());
iterator.cancel = () => { cancelled = true; controller.abort(); };
@@@ -277,4 +303,4 @@ function apiAgent(spec) {
return adapter;
}
--module.exports = { apiAgent, extraHeaders, runTool, inside, TOOL_DEFS, SYSTEM };
++module.exports = { apiAgent, refusalOf, extraHeaders, runTool, inside, TOOL_DEFS, SYSTEM };
ddiff --git a/harness/events.js b/harness/events.js
iindex 513abaf..7d770c6 100644
---- a/harness/events.js
++++ b/harness/events.js
@@@ -54,12 +54,12 @@ function usage({ input = 0, output = 0, cacheRead = 0, cacheWrite = 0 } = {}) {
return { input_tokens: n(input), output_tokens: n(output), cache_read_input_tokens: n(cacheRead), cache_creation_input_tokens: n(cacheWrite) };
}
--/** The one result line an agent run ends with. */
--function result({ ok, text: final, sessionId, model, startedAt, turns = 0, costUsd = 0, usage: u = usage() }) {
++/** The one result line an agent run ends with. `extra` carries the API's own facts (resolved_model, disclosure). */
++function result({ ok, text: final, sessionId, model, startedAt, turns = 0, costUsd = 0, usage: u = usage(), ...extra }) {
return {
type: 'result', subtype: ok ? 'success' : 'error', is_error: !ok, result: scrub(final || ''),
session_id: sessionId || null, model: model || null, duration_ms: startedAt ? Date.now() - startedAt : 0,
-- num_turns: turns, total_cost_usd: Math.round((Number(costUsd) || 0) * 1e5) / 1e5, usage: u,
++ num_turns: turns, total_cost_usd: Math.round((Number(costUsd) || 0) * 1e5) / 1e5, usage: u, ...extra,
};
}
ddiff --git a/harness/job.js b/harness/job.js
iindex c6e8934..7f94bb7 100644
---- a/harness/job.js
++++ b/harness/job.js
@@@ -310,7 +310,7 @@ function shotWithBytes(shot, budget) {
function compact(e, ctx) {
const cut = (s) => cutTo(s, MAX_TEXT);
const parent = e.parent_tool_use_id ? { parent: String(e.parent_tool_use_id) } : {};
-- if (e.type === 'system' && e.subtype === 'init') return { t: 'agent', agent: e.agent, model: e.model || null, session: e.session_id || null };
++ if (e.type === 'system' && e.subtype === 'init') return { t: 'agent', agent: e.agent, model: e.model || null, session: e.session_id || null, ...(e.resolved_model ? { resolved_model: String(e.resolved_model).slice(0, 300) } : {}) };
if (e.type === 'system' && e.subtype === 'handoff') return { t: 'handoff', from: e.from, ...(e.to ? { to: e.to } : {}), reason: cut(e.reason) };
if (e.type === 'system' && (e.subtype === 'error' || e.subtype === 'warning')) return { t: 'note', level: e.subtype, text: cut(e.text) };
if (e.type === 'system' && e.subtype === 'tool_progress') return { t: 'tool_progress', id: String(e.tool_use_id || ''), chunk: clean(cutTo(e.text, MAX_OUTPUT)) };
@@@ -337,7 +337,12 @@ function compact(e, ctx) {
});
}
if (e.type === 'result') {
-- return { t: 'done', ok: !e.is_error, text: cut(e.result), duration_ms: e.duration_ms || 0, cost_usd: e.total_cost_usd || 0, turns: e.num_turns || 0 };
++ return {
++ t: 'done', ok: !e.is_error, text: cut(e.result), duration_ms: e.duration_ms || 0, cost_usd: e.total_cost_usd || 0, turns: e.num_turns || 0,
++ ...(e.model ? { model: String(e.model).slice(0, 300) } : {}),
++ ...(e.resolved_model ? { resolved_model: String(e.resolved_model).slice(0, 300) } : {}),
++ ...(e.disclosure && typeof e.disclosure === 'object' ? { disclosure: e.disclosure } : {}),
++ };
}
return null;
}
@@@ -680,6 +685,10 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tm
result.summary = answer.slice(0, 4000);
if (job.kind === 'bench') result.output = answer.slice(0, 200000);
result.cost_usd = last && last.total_cost_usd ? last.total_cost_usd : 0;
++ // OpenVibe.AI's own disclosure: the model that actually ran, inside x_openvibe. Kept where Codes keeps a
++ // run's result, so the attempt records the resolved model, not only the one that was asked for.
++ if (last && last.resolved_model) result.resolved_model = String(last.resolved_model).slice(0, 300);
++ if (last && last.disclosure && typeof last.disclosure === 'object') result.disclosure = last.disclosure;
const usage = last && last.usage && typeof last.usage === 'object' ? last.usage : {};
result.tokens = Number(last && (last.total_tokens || last.tokens)) || (Number(usage.input_tokens) || 0) + (Number(usage.output_tokens) || 0);
// The session the next message resumes: a conductor's turn ends, its conversation does not.
@@@ -697,9 +706,9 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tm
files: result.files.map((f) => f.path),
adapters: proof,
author: { agent: job.agent, model: job.model },
-- emit: (item) => say({ t: 'proof', kind: item.kind, label: item.label, ok: item.ok, skipped: !!item.skipped, summary: item.summary, family: item.family || null }),
++ emit: (item) => say({ t: 'proof', kind: item.kind, label: item.label, ok: item.ok, skipped: !!item.skipped, summary: item.summary, family: item.family || null, model: item.model || null }),
});
-- result.proof = { items: collected.items.map((i) => ({ kind: i.kind, label: i.label, ok: i.ok, skipped: !!i.skipped, summary: i.summary, family: i.family || null, detail: i.shots ? { shots: i.shots.map((s) => shotWithBytes(s, pictures)) } : (i.kind === 'diff' ? { files: i.files, stats: i.stats } : null) })), outcome: collected.outcome };
++ result.proof = { items: collected.items.map((i) => ({ kind: i.kind, label: i.label, ok: i.ok, skipped: !!i.skipped, summary: i.summary, family: i.family || null, model: i.model || null, requested_model: i.requested_model || null, detail: i.shots ? { shots: i.shots.map((s) => shotWithBytes(s, pictures)) } : (i.kind === 'diff' ? { files: i.files, stats: i.stats } : null) })), outcome: collected.outcome };
} catch (e) {
say({ t: 'note', level: 'warning', text: `The proof could not be collected: ${scrub(e.message)}` });
result.proof = { items: [], outcome: null };
ddiff --git a/migrations/0024_config.sql b/migrations/0024_config.sql
nnew file mode 100644
iindex 0000000..49d9764
---- /dev/null
++++ b/migrations/0024_config.sql
@@@ -0,0 +1,28 @@
++-- phase: expand
++-- D11 owner settings: openvibe-shared/config (config_snapshots, config_keys). Applied migrations are immutable.
++CREATE TABLE IF NOT EXISTS config_snapshots (
++ namespace text COLLATE "C" NOT NULL,
++ revision bigint NOT NULL,
++ service text COLLATE "C" NOT NULL,
++ previous_revision bigint,
++ state text COLLATE "C" NOT NULL CHECK (state IN ('proposed', 'active', 'superseded', 'rejected', 'rolled_back')),
++ values_json text COLLATE "C" NOT NULL, -- the full values; secret-class values are envelopes (never their plaintext)
++ classification_json text COLLATE "C" NOT NULL,
++ values_checksum text COLLATE "C" NOT NULL, -- sha256 of the values as served (secrets as markers)
++ created_at text COLLATE "C" NOT NULL,
++ created_by text COLLATE "C" NOT NULL,
++ activated_at text COLLATE "C",
++ activated_by text COLLATE "C",
++ reason text COLLATE "C",
++ error text COLLATE "C",
++ copied_from bigint,
++ good bigint NOT NULL DEFAULT 0, -- 1 once it activated successfully (last-known-good)
++ PRIMARY KEY (namespace, revision)
++);
++CREATE UNIQUE INDEX IF NOT EXISTS config_snapshots_one_active ON config_snapshots (namespace) WHERE state = 'active';
++-- The fingerprint key of each namespace. Never served, never logged.
++CREATE TABLE IF NOT EXISTS config_keys (
++ namespace text COLLATE "C" PRIMARY KEY,
++ hmac_key bytea NOT NULL, -- the 32-byte fingerprint key, or that key wrapped under the current KEK
++ created_at text COLLATE "C" NOT NULL
++);
ddiff --git a/package-lock.json b/package-lock.json
iindex 9aecd72..a1c3b86 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -15,9 +15,9 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.261.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.288.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.110.0",
"pg": "^8.23.0"
},
"bin": {
@@@ -1149,9 +1149,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.261.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.261.0",
-- "integrity": "sha512-SiXOcXRL9+WivMkKM1W6sGNJMD4uCBR9o7KYZvs99hT2xgYxDvP4rM7tLBudGu1boPH+coMdUXVQAAWbS/3JDw==",
++ "version": "0.288.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.288.0",
++ "integrity": "sha512-AKRP1yXACYNu7okpf4kCDq4ds0PhsoNvD92VlvBWBCjcSlNimSTQhLWIvSa3D31/kQYz91fodo06ZnwuTanOqg==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
@@@ -1193,9 +1193,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.109.2",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",
-- "integrity": "sha512-t9K1yHr6GOJsfamqmgERRTTtiWYWAVmjWBEm4ykTcYjEenbTiqQTwX6CJsZW4lYnqS1IlqEvn+0jG4vXmHSJ3Q==",
++ "version": "4.110.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.110.0",
++ "integrity": "sha512-ZNOSraSgE89jhLTI//lFJALvKCiBJIBwf+V0aCkiIk0VCrPPAytsfsgOgCINU8kXDVdCEnuWWfcA9hZUc54QVA==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex e333d0a..f09e22d 100644
---- a/package.json
++++ b/package.json
@@@ -30,9 +30,9 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.261.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.288.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.110.0",
"pg": "^8.23.0"
},
"engines": {
ddiff --git a/server/ai-catalog-config.js b/server/ai-catalog-config.js
nnew file mode 100644
iindex 0000000..93c3c65
---- /dev/null
++++ b/server/ai-catalog-config.js
@@@ -0,0 +1,93 @@
++'use strict';
++/**
++ * The `codes.ai` namespace (D11 owner settings): the switch that lets Codes' model pickers read OpenVibe.AI's
++ * catalog. CODES_AI_CATALOG stays the pin: while it is set it wins, is read-only in the summary and a write
++ * answers 409 config.pinned_in_env.
++ *
++ * /internal/config is openvibe-shared/config's adminRoutes, guarded by codes.config.manage (Network's service
++ * token only). actor is the owner Network names in X-OV-Subject; X-OV-Fresh: 1 gates a secret or x-danger write
++ * (this namespace has neither). Every activation writes one audit line with the actor and the keys, never a value.
++ */
++const express = require('express');
++const shared = require('openvibe-shared/config');
++const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');
++
++const SERVICE = 'codes';
++const NAMESPACE = 'codes.ai';
++const CAPABILITY = 'codes.config.manage';
++
++const SCHEMA = {
++ $schema: 'https://json-schema.org/draft/2020-12/schema',
++ title: 'OpenVibe.AI in Codes',
++ description: 'Read OpenVibe.AI\'s model catalog so the pickers offer its aliases and exact ids.',
++ type: 'object',
++ additionalProperties: false,
++ required: ['catalog'],
++ properties: {
++ catalog: {
++ type: 'boolean', title: 'AI model catalog', 'x-group': 'AI Exchange', 'x-env': 'CODES_AI_CATALOG',
++ description: 'Read GET /v1/models with Codes\' own service token and cache the answer about five minutes. Off, the pickers keep today\'s one entry.',
++ },
++ },
++};
++const DEFAULTS = Object.freeze({ catalog: false });
++
++function bearer(req) {
++ const h = String((req.headers && req.headers.authorization) || '');
++ return h.startsWith('Bearer ') ? h.slice(7).trim() : null;
++}
++
++function capabilityGuard({ keys, config, log }) {
++ return async function guard(req, res, next) {
++ try {
++ const token = bearer(req);
++ if (!token) return http.sendProblem(res, 401, 'token.missing', { detail: 'a Network service token is required', ctx: req.ov });
++ const publicKey = typeof keys.pemForToken === 'function' ? await keys.pemForToken(token) : keys.get();
++ if (!publicKey) return http.sendProblem(res, 503, 'identity.unavailable', { detail: 'the Network signing key is not loaded yet', ctx: req.ov });
++ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkIssuer, audience: config.serviceAudience });
++ if (!r.ok) return http.sendProblem(res, 401, r.code || 'token.invalid', { detail: r.reason || 'the token does not verify', ctx: req.ov });
++ const sub = String((r.claims && r.claims.sub) || '');
++ if (!sub.startsWith('svc:')) return http.sendProblem(res, 403, 'capability.denied', { detail: `${CAPABILITY} is first-party; an app may not change Codes configuration`, ctx: req.ov });
++ const decision = capabilities.check(r.claims, CAPABILITY);
++ if (!decision.allowed) return http.sendProblem(res, 403, decision.code || 'capability.denied', { detail: decision.reason || `${CAPABILITY} not granted`, ctx: req.ov });
++ req.configClaims = r.claims;
++ return next();
++ } catch (err) {
++ log.error(`[config] codes.ai guard failed: ${(err && err.message) || err}`);
++ return http.sendProblem(res, 500, 'internal.error', { detail: 'the configuration request failed', ctx: req.ov });
++ }
++ };
++}
++
++function actor(req) {
++ const named = String((req.headers && req.headers['x-ov-subject']) || '').trim();
++ return ids.isSubjectId('user', named) ? { type: 'user', id: named } : null;
++}
++
++const requireFresh = (req) => String((req.headers && req.headers['x-ov-fresh']) || '') === '1';
++
++async function create({ db, keys, config, env = process.env, log = console, onActivate = null }) {
++ // CODES_AI_CATALOG has always accepted 1/true/yes/on; the shared store's boolean pin reads true/1.
++ const storeEnv = { ...env };
++ if (['1', 'true', 'yes', 'on'].includes(String(storeEnv.CODES_AI_CATALOG || '').trim().toLowerCase())) storeEnv.CODES_AI_CATALOG = '1';
++ const store = await shared.createConfigStore({
++ db, service: SERVICE, namespace: NAMESPACE, schema: SCHEMA, defaults: DEFAULTS, env: storeEnv, log,
++ onActivate: async (values) => { if (onActivate) await onActivate(values); },
++ onChange: ({ namespace, keys: changed, actor: who, revision }) => {
++ log.info(`[config] ${SERVICE} ${namespace} revision ${revision} by ${who && who.id ? `${who.type}:${who.id}` : 'unknown'}: ${changed.join(', ')}`);
++ },
++ });
++ if (env.OV_CONFIG_KEKS) {
++ try { await store.reseal(); } catch (err) { log.warn(`[config] ${NAMESPACE}: reseal failed: ${(err && err.message) || err}`); }
++ }
++ const router = shared.adminRoutes([store], {
++ basePath: '/internal/config', requireAdmin: capabilityGuard({ keys, config, log }), actor, requireFresh, log,
++ });
++ return {
++ store, router,
++ get: (key) => store.get(key),
++ mount(app) { app.use('/internal/config', express.json({ limit: '128kb' })); router.mount(app); },
++ };
++}
++
++module.exports = { create, SCHEMA, NAMESPACE, CAPABILITY };
ddiff --git a/server/app.js b/server/app.js
iindex a99de17..0cc4d74 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -35,6 +35,7 @@ const { createConductorApi } = require('./http/conductor-api');
const { createRunClient } = require('./domain/run-client');
const { createRunTasks } = require('./domain/run-tasks');
const { createConnections } = require('./domain/connections');
++const { createAiCatalog } = require('./domain/ai-catalog');
const { createRunTokens } = require('./domain/run-tokens');
const { createAttempts } = require('./domain/attempts');
const { createBenchPublisher } = require('./domain/bench-publisher');
@@@ -50,6 +51,7 @@ const { createMovedRoutes } = require('./http/moved');
const { createCodesReadiness } = require('./observability');
const { createActorLimits } = require('./http/actor-limits');
const { createForgejoClient } = require('./domain/forgejo');
++const createAiCatalogConfig = require('./ai-catalog-config').create;
const { createMachines } = require('./domain/machines');
const { createMemory } = require('./domain/memory');
const { createFleet } = require('./domain/fleet');
@@@ -78,6 +80,9 @@ async function createApp(opts = {}) {
const store = opts.store || await openStore(config, { now: opts.now, log });
const keys = createKeyStore({ config, fetchImpl: fetchImpl || globalThis.fetch, log });
++ // D11 owner settings (codes.ai): the catalog switch reads through the store, env pins winning.
++ const aiCatalogConfig = await createAiCatalogConfig({ db: store.db, keys, config, env: opts.env || process.env, log });
++ Object.defineProperty(config.aiCatalog, 'enabled', { enumerable: true, configurable: true, get: () => aiCatalogConfig.get('catalog') });
const sso = createSso({ config, keys, fetchImpl: fetchImpl || globalThis.fetch, now: store.now, log });
// The harness catalog (server/data/harness-offers.json), read once: a bad seed row fails boot.
const harnesses = createHarnesses();
@@@ -88,6 +93,9 @@ async function createApp(opts = {}) {
// A person's own connections (Codes C3): their provider keys live in OpenVibe.AI, and their harnesses run with a
// short-lived run token that names them.
const connections = opts.connections || createConnections({ config, fetchImpl: fetchImpl || globalThis.fetch, log });
++ // OpenVibe.AI's real model catalog (AI Exchange P9), when CODES_AI_CATALOG is on: the pickers read it, and
++ // nothing else depends on it.
++ const aiCatalog = createAiCatalog({ config, fetchImpl: fetchImpl || globalThis.fetch, now: store.now });
const runTokens = createRunTokens({ config, store, fetchImpl: fetchImpl || globalThis.fetch, log });
// The task's attempts (stage C2): every Run job is one attempt, and the engine decides what happens next.
const attempts = createAttempts({ store, config, log, harnesses });
@@@ -103,7 +111,7 @@ async function createApp(opts = {}) {
const benchPriors = opts.benchPriors || createBenchPriors({ config, fetchImpl: fetchImpl || globalThis.fetch, now: store.now, log, ttlMs: config.bench.priorsTtlMs });
const placementContext = createPlacementContext({ store, config, userSettings, outcomes: placementOutcomes, priors: benchPriors, harnesses, now: store.now });
const tasks = createRunTasks({ store, run, config, log, sessions, outbox, runTokens, connections, attempts, fleet, notices, placementContext, harnesses, benchAttemptEnded: opts.benchAttemptEnded || benchPublisher.onTaskTerminal });
-- const ctx = { config, store, keys, sso, harnesses, log, sessions, outbox, run, tasks, connections, runTokens, attempts, fleet, notices, notify, userSettings, benchPublisher, placementOutcomes, benchPriors, placementContext };
++ const ctx = { config, store, keys, aiCatalogConfig, sso, harnesses, log, sessions, outbox, run, tasks, connections, aiCatalog, runTokens, attempts, fleet, notices, notify, userSettings, benchPublisher, placementOutcomes, benchPriors, placementContext };
ctx.forgejo = createForgejoClient({ config, log });
// The C4b screens: the person's machines (from Network), their own choices, the GitHub reader and their usage.
ctx.machines = createMachines({ config, fetchImpl: fetchImpl || globalThis.fetch, log });
@@@ -125,6 +133,7 @@ async function createApp(opts = {}) {
tasks.setConductor(ctx.conductor);
const app = express();
++ aiCatalogConfig.mount(app);
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
// (openvibe-contracts); the operations they mark internal-only are left out.
app.get('/openapi.json', (_req, res) => {
ddiff --git a/server/config.js b/server/config.js
iindex b527b66..3a8ab04 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -10,6 +10,7 @@
require('dotenv').config();
const trim = (s) => String(s || '').replace(/\/+$/, '');
const int = (v, def) => (Number.isFinite(parseInt(v, 10)) ? parseInt(v, 10) : def);
++const bool = (v) => ['1', 'true', 'yes', 'on'].includes(String(v == null ? '' : v).trim().toLowerCase());
function load(env = process.env) {
const nodeEnv = env.NODE_ENV || 'development';
@@@ -82,6 +83,13 @@ function load(env = process.env) {
// (and the harnesses' requests) with the second.
scope: 'ai.credential.manage ai.run.create',
},
++ // AI Exchange P9: OpenVibe.AI's real model catalog (GET /v1/models) in the pickers. Off by default; with the
++ // flag on, AI's own AI_MODEL_CATALOG still decides whether it answers more than the legacy `openvibe` model,
++ // and Codes keeps today's picker until it does. The answer is cached for about five minutes.
++ aiCatalog: {
++ enabled: bool(env.CODES_AI_CATALOG),
++ ttlMs: Math.max(0, int(env.CODES_AI_CATALOG_TTL_MS, 5 * 60_000)),
++ },
// A run token (Codes C3): the short-lived Network token an attempt's harness carries, so that AI knows whose
// key and whose budget the run is. Two hours is the longest a coding attempt may run.
runTokens: {
ddiff --git a/server/data/ai-catalog.js b/server/data/ai-catalog.js
nnew file mode 100644
iindex 0000000..2a6c818
---- /dev/null
++++ b/server/data/ai-catalog.js
@@@ -0,0 +1,145 @@
++'use strict';
++
++/**
++ * OpenVibe.AI's model catalog, as Codes reads it (AI Exchange P4, GET /v1/models with ai.model@1 entries).
++ *
++ * The catalog is only used while CODES_AI_CATALOG is on and OpenVibe.AI's own AI_MODEL_CATALOG answers more than the
++ * legacy single `openvibe` model. A failure, a flag off, or the legacy answer returns null: the pickers then keep the
++ * one OpenVibe.AI entry they have always had.
++ *
++ * parseCatalog(body) GET /v1/models → { aliases, models, entries } or null
++ * catalogChoices(catalog) the entries a picker offers: the dynamic aliases first, then the exact ids
++ * usdPerMillion(nano) integer nano-USD per million tokens → dollars, exactly, never a float
++ *
++ * Every fact here comes from the response. A price is the retail output rate in integer nano-USD strings (D1); an
++ * availability is AI's own word. An alias has no single price (the router picks the model per request), so its entry
++ * says that rather than inventing a number.
++ */
++
++const NANO = 1_000_000_000n;
++/** An exact id: <org>/<model>[:<quant>]@<revision> (D3). */
++const EXACT_ID = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+(?::[A-Za-z0-9._-]+)?@[A-Za-z0-9._-]+$/;
++/** The dynamic policy aliases, in the order a picker offers them; legacy `openvibe` last. */
++const ALIASES = ['openvibe/auto', 'openvibe/economy', 'openvibe/balanced', 'openvibe/fast', 'openvibe/private', 'openvibe'];
++const AVAILABILITY = {
++ available: { state: 'ok', badge: 'Available' },
++ limited: { state: 'warn', badge: 'Limited' },
++ degraded: { state: 'warn', badge: 'Degraded' },
++ deprecated: { state: 'warn', badge: 'Deprecated' },
++ unavailable: { state: 'bad', badge: 'Unavailable' },
++ planned: { state: 'off', badge: 'Planned' },
++ pending_agreement: { state: 'off', badge: 'Pending agreement' },
++ byok_only: { state: 'warn', badge: 'With your own key' },
++};
++
++/**
++ * Integer nano-USD per million tokens as dollars per million tokens, exactly: "1500000000" → "1.50",
++ * "123456789" → "0.123456789". Trailing zeros are dropped, but a money amount keeps at least two decimals.
++ * Null when the value is not a non-negative integer.
++ */
++function usdPerMillion(nano) {
++ const s = String(nano == null ? '' : nano).trim();
++ if (!/^\d{1,19}$/.test(s)) return null;
++ const n = BigInt(s);
++ const whole = n / NANO;
++ let frac = (n % NANO).toString().padStart(9, '0').replace(/0+$/, '');
++ if (frac && frac.length < 2) frac = frac.padEnd(2, '0');
++ return frac ? `${whole}.${frac}` : `${whole}.00`;
++}
++
++/** The availability of a catalog entry as a picker's state, badge and words. */
++function availabilityOf(entry) {
++ const value = String((entry && entry.availability) || 'available');
++ return AVAILABILITY[value] || { state: 'off', badge: value };
++}
++
++/** The retail output rate of one entry in integer nano-USD per million tokens, from either catalog shape. */
++function outputNano(entry) {
++ const flat = entry && entry.retail_rate && entry.retail_rate.output_nano_usd_per_m;
++ if (flat != null) return flat;
++ const nested = entry && entry.retail && entry.retail.nano_usd_per_m && entry.retail.nano_usd_per_m.output;
++ return nested == null ? null : nested;
++}
++
++/** The retail price of one entry, as data-desc's words: the output rate per million tokens. */
++function priceDesc(entry) {
++ const usd = usdPerMillion(outputNano(entry));
++ return usd == null ? null : `$${usd} per million output tokens`;
++}
++
++/** One alias as a picker entry: always dynamic, never a price of its own. */
++function aliasEntry(alias) {
++ const name = String(alias.name || alias.id).trim() || alias.id;
++ const policy = alias.policy ? String(alias.policy) : null;
++ const avail = availabilityOf(alias);
++ return {
++ id: String(alias.id),
++ dynamic: true,
++ name: `${name} (dynamic)`,
++ description: alias.description || null,
++ policy,
++ state: avail.state,
++ badge: 'Dynamic',
++ desc: `Dynamic: ${alias.description || 'the router picks the model for each request'} Its price is what the chosen model's retail rate says.`,
++ };
++}
++
++/** One exact model as a picker entry: its id, its availability and its output price. */
++function modelEntry(model) {
++ const avail = availabilityOf(model);
++ const price = priceDesc(model);
++ return {
++ id: String(model.id),
++ dynamic: false,
++ // The exact id is what a person offers and what is sent: show it, with AI's display name beside it.
++ name: String(model.id),
++ display_name: model.display_name || model.name || null,
++ family: model.family || null,
++ revision: model.revision || null,
++ quant: model.quant || null,
++ availability: model.availability || null,
++ state: avail.state,
++ badge: avail.badge,
++ output_nano_usd_per_m: outputNano(model) == null ? null : String(outputNano(model)),
++ desc: price || 'No published price',
++ };
++}
++
++/**
++ * GET /v1/models as the parts a picker needs. Null when the answer is not a catalog: a failure, no entries, or the
++ * legacy single `openvibe` model (AI_MODEL_CATALOG off), which is today's one entry and stays today's.
++ */
++function parseCatalog(body) {
++ const data = body && Array.isArray(body.data) ? body.data : null;
++ if (!data || !data.length) return null;
++ if (data.length === 1 && data[0] && data[0].id === 'openvibe') return null;
++ const aliases = [];
++ const models = [];
++ for (const entry of data) {
++ if (!entry || typeof entry !== 'object') continue;
++ const id = String(entry.id || '').trim();
++ if (!id) continue;
++ if (entry.dynamic === true || ALIASES.includes(id)) aliases.push(aliasEntry({ ...entry, id }));
++ else if (EXACT_ID.test(id)) models.push(modelEntry({ ...entry, id }));
++ // A row without an exact id is listed by AI but cannot be asked for by id: it is not offered here.
++ }
++ if (!aliases.length && !models.length) return null;
++ const order = (a, b) => {
++ const ai = ALIASES.indexOf(a.id);
++ const bi = ALIASES.indexOf(b.id);
++ return (ai < 0 ? ALIASES.length : ai) - (bi < 0 ? ALIASES.length : bi);
++ };
++ aliases.sort(order);
++ return {
++ aliases: aliases.map((a) => a.id),
++ models: models.map((m) => m.id),
++ entries: [...aliases, ...models], // the picker's order: every alias before every exact id
++ };
++}
++
++/** The catalog's entries (aliases first) for a picker; [] when there is no catalog. */
++function catalogChoices(catalog) {
++ return catalog && Array.isArray(catalog.entries) ? catalog.entries : [];
++}
++
++module.exports = { NANO, EXACT_ID, ALIASES, AVAILABILITY, parseCatalog, catalogChoices, usdPerMillion, outputNano, priceDesc, availabilityOf };
ddiff --git a/server/data/models.js b/server/data/models.js
iindex c98869c..74f439f 100644
---- a/server/data/models.js
++++ b/server/data/models.js
@@@ -6,12 +6,19 @@
* and otherwise the agent's own default, which its CLI keeps current. The composer offers them as one control, "who
* works on it": an agent and its model together. The value is "agent|model" (model empty for the agent's default).
*
++ * OpenVibe.AI is the one agent whose models come from AI itself (AI Exchange P9): while CODES_AI_CATALOG is on and
++ * GET /v1/models answers a real catalog, its choices are the dynamic aliases (openvibe/auto, …) first, then the exact
++ * ids (<org>/<model>[:<quant>]@<revision>), each with its retail output price and availability. The id is sent
++ * unchanged as `model`; nothing here rewrites an exact id or substitutes another model.
++ *
* list(harnesses) → [{ agent, name, models: [{ id, name, note }], efforts }] for the agents Codes knows
-- * choices(harnesses) → [{ value, label, agent, model, group }] for a picker, Auto first
-- * parse(value, harnesses) → { agent, model } or null when it names nothing Codes offers
++ * choices(harnesses, catalog?) → [{ value, label, agent, model, group, state, badge, desc }] for a picker, Auto first
++ * parse(value, harnesses, { aiCatalog }) → { agent, model } or null when it names nothing Codes offers
*/
const { EFFORT_LEVELS } = require('../../engine/adapters/base');
++const OPENVIBE_AI = 'openvibe-ai';
++
const MODELS = {
'claude-code': [
{ id: 'opus', name: 'Opus', note: 'the strongest: hard changes, design, long work' },
@@@ -33,9 +40,23 @@ function list(harnesses) {
}));
}
--function choices(harnesses) {
++function choices(harnesses, catalog = null) {
const out = [{ value: '', label: 'Auto: Codes picks', agent: null, model: null, group: null }];
++ const rows = Array.isArray(catalog) ? catalog : (catalog && Array.isArray(catalog.entries) ? catalog.entries : null);
++ const aiModels = rows && rows.length ? rows : null;
for (const a of list(harnesses)) {
++ if (a.agent === OPENVIBE_AI && aiModels) {
++ // AI's own order: every dynamic alias first, then every exact id. The id travels as the option's model.
++ for (const m of aiModels) {
++ out.push({
++ value: `${a.agent}|${m.id}`, label: `${a.name} · ${m.name}`, note: m.desc || null,
++ agent: a.agent, model: m.id, group: a.name,
++ detail: m.desc || null, state: m.state || null, badge: m.badge || null,
++ dynamic: m.dynamic === true,
++ });
++ }
++ continue;
++ }
for (const m of a.models) {
out.push({ value: `${a.agent}|${m.id}`, label: m.id ? `${a.name} · ${m.name}` : a.name, note: m.note, agent: a.agent, model: m.id || null, group: a.name });
}
@@@ -43,14 +64,17 @@ function choices(harnesses) {
return out;
}
--function parse(value, harnesses) {
++function parse(value, harnesses, { aiCatalog = false } = {}) {
const v = String(value || '');
if (!v) return { agent: null, model: null };
const [agent, model = ''] = v.split('|');
const known = list(harnesses).find((a) => a.agent === agent);
if (!known) return null;
++ // OpenVibe.AI is the authority on its own ids: an alias or an exact id is sent exactly as chosen, and AI answers
++ // ai.model_not_found / ai.model_unavailable when it cannot run it. Codes never rewrites it into another model.
++ if (agent === OPENVIBE_AI && aiCatalog) return { agent, model: model || null };
if (model && !known.models.some((m) => m.id === model)) return null;
return { agent, model: model || null };
}
--module.exports = { MODELS, list, choices, parse };
++module.exports = { MODELS, OPENVIBE_AI, list, choices, parse };
ddiff --git a/server/domain/ai-catalog.js b/server/domain/ai-catalog.js
nnew file mode 100644
iindex 0000000..d2700e5
---- /dev/null
++++ b/server/domain/ai-catalog.js
@@@ -0,0 +1,85 @@
++'use strict';
++
++/**
++ * OpenVibe.AI's catalog, read by Codes itself (AI Exchange P9), behind CODES_AI_CATALOG (off by default).
++ *
++ * Codes already holds a Network app token for OpenVibe.AI (the client-credentials token its harnesses use). This
++ * reads GET /v1/models with that same service token, caches the parsed answer for about five minutes, and returns
++ * null — never an error — when the flag is off, OpenVibe.AI does not answer, or the answer is the legacy single
++ * `openvibe` model. A caller then keeps today's one entry, so a catalog problem cannot change how a task runs.
++ *
++ * createAiCatalog({ config, fetchImpl, now })
++ * read() → { aliases, models, entries } or null (cached)
++ * choices() → the entries a picker offers, aliases first; [] when there is no catalog
++ */
++
++const { createServiceTokenClient } = require('openvibe-sdk/auth');
++const { parseCatalog, catalogChoices } = require('../data/ai-catalog');
++
++const DEFAULT_TTL_MS = 5 * 60_000;
++
++/** The one URL for the model list, whether CODES_AI_URL names the root or already ends in /v1. */
++function modelsUrl(base) {
++ const root = String(base || 'https://ai.openvibe.services').replace(/\/+$/, '');
++ return /\/v1$/.test(root) ? `${root}/models` : `${root}/v1/models`;
++}
++
++function createAiCatalog({ config = {}, fetchImpl = globalThis.fetch, now = Date.now } = {}) {
++ const setting = config.aiCatalog || {};
++ // D11 owner settings: the switch may change live; the token is created whenever Codes has a client secret.
++ const enabledNow = () => setting.enabled === true;
++ const base = String((config.ai && config.ai.url) || 'https://ai.openvibe.services').replace(/\/+$/, '');
++ const url = modelsUrl(base);
++ const ttlMs = setting.ttlMs == null ? DEFAULT_TTL_MS : Math.max(0, Number(setting.ttlMs) || 0);
++ // Codes' own Network token for OpenVibe.AI: the same kind the harness fetches, from the service's client
++ // credentials. Without a client secret there is no catalog, and nothing else changes.
++ const tokens = config.oauth && config.oauth.clientSecret
++ ? createServiceTokenClient({
++ tokenUrl: `${config.networkInternalUrl || 'http://127.0.0.1:4000'}/oauth/token`,
++ clientId: config.oauth.clientId,
++ clientSecret: config.oauth.clientSecret,
++ audience: (config.ai && config.ai.audience) || 'openvibe.ai',
++ scope: 'ai.run.create',
++ fetch: fetchImpl,
++ })
++ : null;
++ let cached = null; // { catalog, until }
++ let inflight = null;
++
++ async function load() {
++ try {
++ const res = await fetchImpl(url, {
++ headers: { ...(await tokens.authHeaders()), Accept: 'application/json' },
++ signal: AbortSignal.timeout(5000),
++ });
++ if (!res.ok) return null;
++ const body = await res.json().catch(() => null);
++ return parseCatalog(body);
++ } catch {
++ // A catalog is an improvement, never a requirement: AI down, a token refused, a slow answer — today's.
++ return null;
++ }
++ }
++
++ /** The parsed catalog, cached about five minutes; null while the flag is off or AI does not answer with one. */
++ async function read() {
++ if (!enabledNow() || !tokens) return null;
++ if (cached && cached.until > now()) return cached.catalog;
++ if (!inflight) {
++ inflight = load().then((catalog) => {
++ if (catalog) cached = { catalog, until: now() + ttlMs };
++ return catalog;
++ }).finally(() => { inflight = null; });
++ }
++ return await inflight;
++ }
++
++ /** The entries for a picker: aliases before exact ids, each with its price and availability; [] without a catalog. */
++ async function choices() {
++ return catalogChoices(await read());
++ }
++
++ return { get enabled() { return enabledNow(); }, url, read, choices };
++}
++
++module.exports = { createAiCatalog, modelsUrl, DEFAULT_TTL_MS };
ddiff --git a/server/domain/run-tasks.js b/server/domain/run-tasks.js
iindex 57b3583..e7b03cc 100644
---- a/server/domain/run-tasks.js
++++ b/server/domain/run-tasks.js
@@@ -126,15 +126,17 @@ function reasonOf(state, text) {
function usageOf(result) {
const d = asObject(result) || {};
const log = Array.isArray(d.log) ? d.log : [];
-- let tokens = 0; let steps = 0; let session = null; let model = null;
++ let tokens = 0; let steps = 0; let session = null; let model = d.resolved_model ? String(d.resolved_model) : null;
for (const line of log) {
if (!line || typeof line !== 'object') continue;
if (line.t === 'usage') tokens += (Number(line.input_tokens) || 0) + (Number(line.output_tokens) || 0);
else if (line.t === 'tool') steps++;
else if (line.t === 'agent') {
if (!session && line.session) session = String(line.session);
-- if (!model && line.model) model = String(line.model);
-- }
++ // OpenVibe.AI's x_openvibe.resolved_model is the model that really ran; it wins over the one asked for.
++ if (line.resolved_model) model = String(line.resolved_model);
++ else if (!model && line.model) model = String(line.model);
++ } else if (line.t === 'done' && line.resolved_model) model = String(line.resolved_model);
}
return {
cost_usd: typeof d.cost_usd === 'number' && Number.isFinite(d.cost_usd) ? d.cost_usd : null,
ddiff --git a/server/http/chat.js b/server/http/chat.js
iindex 3793c4f..7fd9b07 100644
---- a/server/http/chat.js
++++ b/server/http/chat.js
@@@ -130,9 +130,17 @@ const repoLabel = (repo) => { try { return new URL(String(repo)).pathname.split(
function createChatRoutes(ctx) {
const r = asyncRouter();
-- const { config, store, harnesses, tasks, conductor, approvals, userSettings, github, machines, fleet } = ctx;
++ const { config, store, harnesses, tasks, conductor, approvals, userSettings, github, machines, fleet, aiCatalog } = ctx;
/** What each agent is ready for on the person's machines (the stored fleet: no call out), for the pickers. */
const readyFor = async (req) => (fleet && viewer(req) ? readiness(await fleet.agents(req.viewer.subject, harnesses).catch(() => [])) : new Map());
++ /** OpenVibe.AI's catalog for the pickers, or [] (flag off, AI down, or the legacy single model): never an error,
++ * and never more than 1.5 s of a page (the read keeps going and the next page has it cached). */
++ const aiModels = async () => {
++ if (!aiCatalog) return [];
++ let timer;
++ const late = new Promise((resolve) => { timer = setTimeout(resolve, 1500, []); if (timer.unref) timer.unref(); });
++ try { return await Promise.race([aiCatalog.choices().catch(() => []), late]); } finally { clearTimeout(timer); }
++ };
const db = store.db;
const form = express.urlencoded({ extended: false, limit: '32kb' });
const viewer = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
@@@ -344,12 +352,26 @@ function createChatRoutes(ctx) {
}
// ── The composer ─────────────────────────────────────────────────────────────────────────────────────────────
-- function whoPicker(selected, { label = 'Who works on it', auto = null, cls = '', ready = null } = {}) {
-- const all = models.choices(harnesses);
++ function whoPicker(selected, { label = 'Who works on it', auto = null, cls = '', ready = null, catalog = null } = {}) {
++ const all = models.choices(harnesses, catalog);
const groups = new Map();
for (const c of all.slice(1)) { if (!groups.has(c.group)) groups.set(c.group, []); groups.get(c.group).push(c); }
-- // Each agent and model says what it is ready for on the person's machines (Shared's select draws it).
-- const opt = (c) => `<option value="${esc(c.value)}"${selected === c.value ? ' selected' : ''}${c.note ? ` title="${esc(c.note)}"` : ''}${ready ? optionAttrs(ready.get(String(c.value).split('|')[0])) : ''}>${esc(c.label)}</option>`;
++ // A saved default agent ("openvibe-ai|") keeps its selection while its models come from AI's catalog: the
++ // picker shows that agent's own default (legacy `openvibe`, else its first catalog entry) instead of falling
++ // back to Auto.
++ let chosen = selected;
++ if (selected && selected.endsWith('|') && !all.some((c) => c.value === selected)) {
++ const agent = selected.slice(0, -1);
++ const preferred = all.find((c) => c.value === `${agent}|openvibe`) || all.find((c) => String(c.value).startsWith(`${agent}|`));
++ if (preferred) chosen = preferred.value;
++ }
++ // Each agent and model says what it is ready for on the person's machines (Shared's select draws it); an
++ // OpenVibe.AI model says its own retail price and availability instead (AI Exchange P9).
++ const opt = (c) => {
++ const own = c.detail != null || c.state != null || c.badge != null;
++ const attrs = own ? optionAttrs({ desc: c.detail, state: c.state, badge: c.badge }) : (ready ? optionAttrs(ready.get(String(c.value).split('|')[0])) : '');
++ return `<option value="${esc(c.value)}"${chosen === c.value ? ' selected' : ''}${c.note ? ` title="${esc(c.note)}"` : ''}${attrs}>${esc(c.label)}</option>`;
++ };
const first = auto ? `<option value="">${esc(auto)}</option>` : opt(all[0]);
return `<label class="ch-pick${cls ? ` ${cls}` : ''}" title="${esc(label)}"><span class="ch-sr">${esc(label)}</span>${ico('agents')}<select name="who" data-ch-who data-ov-select="bare" data-ov-title="${esc(label)}">${first}${[...groups].map(([g, list]) => list.length > 1 ? `<optgroup label="${esc(g)}">${list.map(opt).join('')}</optgroup>` : opt(list[0])).join('')}</select>${ico('chevron', 'ch-caret')}</label>`;
}
@@@ -481,7 +503,8 @@ function createChatRoutes(ctx) {
const projectPick = `<label class="ch-pick ch-for-openvibe" title="Which OpenVibe project"><span class="ch-sr">Which OpenVibe project</span>${ico('ov')}<select name="project" data-ch-project data-ov-select="bare" data-ov-title="Which OpenVibe project"><option value="auto"${chosenProject === 'auto' ? ' selected' : ''}>Codes picks for you</option>${groups.map((g) => `<optgroup label="${esc(g)}">${start.PROJECTS.filter((p) => p.group === g).map((p) => `<option value="${esc(p.repository)}"${chosenProject === p.repository ? ' selected' : ''}${p.line ? ` title="${esc(p.line)}"` : ''}>${esc(p.name)}${p.domain ? ` · ${esc(p.domain)}` : ''}</option>`).join('')}</optgroup>`).join('')}</select>${ico('chevron', 'ch-caret')}</label>`;
const repoPick = `<label class="ch-pick ch-pick-repo ch-for-code" title="Repository"><span class="ch-sr">Repository: owner/name or any Git URL</span>${ico('repo')}<input name="repo" type="text" inputmode="url" spellcheck="false" autocapitalize="off" value="${esc(repo)}" placeholder="owner/name or any Git URL" list="ch-repos" autocomplete="off" data-ch-repo data-ch-repo-url="/api/v1/me/repos"><datalist id="ch-repos">${repos.map((x) => `<option value="${esc(x.url)}">${esc(x.full_name)}</option>`).join('')}</datalist></label>`;
const ready = await readyFor(req);
-- const pickers = kindPick + projectPick + repoPick + whoPicker(settings.default_agent ? `${settings.default_agent}|` : '', { cls: 'ch-for-agents', ready }) + effortPicker('', 'ch-for-agents');
++ const catalog = await aiModels();
++ const pickers = kindPick + projectPick + repoPick + whoPicker(settings.default_agent ? `${settings.default_agent}|` : '', { cls: 'ch-for-agents', ready, catalog }) + effortPicker('', 'ch-for-agents');
// The repositories a person used last are a tap away (the box also searches every one they can reach).
const newRepo = `<a class="ch-chip ch-chip-new" href="/git">${ico('new')}New on OpenVibe Git</a>`;
const recent = `<div class="ch-recent ch-for-code" role="group" aria-label="Your repositories">${newRepo}${repos.length > 1 ? repos.map((x) => `<button class="ch-chip" type="button" data-ch-saved-repo="${esc(x.url)}">${ico('repo')}${esc(x.full_name)}</button>`).join('') : ''}</div>`;
@@@ -546,7 +569,7 @@ function createChatRoutes(ctx) {
repoUrl = start.normalizeRepo(b.repo);
if (!repoUrl) return back(String(b.repo || '').trim() ? 'That is not a Git address Codes can clone: use https://…, git@host:owner/name or owner/name, with no password in it.' : 'Pick a repository (owner/name or any Git URL), choose an OpenVibe site, or start something new.');
}
-- const who = models.parse(b.who !== undefined ? b.who : (b.agent ? `${String(b.agent).trim()}|` : ''), harnesses);
++ const who = models.parse(b.who !== undefined ? b.who : (b.agent ? `${String(b.agent).trim()}|` : ''), harnesses, { aiCatalog: !!(config.aiCatalog && config.aiCatalog.enabled) });
if (!who) return back('Choose who works on it from the list.');
const agent = who.agent || '';
let args;
@@@ -604,7 +627,7 @@ function createChatRoutes(ctx) {
// The stream starts after the last line this page already shows.
const seqRow = live ? await db.maybe('SELECT max(seq) AS seq FROM codes_session_lines WHERE task_id = $1', [row.id]) : null;
const lastSeq = seqRow && seqRow.seq != null ? Number(seqRow.seq) : 0;
-- const pickers = whoPicker('', { label: 'Who carries on', auto: `Same agent (${agentOf(row.agent, agentNames()).name})`, ready: await readyFor(req) }) + effortPicker('');
++ const pickers = whoPicker('', { label: 'Who carries on', auto: `Same agent (${agentOf(row.agent, agentNames()).name})`, ready: await readyFor(req), catalog: await aiModels() }) + effortPicker('');
return frame(req, res, {
currentId: row.id, title: titleOf(row), heading: titleOf(row), state, panel: await sideHtml(row),
turns: [...errorTurns, ...await taskThread(row)],
@@@ -625,7 +648,7 @@ function createChatRoutes(ctx) {
const row = await db.maybe("SELECT * FROM run_tasks WHERE id = $1 AND subject = $2 AND kind <> 'conductor'", [req.params.id, req.viewer.subject]);
if (!row) return res.status(404).send('Not found');
const b = req.body || {};
-- const who = models.parse(b.who !== undefined ? b.who : (b.agent ? `${String(b.agent).trim()}|` : ''), harnesses);
++ const who = models.parse(b.who !== undefined ? b.who : (b.agent ? `${String(b.agent).trim()}|` : ''), harnesses, { aiCatalog: !!(config.aiCatalog && config.aiCatalog.enabled) });
if (!who) return res.redirect(303, `/tasks/${encodeURIComponent(row.id)}?error=${encodeURIComponent('Choose who carries on from the list.')}`);
// The same agent with another model carries on; another agent takes over.
const agent = who.agent && who.agent !== row.agent ? who.agent : null;
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex 91d92de..a9120f6 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -74,6 +74,7 @@ async function startNetworkStub() {
exempt: {
account_data_events: 'the ledger of requests itself: which export and deletion Codes applied',
event_outbox: 'no person column (the SDK outbox keeps envelopes); dropped with the console in 0002 anyway',
++ config_snapshots: 'D11 configuration revisions: created_by names the owner who changed a setting, not a person\'s account data',
},
});
assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
ddiff --git a/test/ai-catalog.test.js b/test/ai-catalog.test.js
nnew file mode 100644
iindex 0000000..218e538
---- /dev/null
++++ b/test/ai-catalog.test.js
@@@ -0,0 +1,196 @@
++'use strict';
++
++/**
++ * AI Exchange P9: OpenVibe.Codes reads OpenVibe.AI's model catalog, behind CODES_AI_CATALOG.
++ *
++ * The parsing is exact and ordered: the dynamic aliases come before the exact ids, an output price is the integer
++ * nano-USD rate in dollars without a float, and a legacy single `openvibe` answer is today's behavior, not a catalog.
++ * The page keeps today's one OpenVibe.AI entry while the flag is off or AI's own catalog is off; with both on it
++ * offers the aliases and exact ids with their prices and availability.
++ */
++const assert = require('assert');
++const http = require('http');
++const { boot, check, done } = require('./helpers/boot');
++const { parseCatalog, usdPerMillion, catalogChoices, priceDesc } = require('../server/data/ai-catalog');
++const { createAiCatalog } = require('../server/domain/ai-catalog');
++const { usageOf } = require('../server/domain/run-tasks');
++const models = require('../server/data/models');
++const { refusalOf } = require('../harness/adapters/api');
++
++const CATALOG = {
++ object: 'list',
++ data: [
++ {
++ id: 'acme/m1:q4_k_m@rev1', object: 'model', display_name: 'alpha-1', family: 'm', variant: 'original',
++ quant: 'q4_k_m', revision: 'rev1', availability: 'available', dynamic: false,
++ retail_rate: { output_nano_usd_per_m: '1500000000', input_nano_usd_per_m: '1000000000', per_request_nano_usd: '0' },
++ },
++ { id: 'openvibe/economy', name: 'Economy', policy: 'cheapest', dynamic: true, availability: 'available', description: 'The lowest cost per successful answer.' },
++ {
++ id: 'acme/m3@rev3', object: 'model', display_name: 'gamma', availability: 'unavailable', dynamic: false,
++ retail_rate: { output_nano_usd_per_m: '123456789' },
++ },
++ { id: 'openvibe', name: 'OpenVibe AI', policy: 'auto', dynamic: true, availability: 'available' },
++ { id: 'openvibe/auto', name: 'Auto', policy: 'auto', dynamic: true, availability: 'available' },
++ ],
++};
++
++/** A stand-in OpenVibe.AI: GET /v1/models in a mode, so a test can turn its own catalog off. */
++async function fakeAi() {
++ const st = { mode: 'catalog', calls: [] };
++ const server = http.createServer((req, res) => {
++ st.calls.push({ method: req.method, url: req.url, auth: req.headers.authorization || null });
++ if (req.method === 'GET' && req.url === '/v1/models') {
++ const body = st.mode === 'legacy'
++ ? { object: 'list', data: [{ id: 'openvibe', object: 'model', owned_by: 'openvibe' }] }
++ : CATALOG;
++ res.writeHead(200, { 'content-type': 'application/json' });
++ return res.end(JSON.stringify(body));
++ }
++ res.writeHead(404, { 'content-type': 'application/json' });
++ res.end(JSON.stringify({ error: { message: 'not found', code: 'not_found' } }));
++ });
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ return {
++ url: `http://127.0.0.1:${server.address().port}`, st,
++ setMode: (m) => { st.mode = m; },
++ close: () => new Promise((r) => server.close(r)),
++ };
++}
++
++(async () => {
++ await check('the catalog is parsed exactly: aliases first, then exact ids, with integer nano-USD prices', () => {
++ const catalog = parseCatalog(CATALOG);
++ assert.deepStrictEqual(catalog.entries.map((e) => e.id), [
++ 'openvibe/auto', 'openvibe/economy', 'openvibe', 'acme/m1:q4_k_m@rev1', 'acme/m3@rev3',
++ ], 'the dynamic aliases, in order, before every exact id');
++ assert.deepStrictEqual(catalog.aliases, ['openvibe/auto', 'openvibe/economy', 'openvibe']);
++ assert.deepStrictEqual(catalog.models, ['acme/m1:q4_k_m@rev1', 'acme/m3@rev3']);
++ const byId = new Map(catalog.entries.map((e) => [e.id, e]));
++ assert.deepStrictEqual([byId.get('openvibe/auto').dynamic, byId.get('openvibe/auto').badge], [true, 'Dynamic']);
++ assert.strictEqual(byId.get('acme/m1:q4_k_m@rev1').desc, '$1.50 per million output tokens');
++ assert.strictEqual(byId.get('acme/m3@rev3').desc, '$0.123456789 per million output tokens');
++ assert.deepStrictEqual([byId.get('acme/m1:q4_k_m@rev1').state, byId.get('acme/m1:q4_k_m@rev1').badge], ['ok', 'Available']);
++ assert.deepStrictEqual([byId.get('acme/m3@rev3').state, byId.get('acme/m3@rev3').badge], ['bad', 'Unavailable']);
++ assert.deepStrictEqual(catalogChoices(catalog), catalog.entries);
++ assert.deepStrictEqual([usdPerMillion('1500000000'), usdPerMillion('123456789'), usdPerMillion('0'), usdPerMillion('nope')],
++ ['1.50', '0.123456789', '0.00', null]);
++ assert.strictEqual(priceDesc({ retail_rate: { output_nano_usd_per_m: '2000000000' } }), '$2.00 per million output tokens');
++ });
++
++ await check('a legacy single openvibe model, or anything that is not a catalog, is null (today\'s behavior)', () => {
++ assert.strictEqual(parseCatalog({ data: [{ id: 'openvibe', object: 'model' }] }), null);
++ assert.strictEqual(parseCatalog({ data: [] }), null);
++ assert.strictEqual(parseCatalog({ nope: true }), null);
++ assert.strictEqual(parseCatalog({ data: [{ id: 'acme/no-exact-id', display_name: 'listed but not addressable' }] }), null);
++ });
++
++ await check('the fetched catalog is cached about five minutes, and a flag off or a legacy answer is no catalog', async () => {
++ const calls = [];
++ let now = 1_000_000;
++ const fetchImpl = async (url) => {
++ calls.push(String(url));
++ if (String(url).endsWith('/oauth/token')) return { ok: true, status: 200, json: async () => ({ access_token: 'svc-token', expires_in: 300 }) };
++ return { ok: true, status: 200, json: async () => CATALOG };
++ };
++ const config = {
++ networkInternalUrl: 'http://network.test', oauth: { clientId: 'codes', clientSecret: 's'.repeat(40) },
++ ai: { url: 'http://ai.test', audience: 'openvibe.ai' },
++ aiCatalog: { enabled: true, ttlMs: 60_000 },
++ };
++ const catalog = createAiCatalog({ config, fetchImpl, now: () => now });
++ const first = await catalog.read();
++ assert.ok(first && first.entries.length, 'the catalog was read');
++ await catalog.read();
++ assert.strictEqual(calls.filter((u) => u.endsWith('/v1/models')).length, 1, 'the second read is cached');
++ assert.ok(calls.some((u) => u.endsWith('/v1/models')), 'read from AI');
++ now += 60_001;
++ await catalog.read();
++ assert.strictEqual(calls.filter((u) => u.endsWith('/v1/models')).length, 2, 'the cache expired after the ttl');
++
++ const off = createAiCatalog({ config: { ...config, aiCatalog: { enabled: false } }, fetchImpl, now: () => now });
++ assert.strictEqual(await off.read(), null);
++ assert.deepStrictEqual(await off.choices(), []);
++ const before = calls.length;
++ await off.read();
++ assert.strictEqual(calls.length, before, 'the flag off never calls AI');
++
++ const legacy = createAiCatalog({
++ config, now: () => now,
++ fetchImpl: async (url) => (String(url).endsWith('/oauth/token')
++ ? { ok: true, status: 200, json: async () => ({ access_token: 'svc-token', expires_in: 300 }) }
++ : { ok: true, status: 200, json: async () => ({ data: [{ id: 'openvibe' }] }) }),
++ });
++ assert.strictEqual(await legacy.read(), null, 'AI\'s own catalog off is today\'s one model');
++ });
++
++ await check('the picker maps every catalog id unchanged, and an exact-id refusal keeps AI\'s code', () => {
++ const harnesses = { list: () => [{ id: 'openvibe-ai', name: 'OpenVibe.AI' }, { id: 'claude-code', name: 'Claude Code' }] };
++ const catalog = parseCatalog(CATALOG);
++ const choices = models.choices(harnesses, catalog.entries).filter((c) => c.agent === 'openvibe-ai');
++ assert.deepStrictEqual(choices.map((c) => c.model), ['openvibe/auto', 'openvibe/economy', 'openvibe', 'acme/m1:q4_k_m@rev1', 'acme/m3@rev3']);
++ assert.deepStrictEqual([choices[0].state, choices[0].badge, choices[0].detail], ['ok', 'Dynamic', catalog.entries[0].desc]);
++ assert.strictEqual(models.parse('openvibe-ai|acme/m1:q4_k_m@rev1', harnesses, { aiCatalog: true }).model, 'acme/m1:q4_k_m@rev1', 'sent unchanged');
++ assert.strictEqual(models.parse('openvibe-ai|acme/m1:q4_k_m@rev1', harnesses), null, 'flag off keeps today: only the default model');
++ assert.match(refusalOf('OpenVibe.AI', 404, { error: { message: 'no artifact acme/x@r', code: 'ai.model_not_found' } }, 'acme/x@r'), /refused the model acme\/x@r: HTTP 404 ai\.model_not_found/);
++ assert.match(refusalOf('OpenVibe.AI', 429, { error: { message: 'slow down' } }, 'x'), /HTTP 429: slow down/);
++ });
++
++ await check('the run records the resolved model from the disclosure where it records the model', () => {
++ assert.strictEqual(usageOf({ resolved_model: 'acme/m1:q4_k_m@rev1', log: [{ t: 'agent', model: 'acme/m1:q4_k_m@rev1' }] }).model, 'acme/m1:q4_k_m@rev1');
++ assert.strictEqual(usageOf({ log: [{ t: 'agent', model: 'openvibe/auto' }, { t: 'done', resolved_model: 'acme/m3@rev3', model: 'acme/m3@rev3' }] }).model, 'acme/m3@rev3', 'a resolved exact id wins');
++ assert.strictEqual(usageOf({ log: [{ t: 'agent', model: 'requested' }] }).model, 'requested', 'today\'s behavior without a disclosure');
++ });
++
++ const ai = await fakeAi();
++ const on = await boot({ env: { CODES_AI_CATALOG: '1', CODES_AI_CATALOG_TTL_MS: '0', CODES_AI_URL: ai.url } });
++ const ada = on.network.addUser('ada');
++ try {
++ await check('with both flags on, the picker offers the aliases first and then the exact ids, with prices and availability', async () => {
++ const r = await on.get('/', { as: ada });
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ const alias = r.text.indexOf('value="openvibe-ai|openvibe/auto"');
++ const economy = r.text.indexOf('value="openvibe-ai|openvibe/economy"');
++ const exact = r.text.indexOf('value="openvibe-ai|acme/m1:q4_k_m@rev1"');
++ const unavailable = r.text.indexOf('value="openvibe-ai|acme/m3@rev3"');
++ assert.ok(alias > 0, 'the auto alias is offered');
++ assert.ok(economy > alias, 'the aliases keep their order');
++ assert.ok(exact > economy, 'the exact ids come after the aliases');
++ assert.ok(unavailable > exact, 'every exact id is offered');
++ assert.match(r.text.slice(alias, alias + 400), /data-badge="Dynamic"/);
++ assert.match(r.text.slice(exact, exact + 400), /data-desc="\$1\.50 per million output tokens"/);
++ assert.match(r.text.slice(unavailable, unavailable + 400), /data-state="bad"/);
++ assert.match(r.text.slice(unavailable, unavailable + 400), /data-badge="Unavailable"/);
++ assert.ok(ai.st.calls.some((c) => c.url === '/v1/models'), 'Codes read AI\'s catalog');
++ assert.ok(ai.st.calls.some((c) => /^Bearer /.test(c.auth || '')), 'with a token');
++ });
++
++ await check('with AI\'s own catalog off, the picker keeps today\'s single OpenVibe.AI entry', async () => {
++ ai.setMode('legacy');
++ const r = await on.get('/', { as: ada });
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('value="openvibe-ai|"'), 'the default OpenVibe.AI entry is still there');
++ assert.ok(!r.text.includes('openvibe-ai|openvibe/auto'), 'no alias from a legacy answer');
++ assert.ok(!r.text.includes('openvibe-ai|acme/m1:q4_k_m@rev1'), 'no exact id from a legacy answer');
++ });
++ } finally {
++ await on.close();
++ }
++
++ // The flag off is exactly today: the old single entry, and AI is never asked for a catalog.
++ const before = ai.st.calls.length;
++ const off = await boot({ env: { CODES_AI_URL: ai.url } });
++ try {
++ const r = await off.get('/', { as: off.network.addUser('ada') });
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('value="openvibe-ai|"'));
++ assert.ok(!r.text.includes('openvibe-ai|openvibe/auto'));
++ assert.strictEqual(ai.st.calls.length, before, 'the flag off never asked AI for a catalog');
++ } finally {
++ await off.close();
++ await ai.close();
++ }
++
++ console.log('ai-catalog: aliases before exact ids, exact nano-USD prices, and today\'s picker while either flag is off');
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/c4-fleet.test.js b/test/c4-fleet.test.js
iindex 5fe91c2..30e81e4 100644
---- a/test/c4-fleet.test.js
++++ b/test/c4-fleet.test.js
@@@ -10,7 +10,17 @@ const { boot, check, done } = require('./helpers/boot');
(async () => {
const t = await boot();
const ada = t.network.addUser('ada');
-- const RESET = '2026-11-04T00:00:00Z';
++ // The reset has to be ahead of now: a window with a reset already past has freed and is not full. A fixed date
++ // made this test depend on the clock (red every day after it); the next Wednesday 00:00 UTC keeps the words the
++ // page says ("· Wed 00:00 UTC") and the premise (96%, full) both true.
++ const nextWednesdayUtc = () => {
++ const d = new Date();
++ d.setUTCHours(0, 0, 0, 0);
++ const days = ((3 - d.getUTCDay() + 7) % 7) || 7; // 3 is Wednesday; never today, whose reset may be past
++ d.setUTCDate(d.getUTCDate() + days);
++ return d.toISOString();
++ };
++ const RESET = nextWednesdayUtc();
// The machine, as OpenVibe.Bot answers it: the page reads Bot and keeps the latest reading per machine.
t.bot.addMachine({
id: 'dev_ada_laptop', node_id: 'nod_ada', name: 'ada-laptop', online: true, fleet_at: new Date().toISOString(),
ddiff --git a/test/contracts.test.js b/test/contracts.test.js
iindex 15891fd..f2ade20 100644
---- a/test/contracts.test.js
++++ b/test/contracts.test.js
@@@ -22,7 +22,7 @@ const { check, done } = require('./helpers/boot');
assert.strictEqual(manifest.exposure.state, 'live');
// Contracts 0.229.0: a first-party service starts, reads and cancels a person's coding task (http/tasks-api.js)
// and reads their Action Center items (http/actions-api.js).
-- assert.deepStrictEqual(manifest.capabilities, ['codes.task.start', 'codes.task.read', 'codes.task.cancel', 'codes.actions.read']);
++ assert.deepStrictEqual(manifest.capabilities, ['codes.task.start', 'codes.task.read', 'codes.task.cancel', 'codes.actions.read', 'codes.config.manage']);
for (const id of manifest.capabilities) assert.strictEqual(contracts.capabilities.get(id).status, 'active', id);
const range = manifest.contractRanges['openvibe-contracts'];
assert.ok(require('openvibe-sdk/core').satisfiesRange(require('openvibe-contracts/package.json').version, range));
ddiff --git a/test/engine-review.test.js b/test/engine-review.test.js
iindex 0cd33e2..272d002 100644
---- a/test/engine-review.test.js
++++ b/test/engine-review.test.js
@@@ -64,6 +64,21 @@ const OK = async () => ({ status: 200, ok: true, json: async () => ({ choices: [
assert.strictEqual(verdict.family, 'deepseek');
});
++ await check('the review records the resolved model and cost from the API\'s disclosure', async () => {
++ const fetchImpl = async () => ({
++ status: 200, ok: true,
++ json: async () => ({
++ choices: [{ message: { content: '{"verdict":"approved","summary":"It holds."}' } }],
++ x_openvibe: { resolved_model: 'deepseek/chat@rev2', provider_access: 'OWN_DEPLOYMENT', cost_usd: 0.0042 },
++ }),
++ });
++ const verdict = await reviewWithAi({ token: 'test-token', model: 'deepseek-chat', patch: '+x', fetchImpl });
++ assert.deepStrictEqual([verdict.model, verdict.resolved_model, verdict.requested_model], ['deepseek/chat@rev2', 'deepseek/chat@rev2', 'deepseek-chat']);
++ assert.strictEqual(verdict.disclosure.provider_access, 'OWN_DEPLOYMENT');
++ assert.strictEqual(verdict.cost_usd, 0.0042);
++ assert.strictEqual(verdict.family, 'deepseek');
++ });
++
await check('a provider that speaks the messages dialect is tried when chat completions is not there', async () => {
const calls = [];
const fetchImpl = async (url) => {
@@@ -86,6 +101,16 @@ const OK = async () => ({ status: 200, ok: true, json: async () => ({ choices: [
await assert.rejects(() => reviewWithAi({ token: 't', model: 'deepseek-chat', patch: '+x', fetchImpl }), /refused the review \(HTTP 429\)/);
});
++ await check('a refused exact id is that error, and the other dialect is not asked for it', async () => {
++ const calls = [];
++ const fetchImpl = async (url) => {
++ calls.push(url);
++ return { status: 404, ok: false, json: async () => ({ error: { message: 'no artifact acme/nope@rev0', code: 'ai.model_not_found' } }) };
++ };
++ await assert.rejects(() => reviewWithAi({ token: 't', model: 'acme/nope@rev0', patch: '+x', fetchImpl }), /ai\.model_not_found/);
++ assert.strictEqual(calls.length, 1, 'no second request: the same refused model is not retried');
++ });
++
console.log('review: a model of another family, one call to OpenVibe.AI, and a verdict read from what it says');
} catch (e) {
console.error(e);
ddiff --git a/test/harness-api.test.js b/test/harness-api.test.js
iindex 8520d9a..d76104a 100644
---- a/test/harness-api.test.js
++++ b/test/harness-api.test.js
@@@ -26,7 +26,7 @@ async function mockApi() {
requests.push({ auth: req.headers.authorization || null, body: parsed });
const step = script.shift();
if (!step) { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ choices: [{ message: { role: 'assistant', content: 'done' } }], usage: { prompt_tokens: 1, completion_tokens: 1 } })); return; }
-- if (step.status) { res.writeHead(step.status, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: { message: step.error } })); return; }
++ if (step.status) { res.writeHead(step.status, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: { message: step.error, ...(step.code ? { code: step.code } : {}) } })); return; }
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ choices: [{ message: { role: 'assistant', content: step.content || '', ...(step.calls ? { tool_calls: step.calls.map((c, i) => ({ id: `call_${requests.length}_${i}`, type: 'function', function: { name: c[0], arguments: JSON.stringify(c[1]) } })) } : {}) } }], usage: { prompt_tokens: 1000, completion_tokens: 100, prompt_cache_hit_tokens: 400 }, ...(step.x ? { x_openvibe: step.x } : {}) }));
});
@@@ -116,6 +116,42 @@ const toolResults = (events) => events.filter((e) => e.type === 'user').flatMap(
} finally { await new Promise((r) => network.close(r)); }
});
++ await check('OpenVibe.AI: an exact id is sent unchanged, its disclosure is recorded, and a refusal is that error', async () => {
++ const exactEnv = { ...env, OPENVIBE_AI_URL: api.url, OPENVIBE_AI_TOKEN: 'ov-task-token' };
++ const exact = createHarness({ env: exactEnv, store: createStore({ env: exactEnv }) });
++ api.set([{
++ content: 'It ran.',
++ x: { run_id: 'run_9', cost_usd: 0.0012, resolved_model: 'acme/m1:q4_k_m@rev1', revision: 'rev1', provider_access: 'OWN_DEPLOYMENT', rate_version: 'rr-1', policy: 'exact', fallback_reason: null },
++ }]);
++ const events = await collect(exact.run({ prompt: 'run exactly', agent: 'openvibe-ai', model: 'acme/m1:q4_k_m@rev1', cwd: work, permission: 'read', handoff: false }));
++ const r = events.find((e) => e.type === 'result');
++ assert.strictEqual(r.is_error, false, r.result);
++ assert.strictEqual(api.requests[0].body.model, 'acme/m1:q4_k_m@rev1', 'the exact id is sent unchanged');
++ assert.strictEqual(r.model, 'acme/m1:q4_k_m@rev1', 'the resolved model is what the run records');
++ assert.strictEqual(r.resolved_model, 'acme/m1:q4_k_m@rev1');
++ assert.strictEqual(r.disclosure.provider_access, 'OWN_DEPLOYMENT');
++ assert.strictEqual(r.total_cost_usd, 0.0012);
++ assert.strictEqual(events.find((e) => e.type === 'system' && e.subtype === 'init').model, 'acme/m1:q4_k_m@rev1', 'the session starts on the model that answered');
++
++ // An alias is sent as the alias; the disclosure says which exact model it resolved to, and that is recorded.
++ api.set([{ content: 'Alias ran.', x: { run_id: 'run_10', cost_usd: 0.001, resolved_model: 'acme/m3@rev3', policy: 'auto' } }]);
++ const aliased = await collect(exact.run({ prompt: 'run the alias', agent: 'openvibe-ai', model: 'openvibe/auto', cwd: work, permission: 'read', handoff: false }));
++ assert.strictEqual(api.requests[0].body.model, 'openvibe/auto', 'the alias is sent unchanged');
++ assert.strictEqual(aliased.find((e) => e.type === 'result').model, 'acme/m3@rev3', 'the resolved exact id is recorded');
++
++ api.set([{ status: 404, code: 'ai.model_not_found', error: 'no artifact acme/nope@rev0' }]);
++ const missing = await collect(exact.run({ prompt: 'run a missing model', agent: 'openvibe-ai', model: 'acme/nope@rev0', cwd: work, permission: 'read', handoff: false }));
++ const missingResult = missing.find((e) => e.type === 'result');
++ assert.strictEqual(api.requests.length, 1, 'one request: never retried with another model');
++ assert.match(missingResult.result, /ai\.model_not_found/);
++ assert.match(missingResult.result, /acme\/nope@rev0/);
++
++ api.set([{ status: 400, code: 'ai.model_unavailable', error: 'the artifact has no active deployment' }]);
++ const unavailable = await collect(exact.run({ prompt: 'run an unavailable model', agent: 'openvibe-ai', model: 'acme/m2@rev2', cwd: work, permission: 'read', handoff: false }));
++ assert.match(unavailable.find((e) => e.type === 'result').result, /ai\.model_unavailable/);
++ assert.strictEqual(api.requests.length, 1, 'the unavailable model was not swapped for another');
++ });
++
await check('read permission offers no writing tool, and a forced write is refused', async () => {
api.set([{ calls: [['write_file', { path: 'src/new.js', content: 'x' }]] }, { content: 'ok' }]);
const events = await collect(h.run({ prompt: 'look', agent: 'openai-compatible', cwd: work, permission: 'read', handoff: false }));
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex 35db503..477a10d 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -41,7 +41,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
// One database per boot (PGlite, or CODES_TEST_STORE=pg: the containers), dropped when the boot closes.
const testdb = await require('./db').testDb();
-- const built = await createApp({ config, store: createStore(testdb.db), log, limitsNow: opts.limitsNow, actorLimits: opts.actorLimits === true, ...(opts.run ? { run: opts.run } : {}), ...(opts.benchAttemptEnded ? { benchAttemptEnded: opts.benchAttemptEnded } : {}), ...(opts.accountSend ? { accountSend: opts.accountSend } : {}), ...(opts.connections ? { connections: opts.connections } : {}), ...(opts.notify ? { notify: opts.notify } : {}), ...(opts.balance ? { balance: opts.balance } : {}) });
++ const built = await createApp({ config, env, store: createStore(testdb.db), log, limitsNow: opts.limitsNow, actorLimits: opts.actorLimits === true, ...(opts.run ? { run: opts.run } : {}), ...(opts.benchAttemptEnded ? { benchAttemptEnded: opts.benchAttemptEnded } : {}), ...(opts.accountSend ? { accountSend: opts.accountSend } : {}), ...(opts.connections ? { connections: opts.connections } : {}), ...(opts.notify ? { notify: opts.notify } : {}), ...(opts.balance ? { balance: opts.balance } : {}) });
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
@@@ -52,6 +52,7 @@ async function boot(opts = {}) {
const headers = { ...(o.headers || {}) };
if (o.as) headers.cookie = [signIn(o.as), headers.cookie].filter(Boolean).join('; ');
if (o.cookie) headers.cookie = o.cookie;
++ if (o.bearer) headers.authorization = `Bearer ${o.bearer}`;
let body = o.body;
if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }
if (o.form) {
ddiff --git a/test/internal-config.test.js b/test/internal-config.test.js
nnew file mode 100644
iindex 0000000..fed29b1
---- /dev/null
++++ b/test/internal-config.test.js
@@@ -0,0 +1,89 @@
++'use strict';
++/**
++ * D11 owner settings: the codes.ai namespace and /internal/config.
++ * Env pins win; codes.config.manage is required; an owner change applies live; no KEK still works;
++ * a secret-class value never leaves a summary, snapshot or history.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++const CAP = ['codes.config.manage'];
++const OWNER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
++const SECRET = 'sk_codes_d11_toppsecret_123456';
++
++(async () => {
++ const t = await boot({ env: { CODES_AI_CATALOG: '1' } });
++ const admin = () => t.network.mintService('svc:network', 'openvibe.codes', CAP);
++ try {
++ await check('the summary: schema, the env pin, sealed:false without a KEK', async () => {
++ const r = await t.get('/internal/config/codes.ai', { bearer: admin() });
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ assert.strictEqual(r.json().namespace, 'codes.ai');
++ assert.strictEqual(r.json().sealed, false);
++ assert.strictEqual(r.json().values.catalog, true);
++ assert.deepStrictEqual(r.json().pins.catalog, { env: 'CODES_AI_CATALOG', pinned: true });
++ assert.ok(!r.text.includes(SECRET));
++ });
++
++ await check('a service token without codes.config.manage is refused', async () => {
++ const wrong = t.network.mintService('svc:reader', 'openvibe.codes', ['codes.task.read']);
++ const r = await t.get('/internal/config/codes.ai', { bearer: wrong });
++ assert.strictEqual(r.status, 403);
++ assert.strictEqual(r.json().code, 'capability.denied');
++ });
++
++ await check('an env-pinned key is read-only', async () => {
++ const r = await t.get('/internal/config/codes.ai', {
++ method: 'POST', bearer: admin(), headers: { 'x-ov-subject': OWNER }, json: { values: { catalog: false }, reason: 'off' },
++ });
++ assert.strictEqual(r.status, 409, r.text.slice(0, 300));
++ assert.strictEqual(r.json().code, 'config.pinned_in_env');
++ });
++ } finally { await t.close(); }
++
++ const live = await boot();
++ const liveAdmin = () => live.network.mintService('svc:network', 'openvibe.codes', CAP);
++ try {
++ await check('an owner change applies live', async () => {
++ assert.strictEqual(live.config.aiCatalog.enabled, false);
++ const r = await live.get('/internal/config/codes.ai', {
++ method: 'POST', bearer: liveAdmin(), headers: { 'x-ov-subject': OWNER }, json: { values: { catalog: true }, reason: 'owner turned it on' },
++ });
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ assert.strictEqual(r.json().revision, 2);
++ assert.strictEqual(live.config.aiCatalog.enabled, true);
++ assert.strictEqual(live.ctx.aiCatalog.enabled, true);
++ assert.ok(live.logs().includes('by user:' + OWNER));
++ assert.ok(!live.logs().includes(SECRET));
++ });
++
++ await check('with a KEK the store is sealed and boot reseals', async () => {
++ const sealed = await boot({ env: { OV_CONFIG_KEKS: 'k1:MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=', OV_CONFIG_KEK_CURRENT: 'k1' } });
++ try {
++ const token = sealed.network.mintService('svc:network', 'openvibe.codes', CAP);
++ const r = await sealed.get('/internal/config/codes.ai', { bearer: token });
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ assert.strictEqual(r.json().sealed, true);
++ assert.strictEqual(r.json().kek, 'k1');
++ } finally { await sealed.close(); }
++ });
++
++ await check('a secret is write-only and never appears in a summary, snapshot or history', async () => {
++ const shared = require('openvibe-shared/config');
++ const extra = await shared.createConfigStore({
++ db: live.ctx.store.db, service: 'codes', namespace: 'codes.test_secrets',
++ schema: { type: 'object', additionalProperties: false, properties: { api_key: { type: 'string' } } },
++ classify: () => 'secret',
++ });
++ const marker = await extra.setSecret('api_key', SECRET, { actor: { type: 'user', id: OWNER }, reason: 'test' });
++ assert.strictEqual(marker.redacted, true);
++ assert.ok(!JSON.stringify(marker).includes(SECRET));
++ const summary = await extra.summary();
++ assert.strictEqual(summary.values.api_key.redacted, true);
++ assert.ok(!JSON.stringify(summary).includes(SECRET));
++ assert.ok(!JSON.stringify(await extra.history()).includes(SECRET));
++ assert.ok(!JSON.stringify(await extra.snapshot(1)).includes(SECRET));
++ });
++ } finally { await live.close(); }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 89e4ffe..cacdf43 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -50,6 +50,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
'server/http/tasks-api.js': 'OpenVibe.Bench (CODES_BENCH_URL, configured), Network projects API (configured) with the verified user token; benchmark ids and task ids are URL-encoded, no URL from a person',
'server/http/pages.js': 'Network projects API (configured) for Improve OpenVibe project creation; Forgejo repo read (CODES_GIT_INTERNAL_URL) for repo details; no URL from a person',
'server/domain/connections.js': 'OpenVibe.AI (CODES_AI_URL, configured) for a person\'s own keys: the credential routes and the two model endpoints, with the provider and the subject as path segments; no URL from a person',
++ 'server/domain/ai-catalog.js': 'OpenVibe.AI (CODES_AI_URL, configured) GET /v1/models with Codes\' own Network service token: the base is the operator-set AI address and the path is fixed; no URL from a person or a developer',
'server/media.js': 'OpenVibe.Media\'s objects API (CODES_MEDIA_URL, configured) with Codes\' Network service token, and the presigned upload URL Media itself answers for that object; never a URL from a person or a model',
'server/domain/run-tokens.js': 'OpenVibe.Network\'s token endpoint (OV_NETWORK_INTERNAL_URL, configured) for a run token; no URL from a person',
'server/domain/machines.js': 'OpenVibe.Network (OV_NETWORK_INTERNAL_URL, configured) /api/v1/me/nodes with the person\'s own session token: their machines and a revoke; the path id is checked against a nod_ shape, never a URL from a person',