@volter/supercode-orchestrator 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +532 -0
- package/activation.mjs +270 -0
- package/adapters/api-server.mjs +615 -0
- package/adapters/chat-sdk.mjs +475 -0
- package/adapters/chunks.mjs +14 -0
- package/adapters/loopback.mjs +37 -0
- package/adapters/rh2.mjs +349 -0
- package/apply/doors.d.ts +2 -0
- package/apply/doors.mjs +292 -0
- package/apply/hermes_door.py +129 -0
- package/apply/index.d.ts +4 -0
- package/apply/index.mjs +441 -0
- package/apply/state.mjs +142 -0
- package/bin/hermes-worker.mjs +11 -0
- package/bin/orchestrator.mjs +730 -0
- package/board.mjs +81 -0
- package/commands.mjs +37 -0
- package/cron.mjs +967 -0
- package/events.mjs +107 -0
- package/gates.mjs +281 -0
- package/hermes-cli.mjs +440 -0
- package/hermes-home.mjs +264 -0
- package/hermes-locks.mjs +188 -0
- package/hosted.mjs +81 -0
- package/index.d.ts +508 -0
- package/index.mjs +38 -0
- package/ir.mjs +205 -0
- package/kanban-worker.mjs +155 -0
- package/lease.mjs +51 -0
- package/loop.mjs +864 -0
- package/mcp/bridge.mjs +133 -0
- package/mcp/hermes-kanban-tools.json +477 -0
- package/mcp/hermes-tools.json +288 -0
- package/mcp/mount.mjs +161 -0
- package/mcp/server.mjs +160 -0
- package/mcp/tools.mjs +57 -0
- package/mcp/verbs.mjs +348 -0
- package/ontology.d.ts +557 -0
- package/operator.mjs +426 -0
- package/orchestration.mjs +166 -0
- package/package.json +51 -0
- package/reducer.mjs +792 -0
- package/webhook.mjs +263 -0
- package/worker-route.mjs +122 -0
package/webhook.mjs
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// Webhooks: `POST /webhooks/<name>` (docs/ORCHESTRATOR-IR.md §4.4).
|
|
2
|
+
//
|
|
3
|
+
// handleWebhook(state, ev, effects) the reducer half: verified, subscribed, new -> a turn
|
|
4
|
+
// renderTemplate(template, payload) `{field}` / `{a.b}` substitution
|
|
5
|
+
// verifySignature(secret, body, sig) HMAC-SHA256, `sha256=<hex>`
|
|
6
|
+
// startWebhookListener(...) node:http, no framework
|
|
7
|
+
//
|
|
8
|
+
// Nothing here is imported statically from `node:`: `handleWebhook` runs
|
|
9
|
+
// inside the pure reducer, so the two I/O-bearing functions load `node:crypto`
|
|
10
|
+
// and `node:http` at call time instead.
|
|
11
|
+
|
|
12
|
+
import { surfaceKeyString, sessionReset } from './ir.mjs';
|
|
13
|
+
import { endBinding, ensureRuntime, expired, leaseId, liveBinding, newObligation } from './reducer.mjs';
|
|
14
|
+
import { compactStamp, resolveTarget } from './cron.mjs';
|
|
15
|
+
import * as events from './events.mjs';
|
|
16
|
+
|
|
17
|
+
/** The synthetic surface a subscription's turns run on (§4.4). */
|
|
18
|
+
export function webhookKey(name) {
|
|
19
|
+
return { platform: 'webhook', kind: 'dm', chat_id: name };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** `{field}` and `{a.b.c}` from the JSON payload; an unknown field is left as written. */
|
|
23
|
+
export function renderTemplate(template, payload) {
|
|
24
|
+
return String(template ?? '').replace(/\{([A-Za-z0-9_][A-Za-z0-9_.]*)\}/g, (whole, path) => {
|
|
25
|
+
let v = payload;
|
|
26
|
+
for (const seg of path.split('.')) {
|
|
27
|
+
if (v === null || typeof v !== 'object') return whole;
|
|
28
|
+
v = v[seg];
|
|
29
|
+
}
|
|
30
|
+
if (v === undefined || v === null) return whole;
|
|
31
|
+
return typeof v === 'object' ? JSON.stringify(v) : String(v);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A body that names its own event, for senders with no event header. */
|
|
36
|
+
function eventType(payload) {
|
|
37
|
+
if (payload === null || typeof payload !== 'object') return null;
|
|
38
|
+
const v = payload.event ?? payload.type ?? payload.action ?? null;
|
|
39
|
+
return typeof v === 'string' ? v : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Where a source names the event and the delivery, first match wins: GitHub,
|
|
44
|
+
* GitLab, Bitbucket (`X-Event-Key`), then the generic spellings. Hermes's
|
|
45
|
+
* webhook adapter reads the same headers (`gateway/platforms/webhook.py`).
|
|
46
|
+
*/
|
|
47
|
+
export const EVENT_HEADERS = Object.freeze(['x-github-event', 'x-gitlab-event', 'x-event-key', 'x-event-type']);
|
|
48
|
+
export const DELIVERY_HEADERS = Object.freeze(['x-github-delivery', 'x-gitlab-event-uuid', 'webhook-id', 'x-request-id']);
|
|
49
|
+
|
|
50
|
+
/** Delivery ids remembered per subscription; a redelivery inside this window is dropped. */
|
|
51
|
+
export const DELIVERY_MEMORY = 200;
|
|
52
|
+
|
|
53
|
+
function firstHeader(headers, names) {
|
|
54
|
+
for (const name of names) {
|
|
55
|
+
const v = Array.isArray(headers?.[name]) ? headers[name][0] : headers?.[name];
|
|
56
|
+
if (typeof v === 'string' && v.trim()) return v.trim();
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* `events` entries are event types (`pull_request`); one with a dot names an
|
|
63
|
+
* action too (`pull_request.opened` matches `<event>.<payload.action>`).
|
|
64
|
+
*/
|
|
65
|
+
export function subscribedTo(events, kind, payload) {
|
|
66
|
+
if (!kind) return false;
|
|
67
|
+
const action = payload && typeof payload === 'object' && typeof payload.action === 'string' ? payload.action : null;
|
|
68
|
+
return events.some((e) => e === kind || (action !== null && e.includes('.') && e === `${kind}.${action}`));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The profile that owns a subscription, and the subscription itself. */
|
|
72
|
+
export function findSubscription(state, name) {
|
|
73
|
+
for (const profile of Object.values(state.profiles)) {
|
|
74
|
+
const sub = profile.subscriptions[name];
|
|
75
|
+
if (sub) return { profile, sub };
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------- the reducer half
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* §4.4. Unverified is dropped and logged. Verified renders the template, opens
|
|
84
|
+
* or reuses the subscription profile's conversation on
|
|
85
|
+
* `{platform:'webhook', kind:'dm', chat_id:name}`, and sends. The turn's
|
|
86
|
+
* `final` is delivered per `subscription.deliver` by `cron.mjs`'s `fireFinal`,
|
|
87
|
+
* which recognises the binding by its `residue.webhook` mark.
|
|
88
|
+
*/
|
|
89
|
+
export function handleWebhook(state, ev, effects) {
|
|
90
|
+
const now = ev.at;
|
|
91
|
+
if (!ev.verified) {
|
|
92
|
+
effects.push({ type: 'log', level: 'warn', message: `webhook ${ev.name}: signature not verified, dropped` });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const found = findSubscription(state, ev.name);
|
|
96
|
+
if (!found) {
|
|
97
|
+
effects.push({ type: 'log', level: 'warn', message: `webhook ${ev.name}: no subscription, dropped` });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const { profile, sub } = found;
|
|
101
|
+
if (sub.events?.length) {
|
|
102
|
+
// the source's header names the event (`X-GitHub-Event: pull_request`);
|
|
103
|
+
// a body's own `action` is not the event. A call naming none is not one
|
|
104
|
+
// of the subscribed events, as in Hermes.
|
|
105
|
+
const kind = ev.event ?? eventType(ev.payload);
|
|
106
|
+
if (!subscribedTo(sub.events, kind, ev.payload)) {
|
|
107
|
+
const action = typeof ev.payload?.action === 'string' ? `.${ev.payload.action}` : '';
|
|
108
|
+
effects.push({ type: 'log', level: 'info', message: `webhook ${ev.name}: ${kind ? `${kind}${action}` : 'an unnamed event'} is not subscribed, dropped` });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (ev.delivery_id) {
|
|
113
|
+
// a redelivery (GitHub's "Redeliver", a sender's retry) is the same event:
|
|
114
|
+
// it runs once. The memory is runtime state (§2.8), as Hermes's is: a
|
|
115
|
+
// restart forgets it.
|
|
116
|
+
const seen = ((ensureRuntime(state).webhook_deliveries ??= {})[ev.name] ??= []);
|
|
117
|
+
if (seen.includes(ev.delivery_id)) {
|
|
118
|
+
effects.push({ type: 'log', level: 'info', message: `webhook ${ev.name}: delivery ${ev.delivery_id} already handled, dropped` });
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
seen.push(ev.delivery_id);
|
|
122
|
+
if (seen.length > DELIVERY_MEMORY) seen.splice(0, seen.length - DELIVERY_MEMORY);
|
|
123
|
+
}
|
|
124
|
+
// Hermes `deliver_only` (`gateway/platforms/webhook.py`): no agent — the
|
|
125
|
+
// rendered prompt IS the message, delivered to the subscription's target.
|
|
126
|
+
// A subscription with nowhere real to deliver is refused, as Hermes refuses
|
|
127
|
+
// it at startup.
|
|
128
|
+
if (sub.residue?.deliver_only === true) {
|
|
129
|
+
const target = resolveTarget(null, profile, sub.deliver ?? null);
|
|
130
|
+
if (target.kind === 'local') {
|
|
131
|
+
effects.push({ type: 'log', level: 'warn', message: `webhook ${ev.name}: deliver_only needs a real delivery target, not local` });
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const o = newObligation(profile, target, renderTemplate(sub.prompt_template, ev.payload), { kind: 'webhook', name: ev.name }, now);
|
|
135
|
+
effects.push({ type: 'log', level: 'info', message: `webhook ${ev.name}: direct delivery to ${target.platform}` });
|
|
136
|
+
effects.push({ type: 'deliver', profile: profile.name, obligation_id: o.id });
|
|
137
|
+
effects.push({ type: 'save' });
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (!profile.worker) {
|
|
141
|
+
effects.push({ type: 'log', level: 'warn', message: `webhook ${ev.name}: profile ${profile.name} has no worker configured` });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const key = webhookKey(ev.name);
|
|
146
|
+
const keyString = surfaceKeyString(key);
|
|
147
|
+
let b = liveBinding(profile, keyString);
|
|
148
|
+
if (b) {
|
|
149
|
+
const reason = expired(b, sessionReset(profile), now);
|
|
150
|
+
if (reason) { endBinding(state, profile.name, keyString, reason, now, effects); b = null; }
|
|
151
|
+
}
|
|
152
|
+
if (!b) {
|
|
153
|
+
b = {
|
|
154
|
+
key,
|
|
155
|
+
worker: { harness: profile.worker.harness, session_id: `webhook_${ev.name}_${compactStamp(now)}`, locator: null },
|
|
156
|
+
started_at: now, last_activity_at: now, ended_at: null, end_reason: null,
|
|
157
|
+
handoff: null, recurrence: null, residue: { webhook: ev.name },
|
|
158
|
+
};
|
|
159
|
+
profile.bindings[keyString] = b;
|
|
160
|
+
effects.push({ type: 'runtime_start', profile: profile.name, keyString, worker: profile.worker, prompt: null });
|
|
161
|
+
}
|
|
162
|
+
b.residue.webhook = ev.name;
|
|
163
|
+
b.last_activity_at = now;
|
|
164
|
+
|
|
165
|
+
const text = renderTemplate(sub.prompt_template, ev.payload);
|
|
166
|
+
const rt = ensureRuntime(state);
|
|
167
|
+
const lid = leaseId(profile.name, keyString);
|
|
168
|
+
if (rt.turns[lid] || !rt.leases[lid]) (rt.queues[lid] ??= []).push({ text, at: now });
|
|
169
|
+
else {
|
|
170
|
+
rt.turns[lid] = { started_at: now, buffer: '' };
|
|
171
|
+
effects.push({ type: 'send_input', profile: profile.name, keyString, text });
|
|
172
|
+
}
|
|
173
|
+
effects.push({ type: 'save' });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------- signatures
|
|
177
|
+
|
|
178
|
+
/** `sha256=<hex>` of the raw body under `secret`. */
|
|
179
|
+
export async function signBody(secret, body) {
|
|
180
|
+
const { createHmac } = await import('node:crypto');
|
|
181
|
+
return `sha256=${createHmac('sha256', String(secret)).update(body).digest('hex')}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Fail-closed: no secret, no header, or a mismatch is unverified. */
|
|
185
|
+
export async function verifySignature(secret, body, header) {
|
|
186
|
+
if (!secret || !header) return false;
|
|
187
|
+
const { timingSafeEqual } = await import('node:crypto');
|
|
188
|
+
const expected = Buffer.from(await signBody(secret, body));
|
|
189
|
+
const given = Buffer.from(String(header));
|
|
190
|
+
return expected.length === given.length && timingSafeEqual(expected, given);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The subscription's secret, resolved out of the vault (never out of `state`). */
|
|
194
|
+
export function subscriptionSecret(sub, vault) {
|
|
195
|
+
const ref = sub?.secret;
|
|
196
|
+
if (!ref) return null;
|
|
197
|
+
const name = ref.dotenv ?? ref.env;
|
|
198
|
+
return vault?.get(name) ?? null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---------------------------------------------------------------- the listener
|
|
202
|
+
|
|
203
|
+
const MAX_BODY = 1024 * 1024;
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* `POST /webhooks/<name>`. Reads the raw body (a signature is over bytes, not
|
|
207
|
+
* over a re-serialized object), verifies it, and hands the loop a `webhook`
|
|
208
|
+
* event either way — the reducer decides what a bad signature means. Binds on
|
|
209
|
+
* 127.0.0.1; `port` 0 lets the OS choose (tests read `.port`).
|
|
210
|
+
*/
|
|
211
|
+
export async function startWebhookListener({ state, vault, port = 0, host = '127.0.0.1', dispatch, clock, log = () => {} }) {
|
|
212
|
+
const { createServer } = await import('node:http');
|
|
213
|
+
const server = createServer((req, res) => {
|
|
214
|
+
const match = /^\/webhooks\/([^/?#]+)/.exec(req.url ?? '');
|
|
215
|
+
if (req.method !== 'POST' || !match) { res.writeHead(404, { 'content-type': 'application/json' }); res.end('{"error":"not found"}'); return; }
|
|
216
|
+
let name;
|
|
217
|
+
try { name = decodeURIComponent(match[1]); } catch {
|
|
218
|
+
res.writeHead(400, { 'content-type': 'application/json' }); res.end('{"error":"bad subscription name"}'); return;
|
|
219
|
+
}
|
|
220
|
+
const chunks = [];
|
|
221
|
+
let size = 0;
|
|
222
|
+
req.on('data', (c) => {
|
|
223
|
+
size += c.length;
|
|
224
|
+
if (size > MAX_BODY) { res.writeHead(413); res.end(); req.destroy(); return; }
|
|
225
|
+
chunks.push(c);
|
|
226
|
+
});
|
|
227
|
+
req.on('end', () => {
|
|
228
|
+
const body = Buffer.concat(chunks).toString('utf8');
|
|
229
|
+
void handleRequest({ state, vault, name, body, headers: req.headers, dispatch, clock, log, res });
|
|
230
|
+
});
|
|
231
|
+
req.on('error', (error) => log('warn', `webhook ${name}: ${error.message}`));
|
|
232
|
+
});
|
|
233
|
+
await new Promise((resolve, reject) => {
|
|
234
|
+
server.once('error', reject);
|
|
235
|
+
server.listen(port, host, () => { server.removeListener('error', reject); resolve(); });
|
|
236
|
+
});
|
|
237
|
+
server.unref?.();
|
|
238
|
+
return {
|
|
239
|
+
server,
|
|
240
|
+
port: server.address().port,
|
|
241
|
+
close: () => new Promise((resolve) => server.close(() => resolve())),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function handleRequest({ state, vault, name, body, headers, dispatch, clock, log, res }) {
|
|
246
|
+
const found = findSubscription(state, name);
|
|
247
|
+
if (!found) {
|
|
248
|
+
log('warn', `webhook ${name}: no subscription`);
|
|
249
|
+
res.writeHead(404, { 'content-type': 'application/json' });
|
|
250
|
+
res.end('{"error":"unknown subscription"}');
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const header = headers['x-signature-256'] ?? headers['x-hub-signature-256'] ?? null;
|
|
254
|
+
const verified = await verifySignature(subscriptionSecret(found.sub, vault), body, header);
|
|
255
|
+
let payload;
|
|
256
|
+
try { payload = JSON.parse(body); } catch { payload = { body }; }
|
|
257
|
+
const at = clock();
|
|
258
|
+
res.writeHead(verified ? 202 : 401, { 'content-type': 'application/json' });
|
|
259
|
+
res.end(JSON.stringify({ ok: verified, name }));
|
|
260
|
+
const event = firstHeader(headers, EVENT_HEADERS) ?? eventType(payload);
|
|
261
|
+
const delivery_id = firstHeader(headers, DELIVERY_HEADERS);
|
|
262
|
+
await dispatch(events.webhook({ name, payload, verified, at, event, delivery_id }));
|
|
263
|
+
}
|
package/worker-route.mjs
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// The worker's model and route from the home's own `model:` block (docs/plans/hermes-compat.md row 6).
|
|
2
|
+
// A Hermes home names its model as Hermes reads it: `model.default`, `model.provider`, and for a custom
|
|
3
|
+
// endpoint `model.base_url`, `model.api_key` and `model.api_mode`, secrets as `${NAME}`. The worker gets
|
|
4
|
+
// the same model on the same endpoint, through its own configuration:
|
|
5
|
+
//
|
|
6
|
+
// codex `-c model=…` and `-c model_provider=…` at launch, and `[model_providers.<id>]` in the Codex
|
|
7
|
+
// home's `config.toml` with the endpoint resolved, `env_key` naming the key's variable (carried
|
|
8
|
+
// in the worker's launch environment) and `wire_api = "responses"`. Codex has no
|
|
9
|
+
// chat-completions wire, so `api_mode: chat_completions` is served on the endpoint's Responses
|
|
10
|
+
// API; an endpoint without one fails the worker's first call by name. Measured: Open Autonomy's
|
|
11
|
+
// model rail answers `/v1/responses` in its gateway's own stream (`response.stream`, no
|
|
12
|
+
// `response.completed`), which Codex cannot read; a Codex worker there needs an endpoint that
|
|
13
|
+
// speaks OpenAI's Responses stream.
|
|
14
|
+
//
|
|
15
|
+
// `provider: openai-codex` (Hermes's word for the owner's ChatGPT subscription) is the same table
|
|
16
|
+
// pointed at the valve's Codex forward, `HERMES_CODEX_BASE_URL` in the home's `.env` (the address
|
|
17
|
+
// Hermes's own pool uses), with the stand-in key `valve`: the forward holds no login and fetches the
|
|
18
|
+
// host Codex's current one per call, so the worker never holds it (Open Autonomy ADR 0001). Measured:
|
|
19
|
+
// a Codex with a login-free home answers through it, tool calls and MCP tools included.
|
|
20
|
+
//
|
|
21
|
+
// claude-code its own environment: `ANTHROPIC_BASE_URL` (the endpoint without its `/v1`, since Claude
|
|
22
|
+
// Code appends `/v1/messages`), the key as `ANTHROPIC_AUTH_TOKEN`, and the model as every alias
|
|
23
|
+
// Claude Code resolves (`ANTHROPIC_MODEL`, `ANTHROPIC_DEFAULT_{OPUS,SONNET,HAIKU}_MODEL`), so no
|
|
24
|
+
// background call reaches for a model nobody chose; `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC`
|
|
25
|
+
// keeps it off Anthropic's own services, which hold no credential here. The endpoint serves the
|
|
26
|
+
// Anthropic Messages wire (Open Autonomy's rail does, streamed, tools included: measured).
|
|
27
|
+
//
|
|
28
|
+
// A route this table does not have is refused by name at launch, never run on the worker's default:
|
|
29
|
+
// that would spend on a model nobody chose.
|
|
30
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
31
|
+
import { join } from 'node:path';
|
|
32
|
+
import { MODEL_ARGUMENTS } from './activation.mjs';
|
|
33
|
+
|
|
34
|
+
/** The Codex provider id a Hermes provider is served under. */
|
|
35
|
+
const PROVIDER_ID = { custom: 'hermes_custom', openai: 'openai', 'openai-codex': 'hermes_codex' };
|
|
36
|
+
|
|
37
|
+
/** The variable a literal key is carried under, when the home names none. */
|
|
38
|
+
export const LITERAL_KEY_ENV = 'SUPERCODE_MODEL_API_KEY';
|
|
39
|
+
|
|
40
|
+
const REF = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/;
|
|
41
|
+
|
|
42
|
+
function resolve(value, vault) {
|
|
43
|
+
const ref = typeof value === 'string' ? REF.exec(value.trim()) : null;
|
|
44
|
+
if (!ref) return { value, name: null };
|
|
45
|
+
const got = vault?.get?.(ref[1]) ?? process.env[ref[1]];
|
|
46
|
+
if (got === undefined) throw new Error(`model: \${${ref[1]}} is in neither the home's .env nor the environment`);
|
|
47
|
+
return { value: got, name: ref[1] };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The Hermes `model:` block as a route: `{ model, provider, base_url?, api_key?, api_mode? }`, or null. */
|
|
51
|
+
export function hermesModel(profile) {
|
|
52
|
+
const block = profile?.residue?.config?.model;
|
|
53
|
+
if (!block) return null;
|
|
54
|
+
if (typeof block === 'string') return { model: block };
|
|
55
|
+
return { model: block.default ?? block.model ?? null, provider: block.provider ?? null, base_url: block.base_url ?? null, api_key: block.api_key ?? null, api_mode: block.api_mode ?? null };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The worker's route for one launch: `{ model, args, env }` (launch arguments and launch environment),
|
|
60
|
+
* writing the provider table into the worker's config home. The launch's `worker.model` (a job's own
|
|
61
|
+
* model, or the `worker:` block's) wins over `model.default`.
|
|
62
|
+
*/
|
|
63
|
+
export function workerRoute(profile, worker, vault) {
|
|
64
|
+
const route = hermesModel(profile);
|
|
65
|
+
// `model.default` reaches only a worker that takes a model at launch; one that reads the home's own
|
|
66
|
+
// `model:` itself (Hermes over ACP) keeps it there
|
|
67
|
+
const model = worker?.model ?? (MODEL_ARGUMENTS[worker?.harness] ? route?.model ?? null : null);
|
|
68
|
+
if (route?.provider && worker?.harness === 'claude-code') return claudeRoute(profile, route, model, vault);
|
|
69
|
+
if (!route?.provider || worker?.harness !== 'codex') return { model, args: [], env: {} };
|
|
70
|
+
const id = PROVIDER_ID[route.provider];
|
|
71
|
+
if (!id) throw new Error(`model.provider ${route.provider} has no Codex route in this orchestrator yet; refused rather than run on Codex's default`);
|
|
72
|
+
const env = {};
|
|
73
|
+
const table = { name: `hermes ${route.provider}`, wire_api: 'responses' };
|
|
74
|
+
if (route.provider === 'openai-codex') {
|
|
75
|
+
const forward = vault?.get?.('HERMES_CODEX_BASE_URL') ?? process.env.HERMES_CODEX_BASE_URL;
|
|
76
|
+
if (!forward) throw new Error('model.provider openai-codex needs the Codex forward (HERMES_CODEX_BASE_URL in the home\'s .env, which the kit\'s start writes); refused rather than run on a login the worker would have to hold');
|
|
77
|
+
table.base_url = forward;
|
|
78
|
+
table.env_key = LITERAL_KEY_ENV;
|
|
79
|
+
env[LITERAL_KEY_ENV] = 'valve';
|
|
80
|
+
writeProviderTable(join(profile.dir, 'codex'), id, table);
|
|
81
|
+
return { model, args: ['-c', `model_provider=${JSON.stringify(id)}`], env };
|
|
82
|
+
}
|
|
83
|
+
if (route.base_url) table.base_url = resolve(route.base_url, vault).value;
|
|
84
|
+
if (route.api_key) {
|
|
85
|
+
const key = resolve(route.api_key, vault);
|
|
86
|
+
const name = key.name ?? LITERAL_KEY_ENV;
|
|
87
|
+
env[name] = key.value;
|
|
88
|
+
table.env_key = name;
|
|
89
|
+
}
|
|
90
|
+
if (id !== 'openai' || route.base_url) writeProviderTable(join(profile.dir, 'codex'), id, table);
|
|
91
|
+
return { model, args: ['-c', `model_provider=${JSON.stringify(id)}`], env };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Claude Code's route: its own environment and the endpoint's Messages wire. */
|
|
95
|
+
function claudeRoute(profile, route, model, vault) {
|
|
96
|
+
if (!['custom', 'anthropic'].includes(route.provider)) throw new Error(`model.provider ${route.provider} has no Claude Code route in this orchestrator yet; refused rather than run on Claude Code's default`);
|
|
97
|
+
if (!route.base_url) throw new Error('Claude Code runs on a model with an endpoint (model.base_url); refused rather than run on Claude Code\'s default');
|
|
98
|
+
const env = { CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' };
|
|
99
|
+
if (route.base_url) env.ANTHROPIC_BASE_URL = String(resolve(route.base_url, vault).value).replace(/\/v1\/?$/, '').replace(/\/$/, '');
|
|
100
|
+
if (route.api_key) env.ANTHROPIC_AUTH_TOKEN = resolve(route.api_key, vault).value;
|
|
101
|
+
if (model) for (const name of ['ANTHROPIC_MODEL', 'ANTHROPIC_DEFAULT_OPUS_MODEL', 'ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL']) env[name] = model;
|
|
102
|
+
return { model, args: [], env };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** `[model_providers.<id>]` in the Codex home's `config.toml`, replaced whole, the rest of the file kept. */
|
|
106
|
+
function writeProviderTable(home, id, table) {
|
|
107
|
+
mkdirSync(home, { recursive: true });
|
|
108
|
+
const file = join(home, 'config.toml');
|
|
109
|
+
const text = existsSync(file) ? readFileSync(file, 'utf8') : '';
|
|
110
|
+
const header = `[model_providers.${id}]`;
|
|
111
|
+
const block = [header, ...Object.entries(table).map(([k, v]) => `${k} = ${JSON.stringify(String(v))}`)].join('\n');
|
|
112
|
+
const lines = text.split('\n');
|
|
113
|
+
const start = lines.findIndex((line) => line.trim() === header);
|
|
114
|
+
let next;
|
|
115
|
+
if (start < 0) next = `${text}${text && !text.endsWith('\n') ? '\n' : ''}${text ? '\n' : ''}${block}\n`;
|
|
116
|
+
else {
|
|
117
|
+
let end = start + 1;
|
|
118
|
+
while (end < lines.length && !/^\s*\[/.test(lines[end])) end += 1;
|
|
119
|
+
next = [...lines.slice(0, start), ...block.split('\n'), ...lines.slice(end)].join('\n');
|
|
120
|
+
}
|
|
121
|
+
if (next !== text) writeFileSync(file, next, { mode: 0o600 });
|
|
122
|
+
}
|