openvibers/openvibe.codes Commits

DeepSeek through OpenVibe.Exchange's compatible API (#384)

OP OpenVibers committed 2 days ago · 606d903a1a · 9 files, +321 −30

* DeepSeek through the Exchange: the apiAgent's default path is OpenVibe.Exchange's compatible API, with the SDK's Exchange client, the same model id and tool loop; the direct DeepSeek API only on an operator's own key and an explicit self-host switch. The run's result carries the Exchange receipt id.

run-tokens gives a hosted deepseek attempt the Exchange pair (the person's run token as the key) and keeps the old pair for older harnesses; an own provider key still rides the credential header on the AI path.

* The Exchange asks for the DeepSeek model by its catalog id: deepseek/deepseek-flash for the agent's deepseek-chat (and deepseek/deepseek-v4-pro for deepseek-reasoner), an Exchange id as given, OPENVIBE_EXCHANGE_MODEL to override. DeepSeek's own API keeps deepseek-chat on the direct path.

Changes

ddiff --git a/.env.example b/.env.example
iindex b49870a..23ee708 100644
---- a/.env.example
++++ b/.env.example
@@@ -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).
ddiff --git a/README.md b/README.md
iindex 2e2e765..5025bad 100644
---- a/README.md
++++ b/README.md
@@@ -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` |
ddiff --git a/bin/openvibe-codes.js b/bin/openvibe-codes.js
iindex bcd4298..edea7bb 100755
---- a/bin/openvibe-codes.js
++++ b/bin/openvibe-codes.js
@@@ -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,
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
ddiff --git a/harness/adapters/api.js b/harness/adapters/api.js
iindex cf42128..497b8b0 100644
---- a/harness/adapters/api.js
++++ b/harness/adapters/api.js
@@@ -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 };
ddiff --git a/harness/index.js b/harness/index.js
iindex 5570ab3..0937ffa 100644
---- a/harness/index.js
++++ b/harness/index.js
@@@ -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 };
ddiff --git a/harness/job.js b/harness/job.js
iindex 4778c12..dfd7f81 100644
---- a/harness/job.js
++++ b/harness/job.js
@@@ -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
ddiff --git a/server/config.js b/server/config.js
iindex eb890a3..630c578 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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'),
ddiff --git a/server/domain/run-tokens.js b/server/domain/run-tokens.js
iindex f1596b5..022ca72 100644
---- a/server/domain/run-tokens.js
++++ b/server/domain/run-tokens.js
@@@ -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;
ddiff --git a/test/harness-exchange.test.js b/test/harness-exchange.test.js
nnew file mode 100644
iindex 0000000..5649e7c
---- /dev/null
++++ b/test/harness-exchange.test.js
@@@ -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); });