openvibers/openvibe.codes Commits
D11 owner settings: codes.ai (#274)
Changes
@@@ -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@@@ -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.## ConfigurationSee [.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).@@@ -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 = {@@@ -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 };@@@ -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,};}@@@ -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, tmresult.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, tmfiles: 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 };nnew file mode 100644@@@ -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++);@@@ -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",@@@ -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": {nnew file mode 100644@@@ -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 };@@@ -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) => {@@@ -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: {nnew file mode 100644@@@ -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 };@@@ -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 };nnew file mode 100644@@@ -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 };@@@ -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,@@@ -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;@@@ -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(', ')}`);nnew file mode 100644@@@ -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); });@@@ -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(),@@@ -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));@@@ -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);@@@ -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 }));@@@ -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) {nnew file mode 100644@@@ -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); });@@@ -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',
@@ -30,6 +30,15 @@ OV_OAUTH_CLIENT_SECRET=
@@ -30,6 +30,15 @@ OV_OAUTH_CLIENT_SECRET=
# optional: defaults to true in production
# optional: defaults to true in production
# COOKIE_SECURE=true
# 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
# 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.
# hard-coded home; without it a job gets no lessons. Never a secret.
#CODES_KNOW_LESSONS=/home/openvibe/agents/know/lessons.js
#CODES_KNOW_LESSONS=/home/openvibe/agents/know/lessons.js
@@ -129,11 +129,16 @@ No path in Codes sends or accepts a shared loopback key (tested by grep and at r
@@ -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/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/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/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
## 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`.
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
### 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).
`/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).
@@ -130,7 +130,7 @@ function readVerdict(text) {
@@ -130,7 +130,7 @@ function readVerdict(text) {
* @param {string} [what.patch] the diff
* @param {string} [what.patch] the diff
* @param {string} [what.summary] what the change was for
* @param {string} [what.summary] what the change was for
* @param {function} [what.fetchImpl] fetch, for a test
* @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({
async function reviewWithAi({
baseUrl = 'https://ai.openvibe.services/v1',
baseUrl = 'https://ai.openvibe.services/v1',
@@ -150,19 +150,40 @@ async function reviewWithAi({
@@ -150,19 +150,40 @@ async function reviewWithAi({
let res = await fetchImpl(`${root}/chat/completions`, {
let res = await fetchImpl(`${root}/chat/completions`, {
method: 'POST', headers, body: JSON.stringify({ model, messages: [{ role: 'user', content: prompt }] }),
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`, {
res = await fetchImpl(`${root}/messages`, {
method: 'POST', headers, body: JSON.stringify({ model, max_tokens: 1200, messages: [{ role: 'user', content: prompt }] }),
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
const text = body && body.choices && body.choices[0] && body.choices[0].message && body.choices[0].message.content
? 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(' ') : '');
: (body && body.content && Array.isArray(body.content) ? body.content.map((c) => c && c.text).filter(Boolean).join(' ') : '');
const verdict = readVerdict(text);
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 = {
module.exports = {
@@ -170,6 +170,20 @@ function extraHeaders(env) {
@@ -170,6 +170,20 @@ function extraHeaders(env) {
return out;
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.
* 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).
* 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) {
@@ -207,6 +221,9 @@ function apiAgent(spec) {
let lastText = '';
let lastText = '';
let turns = 0;
let turns = 0;
let failure = null;
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 {
try {
while (turns < maxTurns && !cancelled) {
while (turns < maxTurns && !cancelled) {
turns++;
turns++;
@@ -218,25 +235,32 @@ function apiAgent(spec) {
@@ -218,25 +235,32 @@ function apiAgent(spec) {
signal: controller.signal,
signal: controller.signal,
});
});
const body = await res.json().catch(() => null);
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]) {
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);
yield ev.error(failure);
break;
break;
}
}
const usage = body.usage || {};
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.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;
u.cacheRead += +(usage.prompt_cache_hit_tokens || (usage.prompt_tokens_details && usage.prompt_tokens_details.cached_tokens)) || 0;
const msg = body.choices[0].message || {};
const msg = body.choices[0].message || {};
messages.push({ role: 'assistant', content: msg.content || '', ...(msg.tool_calls ? { tool_calls: msg.tool_calls } : {}) });
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 : [];
const calls = Array.isArray(msg.tool_calls) ? msg.tool_calls : [];
if (!calls.length) break;
if (!calls.length) break;
for (const call of calls) {
for (const call of calls) {
const name = call.function && call.function.name;
const name = call.function && call.function.name;
let args = {};
let args = {};
try { args = JSON.parse((call.function && call.function.arguments) || '{}'); } catch { /* answered as an error below */ }
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;
let out;
const def = TOOL_DEFS[name];
const def = TOOL_DEFS[name];
if (!def) out = [`unknown tool ${name}`, true];
if (!def) out = [`unknown tool ${name}`, true];
@@ -265,9 +289,11 @@ function apiAgent(spec) {
@@ -265,9 +289,11 @@ function apiAgent(spec) {
failure = cancelled ? `${spec.name} was stopped` : `${spec.name} could not be reached: ${err.message}`;
failure = cancelled ? `${spec.name} was stopped` : `${spec.name} could not be reached: ${err.message}`;
yield ev.error(failure);
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;
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 }) });
usage: ev.usage({ input: u.input - u.cacheRead, output: u.output, cacheRead: u.cacheRead }) });
}());
}());
iterator.cancel = () => { cancelled = true; controller.abort(); };
iterator.cancel = () => { cancelled = true; controller.abort(); };
@@ -277,4 +303,4 @@ function apiAgent(spec) {
@@ -277,4 +303,4 @@ function apiAgent(spec) {
return adapter;
return adapter;
}
}
-module.exports = { apiAgent, extraHeaders, runTool, inside, TOOL_DEFS, SYSTEM };
+module.exports = { apiAgent, refusalOf, extraHeaders, runTool, inside, TOOL_DEFS, SYSTEM };
@@ -54,12 +54,12 @@ function usage({ input = 0, output = 0, cacheRead = 0, cacheWrite = 0 } = {}) {
@@ -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) };
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 {
return {
type: 'result', subtype: ok ? 'success' : 'error', is_error: !ok, result: scrub(final || ''),
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,
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,
};
};
}
}
@@ -310,7 +310,7 @@ function shotWithBytes(shot, budget) {
@@ -310,7 +310,7 @@ function shotWithBytes(shot, budget) {
function compact(e, ctx) {
function compact(e, ctx) {
const cut = (s) => cutTo(s, MAX_TEXT);
const cut = (s) => cutTo(s, MAX_TEXT);
const parent = e.parent_tool_use_id ? { parent: String(e.parent_tool_use_id) } : {};
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 === '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 === '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)) };
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) {
@@ -337,7 +337,12 @@ function compact(e, ctx) {
});
});
}
}
if (e.type === 'result') {
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;
return null;
}
}
@@ -680,6 +685,10 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tm
@@ -680,6 +685,10 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tm
result.summary = answer.slice(0, 4000);
result.summary = answer.slice(0, 4000);
if (job.kind === 'bench') result.output = answer.slice(0, 200000);
if (job.kind === 'bench') result.output = answer.slice(0, 200000);
result.cost_usd = last && last.total_cost_usd ? last.total_cost_usd : 0;
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 : {};
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);
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.
// 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
@@ -697,9 +706,9 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tm
files: result.files.map((f) => f.path),
files: result.files.map((f) => f.path),
adapters: proof,
adapters: proof,
author: { agent: job.agent, model: job.model },
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) {
} catch (e) {
say({ t: 'note', level: 'warning', text: `The proof could not be collected: ${scrub(e.message)}` });
say({ t: 'note', level: 'warning', text: `The proof could not be collected: ${scrub(e.message)}` });
result.proof = { items: [], outcome: null };
result.proof = { items: [], outcome: null };
new file mode 100644
new file mode 100644
@@ -0,0 +1,28 @@
@@ -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
+);
@@ -15,9 +15,9 @@
@@ -15,9 +15,9 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.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-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"
"pg": "^8.23.0"
},
},
"bin": {
"bin": {
@@ -1149,9 +1149,9 @@
@@ -1149,9 +1149,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -1193,9 +1193,9 @@
@@ -1193,9 +1193,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"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",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -30,9 +30,9 @@
@@ -30,9 +30,9 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.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-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"
"pg": "^8.23.0"
},
},
"engines": {
"engines": {
new file mode 100644
new file mode 100644
@@ -0,0 +1,93 @@
@@ -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 };
@@ -35,6 +35,7 @@ const { createConductorApi } = require('./http/conductor-api');
@@ -35,6 +35,7 @@ const { createConductorApi } = require('./http/conductor-api');
const { createRunClient } = require('./domain/run-client');
const { createRunClient } = require('./domain/run-client');
const { createRunTasks } = require('./domain/run-tasks');
const { createRunTasks } = require('./domain/run-tasks');
const { createConnections } = require('./domain/connections');
const { createConnections } = require('./domain/connections');
+const { createAiCatalog } = require('./domain/ai-catalog');
const { createRunTokens } = require('./domain/run-tokens');
const { createRunTokens } = require('./domain/run-tokens');
const { createAttempts } = require('./domain/attempts');
const { createAttempts } = require('./domain/attempts');
const { createBenchPublisher } = require('./domain/bench-publisher');
const { createBenchPublisher } = require('./domain/bench-publisher');
@@ -50,6 +51,7 @@ const { createMovedRoutes } = require('./http/moved');
@@ -50,6 +51,7 @@ const { createMovedRoutes } = require('./http/moved');
const { createCodesReadiness } = require('./observability');
const { createCodesReadiness } = require('./observability');
const { createActorLimits } = require('./http/actor-limits');
const { createActorLimits } = require('./http/actor-limits');
const { createForgejoClient } = require('./domain/forgejo');
const { createForgejoClient } = require('./domain/forgejo');
+const createAiCatalogConfig = require('./ai-catalog-config').create;
const { createMachines } = require('./domain/machines');
const { createMachines } = require('./domain/machines');
const { createMemory } = require('./domain/memory');
const { createMemory } = require('./domain/memory');
const { createFleet } = require('./domain/fleet');
const { createFleet } = require('./domain/fleet');
@@ -78,6 +80,9 @@ async function createApp(opts = {}) {
@@ -78,6 +80,9 @@ async function createApp(opts = {}) {
const store = opts.store || await openStore(config, { now: opts.now, log });
const store = opts.store || await openStore(config, { now: opts.now, log });
const keys = createKeyStore({ config, fetchImpl: fetchImpl || globalThis.fetch, 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 });
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.
// The harness catalog (server/data/harness-offers.json), read once: a bad seed row fails boot.
const harnesses = createHarnesses();
const harnesses = createHarnesses();
@@ -88,6 +93,9 @@ async function createApp(opts = {}) {
@@ -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
// 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.
// short-lived run token that names them.
const connections = opts.connections || createConnections({ config, fetchImpl: fetchImpl || globalThis.fetch, log });
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 });
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.
// 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 });
const attempts = createAttempts({ store, config, log, harnesses });
@@ -103,7 +111,7 @@ async function createApp(opts = {}) {
@@ -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 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 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 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 });
ctx.forgejo = createForgejoClient({ config, log });
// The C4b screens: the person's machines (from Network), their own choices, the GitHub reader and their usage.
// 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 });
ctx.machines = createMachines({ config, fetchImpl: fetchImpl || globalThis.fetch, log });
@@ -125,6 +133,7 @@ async function createApp(opts = {}) {
@@ -125,6 +133,7 @@ async function createApp(opts = {}) {
tasks.setConductor(ctx.conductor);
tasks.setConductor(ctx.conductor);
const app = express();
const app = express();
+ aiCatalogConfig.mount(app);
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
// 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.
// (openvibe-contracts); the operations they mark internal-only are left out.
app.get('/openapi.json', (_req, res) => {
app.get('/openapi.json', (_req, res) => {
@@ -10,6 +10,7 @@
@@ -10,6 +10,7 @@
require('dotenv').config();
require('dotenv').config();
const trim = (s) => String(s || '').replace(/\/+$/, '');
const trim = (s) => String(s || '').replace(/\/+$/, '');
const int = (v, def) => (Number.isFinite(parseInt(v, 10)) ? parseInt(v, 10) : def);
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) {
function load(env = process.env) {
const nodeEnv = env.NODE_ENV || 'development';
const nodeEnv = env.NODE_ENV || 'development';
@@ -82,6 +83,13 @@ function load(env = process.env) {
@@ -82,6 +83,13 @@ function load(env = process.env) {
// (and the harnesses' requests) with the second.
// (and the harnesses' requests) with the second.
scope: 'ai.credential.manage ai.run.create',
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
// 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.
// key and whose budget the run is. Two hours is the longest a coding attempt may run.
runTokens: {
runTokens: {
new file mode 100644
new file mode 100644
@@ -0,0 +1,145 @@
@@ -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 };
@@ -6,12 +6,19 @@
@@ -6,12 +6,19 @@
* and otherwise the agent's own default, which its CLI keeps current. The composer offers them as one control, "who
* 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).
* 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
* 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 { EFFORT_LEVELS } = require('../../engine/adapters/base');
+const OPENVIBE_AI = 'openvibe-ai';
+
const MODELS = {
const MODELS = {
'claude-code': [
'claude-code': [
{ id: 'opus', name: 'Opus', note: 'the strongest: hard changes, design, long work' },
{ id: 'opus', name: 'Opus', note: 'the strongest: hard changes, design, long work' },
@@ -33,9 +40,23 @@ function list(harnesses) {
@@ -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 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)) {
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) {
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 });
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) {
@@ -43,14 +64,17 @@ function choices(harnesses) {
return out;
return out;
}
}
-function parse(value, harnesses) {
+function parse(value, harnesses, { aiCatalog = false } = {}) {
const v = String(value || '');
const v = String(value || '');
if (!v) return { agent: null, model: null };
if (!v) return { agent: null, model: null };
const [agent, model = ''] = v.split('|');
const [agent, model = ''] = v.split('|');
const known = list(harnesses).find((a) => a.agent === agent);
const known = list(harnesses).find((a) => a.agent === agent);
if (!known) return null;
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;
if (model && !known.models.some((m) => m.id === model)) return null;
return { agent, model: model || null };
return { agent, model: model || null };
}
}
-module.exports = { MODELS, list, choices, parse };
+module.exports = { MODELS, OPENVIBE_AI, list, choices, parse };
new file mode 100644
new file mode 100644
@@ -0,0 +1,85 @@
@@ -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 };
@@ -126,15 +126,17 @@ function reasonOf(state, text) {
@@ -126,15 +126,17 @@ function reasonOf(state, text) {
function usageOf(result) {
function usageOf(result) {
const d = asObject(result) || {};
const d = asObject(result) || {};
const log = Array.isArray(d.log) ? d.log : [];
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) {
for (const line of log) {
if (!line || typeof line !== 'object') continue;
if (!line || typeof line !== 'object') continue;
if (line.t === 'usage') tokens += (Number(line.input_tokens) || 0) + (Number(line.output_tokens) || 0);
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 === 'tool') steps++;
else if (line.t === 'agent') {
else if (line.t === 'agent') {
if (!session && line.session) session = String(line.session);
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 {
return {
cost_usd: typeof d.cost_usd === 'number' && Number.isFinite(d.cost_usd) ? d.cost_usd : null,
cost_usd: typeof d.cost_usd === 'number' && Number.isFinite(d.cost_usd) ? d.cost_usd : null,
@@ -130,9 +130,17 @@ const repoLabel = (repo) => { try { return new URL(String(repo)).pathname.split(
@@ -130,9 +130,17 @@ const repoLabel = (repo) => { try { return new URL(String(repo)).pathname.split(
function createChatRoutes(ctx) {
function createChatRoutes(ctx) {
const r = asyncRouter();
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. */
/** 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());
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 db = store.db;
const form = express.urlencoded({ extended: false, limit: '32kb' });
const form = express.urlencoded({ extended: false, limit: '32kb' });
const viewer = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
const viewer = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
@@ -344,12 +352,26 @@ function createChatRoutes(ctx) {
@@ -344,12 +352,26 @@ function createChatRoutes(ctx) {
}
}
// ── The composer ─────────────────────────────────────────────────────────────────────────────────────────────
// ── 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();
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); }
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]);
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>`;
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) {
@@ -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 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 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 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).
// 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 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>`;
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) {
@@ -546,7 +569,7 @@ function createChatRoutes(ctx) {
repoUrl = start.normalizeRepo(b.repo);
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.');
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.');
if (!who) return back('Choose who works on it from the list.');
const agent = who.agent || '';
const agent = who.agent || '';
let args;
let args;
@@ -604,7 +627,7 @@ function createChatRoutes(ctx) {
@@ -604,7 +627,7 @@ function createChatRoutes(ctx) {
// The stream starts after the last line this page already shows.
// 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 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 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, {
return frame(req, res, {
currentId: row.id, title: titleOf(row), heading: titleOf(row), state, panel: await sideHtml(row),
currentId: row.id, title: titleOf(row), heading: titleOf(row), state, panel: await sideHtml(row),
turns: [...errorTurns, ...await taskThread(row)],
turns: [...errorTurns, ...await taskThread(row)],
@@ -625,7 +648,7 @@ function createChatRoutes(ctx) {
@@ -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]);
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');
if (!row) return res.status(404).send('Not found');
const b = req.body || {};
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.')}`);
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.
// The same agent with another model carries on; another agent takes over.
const agent = who.agent && who.agent !== row.agent ? who.agent : null;
const agent = who.agent && who.agent !== row.agent ? who.agent : null;
@@ -74,6 +74,7 @@ async function startNetworkStub() {
@@ -74,6 +74,7 @@ async function startNetworkStub() {
exempt: {
exempt: {
account_data_events: 'the ledger of requests itself: which export and deletion Codes applied',
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',
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(', ')}`);
assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,196 @@
@@ -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); });
@@ -10,7 +10,17 @@ const { boot, check, done } = require('./helpers/boot');
@@ -10,7 +10,17 @@ const { boot, check, done } = require('./helpers/boot');
(async () => {
(async () => {
const t = await boot();
const t = await boot();
const ada = t.network.addUser('ada');
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.
// The machine, as OpenVibe.Bot answers it: the page reads Bot and keeps the latest reading per machine.
t.bot.addMachine({
t.bot.addMachine({
id: 'dev_ada_laptop', node_id: 'nod_ada', name: 'ada-laptop', online: true, fleet_at: new Date().toISOString(),
id: 'dev_ada_laptop', node_id: 'nod_ada', name: 'ada-laptop', online: true, fleet_at: new Date().toISOString(),
@@ -22,7 +22,7 @@ const { check, done } = require('./helpers/boot');
@@ -22,7 +22,7 @@ const { check, done } = require('./helpers/boot');
assert.strictEqual(manifest.exposure.state, 'live');
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)
// 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).
// 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);
for (const id of manifest.capabilities) assert.strictEqual(contracts.capabilities.get(id).status, 'active', id);
const range = manifest.contractRanges['openvibe-contracts'];
const range = manifest.contractRanges['openvibe-contracts'];
assert.ok(require('openvibe-sdk/core').satisfiesRange(require('openvibe-contracts/package.json').version, range));
assert.ok(require('openvibe-sdk/core').satisfiesRange(require('openvibe-contracts/package.json').version, range));
@@ -64,6 +64,21 @@ const OK = async () => ({ status: 200, ok: true, json: async () => ({ choices: [
@@ -64,6 +64,21 @@ const OK = async () => ({ status: 200, ok: true, json: async () => ({ choices: [
assert.strictEqual(verdict.family, 'deepseek');
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 () => {
await check('a provider that speaks the messages dialect is tried when chat completions is not there', async () => {
const calls = [];
const calls = [];
const fetchImpl = async (url) => {
const fetchImpl = async (url) => {
@@ -86,6 +101,16 @@ const OK = async () => ({ status: 200, ok: true, json: async () => ({ choices: [
@@ -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 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');
console.log('review: a model of another family, one call to OpenVibe.AI, and a verdict read from what it says');
} catch (e) {
} catch (e) {
console.error(e);
console.error(e);
@@ -26,7 +26,7 @@ async function mockApi() {
@@ -26,7 +26,7 @@ async function mockApi() {
requests.push({ auth: req.headers.authorization || null, body: parsed });
requests.push({ auth: req.headers.authorization || null, body: parsed });
const step = script.shift();
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) { 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.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 } : {}) }));
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(
@@ -116,6 +116,42 @@ const toolResults = (events) => events.filter((e) => e.type === 'user').flatMap(
} finally { await new Promise((r) => network.close(r)); }
} 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 () => {
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' }]);
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 }));
const events = await collect(h.run({ prompt: 'look', agent: 'openai-compatible', cwd: work, permission: 'read', handoff: false }));
@@ -41,7 +41,7 @@ async function boot(opts = {}) {
@@ -41,7 +41,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
const { createStore } = require('../../server/db');
// One database per boot (PGlite, or CODES_TEST_STORE=pg: the containers), dropped when the boot closes.
// One database per boot (PGlite, or CODES_TEST_STORE=pg: the containers), dropped when the boot closes.
const testdb = await require('./db').testDb();
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();
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 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}`;
const base = `http://127.0.0.1:${server.address().port}`;
@@ -52,6 +52,7 @@ async function boot(opts = {}) {
@@ -52,6 +52,7 @@ async function boot(opts = {}) {
const headers = { ...(o.headers || {}) };
const headers = { ...(o.headers || {}) };
if (o.as) headers.cookie = [signIn(o.as), headers.cookie].filter(Boolean).join('; ');
if (o.as) headers.cookie = [signIn(o.as), headers.cookie].filter(Boolean).join('; ');
if (o.cookie) headers.cookie = o.cookie;
if (o.cookie) headers.cookie = o.cookie;
+ if (o.bearer) headers.authorization = `Bearer ${o.bearer}`;
let body = o.body;
let body = o.body;
if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }
if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }
if (o.form) {
if (o.form) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,89 @@
@@ -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); });
@@ -50,6 +50,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
@@ -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/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/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/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/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/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',
'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',