openvibers/openvibe.codes Commits
DeepSeek through OpenVibe.Exchange's compatible API (#384)
Changes
@@@ -49,6 +49,10 @@ OV_OAUTH_CLIENT_SECRET=#CODES_AI_CATALOG=0#CODES_AI_CATALOG_TTL_MS=300000++# OpenVibe.Exchange's compatible API: the deepseek harness's default path (a run token names the person; the same++# chat-completions API and model ids as OpenVibe.AI). CODES_EXCHANGE_URL overrides https://api.openvibe.exchange.++#CODES_EXCHANGE_URL=https://api.openvibe.exchange++# Goals and the independent verifier (AL1), off by default. With CODES_GOALS_ENABLED=1 a task may carry a goal and a# typed check; the harness runs the check in the task's workspace and a cheap fast model on OpenVibe.AI accepts or# rejects with a reason. CODES_GOALS_MODEL names that rung (default deepseek-chat).@@@ -65,7 +65,7 @@ More: [docs/cli.md](docs/cli.md).| OpenCode | `opencode run --format json` | `opencode` on PATH || Command Code | `cmd --print --output-format json` | `cmd` on PATH || Aider | `aider --message` | `aider` on PATH |--| DeepSeek | the harness's own agent loop ([harness/adapters/api.js](harness/adapters/api.js)) | `DEEPSEEK_API_KEY` |++| DeepSeek | the harness's own tool loop, through OpenVibe.Exchange's compatible API with the SDK's Exchange client ([harness/adapters/api.js](harness/adapters/api.js), `openvibe-sdk/exchange`) | `OPENVIBE_EXCHANGE_KEY` (an Exchange key from openvibe.exchange; a person's or the service's) or a Codes run token; `OPENVIBE_EXCHANGE_URL` overrides the endpoint and `OPENVIBE_EXCHANGE_MODEL` the model (the default is the Exchange's id for the agent's DeepSeek chat model, `deepseek/deepseek-flash`). The direct DeepSeek API is used only for a self-hosted run: `OPENVIBE_CODES_DEEPSEEK_DIRECT=1` and your own `DEEPSEEK_API_KEY` (with `DEEPSEEK_BASE_URL` for another compatible server) || OpenVibe.AI | the same loop against AI's `/v1/chat/completions`; each answer reports its cost | `OPENVIBE_APP_ID` + `OPENVIBE_APP_SECRET` (an app with `ai.app.run`; tokens are fetched and refreshed), or `OPENVIBE_AI_TOKEN`. App runs on AI's router use free capacity without tools, so name your project's own provider: `OPENVIBE_AI_SECRET` (a project secret granted to `ai` for reveal to the provider's origin), `OPENVIBE_AI_PROVIDER_URL` (default `https://api.openai.com/v1`), `OPENVIBE_AI_PROVIDER_MODEL`. The key stays in OpenVibe.Network, never on the machine || Your own model | the same loop against any OpenAI-compatible server | `OPENVIBE_CODES_BASE_URL`, `OPENVIBE_CODES_MODEL`, optional `OPENVIBE_CODES_API_KEY` |@@@ -54,9 +54,11 @@ const USAGE = `openvibe-codes ${VERSION} — code with any agent, from your termopenvibe-codes mcp the Codes MCP tools for a Claude Code session: tasks, the fleet,shipping status, and the conductor's approvals (stdio JSON-RPC)--Environment: DEEPSEEK_API_KEY · OPENVIBE_CODES_BASE_URL, OPENVIBE_CODES_MODEL, OPENVIBE_CODES_API_KEY--(your own OpenAI-compatible server) · OPENVIBE_CODES_HOME (where runs are kept) · OPENVIBE_TOKEN (your--Network token, for 'task') and OPENVIBE_CODES_URL (the server task talks to).++Environment: OPENVIBE_EXCHANGE_KEY (your Exchange key; the deepseek agent calls OpenVibe.Exchange by++default) · DEEPSEEK_API_KEY, OPENVIBE_CODES_DEEPSEEK_DIRECT=1 (your own DeepSeek key on a self-hosted run) ·++OPENVIBE_CODES_BASE_URL, OPENVIBE_CODES_MODEL, OPENVIBE_CODES_API_KEY (your own OpenAI-compatible server) ·++OPENVIBE_CODES_HOME (where runs are kept) · OPENVIBE_TOKEN (your Network token, for 'task') and++OPENVIBE_CODES_URL (the server task talks to).Guide: https://openvibe.codes/start`;let agentMode = false; // --for-agent: the final error handler answers in the same envelope@@@ -18,6 +18,7 @@const fs = require('fs');const path = require('path');const { spawn } = require('child_process');++const { createExchangeClient } = require('openvibe-sdk/exchange');const ev = require('../events');const SYSTEM = `You are a careful coding agent working in a repository on the user's machine. Use the tools to look before@@@ -309,6 +310,23 @@ function extraHeaders(env) {return out;}++/**++ * One turn through OpenVibe.Exchange's compatible API, using the SDK's Exchange client: the same Chat Completions body++ * and tool loop as the direct path, with the Exchange's key, timeout and receipt. `key` is the person's or the++ * service's Exchange key; `token(env, fetch)` names one minted for the request instead (a Network token).++ */++async function exchangeTurn(spec, { env, doFetch, payload, signal }) {++ const key = spec.exchange.token ? await spec.exchange.token(env, doFetch) : spec.exchange.key;++ if (!key) throw Object.assign(new Error('no Exchange key is connected'), { code: 'exchange.no_key' });++ const client = createExchangeClient({++ developerKey: String(key),++ baseUrl: String(spec.exchange.baseUrl || 'https://api.openvibe.exchange').replace(/\/+$/, ''),++ fetchImpl: doFetch,++ timeoutMs: 120_000,++ });++ return await client.chat(payload, { signal });++}++/*** 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.@@@ -324,7 +342,11 @@ function refusalOf(name, status, body, model) {}/**-- * apiAgent({ id, name, baseUrl, keyEnv, model, priceIn, priceOut, token }) → an adapter.++ * apiAgent({ id, name, baseUrl, keyEnv, model, priceIn, priceOut, token, exchange, available }) → an adapter.++ * exchange: { baseUrl, key } or { baseUrl, token(env, fetch) } → every turn goes through OpenVibe.Exchange's++ * compatible API with the SDK's Exchange client, instead of the direct fetch. mapModel(model, env) names the model++ * the transport should ask for (the Exchange has its own catalog ids for the same models). available(env) answers the catalog's++ * "can this run here, and why not" for an adapter whose credentials are resolved by the caller.* extra(env) → fields added to every request body (OpenVibe.AI's x_openvibe.provider: the project's own provider).* token(env, fetch) → a bearer, asked before every request (OpenVibe.AI takes a five-minute Network token, refreshed by* the function); without it the key is keyEnv's value. When the API reports each answer's cost (OpenVibe.AI's@@@ -333,13 +355,14 @@ function refusalOf(name, status, body, model) {*/function apiAgent(spec) {const adapter = {-- id: spec.id, name: spec.name, kind: 'api', baseUrl: spec.baseUrl, keyEnv: spec.keyEnv || null, defaultModel: spec.model,++ id: spec.id, name: spec.name, kind: 'api', baseUrl: spec.exchange ? spec.exchange.baseUrl : spec.baseUrl, keyEnv: spec.keyEnv || null, defaultModel: spec.model,++ ...(spec.available ? { available: spec.available } : {}),start(t, ctx = {}) {const env = ctx.env || process.env;const doFetch = ctx.fetch || globalThis.fetch;-- const base = String(t.baseUrl || spec.baseUrl).replace(/\/+$/, '');++ const base = String(t.baseUrl || spec.baseUrl || (spec.exchange && spec.exchange.baseUrl) || '').replace(/\/+$/, '');const key = spec.keyEnv ? env[spec.keyEnv] : (t.apiKey || '');-- const model = t.model || spec.model;++ const model = spec.mapModel ? spec.mapModel(t.model || spec.model, env) : (t.model || spec.model);const level = LEVELS[t.permission] || 1;// Tools in a fixed name order, and the system message above never carries the task: the request prefix a// provider caches (system + tools) is byte-identical from one task to the next, and only the prompt differs.@@@ -370,35 +393,57 @@ function apiAgent(spec) {// The model AI says ran, and its route disclosure (x_openvibe), recorded on the run's result.let resolvedModel = null;let disclosure = null;-- // The latest turn's AI run, advise asks against; and the advice spent on this task.++ // The latest turn's AI run, advise asks against; the advice spent on this task; and the Exchange's++ // receipt for the answer (x_openvibe.receipt_id), carried on the result for the check and the page.let adviceRunId = null;++ let receiptId = null;let adviceUsed = 0;try {while (turns < maxTurns && !cancelled) {turns++;-- const bearer = spec.token ? await spec.token(env, doFetch) : key;-- const res = await doFetch(`${base}/chat/completions`, {-- method: 'POST',-- headers: { 'content-type': 'application/json', ...(bearer ? { authorization: `Bearer ${bearer}` } : {}), ...extraHeaders(env) },-- body: JSON.stringify({ ...(spec.extra ? spec.extra(env) : {}), model, messages, tools, tool_choice: 'auto',-- // Codes L31/AI AL3: the reasoning level the caller asked for, and the option to ask-- // the AI router's advisor for guidance. AI ignores both when its own request does not-- // support them; a harness that has no setting never sees them.-- ...(t.effort ? { effort: t.effort } : {}), ...(t.advisor ? { advisor: t.advisor } : {}) }),-- signal: controller.signal,-- });-- const body = await res.json().catch(() => null);++ const payload = { ...(spec.extra ? spec.extra(env) : {}), model, messages, tools, tool_choice: 'auto',++ // Codes L31/AI AL3: the reasoning level the caller asked for, and the option to ask++ // the AI router's advisor for guidance. AI ignores both when its own request does not++ // support them; a harness that has no setting never sees them.++ ...(t.effort ? { effort: t.effort } : {}), ...(t.advisor ? { advisor: t.advisor } : {}) };++ let body;++ let res = null;++ if (spec.exchange) {++ // The default path: the Exchange's compatible API through the SDK's Exchange client (the++ // person's or the service's Exchange key), with the same tool loop as before.++ try {++ body = await exchangeTurn(spec, { env, doFetch, payload, signal: controller.signal });++ } catch (err) {++ failure = err.status++ ? `${spec.name} was refused by the Exchange (HTTP ${err.status}${err.code ? `, ${err.code}` : ''}): ${err.message}`++ : `${spec.name} could not reach the Exchange: ${err.message}`;++ yield ev.error(failure);++ break;++ }++ } else {++ const bearer = spec.token ? await spec.token(env, doFetch) : key;++ res = await doFetch(`${base}/chat/completions`, {++ method: 'POST',++ headers: { 'content-type': 'application/json', ...(bearer ? { authorization: `Bearer ${bearer}` } : {}), ...extraHeaders(env) },++ body: JSON.stringify(payload),++ signal: controller.signal,++ });++ 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);if (x.run_id) adviceRunId = String(x.run_id);++ if (x.receipt_id) receiptId = String(x.receipt_id);disclosure = { ...(disclosure || {}), ...x };}if (!adviceRunId && body && typeof body.id === 'string' && body.id.startsWith('chatcmpl-')) adviceRunId = body.id.slice('chatcmpl-'.length);-- if (!res.ok || !body || !Array.isArray(body.choices) || !body.choices[0]) {++ if ((res && !res.ok) || !body || !Array.isArray(body.choices) || !body.choices[0]) {// 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);++ failure = spec.exchange++ ? `${spec.name} answered without a completion${body && body.error ? `: ${String((body.error && (body.error.message || body.error)) || body.error).slice(0, 300)}` : ''}`++ : refusalOf(spec.name, res.status, body, model);yield ev.error(failure);break;}@@@ -472,6 +517,7 @@ function apiAgent(spec) {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: resolvedModel || model, startedAt, turns, costUsd: cost,...(resolvedModel ? { resolved_model: resolvedModel } : {}),++ ...(receiptId ? { receipt_id: receiptId } : {}),...(disclosure ? { disclosure } : {}),usage: ev.usage({ input: u.input - u.cacheRead, output: u.output, cacheRead: u.cacheRead }) });}());@@@ -482,4 +528,4 @@ function apiAgent(spec) {return adapter;}--module.exports = { apiAgent, refusalOf, extraHeaders, runTool, inside, refusedCommand, adviceTranscript, TOOL_DEFS, SYSTEM };++module.exports = { apiAgent, exchangeTurn, refusalOf, extraHeaders, runTool, inside, refusedCommand, adviceTranscript, TOOL_DEFS, SYSTEM };@@@ -53,6 +53,88 @@ function projectProvider(env) {return { x_openvibe: { provider: { base_url: env.OPENVIBE_AI_PROVIDER_URL || 'https://api.openai.com/v1', model: env.OPENVIBE_AI_PROVIDER_MODEL || 'gpt-5-mini', secret: env.OPENVIBE_AI_SECRET } } };}++/** The Exchange's compatible API. The SDK's Exchange client adds /v1/chat/completions to this origin. */++const EXCHANGE_URL = 'https://api.openvibe.exchange';++++const trimSlash = (s) => String(s || '').replace(/\/+$/, '');++const yes = (v) => /^(1|true|yes|on)$/i.test(String(v == null ? '' : v).trim());++++/** Is this address OpenVibe's own (the Exchange, or OpenVibe.AI behind it)? The direct DeepSeek path never names it. */++function platformHost(value) {++ try {++ const host = new URL(String(value)).hostname.toLowerCase();++ return ['openvibe.ai', 'openvibe.services', 'openvibe.exchange'].some((name) => host === name || host.endsWith(`.${name}`));++ } catch { return false; }++}++++/**++ * The Exchange key the deepseek agent sends with: the person's own Exchange key first, then the service's, then a++ * Network token minted from the app's client credentials (the way the openvibe-ai harness gets its bearer). A hosted++ * run's older environment (a Codes run token at AI as DEEPSEEK_BASE_URL/DEEPSEEK_API_KEY) is accepted as the person's++ * key too, unless the request names one of their own provider keys with OPENVIBE_CODES_HEADERS (that key rides a++ * header the SDK's Exchange client cannot add, so that run keeps the AI path).++ */++function exchangeKeyOf(env) {++ if (env.OPENVIBE_EXCHANGE_KEY) return { key: env.OPENVIBE_EXCHANGE_KEY };++ if (env.OPENVIBE_EXCHANGE_SERVICE_KEY) return { key: env.OPENVIBE_EXCHANGE_SERVICE_KEY };++ if (env.OPENVIBE_APP_ID && env.OPENVIBE_APP_SECRET) return { token: networkToken() };++ if (env.DEEPSEEK_API_KEY && platformHost(env.DEEPSEEK_BASE_URL) && !env.OPENVIBE_CODES_HEADERS) return { key: env.DEEPSEEK_API_KEY };++ return null;++}++++/**++ * The deepseek harness: OpenVibe.Exchange's compatible API by default, through the SDK's Exchange client, with the++ * same model id and tool loop. The direct DeepSeek API stays only for an operator's own key on a self-hosted run:++ *++ * - OPENVIBE_CODES_DEEPSEEK_DIRECT=1 with DEEPSEEK_API_KEY → always direct (their own key, their own endpoint);++ * - DEEPSEEK_API_KEY with no Exchange key and no DEEPSEEK_BASE_URL (or a non-OpenVibe one) → direct, the old++ * behaviour for a machine that runs DeepSeek with its own key;++ * - a request naming the person's own provider key (OPENVIBE_CODES_HEADERS) → direct to the platform address,++ * because that credential header cannot ride the Exchange client.++ * A bare hosted run token at OpenVibe.AI (DEEPSEEK_BASE_URL there, DEEPSEEK_API_KEY = run token) goes through++ * Exchange: api.openvibe.exchange speaks the same compatible API and takes the same person's token.++ */++/** The Exchange's catalog ids for the models the deepseek agent names (deepseek-chat is DeepSeek's own alias). */++const EXCHANGE_MODELS = { 'deepseek-chat': 'deepseek/deepseek-flash', 'deepseek-reasoner': 'deepseek/deepseek-v4-pro' };++/** A model the Exchange names itself (a catalog id or a dynamic alias) goes through as it is. */++const EXCHANGE_NAME = /^(openvibe(\/|$)|@preset\/)/;++++/** The model the Exchange should run: its own id for the agent's model, a catalog id as given, a dynamic alias as given. */++function exchangeModelOf(model, env) {++ const named = String((env && env.OPENVIBE_EXCHANGE_MODEL) || model || '').trim();++ if (EXCHANGE_NAME.test(named) || named.includes('/')) return named;++ if (EXCHANGE_MODELS[named]) return EXCHANGE_MODELS[named];++ return `deepseek/${named || 'deepseek-flash'}`;++}++++function deepseekAdapter(env) {++ const ownKey = env.DEEPSEEK_API_KEY;++ const ownBase = trimSlash(env.DEEPSEEK_BASE_URL);++ const forced = yes(env.OPENVIBE_CODES_DEEPSEEK_DIRECT);++ const ownCredential = Boolean(env.OPENVIBE_CODES_HEADERS);++ const exchange = ownCredential && !env.OPENVIBE_EXCHANGE_KEY && !env.OPENVIBE_EXCHANGE_SERVICE_KEY ? null : exchangeKeyOf(env);++ const direct = ownKey && (forced || ownCredential || (!exchange && (ownBase ? !platformHost(ownBase) : true)));++ if (direct) {++ return apiAgent({ id: 'deepseek', name: 'DeepSeek', baseUrl: ownBase || 'https://api.deepseek.com', keyEnv: 'DEEPSEEK_API_KEY', model: 'deepseek-chat', priceIn: 0.00027, priceOut: 0.0011 });++ }++ if (exchange) {++ const baseUrl = trimSlash(env.OPENVIBE_EXCHANGE_URL) || EXCHANGE_URL;++ return apiAgent({++ id: 'deepseek', name: 'DeepSeek', model: 'deepseek-chat',++ exchange: { baseUrl, ...exchange },++ // The same DeepSeek model under the Exchange's own catalog id (deepseek/deepseek-flash is DeepSeek's++ // V4.1 Flash, the model deepseek-chat names today); OPENVIBE_EXCHANGE_MODEL overrides it.++ mapModel: exchangeModelOf,++ priceIn: 0.00027, priceOut: 0.0011,++ available: () => ({ available: true, where: baseUrl }),++ });++ }++ return apiAgent({++ id: 'deepseek', name: 'DeepSeek', model: 'deepseek-chat', priceIn: 0.00027, priceOut: 0.0011,++ available: () => ({ available: false, reason: 'set OPENVIBE_EXCHANGE_KEY (an Exchange key from openvibe.exchange) for the default path, or DEEPSEEK_API_KEY and OPENVIBE_CODES_DEEPSEEK_DIRECT=1 for your own DeepSeek key' }),++ });++}++/** The adapter for each catalog harness (the catalog row's id). */function adaptersFor(env) {return {@@@ -65,7 +147,7 @@ function adaptersFor(env) {opencode: cli.opencode,'command-code': cli.commandCode,aider: cli.aider,-- deepseek: apiAgent({ id: 'deepseek', name: 'DeepSeek', baseUrl: env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com', keyEnv: 'DEEPSEEK_API_KEY', model: 'deepseek-chat', priceIn: 0.00027, priceOut: 0.0011 }),++ deepseek: deepseekAdapter(env),// OpenVibe.AI's OpenAI-compatible endpoint: its router picks the model, each answer reports its cost.// OPENVIBE_AI_SECRET names a project secret granted to ai (ADR-056): AI sends each request to the project's own// provider (OPENVIBE_AI_PROVIDER_URL, OPENVIBE_AI_PROVIDER_MODEL) with that key, revealed per request by Network.@@@ -98,6 +180,7 @@ function availability(adapter, env) {if (adapter.version) { const v = adapter.version(env); if (v) out.version = v; }return out;}++ if (typeof adapter.available === 'function') return adapter.available(env);if (adapter.id === 'openvibe-ai') return env.OPENVIBE_AI_TOKEN || (env.OPENVIBE_APP_ID && env.OPENVIBE_APP_SECRET) ? { available: true, where: adapter.baseUrl } : { available: false, reason: 'set OPENVIBE_APP_ID and OPENVIBE_APP_SECRET (an app of your project on openvibe.services with ai.app.run)' };if (adapter.id === 'openai-compatible') return env.OPENVIBE_CODES_BASE_URL ? { available: true, where: env.OPENVIBE_CODES_BASE_URL } : { available: false, reason: 'set OPENVIBE_CODES_BASE_URL (and OPENVIBE_CODES_MODEL) to your server' };return env[adapter.keyEnv] ? { available: true, where: adapter.baseUrl } : { available: false, reason: `set ${adapter.keyEnv}` };@@@ -267,4 +350,4 @@ function createHarness({ env = process.env, fetch: fetchImpl = globalThis.fetch,return { agents, route, run, store, adapters };}--module.exports = { createHarness, adaptersFor, availability, handoffNote, onPath };++module.exports = { createHarness, adaptersFor, availability, handoffNote, onPath, exchangeKeyOf, deepseekAdapter, exchangeModelOf, platformHost };@@@ -138,7 +138,10 @@ const ENV_NAMES = new Set(['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY', 'ANTHROPIC_CUSTOM_HEADERS', 'ANTHROPIC_MODEL','OPENAI_BASE_URL', 'OPENAI_API_KEY', 'OPENAI_CUSTOM_HEADERS','DEEPSEEK_BASE_URL', 'DEEPSEEK_API_KEY',-- 'OPENVIBE_AI_URL', 'OPENVIBE_AI_TOKEN', 'OPENVIBE_CODES_BASE_URL', 'OPENVIBE_CODES_API_KEY',++ 'OPENVIBE_AI_URL', 'OPENVIBE_AI_TOKEN',++ // The Exchange's compatible API (Codes C3's run token, or a person's Exchange key): the deepseek harness's default.++ 'OPENVIBE_EXCHANGE_URL', 'OPENVIBE_EXCHANGE_KEY', 'OPENVIBE_EXCHANGE_SERVICE_KEY', 'OPENVIBE_EXCHANGE_MODEL',++ 'OPENVIBE_CODES_BASE_URL', 'OPENVIBE_CODES_API_KEY','OPENVIBE_CODES_MODEL', 'OPENVIBE_CODES_HEADERS',// A repository on OpenVibe Git: the person's credential line for its host. The local root lets a session on the// same host clone from the disk (G3) instead of the network. For git only: bin/openvibe-codes.js keeps them out@@@ -86,6 +86,9 @@ function load(env = process.env) {// (and the harnesses' requests) with the second.scope: 'ai.credential.manage ai.run.create',},++ // OpenVibe.Exchange's compatible API: the deepseek harness's default path (openvibe-sdk/exchange). The run++ // token names the person; the Exchange speaks the same chat-completions API and the same model ids.++ exchange: { url: trim(env.CODES_EXCHANGE_URL || 'https://api.openvibe.exchange') },// 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.@@@ -100,8 +103,9 @@ function load(env = process.env) {// The harness env variable names a job may set (harness/job.js): the model APIs' own names, nothing else.envNames: ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_CUSTOM_HEADERS', 'OPENAI_BASE_URL','OPENAI_API_KEY', 'OPENAI_CUSTOM_HEADERS', 'DEEPSEEK_BASE_URL', 'DEEPSEEK_API_KEY', 'OPENVIBE_AI_URL',-- 'OPENVIBE_AI_TOKEN', 'OPENVIBE_CODES_BASE_URL', 'OPENVIBE_CODES_API_KEY', 'OPENVIBE_CODES_MODEL',-- 'OPENVIBE_CODES_HEADERS'],++ 'OPENVIBE_AI_TOKEN', 'OPENVIBE_EXCHANGE_URL', 'OPENVIBE_EXCHANGE_KEY', 'OPENVIBE_EXCHANGE_SERVICE_KEY',++ 'OPENVIBE_EXCHANGE_MODEL',++ 'OPENVIBE_CODES_BASE_URL', 'OPENVIBE_CODES_API_KEY', 'OPENVIBE_CODES_MODEL', 'OPENVIBE_CODES_HEADERS'],},bench: {url: trim(env.CODES_BENCH_URL || 'https://bench.openvibe.services'),@@@ -51,6 +51,11 @@ function aiInternal(config) {return String((config.ai && (config.ai.internalUrl || config.ai.url)) || 'https://ai.openvibe.services').replace(/\/+$/, '');}++/** The Exchange's compatible API: where the deepseek harness sends its turns (openvibe-sdk/exchange). */++function exchangeBase(config) {++ return String((config.exchange && config.exchange.url) || 'https://api.openvibe.exchange').replace(/\/+$/, '');++}++/*** The environment a harness of this kind gets: where to send its requests, the token to send, and (when the person* has a key for that provider) the header that says which key.@@@ -72,7 +77,14 @@ function envFor({ config, harness, token, provider, ownKey }) {env.OPENAI_CUSTOM_HEADERS = credential;env.OPENVIBE_CODES_HEADERS = JSON.stringify({ 'x-openvibe-credential': provider });}-- if (harness === 'deepseek') { env.DEEPSEEK_BASE_URL = `${base}/v1`; env.DEEPSEEK_API_KEY = token; }++ if (harness === 'deepseek') {++ // The default path: the Exchange's compatible API through the SDK's Exchange client, the run token as the++ // person's Exchange key. When the request names the person's own DeepSeek key (ownKey), that key rides the++ // x-openvibe-credential header, which the Exchange client cannot add: that attempt keeps the AI path below.++ if (!ownKey) { env.OPENVIBE_EXCHANGE_URL = exchangeBase(config); env.OPENVIBE_EXCHANGE_KEY = token; }++ // A harness older than the Exchange client still reads these names; it sends the same token to OpenVibe.AI.++ env.DEEPSEEK_BASE_URL = `${base}/v1`; env.DEEPSEEK_API_KEY = token;++ }if (harness === 'openvibe-ai') { env.OPENVIBE_AI_URL = `${base}/v1`; env.OPENVIBE_AI_TOKEN = token; }if (harness === 'openai-compatible') { env.OPENVIBE_CODES_BASE_URL = `${base}/v1`; env.OPENVIBE_CODES_API_KEY = token; }return env;nnew file mode 100644@@@ -0,0 +1,137 @@++'use strict';++/**++ * The deepseek harness's default path: its turns go to OpenVibe.Exchange's compatible API through the SDK's Exchange++ * client (openvibe-sdk/exchange), with the same model id and tool loop; no request reaches api.deepseek.com. The direct++ * DeepSeek API is used only for an operator's own key on a self-hosted run (OPENVIBE_CODES_DEEPSEEK_DIRECT=1), and the++ * Exchange's answer's receipt id and reported cost are carried on the run's result.++ */++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const { createHarness, exchangeKeyOf, exchangeModelOf, platformHost } = require('../harness');++const { createStore } = require('../harness/sessions');++const { check, done } = require('./helpers/boot');++++/** A stand-in Exchange: records every request; fails the test if the direct DeepSeek host is called on the default path. */++function mockExchange({ script = [] } = {}) {++ const requests = [];++ const fetchImpl = async (url, init = {}) => {++ const href = String(url);++ if (/api\.deepseek\.com/i.test(href)) throw new Error(`the direct DeepSeek API was called on the default path: ${href}`);++ let body = null;++ try { body = init.body ? JSON.parse(init.body) : null; } catch { body = null; }++ const headers = Object.fromEntries(Object.entries(init.headers || {}).map(([k, v]) => [k.toLowerCase(), v]));++ requests.push({ url: href, headers, body: body && JSON.parse(JSON.stringify(body)) });++ const step = script.length ? script.shift() : null;++ if (step && step.status) {++ return new Response(JSON.stringify(step.body || { error: { message: 'refused' } }), { status: step.status, headers: { 'content-type': 'application/json' } });++ }++ const message = step && step.calls++ ? { role: 'assistant', content: '', tool_calls: step.calls.map((c, i) => ({ id: `call_${requests.length}_${i}`, type: 'function', function: { name: c[0], arguments: JSON.stringify(c[1]) } })) }++ : { role: 'assistant', content: (step && step.content) || 'all done' };++ return new Response(JSON.stringify({++ id: 'chatcmpl-run_test', object: 'chat.completion',++ choices: [{ index: 0, message, finish_reason: 'stop' }],++ usage: { prompt_tokens: 1000, completion_tokens: 100, prompt_cache_hit_tokens: 400 },++ x_openvibe: { run_id: 'run_test', model: 'deepseek-chat', cost_usd: 0.00042, receipt_id: 'rcpt_test' },++ }), { status: 200, headers: { 'content-type': 'application/json' } });++ };++ return { requests, fetchImpl };++}++++const collect = async (run) => { const out = []; for await (const e of run) out.push(e); return out; };++++(async () => {++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codes-exchange-'));++ const work = path.join(dir, 'work');++ fs.mkdirSync(path.join(work, 'src'), { recursive: true });++ fs.writeFileSync(path.join(work, 'src', 'a.js'), 'const name = 1;\n');++ const baseEnv = { PATH: '/nonexistent', OPENVIBE_CODES_HOME: path.join(dir, 'home') };++ const EXCHANGE_KEY = `ovx_${'a'.repeat(43)}`;++++ await check('the default path sends the turn to OpenVibe.Exchange through the SDK client, never to api.deepseek.com', async () => {++ const api = mockExchange({ script: [++ { calls: [['read_file', { path: 'src/a.js' }]] },++ { content: 'It exports name.' },++ ] });++ const env = { ...baseEnv, OPENVIBE_EXCHANGE_KEY: EXCHANGE_KEY };++ const h = createHarness({ env, fetch: api.fetchImpl, store: createStore({ env }) });++ assert.strictEqual(h.agents().find((a) => a.id === 'deepseek').available, true);++ const events = await collect(h.run({ prompt: 'what does a.js export?', agent: 'deepseek', cwd: work, permission: 'read', handoff: false }));++ const r = events.find((e) => e.type === 'result');++ assert.strictEqual(r.is_error, false, r.result);++ assert.strictEqual(r.result, 'It exports name.');++ assert.strictEqual(api.requests.length, 2, 'the tool loop ran both turns');++ assert.strictEqual(api.requests[0].url, 'https://api.openvibe.exchange/v1/chat/completions');++ assert.strictEqual(api.requests[0].headers.authorization, `Bearer ${EXCHANGE_KEY}`);++ assert.strictEqual(api.requests[0].body.model, 'deepseek/deepseek-flash', 'the Exchange\'s id for the same DeepSeek model');++ assert.ok(api.requests[0].body.tools.some((t) => t.function.name === 'read_file'), 'the same tools');++ assert.ok(api.requests[1].body.messages.some((m) => m.role === 'tool' && m.content.includes('const name = 1;')), 'the tool result went back to the model');++ assert.strictEqual(r.receipt_id, 'rcpt_test', 'the Exchange receipt is on the result');++ assert.strictEqual(r.total_cost_usd, 0.00084, 'the reported costs are summed');++ assert.deepStrictEqual([r.usage.input_tokens, r.usage.cache_read_input_tokens, r.usage.output_tokens], [1200, 800, 200]);++ });++++ await check('the agent\'s model ids map to the Exchange\'s catalog ids, and an Exchange id is not mapped again', () => {++ assert.strictEqual(exchangeModelOf('deepseek-chat'), 'deepseek/deepseek-flash');++ assert.strictEqual(exchangeModelOf('deepseek-reasoner'), 'deepseek/deepseek-v4-pro');++ assert.strictEqual(exchangeModelOf('deepseek/deepseek-v4-pro'), 'deepseek/deepseek-v4-pro');++ assert.strictEqual(exchangeModelOf('openvibe'), 'openvibe');++ assert.strictEqual(exchangeModelOf('deepseek-chat', { OPENVIBE_EXCHANGE_MODEL: 'openvibe/auto' }), 'openvibe/auto');++ });++++ await check('a service Exchange key is the fallback, and the Exchange URL can be pointed elsewhere', async () => {++ const api = mockExchange({ script: [{ content: 'ok' }] });++ const env = { ...baseEnv, OPENVIBE_EXCHANGE_SERVICE_KEY: `ovx_${'b'.repeat(43)}`, OPENVIBE_EXCHANGE_URL: 'https://exchange.test' };++ const h = createHarness({ env, fetch: api.fetchImpl, store: createStore({ env }) });++ const events = await collect(h.run({ prompt: 'say ok', agent: 'deepseek', cwd: work, permission: 'read', handoff: false }));++ assert.strictEqual(events.find((e) => e.type === 'result').is_error, false);++ assert.strictEqual(api.requests[0].url, 'https://exchange.test/v1/chat/completions');++ assert.strictEqual(api.requests[0].headers.authorization, `Bearer ovx_${'b'.repeat(43)}`);++ });++++ await check('the direct DeepSeek API is used only with OPENVIBE_CODES_DEEPSEEK_DIRECT=1 and an operator key', async () => {++ const requests = [];++ const fetchImpl = async (url, init = {}) => {++ requests.push({ url: String(url), headers: { ...(init.headers || {}) } });++ return new Response(JSON.stringify({ choices: [{ message: { role: 'assistant', content: 'direct' } }], usage: { prompt_tokens: 1, completion_tokens: 1 } }), { status: 200, headers: { 'content-type': 'application/json' } });++ };++ const env = { ...baseEnv, OPENVIBE_CODES_DEEPSEEK_DIRECT: '1', DEEPSEEK_API_KEY: 'sk-own-key', DEEPSEEK_BASE_URL: 'https://api.deepseek.com', OPENVIBE_EXCHANGE_KEY: EXCHANGE_KEY };++ const h = createHarness({ env, fetch: fetchImpl, store: createStore({ env }) });++ const events = await collect(h.run({ prompt: 'say direct', agent: 'deepseek', cwd: work, permission: 'read', handoff: false }));++ assert.strictEqual(events.find((e) => e.type === 'result').is_error, false);++ assert.strictEqual(requests.length, 1);++ assert.strictEqual(requests[0].url, 'https://api.deepseek.com/chat/completions');++ assert.strictEqual(requests[0].headers.authorization, 'Bearer sk-own-key');++ });++++ await check('a hosted deepseek attempt gets the Exchange pair with the run token, and keeps the old pair for older harnesses', () => {++ const { envFor } = require('../server/domain/run-tokens');++ const config = { ai: { url: 'https://ai.openvibe.services' }, exchange: { url: 'https://api.openvibe.exchange' } };++ const env = envFor({ config, harness: 'deepseek', token: 'run-token', provider: 'deepseek', ownKey: false });++ assert.strictEqual(env.OPENVIBE_EXCHANGE_URL, 'https://api.openvibe.exchange');++ assert.strictEqual(env.OPENVIBE_EXCHANGE_KEY, 'run-token');++ assert.strictEqual(env.DEEPSEEK_BASE_URL, 'https://ai.openvibe.services/v1', 'an older harness still reads these');++ assert.strictEqual(env.DEEPSEEK_API_KEY, 'run-token');++ // The person's own DeepSeek key names itself in OPENVIBE_CODES_HEADERS, which the SDK client cannot send:++ // that attempt keeps the AI path, so the header is never dropped.++ const own = envFor({ config, harness: 'deepseek', token: 'run-token', provider: 'deepseek', ownKey: true });++ assert.strictEqual(own.OPENVIBE_EXCHANGE_KEY, undefined);++ assert.strictEqual(own.OPENVIBE_EXCHANGE_URL, undefined);++ assert.strictEqual(own.DEEPSEEK_API_KEY, 'run-token');++ });++++ await check('without an Exchange key or an own DeepSeek key the agent is unavailable, and the reason names both', () => {++ const h = createHarness({ env: baseEnv, store: createStore({ env: baseEnv }) });++ const ds = h.agents().find((a) => a.id === 'deepseek');++ assert.strictEqual(ds.available, false);++ assert.match(ds.reason, /OPENVIBE_EXCHANGE_KEY/);++ assert.match(ds.reason, /DEEPSEEK_API_KEY/);++ assert.strictEqual(exchangeKeyOf(baseEnv), null);++ assert.strictEqual(platformHost('https://ai.openvibe.services/v1'), true);++ assert.strictEqual(platformHost('https://api.deepseek.com'), false);++ });++++ done();++})().catch((e) => { console.error(e); process.exit(1); });
@@ -49,6 +49,10 @@ OV_OAUTH_CLIENT_SECRET=
@@ -49,6 +49,10 @@ OV_OAUTH_CLIENT_SECRET=
#CODES_AI_CATALOG=0
#CODES_AI_CATALOG=0
#CODES_AI_CATALOG_TTL_MS=300000
#CODES_AI_CATALOG_TTL_MS=300000
+# OpenVibe.Exchange's compatible API: the deepseek harness's default path (a run token names the person; the same
+# chat-completions API and model ids as OpenVibe.AI). CODES_EXCHANGE_URL overrides https://api.openvibe.exchange.
+#CODES_EXCHANGE_URL=https://api.openvibe.exchange
+
# Goals and the independent verifier (AL1), off by default. With CODES_GOALS_ENABLED=1 a task may carry a goal and a
# Goals and the independent verifier (AL1), off by default. With CODES_GOALS_ENABLED=1 a task may carry a goal and a
# typed check; the harness runs the check in the task's workspace and a cheap fast model on OpenVibe.AI accepts or
# typed check; the harness runs the check in the task's workspace and a cheap fast model on OpenVibe.AI accepts or
# rejects with a reason. CODES_GOALS_MODEL names that rung (default deepseek-chat).
# rejects with a reason. CODES_GOALS_MODEL names that rung (default deepseek-chat).
@@ -65,7 +65,7 @@ More: [docs/cli.md](docs/cli.md).
@@ -65,7 +65,7 @@ More: [docs/cli.md](docs/cli.md).
| OpenCode | `opencode run --format json` | `opencode` on PATH |
| OpenCode | `opencode run --format json` | `opencode` on PATH |
| Command Code | `cmd --print --output-format json` | `cmd` on PATH |
| Command Code | `cmd --print --output-format json` | `cmd` on PATH |
| Aider | `aider --message` | `aider` on PATH |
| Aider | `aider --message` | `aider` on PATH |
-| DeepSeek | the harness's own agent loop ([harness/adapters/api.js](harness/adapters/api.js)) | `DEEPSEEK_API_KEY` |
+| DeepSeek | the harness's own tool loop, through OpenVibe.Exchange's compatible API with the SDK's Exchange client ([harness/adapters/api.js](harness/adapters/api.js), `openvibe-sdk/exchange`) | `OPENVIBE_EXCHANGE_KEY` (an Exchange key from openvibe.exchange; a person's or the service's) or a Codes run token; `OPENVIBE_EXCHANGE_URL` overrides the endpoint and `OPENVIBE_EXCHANGE_MODEL` the model (the default is the Exchange's id for the agent's DeepSeek chat model, `deepseek/deepseek-flash`). The direct DeepSeek API is used only for a self-hosted run: `OPENVIBE_CODES_DEEPSEEK_DIRECT=1` and your own `DEEPSEEK_API_KEY` (with `DEEPSEEK_BASE_URL` for another compatible server) |
| OpenVibe.AI | the same loop against AI's `/v1/chat/completions`; each answer reports its cost | `OPENVIBE_APP_ID` + `OPENVIBE_APP_SECRET` (an app with `ai.app.run`; tokens are fetched and refreshed), or `OPENVIBE_AI_TOKEN`. App runs on AI's router use free capacity without tools, so name your project's own provider: `OPENVIBE_AI_SECRET` (a project secret granted to `ai` for reveal to the provider's origin), `OPENVIBE_AI_PROVIDER_URL` (default `https://api.openai.com/v1`), `OPENVIBE_AI_PROVIDER_MODEL`. The key stays in OpenVibe.Network, never on the machine |
| OpenVibe.AI | the same loop against AI's `/v1/chat/completions`; each answer reports its cost | `OPENVIBE_APP_ID` + `OPENVIBE_APP_SECRET` (an app with `ai.app.run`; tokens are fetched and refreshed), or `OPENVIBE_AI_TOKEN`. App runs on AI's router use free capacity without tools, so name your project's own provider: `OPENVIBE_AI_SECRET` (a project secret granted to `ai` for reveal to the provider's origin), `OPENVIBE_AI_PROVIDER_URL` (default `https://api.openai.com/v1`), `OPENVIBE_AI_PROVIDER_MODEL`. The key stays in OpenVibe.Network, never on the machine |
| Your own model | the same loop against any OpenAI-compatible server | `OPENVIBE_CODES_BASE_URL`, `OPENVIBE_CODES_MODEL`, optional `OPENVIBE_CODES_API_KEY` |
| Your own model | the same loop against any OpenAI-compatible server | `OPENVIBE_CODES_BASE_URL`, `OPENVIBE_CODES_MODEL`, optional `OPENVIBE_CODES_API_KEY` |
@@ -54,9 +54,11 @@ const USAGE = `openvibe-codes ${VERSION} — code with any agent, from your term
@@ -54,9 +54,11 @@ const USAGE = `openvibe-codes ${VERSION} — code with any agent, from your term
openvibe-codes mcp the Codes MCP tools for a Claude Code session: tasks, the fleet,
openvibe-codes mcp the Codes MCP tools for a Claude Code session: tasks, the fleet,
shipping status, and the conductor's approvals (stdio JSON-RPC)
shipping status, and the conductor's approvals (stdio JSON-RPC)
-Environment: DEEPSEEK_API_KEY · OPENVIBE_CODES_BASE_URL, OPENVIBE_CODES_MODEL, OPENVIBE_CODES_API_KEY
-(your own OpenAI-compatible server) · OPENVIBE_CODES_HOME (where runs are kept) · OPENVIBE_TOKEN (your
-Network token, for 'task') and OPENVIBE_CODES_URL (the server task talks to).
+Environment: OPENVIBE_EXCHANGE_KEY (your Exchange key; the deepseek agent calls OpenVibe.Exchange by
+default) · DEEPSEEK_API_KEY, OPENVIBE_CODES_DEEPSEEK_DIRECT=1 (your own DeepSeek key on a self-hosted run) ·
+OPENVIBE_CODES_BASE_URL, OPENVIBE_CODES_MODEL, OPENVIBE_CODES_API_KEY (your own OpenAI-compatible server) ·
+OPENVIBE_CODES_HOME (where runs are kept) · OPENVIBE_TOKEN (your Network token, for 'task') and
+OPENVIBE_CODES_URL (the server task talks to).
Guide: https://openvibe.codes/start`;
Guide: https://openvibe.codes/start`;
let agentMode = false; // --for-agent: the final error handler answers in the same envelope
let agentMode = false; // --for-agent: the final error handler answers in the same envelope
@@ -18,6 +18,7 @@
@@ -18,6 +18,7 @@
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
const { spawn } = require('child_process');
const { spawn } = require('child_process');
+const { createExchangeClient } = require('openvibe-sdk/exchange');
const ev = require('../events');
const ev = require('../events');
const SYSTEM = `You are a careful coding agent working in a repository on the user's machine. Use the tools to look before
const SYSTEM = `You are a careful coding agent working in a repository on the user's machine. Use the tools to look before
@@ -309,6 +310,23 @@ function extraHeaders(env) {
@@ -309,6 +310,23 @@ function extraHeaders(env) {
return out;
return out;
}
}
+/**
+ * One turn through OpenVibe.Exchange's compatible API, using the SDK's Exchange client: the same Chat Completions body
+ * and tool loop as the direct path, with the Exchange's key, timeout and receipt. `key` is the person's or the
+ * service's Exchange key; `token(env, fetch)` names one minted for the request instead (a Network token).
+ */
+async function exchangeTurn(spec, { env, doFetch, payload, signal }) {
+ const key = spec.exchange.token ? await spec.exchange.token(env, doFetch) : spec.exchange.key;
+ if (!key) throw Object.assign(new Error('no Exchange key is connected'), { code: 'exchange.no_key' });
+ const client = createExchangeClient({
+ developerKey: String(key),
+ baseUrl: String(spec.exchange.baseUrl || 'https://api.openvibe.exchange').replace(/\/+$/, ''),
+ fetchImpl: doFetch,
+ timeoutMs: 120_000,
+ });
+ return await client.chat(payload, { signal });
+}
+
/**
/**
* The API's refusal in the person's words. An exact id AI refused keeps its own code (ai.model_not_found,
* 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.
* ai.model_unavailable): the reason a chosen model did not run is shown as that, never as a different model's answer.
@@ -324,7 +342,11 @@ function refusalOf(name, status, body, model) {
@@ -324,7 +342,11 @@ function refusalOf(name, status, body, model) {
}
}
/**
/**
- * apiAgent({ id, name, baseUrl, keyEnv, model, priceIn, priceOut, token }) → an adapter.
+ * apiAgent({ id, name, baseUrl, keyEnv, model, priceIn, priceOut, token, exchange, available }) → an adapter.
+ * exchange: { baseUrl, key } or { baseUrl, token(env, fetch) } → every turn goes through OpenVibe.Exchange's
+ * compatible API with the SDK's Exchange client, instead of the direct fetch. mapModel(model, env) names the model
+ * the transport should ask for (the Exchange has its own catalog ids for the same models). available(env) answers the catalog's
+ * "can this run here, and why not" for an adapter whose credentials are resolved by the caller.
* 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).
* token(env, fetch) → a bearer, asked before every request (OpenVibe.AI takes a five-minute Network token, refreshed by
* token(env, fetch) → a bearer, asked before every request (OpenVibe.AI takes a five-minute Network token, refreshed by
* the function); without it the key is keyEnv's value. When the API reports each answer's cost (OpenVibe.AI's
* the function); without it the key is keyEnv's value. When the API reports each answer's cost (OpenVibe.AI's
@@ -333,13 +355,14 @@ function refusalOf(name, status, body, model) {
@@ -333,13 +355,14 @@ function refusalOf(name, status, body, model) {
*/
*/
function apiAgent(spec) {
function apiAgent(spec) {
const adapter = {
const adapter = {
- id: spec.id, name: spec.name, kind: 'api', baseUrl: spec.baseUrl, keyEnv: spec.keyEnv || null, defaultModel: spec.model,
+ id: spec.id, name: spec.name, kind: 'api', baseUrl: spec.exchange ? spec.exchange.baseUrl : spec.baseUrl, keyEnv: spec.keyEnv || null, defaultModel: spec.model,
+ ...(spec.available ? { available: spec.available } : {}),
start(t, ctx = {}) {
start(t, ctx = {}) {
const env = ctx.env || process.env;
const env = ctx.env || process.env;
const doFetch = ctx.fetch || globalThis.fetch;
const doFetch = ctx.fetch || globalThis.fetch;
- const base = String(t.baseUrl || spec.baseUrl).replace(/\/+$/, '');
+ const base = String(t.baseUrl || spec.baseUrl || (spec.exchange && spec.exchange.baseUrl) || '').replace(/\/+$/, '');
const key = spec.keyEnv ? env[spec.keyEnv] : (t.apiKey || '');
const key = spec.keyEnv ? env[spec.keyEnv] : (t.apiKey || '');
- const model = t.model || spec.model;
+ const model = spec.mapModel ? spec.mapModel(t.model || spec.model, env) : (t.model || spec.model);
const level = LEVELS[t.permission] || 1;
const level = LEVELS[t.permission] || 1;
// Tools in a fixed name order, and the system message above never carries the task: the request prefix a
// Tools in a fixed name order, and the system message above never carries the task: the request prefix a
// provider caches (system + tools) is byte-identical from one task to the next, and only the prompt differs.
// provider caches (system + tools) is byte-identical from one task to the next, and only the prompt differs.
@@ -370,35 +393,57 @@ function apiAgent(spec) {
@@ -370,35 +393,57 @@ function apiAgent(spec) {
// The model AI says ran, and its route disclosure (x_openvibe), recorded on the run's result.
// The model AI says ran, and its route disclosure (x_openvibe), recorded on the run's result.
let resolvedModel = null;
let resolvedModel = null;
let disclosure = null;
let disclosure = null;
- // The latest turn's AI run, advise asks against; and the advice spent on this task.
+ // The latest turn's AI run, advise asks against; the advice spent on this task; and the Exchange's
+ // receipt for the answer (x_openvibe.receipt_id), carried on the result for the check and the page.
let adviceRunId = null;
let adviceRunId = null;
+ let receiptId = null;
let adviceUsed = 0;
let adviceUsed = 0;
try {
try {
while (turns < maxTurns && !cancelled) {
while (turns < maxTurns && !cancelled) {
turns++;
turns++;
- const bearer = spec.token ? await spec.token(env, doFetch) : key;
- const res = await doFetch(`${base}/chat/completions`, {
- method: 'POST',
- headers: { 'content-type': 'application/json', ...(bearer ? { authorization: `Bearer ${bearer}` } : {}), ...extraHeaders(env) },
- body: JSON.stringify({ ...(spec.extra ? spec.extra(env) : {}), model, messages, tools, tool_choice: 'auto',
- // Codes L31/AI AL3: the reasoning level the caller asked for, and the option to ask
- // the AI router's advisor for guidance. AI ignores both when its own request does not
- // support them; a harness that has no setting never sees them.
- ...(t.effort ? { effort: t.effort } : {}), ...(t.advisor ? { advisor: t.advisor } : {}) }),
- signal: controller.signal,
- });
- const body = await res.json().catch(() => null);
+ const payload = { ...(spec.extra ? spec.extra(env) : {}), model, messages, tools, tool_choice: 'auto',
+ // Codes L31/AI AL3: the reasoning level the caller asked for, and the option to ask
+ // the AI router's advisor for guidance. AI ignores both when its own request does not
+ // support them; a harness that has no setting never sees them.
+ ...(t.effort ? { effort: t.effort } : {}), ...(t.advisor ? { advisor: t.advisor } : {}) };
+ let body;
+ let res = null;
+ if (spec.exchange) {
+ // The default path: the Exchange's compatible API through the SDK's Exchange client (the
+ // person's or the service's Exchange key), with the same tool loop as before.
+ try {
+ body = await exchangeTurn(spec, { env, doFetch, payload, signal: controller.signal });
+ } catch (err) {
+ failure = err.status
+ ? `${spec.name} was refused by the Exchange (HTTP ${err.status}${err.code ? `, ${err.code}` : ''}): ${err.message}`
+ : `${spec.name} could not reach the Exchange: ${err.message}`;
+ yield ev.error(failure);
+ break;
+ }
+ } else {
+ const bearer = spec.token ? await spec.token(env, doFetch) : key;
+ res = await doFetch(`${base}/chat/completions`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', ...(bearer ? { authorization: `Bearer ${bearer}` } : {}), ...extraHeaders(env) },
+ body: JSON.stringify(payload),
+ signal: controller.signal,
+ });
+ body = await res.json().catch(() => null);
+ }
const x = body && body.x_openvibe && typeof body.x_openvibe === 'object' ? body.x_openvibe : null;
const x = body && body.x_openvibe && typeof body.x_openvibe === 'object' ? body.x_openvibe : null;
if (x) {
if (x) {
if (x.resolved_model) resolvedModel = String(x.resolved_model);
if (x.resolved_model) resolvedModel = String(x.resolved_model);
if (x.run_id) adviceRunId = String(x.run_id);
if (x.run_id) adviceRunId = String(x.run_id);
+ if (x.receipt_id) receiptId = String(x.receipt_id);
disclosure = { ...(disclosure || {}), ...x };
disclosure = { ...(disclosure || {}), ...x };
}
}
if (!adviceRunId && body && typeof body.id === 'string' && body.id.startsWith('chatcmpl-')) adviceRunId = body.id.slice('chatcmpl-'.length);
if (!adviceRunId && body && typeof body.id === 'string' && body.id.startsWith('chatcmpl-')) adviceRunId = body.id.slice('chatcmpl-'.length);
- if (!res.ok || !body || !Array.isArray(body.choices) || !body.choices[0]) {
+ if ((res && !res.ok) || !body || !Array.isArray(body.choices) || !body.choices[0]) {
// An exact id AI refused (ai.model_not_found / ai.model_unavailable) is its own answer:
// 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.
// it is reported as it is, and this loop never asks for a different model instead.
- failure = refusalOf(spec.name, res.status, body, model);
+ failure = spec.exchange
+ ? `${spec.name} answered without a completion${body && body.error ? `: ${String((body.error && (body.error.message || body.error)) || body.error).slice(0, 300)}` : ''}`
+ : refusalOf(spec.name, res.status, body, model);
yield ev.error(failure);
yield ev.error(failure);
break;
break;
}
}
@@ -472,6 +517,7 @@ function apiAgent(spec) {
@@ -472,6 +517,7 @@ function apiAgent(spec) {
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: resolvedModel || 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 } : {}),
...(resolvedModel ? { resolved_model: resolvedModel } : {}),
+ ...(receiptId ? { receipt_id: receiptId } : {}),
...(disclosure ? { disclosure } : {}),
...(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 }) });
}());
}());
@@ -482,4 +528,4 @@ function apiAgent(spec) {
@@ -482,4 +528,4 @@ function apiAgent(spec) {
return adapter;
return adapter;
}
}
-module.exports = { apiAgent, refusalOf, extraHeaders, runTool, inside, refusedCommand, adviceTranscript, TOOL_DEFS, SYSTEM };
+module.exports = { apiAgent, exchangeTurn, refusalOf, extraHeaders, runTool, inside, refusedCommand, adviceTranscript, TOOL_DEFS, SYSTEM };
@@ -53,6 +53,88 @@ function projectProvider(env) {
@@ -53,6 +53,88 @@ function projectProvider(env) {
return { x_openvibe: { provider: { base_url: env.OPENVIBE_AI_PROVIDER_URL || 'https://api.openai.com/v1', model: env.OPENVIBE_AI_PROVIDER_MODEL || 'gpt-5-mini', secret: env.OPENVIBE_AI_SECRET } } };
return { x_openvibe: { provider: { base_url: env.OPENVIBE_AI_PROVIDER_URL || 'https://api.openai.com/v1', model: env.OPENVIBE_AI_PROVIDER_MODEL || 'gpt-5-mini', secret: env.OPENVIBE_AI_SECRET } } };
}
}
+/** The Exchange's compatible API. The SDK's Exchange client adds /v1/chat/completions to this origin. */
+const EXCHANGE_URL = 'https://api.openvibe.exchange';
+
+const trimSlash = (s) => String(s || '').replace(/\/+$/, '');
+const yes = (v) => /^(1|true|yes|on)$/i.test(String(v == null ? '' : v).trim());
+
+/** Is this address OpenVibe's own (the Exchange, or OpenVibe.AI behind it)? The direct DeepSeek path never names it. */
+function platformHost(value) {
+ try {
+ const host = new URL(String(value)).hostname.toLowerCase();
+ return ['openvibe.ai', 'openvibe.services', 'openvibe.exchange'].some((name) => host === name || host.endsWith(`.${name}`));
+ } catch { return false; }
+}
+
+/**
+ * The Exchange key the deepseek agent sends with: the person's own Exchange key first, then the service's, then a
+ * Network token minted from the app's client credentials (the way the openvibe-ai harness gets its bearer). A hosted
+ * run's older environment (a Codes run token at AI as DEEPSEEK_BASE_URL/DEEPSEEK_API_KEY) is accepted as the person's
+ * key too, unless the request names one of their own provider keys with OPENVIBE_CODES_HEADERS (that key rides a
+ * header the SDK's Exchange client cannot add, so that run keeps the AI path).
+ */
+function exchangeKeyOf(env) {
+ if (env.OPENVIBE_EXCHANGE_KEY) return { key: env.OPENVIBE_EXCHANGE_KEY };
+ if (env.OPENVIBE_EXCHANGE_SERVICE_KEY) return { key: env.OPENVIBE_EXCHANGE_SERVICE_KEY };
+ if (env.OPENVIBE_APP_ID && env.OPENVIBE_APP_SECRET) return { token: networkToken() };
+ if (env.DEEPSEEK_API_KEY && platformHost(env.DEEPSEEK_BASE_URL) && !env.OPENVIBE_CODES_HEADERS) return { key: env.DEEPSEEK_API_KEY };
+ return null;
+}
+
+/**
+ * The deepseek harness: OpenVibe.Exchange's compatible API by default, through the SDK's Exchange client, with the
+ * same model id and tool loop. The direct DeepSeek API stays only for an operator's own key on a self-hosted run:
+ *
+ * - OPENVIBE_CODES_DEEPSEEK_DIRECT=1 with DEEPSEEK_API_KEY → always direct (their own key, their own endpoint);
+ * - DEEPSEEK_API_KEY with no Exchange key and no DEEPSEEK_BASE_URL (or a non-OpenVibe one) → direct, the old
+ * behaviour for a machine that runs DeepSeek with its own key;
+ * - a request naming the person's own provider key (OPENVIBE_CODES_HEADERS) → direct to the platform address,
+ * because that credential header cannot ride the Exchange client.
+ * A bare hosted run token at OpenVibe.AI (DEEPSEEK_BASE_URL there, DEEPSEEK_API_KEY = run token) goes through
+ * Exchange: api.openvibe.exchange speaks the same compatible API and takes the same person's token.
+ */
+/** The Exchange's catalog ids for the models the deepseek agent names (deepseek-chat is DeepSeek's own alias). */
+const EXCHANGE_MODELS = { 'deepseek-chat': 'deepseek/deepseek-flash', 'deepseek-reasoner': 'deepseek/deepseek-v4-pro' };
+/** A model the Exchange names itself (a catalog id or a dynamic alias) goes through as it is. */
+const EXCHANGE_NAME = /^(openvibe(\/|$)|@preset\/)/;
+
+/** The model the Exchange should run: its own id for the agent's model, a catalog id as given, a dynamic alias as given. */
+function exchangeModelOf(model, env) {
+ const named = String((env && env.OPENVIBE_EXCHANGE_MODEL) || model || '').trim();
+ if (EXCHANGE_NAME.test(named) || named.includes('/')) return named;
+ if (EXCHANGE_MODELS[named]) return EXCHANGE_MODELS[named];
+ return `deepseek/${named || 'deepseek-flash'}`;
+}
+
+function deepseekAdapter(env) {
+ const ownKey = env.DEEPSEEK_API_KEY;
+ const ownBase = trimSlash(env.DEEPSEEK_BASE_URL);
+ const forced = yes(env.OPENVIBE_CODES_DEEPSEEK_DIRECT);
+ const ownCredential = Boolean(env.OPENVIBE_CODES_HEADERS);
+ const exchange = ownCredential && !env.OPENVIBE_EXCHANGE_KEY && !env.OPENVIBE_EXCHANGE_SERVICE_KEY ? null : exchangeKeyOf(env);
+ const direct = ownKey && (forced || ownCredential || (!exchange && (ownBase ? !platformHost(ownBase) : true)));
+ if (direct) {
+ return apiAgent({ id: 'deepseek', name: 'DeepSeek', baseUrl: ownBase || 'https://api.deepseek.com', keyEnv: 'DEEPSEEK_API_KEY', model: 'deepseek-chat', priceIn: 0.00027, priceOut: 0.0011 });
+ }
+ if (exchange) {
+ const baseUrl = trimSlash(env.OPENVIBE_EXCHANGE_URL) || EXCHANGE_URL;
+ return apiAgent({
+ id: 'deepseek', name: 'DeepSeek', model: 'deepseek-chat',
+ exchange: { baseUrl, ...exchange },
+ // The same DeepSeek model under the Exchange's own catalog id (deepseek/deepseek-flash is DeepSeek's
+ // V4.1 Flash, the model deepseek-chat names today); OPENVIBE_EXCHANGE_MODEL overrides it.
+ mapModel: exchangeModelOf,
+ priceIn: 0.00027, priceOut: 0.0011,
+ available: () => ({ available: true, where: baseUrl }),
+ });
+ }
+ return apiAgent({
+ id: 'deepseek', name: 'DeepSeek', model: 'deepseek-chat', priceIn: 0.00027, priceOut: 0.0011,
+ available: () => ({ available: false, reason: 'set OPENVIBE_EXCHANGE_KEY (an Exchange key from openvibe.exchange) for the default path, or DEEPSEEK_API_KEY and OPENVIBE_CODES_DEEPSEEK_DIRECT=1 for your own DeepSeek key' }),
+ });
+}
+
/** The adapter for each catalog harness (the catalog row's id). */
/** The adapter for each catalog harness (the catalog row's id). */
function adaptersFor(env) {
function adaptersFor(env) {
return {
return {
@@ -65,7 +147,7 @@ function adaptersFor(env) {
@@ -65,7 +147,7 @@ function adaptersFor(env) {
opencode: cli.opencode,
opencode: cli.opencode,
'command-code': cli.commandCode,
'command-code': cli.commandCode,
aider: cli.aider,
aider: cli.aider,
- deepseek: apiAgent({ id: 'deepseek', name: 'DeepSeek', baseUrl: env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com', keyEnv: 'DEEPSEEK_API_KEY', model: 'deepseek-chat', priceIn: 0.00027, priceOut: 0.0011 }),
+ deepseek: deepseekAdapter(env),
// OpenVibe.AI's OpenAI-compatible endpoint: its router picks the model, each answer reports its cost.
// OpenVibe.AI's OpenAI-compatible endpoint: its router picks the model, each answer reports its cost.
// OPENVIBE_AI_SECRET names a project secret granted to ai (ADR-056): AI sends each request to the project's own
// OPENVIBE_AI_SECRET names a project secret granted to ai (ADR-056): AI sends each request to the project's own
// provider (OPENVIBE_AI_PROVIDER_URL, OPENVIBE_AI_PROVIDER_MODEL) with that key, revealed per request by Network.
// provider (OPENVIBE_AI_PROVIDER_URL, OPENVIBE_AI_PROVIDER_MODEL) with that key, revealed per request by Network.
@@ -98,6 +180,7 @@ function availability(adapter, env) {
@@ -98,6 +180,7 @@ function availability(adapter, env) {
if (adapter.version) { const v = adapter.version(env); if (v) out.version = v; }
if (adapter.version) { const v = adapter.version(env); if (v) out.version = v; }
return out;
return out;
}
}
+ if (typeof adapter.available === 'function') return adapter.available(env);
if (adapter.id === 'openvibe-ai') return env.OPENVIBE_AI_TOKEN || (env.OPENVIBE_APP_ID && env.OPENVIBE_APP_SECRET) ? { available: true, where: adapter.baseUrl } : { available: false, reason: 'set OPENVIBE_APP_ID and OPENVIBE_APP_SECRET (an app of your project on openvibe.services with ai.app.run)' };
if (adapter.id === 'openvibe-ai') return env.OPENVIBE_AI_TOKEN || (env.OPENVIBE_APP_ID && env.OPENVIBE_APP_SECRET) ? { available: true, where: adapter.baseUrl } : { available: false, reason: 'set OPENVIBE_APP_ID and OPENVIBE_APP_SECRET (an app of your project on openvibe.services with ai.app.run)' };
if (adapter.id === 'openai-compatible') return env.OPENVIBE_CODES_BASE_URL ? { available: true, where: env.OPENVIBE_CODES_BASE_URL } : { available: false, reason: 'set OPENVIBE_CODES_BASE_URL (and OPENVIBE_CODES_MODEL) to your server' };
if (adapter.id === 'openai-compatible') return env.OPENVIBE_CODES_BASE_URL ? { available: true, where: env.OPENVIBE_CODES_BASE_URL } : { available: false, reason: 'set OPENVIBE_CODES_BASE_URL (and OPENVIBE_CODES_MODEL) to your server' };
return env[adapter.keyEnv] ? { available: true, where: adapter.baseUrl } : { available: false, reason: `set ${adapter.keyEnv}` };
return env[adapter.keyEnv] ? { available: true, where: adapter.baseUrl } : { available: false, reason: `set ${adapter.keyEnv}` };
@@ -267,4 +350,4 @@ function createHarness({ env = process.env, fetch: fetchImpl = globalThis.fetch,
@@ -267,4 +350,4 @@ function createHarness({ env = process.env, fetch: fetchImpl = globalThis.fetch,
return { agents, route, run, store, adapters };
return { agents, route, run, store, adapters };
}
}
-module.exports = { createHarness, adaptersFor, availability, handoffNote, onPath };
+module.exports = { createHarness, adaptersFor, availability, handoffNote, onPath, exchangeKeyOf, deepseekAdapter, exchangeModelOf, platformHost };
@@ -138,7 +138,10 @@ const ENV_NAMES = new Set([
@@ -138,7 +138,10 @@ const ENV_NAMES = new Set([
'ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY', 'ANTHROPIC_CUSTOM_HEADERS', 'ANTHROPIC_MODEL',
'ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY', 'ANTHROPIC_CUSTOM_HEADERS', 'ANTHROPIC_MODEL',
'OPENAI_BASE_URL', 'OPENAI_API_KEY', 'OPENAI_CUSTOM_HEADERS',
'OPENAI_BASE_URL', 'OPENAI_API_KEY', 'OPENAI_CUSTOM_HEADERS',
'DEEPSEEK_BASE_URL', 'DEEPSEEK_API_KEY',
'DEEPSEEK_BASE_URL', 'DEEPSEEK_API_KEY',
- 'OPENVIBE_AI_URL', 'OPENVIBE_AI_TOKEN', 'OPENVIBE_CODES_BASE_URL', 'OPENVIBE_CODES_API_KEY',
+ 'OPENVIBE_AI_URL', 'OPENVIBE_AI_TOKEN',
+ // The Exchange's compatible API (Codes C3's run token, or a person's Exchange key): the deepseek harness's default.
+ 'OPENVIBE_EXCHANGE_URL', 'OPENVIBE_EXCHANGE_KEY', 'OPENVIBE_EXCHANGE_SERVICE_KEY', 'OPENVIBE_EXCHANGE_MODEL',
+ 'OPENVIBE_CODES_BASE_URL', 'OPENVIBE_CODES_API_KEY',
'OPENVIBE_CODES_MODEL', 'OPENVIBE_CODES_HEADERS',
'OPENVIBE_CODES_MODEL', 'OPENVIBE_CODES_HEADERS',
// A repository on OpenVibe Git: the person's credential line for its host. The local root lets a session on the
// A repository on OpenVibe Git: the person's credential line for its host. The local root lets a session on the
// same host clone from the disk (G3) instead of the network. For git only: bin/openvibe-codes.js keeps them out
// same host clone from the disk (G3) instead of the network. For git only: bin/openvibe-codes.js keeps them out
@@ -86,6 +86,9 @@ function load(env = process.env) {
@@ -86,6 +86,9 @@ 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',
},
},
+ // OpenVibe.Exchange's compatible API: the deepseek harness's default path (openvibe-sdk/exchange). The run
+ // token names the person; the Exchange speaks the same chat-completions API and the same model ids.
+ exchange: { url: trim(env.CODES_EXCHANGE_URL || 'https://api.openvibe.exchange') },
// AI Exchange P9: OpenVibe.AI's real model catalog (GET /v1/models) in the pickers. Off by default; with the
// 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,
// 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.
// and Codes keeps today's picker until it does. The answer is cached for about five minutes.
@@ -100,8 +103,9 @@ function load(env = process.env) {
@@ -100,8 +103,9 @@ function load(env = process.env) {
// The harness env variable names a job may set (harness/job.js): the model APIs' own names, nothing else.
// The harness env variable names a job may set (harness/job.js): the model APIs' own names, nothing else.
envNames: ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_CUSTOM_HEADERS', 'OPENAI_BASE_URL',
envNames: ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_CUSTOM_HEADERS', 'OPENAI_BASE_URL',
'OPENAI_API_KEY', 'OPENAI_CUSTOM_HEADERS', 'DEEPSEEK_BASE_URL', 'DEEPSEEK_API_KEY', 'OPENVIBE_AI_URL',
'OPENAI_API_KEY', 'OPENAI_CUSTOM_HEADERS', 'DEEPSEEK_BASE_URL', 'DEEPSEEK_API_KEY', 'OPENVIBE_AI_URL',
- 'OPENVIBE_AI_TOKEN', 'OPENVIBE_CODES_BASE_URL', 'OPENVIBE_CODES_API_KEY', 'OPENVIBE_CODES_MODEL',
- 'OPENVIBE_CODES_HEADERS'],
+ 'OPENVIBE_AI_TOKEN', 'OPENVIBE_EXCHANGE_URL', 'OPENVIBE_EXCHANGE_KEY', 'OPENVIBE_EXCHANGE_SERVICE_KEY',
+ 'OPENVIBE_EXCHANGE_MODEL',
+ 'OPENVIBE_CODES_BASE_URL', 'OPENVIBE_CODES_API_KEY', 'OPENVIBE_CODES_MODEL', 'OPENVIBE_CODES_HEADERS'],
},
},
bench: {
bench: {
url: trim(env.CODES_BENCH_URL || 'https://bench.openvibe.services'),
url: trim(env.CODES_BENCH_URL || 'https://bench.openvibe.services'),
@@ -51,6 +51,11 @@ function aiInternal(config) {
@@ -51,6 +51,11 @@ function aiInternal(config) {
return String((config.ai && (config.ai.internalUrl || config.ai.url)) || 'https://ai.openvibe.services').replace(/\/+$/, '');
return String((config.ai && (config.ai.internalUrl || config.ai.url)) || 'https://ai.openvibe.services').replace(/\/+$/, '');
}
}
+/** The Exchange's compatible API: where the deepseek harness sends its turns (openvibe-sdk/exchange). */
+function exchangeBase(config) {
+ return String((config.exchange && config.exchange.url) || 'https://api.openvibe.exchange').replace(/\/+$/, '');
+}
+
/**
/**
* The environment a harness of this kind gets: where to send its requests, the token to send, and (when the person
* The environment a harness of this kind gets: where to send its requests, the token to send, and (when the person
* has a key for that provider) the header that says which key.
* has a key for that provider) the header that says which key.
@@ -72,7 +77,14 @@ function envFor({ config, harness, token, provider, ownKey }) {
@@ -72,7 +77,14 @@ function envFor({ config, harness, token, provider, ownKey }) {
env.OPENAI_CUSTOM_HEADERS = credential;
env.OPENAI_CUSTOM_HEADERS = credential;
env.OPENVIBE_CODES_HEADERS = JSON.stringify({ 'x-openvibe-credential': provider });
env.OPENVIBE_CODES_HEADERS = JSON.stringify({ 'x-openvibe-credential': provider });
}
}
- if (harness === 'deepseek') { env.DEEPSEEK_BASE_URL = `${base}/v1`; env.DEEPSEEK_API_KEY = token; }
+ if (harness === 'deepseek') {
+ // The default path: the Exchange's compatible API through the SDK's Exchange client, the run token as the
+ // person's Exchange key. When the request names the person's own DeepSeek key (ownKey), that key rides the
+ // x-openvibe-credential header, which the Exchange client cannot add: that attempt keeps the AI path below.
+ if (!ownKey) { env.OPENVIBE_EXCHANGE_URL = exchangeBase(config); env.OPENVIBE_EXCHANGE_KEY = token; }
+ // A harness older than the Exchange client still reads these names; it sends the same token to OpenVibe.AI.
+ env.DEEPSEEK_BASE_URL = `${base}/v1`; env.DEEPSEEK_API_KEY = token;
+ }
if (harness === 'openvibe-ai') { env.OPENVIBE_AI_URL = `${base}/v1`; env.OPENVIBE_AI_TOKEN = token; }
if (harness === 'openvibe-ai') { env.OPENVIBE_AI_URL = `${base}/v1`; env.OPENVIBE_AI_TOKEN = token; }
if (harness === 'openai-compatible') { env.OPENVIBE_CODES_BASE_URL = `${base}/v1`; env.OPENVIBE_CODES_API_KEY = token; }
if (harness === 'openai-compatible') { env.OPENVIBE_CODES_BASE_URL = `${base}/v1`; env.OPENVIBE_CODES_API_KEY = token; }
return env;
return env;
new file mode 100644
new file mode 100644
@@ -0,0 +1,137 @@
@@ -0,0 +1,137 @@
+'use strict';
+/**
+ * The deepseek harness's default path: its turns go to OpenVibe.Exchange's compatible API through the SDK's Exchange
+ * client (openvibe-sdk/exchange), with the same model id and tool loop; no request reaches api.deepseek.com. The direct
+ * DeepSeek API is used only for an operator's own key on a self-hosted run (OPENVIBE_CODES_DEEPSEEK_DIRECT=1), and the
+ * Exchange's answer's receipt id and reported cost are carried on the run's result.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { createHarness, exchangeKeyOf, exchangeModelOf, platformHost } = require('../harness');
+const { createStore } = require('../harness/sessions');
+const { check, done } = require('./helpers/boot');
+
+/** A stand-in Exchange: records every request; fails the test if the direct DeepSeek host is called on the default path. */
+function mockExchange({ script = [] } = {}) {
+ const requests = [];
+ const fetchImpl = async (url, init = {}) => {
+ const href = String(url);
+ if (/api\.deepseek\.com/i.test(href)) throw new Error(`the direct DeepSeek API was called on the default path: ${href}`);
+ let body = null;
+ try { body = init.body ? JSON.parse(init.body) : null; } catch { body = null; }
+ const headers = Object.fromEntries(Object.entries(init.headers || {}).map(([k, v]) => [k.toLowerCase(), v]));
+ requests.push({ url: href, headers, body: body && JSON.parse(JSON.stringify(body)) });
+ const step = script.length ? script.shift() : null;
+ if (step && step.status) {
+ return new Response(JSON.stringify(step.body || { error: { message: 'refused' } }), { status: step.status, headers: { 'content-type': 'application/json' } });
+ }
+ const message = step && step.calls
+ ? { role: 'assistant', content: '', tool_calls: step.calls.map((c, i) => ({ id: `call_${requests.length}_${i}`, type: 'function', function: { name: c[0], arguments: JSON.stringify(c[1]) } })) }
+ : { role: 'assistant', content: (step && step.content) || 'all done' };
+ return new Response(JSON.stringify({
+ id: 'chatcmpl-run_test', object: 'chat.completion',
+ choices: [{ index: 0, message, finish_reason: 'stop' }],
+ usage: { prompt_tokens: 1000, completion_tokens: 100, prompt_cache_hit_tokens: 400 },
+ x_openvibe: { run_id: 'run_test', model: 'deepseek-chat', cost_usd: 0.00042, receipt_id: 'rcpt_test' },
+ }), { status: 200, headers: { 'content-type': 'application/json' } });
+ };
+ return { requests, fetchImpl };
+}
+
+const collect = async (run) => { const out = []; for await (const e of run) out.push(e); return out; };
+
+(async () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codes-exchange-'));
+ const work = path.join(dir, 'work');
+ fs.mkdirSync(path.join(work, 'src'), { recursive: true });
+ fs.writeFileSync(path.join(work, 'src', 'a.js'), 'const name = 1;\n');
+ const baseEnv = { PATH: '/nonexistent', OPENVIBE_CODES_HOME: path.join(dir, 'home') };
+ const EXCHANGE_KEY = `ovx_${'a'.repeat(43)}`;
+
+ await check('the default path sends the turn to OpenVibe.Exchange through the SDK client, never to api.deepseek.com', async () => {
+ const api = mockExchange({ script: [
+ { calls: [['read_file', { path: 'src/a.js' }]] },
+ { content: 'It exports name.' },
+ ] });
+ const env = { ...baseEnv, OPENVIBE_EXCHANGE_KEY: EXCHANGE_KEY };
+ const h = createHarness({ env, fetch: api.fetchImpl, store: createStore({ env }) });
+ assert.strictEqual(h.agents().find((a) => a.id === 'deepseek').available, true);
+ const events = await collect(h.run({ prompt: 'what does a.js export?', agent: 'deepseek', cwd: work, permission: 'read', handoff: false }));
+ const r = events.find((e) => e.type === 'result');
+ assert.strictEqual(r.is_error, false, r.result);
+ assert.strictEqual(r.result, 'It exports name.');
+ assert.strictEqual(api.requests.length, 2, 'the tool loop ran both turns');
+ assert.strictEqual(api.requests[0].url, 'https://api.openvibe.exchange/v1/chat/completions');
+ assert.strictEqual(api.requests[0].headers.authorization, `Bearer ${EXCHANGE_KEY}`);
+ assert.strictEqual(api.requests[0].body.model, 'deepseek/deepseek-flash', 'the Exchange\'s id for the same DeepSeek model');
+ assert.ok(api.requests[0].body.tools.some((t) => t.function.name === 'read_file'), 'the same tools');
+ assert.ok(api.requests[1].body.messages.some((m) => m.role === 'tool' && m.content.includes('const name = 1;')), 'the tool result went back to the model');
+ assert.strictEqual(r.receipt_id, 'rcpt_test', 'the Exchange receipt is on the result');
+ assert.strictEqual(r.total_cost_usd, 0.00084, 'the reported costs are summed');
+ assert.deepStrictEqual([r.usage.input_tokens, r.usage.cache_read_input_tokens, r.usage.output_tokens], [1200, 800, 200]);
+ });
+
+ await check('the agent\'s model ids map to the Exchange\'s catalog ids, and an Exchange id is not mapped again', () => {
+ assert.strictEqual(exchangeModelOf('deepseek-chat'), 'deepseek/deepseek-flash');
+ assert.strictEqual(exchangeModelOf('deepseek-reasoner'), 'deepseek/deepseek-v4-pro');
+ assert.strictEqual(exchangeModelOf('deepseek/deepseek-v4-pro'), 'deepseek/deepseek-v4-pro');
+ assert.strictEqual(exchangeModelOf('openvibe'), 'openvibe');
+ assert.strictEqual(exchangeModelOf('deepseek-chat', { OPENVIBE_EXCHANGE_MODEL: 'openvibe/auto' }), 'openvibe/auto');
+ });
+
+ await check('a service Exchange key is the fallback, and the Exchange URL can be pointed elsewhere', async () => {
+ const api = mockExchange({ script: [{ content: 'ok' }] });
+ const env = { ...baseEnv, OPENVIBE_EXCHANGE_SERVICE_KEY: `ovx_${'b'.repeat(43)}`, OPENVIBE_EXCHANGE_URL: 'https://exchange.test' };
+ const h = createHarness({ env, fetch: api.fetchImpl, store: createStore({ env }) });
+ const events = await collect(h.run({ prompt: 'say ok', agent: 'deepseek', cwd: work, permission: 'read', handoff: false }));
+ assert.strictEqual(events.find((e) => e.type === 'result').is_error, false);
+ assert.strictEqual(api.requests[0].url, 'https://exchange.test/v1/chat/completions');
+ assert.strictEqual(api.requests[0].headers.authorization, `Bearer ovx_${'b'.repeat(43)}`);
+ });
+
+ await check('the direct DeepSeek API is used only with OPENVIBE_CODES_DEEPSEEK_DIRECT=1 and an operator key', async () => {
+ const requests = [];
+ const fetchImpl = async (url, init = {}) => {
+ requests.push({ url: String(url), headers: { ...(init.headers || {}) } });
+ return new Response(JSON.stringify({ choices: [{ message: { role: 'assistant', content: 'direct' } }], usage: { prompt_tokens: 1, completion_tokens: 1 } }), { status: 200, headers: { 'content-type': 'application/json' } });
+ };
+ const env = { ...baseEnv, OPENVIBE_CODES_DEEPSEEK_DIRECT: '1', DEEPSEEK_API_KEY: 'sk-own-key', DEEPSEEK_BASE_URL: 'https://api.deepseek.com', OPENVIBE_EXCHANGE_KEY: EXCHANGE_KEY };
+ const h = createHarness({ env, fetch: fetchImpl, store: createStore({ env }) });
+ const events = await collect(h.run({ prompt: 'say direct', agent: 'deepseek', cwd: work, permission: 'read', handoff: false }));
+ assert.strictEqual(events.find((e) => e.type === 'result').is_error, false);
+ assert.strictEqual(requests.length, 1);
+ assert.strictEqual(requests[0].url, 'https://api.deepseek.com/chat/completions');
+ assert.strictEqual(requests[0].headers.authorization, 'Bearer sk-own-key');
+ });
+
+ await check('a hosted deepseek attempt gets the Exchange pair with the run token, and keeps the old pair for older harnesses', () => {
+ const { envFor } = require('../server/domain/run-tokens');
+ const config = { ai: { url: 'https://ai.openvibe.services' }, exchange: { url: 'https://api.openvibe.exchange' } };
+ const env = envFor({ config, harness: 'deepseek', token: 'run-token', provider: 'deepseek', ownKey: false });
+ assert.strictEqual(env.OPENVIBE_EXCHANGE_URL, 'https://api.openvibe.exchange');
+ assert.strictEqual(env.OPENVIBE_EXCHANGE_KEY, 'run-token');
+ assert.strictEqual(env.DEEPSEEK_BASE_URL, 'https://ai.openvibe.services/v1', 'an older harness still reads these');
+ assert.strictEqual(env.DEEPSEEK_API_KEY, 'run-token');
+ // The person's own DeepSeek key names itself in OPENVIBE_CODES_HEADERS, which the SDK client cannot send:
+ // that attempt keeps the AI path, so the header is never dropped.
+ const own = envFor({ config, harness: 'deepseek', token: 'run-token', provider: 'deepseek', ownKey: true });
+ assert.strictEqual(own.OPENVIBE_EXCHANGE_KEY, undefined);
+ assert.strictEqual(own.OPENVIBE_EXCHANGE_URL, undefined);
+ assert.strictEqual(own.DEEPSEEK_API_KEY, 'run-token');
+ });
+
+ await check('without an Exchange key or an own DeepSeek key the agent is unavailable, and the reason names both', () => {
+ const h = createHarness({ env: baseEnv, store: createStore({ env: baseEnv }) });
+ const ds = h.agents().find((a) => a.id === 'deepseek');
+ assert.strictEqual(ds.available, false);
+ assert.match(ds.reason, /OPENVIBE_EXCHANGE_KEY/);
+ assert.match(ds.reason, /DEEPSEEK_API_KEY/);
+ assert.strictEqual(exchangeKeyOf(baseEnv), null);
+ assert.strictEqual(platformHost('https://ai.openvibe.services/v1'), true);
+ assert.strictEqual(platformHost('https://api.deepseek.com'), false);
+ });
+
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });