@amenophis1er/foreman 0.1.0
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/DESIGN.md +408 -0
- package/LICENSE +15 -0
- package/README.md +133 -0
- package/bin/foreman.mjs +58 -0
- package/package.json +68 -0
- package/scripts/prepare.mjs +48 -0
- package/skills/director/SKILL.md +65 -0
- package/src/anthropic-models.ts +54 -0
- package/src/ask.test.ts +88 -0
- package/src/ask.ts +95 -0
- package/src/attachments.test.ts +33 -0
- package/src/attachments.ts +60 -0
- package/src/cli.test.ts +27 -0
- package/src/cli.ts +297 -0
- package/src/codex.test.ts +328 -0
- package/src/codex.ts +196 -0
- package/src/cost-basis.test.ts +76 -0
- package/src/deck.test.ts +402 -0
- package/src/deck.ts +892 -0
- package/src/fork.test.ts +31 -0
- package/src/gateway/ledger.cjs +326 -0
- package/src/gateway/ledger.test.ts +255 -0
- package/src/gateway/llm-gateway.cjs +1411 -0
- package/src/gateway/llm-gateway.test.ts +478 -0
- package/src/gateway.test.ts +226 -0
- package/src/gateway.ts +309 -0
- package/src/instance.ts +124 -0
- package/src/models.test.ts +147 -0
- package/src/models.ts +158 -0
- package/src/notify/commands.test.ts +28 -0
- package/src/notify/commands.ts +73 -0
- package/src/notify/telegram.ts +259 -0
- package/src/notify.test.ts +343 -0
- package/src/notify.ts +495 -0
- package/src/ollama.test.ts +49 -0
- package/src/ollama.ts +49 -0
- package/src/openai-prices.test.ts +58 -0
- package/src/openai-prices.ts +106 -0
- package/src/orchestrator.test.ts +1147 -0
- package/src/orchestrator.ts +2325 -0
- package/src/planner.test.ts +60 -0
- package/src/planner.ts +505 -0
- package/src/policy.test.ts +411 -0
- package/src/policy.ts +599 -0
- package/src/preflight.ts +348 -0
- package/src/prices.test.ts +69 -0
- package/src/prices.ts +90 -0
- package/src/provider.test.ts +366 -0
- package/src/provider.ts +502 -0
- package/src/secrets.test.ts +143 -0
- package/src/secrets.ts +66 -0
- package/src/server.ts +1992 -0
- package/src/services.test.ts +53 -0
- package/src/services.ts +102 -0
- package/src/sse-events.test.ts +83 -0
- package/src/store.test.ts +119 -0
- package/src/store.ts +346 -0
- package/src/tailscale.test.ts +32 -0
- package/src/tailscale.ts +79 -0
- package/src/title.ts +138 -0
- package/src/types.ts +442 -0
- package/ui/dist/assets/index-LAj0Dy9p.css +1 -0
- package/ui/dist/assets/index-lcBy-uRZ.js +65 -0
- package/ui/dist/favicon.svg +8 -0
- package/ui/dist/index.html +14 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gateway supervisor tests.
|
|
3
|
+
*
|
|
4
|
+
* These drive a real child process against a stub upstream rather than mocking
|
|
5
|
+
* the spawn: the things that break here — a port that never binds, a gateway
|
|
6
|
+
* that dies, a credential that does or does not reach the upstream — are all
|
|
7
|
+
* properties of an actual process, and a mock would assert the design back at
|
|
8
|
+
* itself.
|
|
9
|
+
*/
|
|
10
|
+
import test from 'node:test';
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
import http from 'node:http';
|
|
13
|
+
import {
|
|
14
|
+
ensureGateway, gatewayStatus, gatewayUsage, reapNow, releaseGateways, stopGateways,
|
|
15
|
+
} from './gateway.js';
|
|
16
|
+
import { providerEnv, resolveProvider } from './provider.js';
|
|
17
|
+
|
|
18
|
+
const ROOT = '/tmp/foreman-gateway-test';
|
|
19
|
+
|
|
20
|
+
/** A minimal OpenAI-compatible endpoint that records what it was sent. */
|
|
21
|
+
function stubUpstream(): Promise<{
|
|
22
|
+
url: string; seen: Array<{ auth?: string; body: any }>; close: () => Promise<void>;
|
|
23
|
+
}> {
|
|
24
|
+
const seen: Array<{ auth?: string; body: any }> = [];
|
|
25
|
+
const server = http.createServer((req, res) => {
|
|
26
|
+
const chunks: Buffer[] = [];
|
|
27
|
+
req.on('data', (c: Buffer) => chunks.push(c));
|
|
28
|
+
req.on('end', () => {
|
|
29
|
+
let body: any = null;
|
|
30
|
+
try { body = JSON.parse(Buffer.concat(chunks).toString()); } catch { /* not json */ }
|
|
31
|
+
seen.push({ auth: req.headers.authorization as string | undefined, body });
|
|
32
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
33
|
+
res.end(JSON.stringify({
|
|
34
|
+
id: 'chatcmpl-1', object: 'chat.completion', model: body?.model ?? 'stub',
|
|
35
|
+
choices: [{ index: 0, message: { role: 'assistant', content: 'pong' }, finish_reason: 'stop' }],
|
|
36
|
+
usage: { prompt_tokens: 3, completion_tokens: 1, total_tokens: 4 },
|
|
37
|
+
}));
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
return new Promise((resolve) => {
|
|
41
|
+
server.listen(0, '127.0.0.1', () => {
|
|
42
|
+
const addr = server.address();
|
|
43
|
+
const port = typeof addr === 'object' && addr ? addr.port : 0;
|
|
44
|
+
resolve({
|
|
45
|
+
url: `http://127.0.0.1:${port}`,
|
|
46
|
+
seen,
|
|
47
|
+
close: () => new Promise((r) => server.close(() => r())),
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
test('a gateway starts, translates, and carries the agent credential through', async (t) => {
|
|
54
|
+
const upstream = await stubUpstream();
|
|
55
|
+
t.after(async () => { stopGateways(); await upstream.close(); });
|
|
56
|
+
|
|
57
|
+
const provider = await resolveProvider(
|
|
58
|
+
{ kind: 'openai-compatible', id: 'stub', baseUrl: upstream.url, apiKeyEnv: 'TEST_GW_KEY' },
|
|
59
|
+
ROOT,
|
|
60
|
+
);
|
|
61
|
+
process.env.TEST_GW_KEY = 'sk-stub-key';
|
|
62
|
+
const withKey = { ...(await resolveProvider(
|
|
63
|
+
{ kind: 'openai-compatible', id: 'stub', baseUrl: upstream.url, apiKeyEnv: 'TEST_GW_KEY' }, ROOT)) };
|
|
64
|
+
assert.equal(withKey.problem, undefined);
|
|
65
|
+
assert.equal(provider.wire, 'gateway-openai');
|
|
66
|
+
|
|
67
|
+
const gatewayUrl = await ensureGateway(withKey);
|
|
68
|
+
assert.match(gatewayUrl, /^http:\/\/127\.0\.0\.1:\d+$/);
|
|
69
|
+
assert.notEqual(new URL(gatewayUrl).port, '11434', 'must not squat Ollama’s port');
|
|
70
|
+
|
|
71
|
+
// Speak to it the way the SDK does: Anthropic Messages in, x-api-key header.
|
|
72
|
+
const res = await fetch(`${gatewayUrl}/v1/messages`, {
|
|
73
|
+
method: 'POST',
|
|
74
|
+
headers: { 'content-type': 'application/json', 'x-api-key': 'sk-stub-key' },
|
|
75
|
+
body: JSON.stringify({
|
|
76
|
+
model: 'gpt-5', max_tokens: 16,
|
|
77
|
+
messages: [{ role: 'user', content: 'ping' }],
|
|
78
|
+
}),
|
|
79
|
+
});
|
|
80
|
+
assert.equal(res.status, 200);
|
|
81
|
+
const body = await res.json() as any;
|
|
82
|
+
|
|
83
|
+
// Out the far side it must look like an Anthropic response.
|
|
84
|
+
assert.equal(body.type, 'message');
|
|
85
|
+
assert.equal(body.role, 'assistant');
|
|
86
|
+
assert.equal(body.content[0].text, 'pong');
|
|
87
|
+
|
|
88
|
+
// And the upstream must have been spoken to in OpenAI's dialect, holding the
|
|
89
|
+
// credential the agent sent — not one the supervisor knows.
|
|
90
|
+
assert.equal(upstream.seen.length, 1);
|
|
91
|
+
assert.equal(upstream.seen[0].auth, 'Bearer sk-stub-key');
|
|
92
|
+
assert.equal(upstream.seen[0].body.messages[0].content, 'ping');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('the same upstream is served by one process, a different one by another', async (t) => {
|
|
96
|
+
const a = await stubUpstream();
|
|
97
|
+
const b = await stubUpstream();
|
|
98
|
+
t.after(async () => { stopGateways(); await a.close(); await b.close(); });
|
|
99
|
+
|
|
100
|
+
const mk = (url: string, id: string) =>
|
|
101
|
+
resolveProvider({ kind: 'openai-compatible', id, baseUrl: url }, ROOT);
|
|
102
|
+
|
|
103
|
+
const first = await ensureGateway(await mk(a.url, 'a'));
|
|
104
|
+
const same = await ensureGateway(await mk(a.url, 'a2'));
|
|
105
|
+
const other = await ensureGateway(await mk(b.url, 'b'));
|
|
106
|
+
|
|
107
|
+
assert.equal(first, same, 'one upstream, one process');
|
|
108
|
+
assert.notEqual(first, other, 'two upstreams cannot share a process');
|
|
109
|
+
assert.equal(gatewayStatus().length, 2);
|
|
110
|
+
assert.ok(gatewayStatus().every((g) => !('apiKey' in g)), 'status must never carry a secret');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('an agent env built for a gateway points at that gateway', async (t) => {
|
|
114
|
+
const upstream = await stubUpstream();
|
|
115
|
+
t.after(async () => { stopGateways(); await upstream.close(); });
|
|
116
|
+
|
|
117
|
+
const p = await resolveProvider(
|
|
118
|
+
{ kind: 'openai-compatible', id: 'e2e', baseUrl: upstream.url, model: 'llama3' }, ROOT);
|
|
119
|
+
const url = await ensureGateway(p);
|
|
120
|
+
const { env } = providerEnv(p, url);
|
|
121
|
+
assert.equal(env?.ANTHROPIC_BASE_URL, url);
|
|
122
|
+
assert.ok(env?.ANTHROPIC_API_KEY, 'the invariant still holds through the supervisor');
|
|
123
|
+
assert.equal(env?.ANTHROPIC_DEFAULT_SONNET_MODEL, 'llama3');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('a gateway a run still holds is never reaped, however quiet', async (t) => {
|
|
127
|
+
const upstream = await stubUpstream();
|
|
128
|
+
t.after(async () => { stopGateways(); await upstream.close(); });
|
|
129
|
+
|
|
130
|
+
const p = await resolveProvider(
|
|
131
|
+
{ kind: 'openai-compatible', id: 'held', baseUrl: upstream.url }, ROOT);
|
|
132
|
+
|
|
133
|
+
// Idle time moves when an agent env is BUILT, which happens once at
|
|
134
|
+
// dispatch — not when requests flow. Reaping on that alone pulled the proxy
|
|
135
|
+
// out from under a thirty-minute mission ten minutes in, and every call
|
|
136
|
+
// after it failed with a refused connection.
|
|
137
|
+
const url = await ensureGateway(p, 'run-1');
|
|
138
|
+
assert.equal(gatewayStatus().length, 1);
|
|
139
|
+
|
|
140
|
+
reapNow(Date.now() + 60 * 60_000);
|
|
141
|
+
assert.equal(gatewayStatus().length, 1, 'held by run-1, so it must survive');
|
|
142
|
+
|
|
143
|
+
const stillThere = await fetch(`${url}/v1/messages`, {
|
|
144
|
+
method: 'POST',
|
|
145
|
+
headers: { 'content-type': 'application/json', 'x-api-key': 'k' },
|
|
146
|
+
body: JSON.stringify({ model: 'm', max_tokens: 4, messages: [{ role: 'user', content: 'hi' }] }),
|
|
147
|
+
});
|
|
148
|
+
assert.equal(stillThere.status, 200, 'a held gateway still answers');
|
|
149
|
+
|
|
150
|
+
releaseGateways('run-1');
|
|
151
|
+
reapNow(Date.now() + 60 * 60_000);
|
|
152
|
+
assert.equal(gatewayStatus().length, 0, 'released and idle, so it goes');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('two runs holding one gateway: the first to finish does not kill it', async (t) => {
|
|
156
|
+
const upstream = await stubUpstream();
|
|
157
|
+
t.after(async () => { stopGateways(); await upstream.close(); });
|
|
158
|
+
|
|
159
|
+
const p = await resolveProvider(
|
|
160
|
+
{ kind: 'openai-compatible', id: 'shared', baseUrl: upstream.url }, ROOT);
|
|
161
|
+
await ensureGateway(p, 'run-a');
|
|
162
|
+
await ensureGateway(p, 'run-b');
|
|
163
|
+
|
|
164
|
+
releaseGateways('run-a');
|
|
165
|
+
reapNow(Date.now() + 60 * 60_000);
|
|
166
|
+
assert.equal(gatewayStatus().length, 1, 'run-b still needs it');
|
|
167
|
+
|
|
168
|
+
releaseGateways('run-b');
|
|
169
|
+
reapNow(Date.now() + 60 * 60_000);
|
|
170
|
+
assert.equal(gatewayStatus().length, 0);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('a run key on the path is stripped, attributed, and never sent upstream', async (t) => {
|
|
174
|
+
// The mechanism the whole ledger rests on: Foreman points an agent at
|
|
175
|
+
// `.../run/<key>`, the SDK preserves that prefix (measured — it sends
|
|
176
|
+
// `POST /run/<key>/v1/messages`), and the gateway must both count against
|
|
177
|
+
// the key AND hand the upstream the path it expects.
|
|
178
|
+
const upstream = await stubUpstream();
|
|
179
|
+
t.after(async () => { stopGateways(); await upstream.close(); });
|
|
180
|
+
|
|
181
|
+
const p = await resolveProvider(
|
|
182
|
+
{ kind: 'openai-compatible', id: 'ledger', baseUrl: upstream.url }, ROOT);
|
|
183
|
+
const url = await ensureGateway(p, 'run-x');
|
|
184
|
+
|
|
185
|
+
const ask = (path: string) => fetch(`${url}${path}/v1/messages`, {
|
|
186
|
+
method: 'POST',
|
|
187
|
+
headers: { 'content-type': 'application/json', 'x-api-key': 'k' },
|
|
188
|
+
body: JSON.stringify({ model: 'm', max_tokens: 8, messages: [{ role: 'user', content: 'hi' }] }),
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const res = await ask('/run/mission-1.0');
|
|
192
|
+
assert.equal(res.status, 200, 'a keyed request is served exactly like an unkeyed one');
|
|
193
|
+
const body = await res.json() as { content: Array<{ text: string }> };
|
|
194
|
+
assert.equal(body.content[0].text, 'pong', 'the agent must not be able to tell it was counted');
|
|
195
|
+
|
|
196
|
+
// The upstream sees its own API, not Foreman's routing.
|
|
197
|
+
assert.equal(upstream.seen.length, 1);
|
|
198
|
+
|
|
199
|
+
const totals = await gatewayUsage('mission-1.0');
|
|
200
|
+
assert.ok(totals, 'the key should have been attributed');
|
|
201
|
+
assert.equal(totals!.inputTokens, 3, 'the stub reports 3 prompt tokens');
|
|
202
|
+
assert.equal(totals!.outputTokens, 1);
|
|
203
|
+
assert.equal(totals!.calls, 1);
|
|
204
|
+
|
|
205
|
+
// A different attempt of the same run is a different bucket.
|
|
206
|
+
assert.equal(await gatewayUsage('mission-1.1'), null);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('an unkeyed request is still served, just not counted', async (t) => {
|
|
210
|
+
// A gateway that refused traffic it could not label would turn bookkeeping
|
|
211
|
+
// into an outage.
|
|
212
|
+
const upstream = await stubUpstream();
|
|
213
|
+
t.after(async () => { stopGateways(); await upstream.close(); });
|
|
214
|
+
|
|
215
|
+
const p = await resolveProvider(
|
|
216
|
+
{ kind: 'openai-compatible', id: 'unkeyed', baseUrl: upstream.url }, ROOT);
|
|
217
|
+
const url = await ensureGateway(p, 'run-y');
|
|
218
|
+
|
|
219
|
+
const res = await fetch(`${url}/v1/messages`, {
|
|
220
|
+
method: 'POST',
|
|
221
|
+
headers: { 'content-type': 'application/json', 'x-api-key': 'k' },
|
|
222
|
+
body: JSON.stringify({ model: 'm', max_tokens: 8, messages: [{ role: 'user', content: 'hi' }] }),
|
|
223
|
+
});
|
|
224
|
+
assert.equal(res.status, 200);
|
|
225
|
+
assert.equal((await res.json() as any).content[0].text, 'pong');
|
|
226
|
+
});
|
package/src/gateway.ts
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gateway supervisor — one translating proxy per active provider.
|
|
3
|
+
*
|
|
4
|
+
* `src/gateway/llm-gateway.cjs` is a verbatim copy from a sibling project. It
|
|
5
|
+
* takes its upstream and mode from process env and serves one of them, which
|
|
6
|
+
* is right for a container holding a single agent and wrong for Foreman, where
|
|
7
|
+
* one server may run missions for several projects on different providers at
|
|
8
|
+
* once.
|
|
9
|
+
*
|
|
10
|
+
* The answer is a process per provider rather than a router inside the file.
|
|
11
|
+
* That keeps the copy verbatim — its test suite applies unchanged and
|
|
12
|
+
* re-syncing upstream stays a `cp` — and it buys process isolation and a
|
|
13
|
+
* lifecycle that ends when a provider goes idle. See
|
|
14
|
+
* docs/provider-model-tracker.md, Decisions.
|
|
15
|
+
*
|
|
16
|
+
* Nothing here ever sees a credential. The gateway authenticates per request
|
|
17
|
+
* from the header the agent sends, so a running gateway is a route, not a
|
|
18
|
+
* secret: the key travels agent → gateway → upstream and is never in this
|
|
19
|
+
* process's argv, env, or logs.
|
|
20
|
+
*/
|
|
21
|
+
import { spawn, type ChildProcess } from 'node:child_process';
|
|
22
|
+
import net from 'node:net';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
import type { ResolvedProvider } from './provider.js';
|
|
25
|
+
|
|
26
|
+
// ledger.cjs, not llm-gateway.cjs: the wrapper binds the socket, counts tokens
|
|
27
|
+
// per run, and hands every request to the ported handler unchanged. See its
|
|
28
|
+
// header for why the counting lives outside the file it wraps.
|
|
29
|
+
const GATEWAY_CLI = fileURLToPath(new URL('./gateway/ledger.cjs', import.meta.url));
|
|
30
|
+
|
|
31
|
+
/** Give up on a gateway that has not bound its port by then. */
|
|
32
|
+
const START_TIMEOUT_MS = 10_000;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A gateway held by nobody for this long is shut down. Restarting one costs
|
|
36
|
+
* ~200ms, so reaping is cheap — but only once nothing can still need it.
|
|
37
|
+
*/
|
|
38
|
+
const IDLE_MS = 10 * 60_000;
|
|
39
|
+
|
|
40
|
+
/** Restarts within this window count toward the crash-loop cutoff. */
|
|
41
|
+
const CRASH_WINDOW_MS = 60_000;
|
|
42
|
+
const MAX_RESTARTS_PER_WINDOW = 3;
|
|
43
|
+
|
|
44
|
+
interface Gateway {
|
|
45
|
+
key: string;
|
|
46
|
+
port: number;
|
|
47
|
+
mode: 'openai' | 'codex';
|
|
48
|
+
upstream: string;
|
|
49
|
+
child: ChildProcess;
|
|
50
|
+
/** Bumped whenever an agent env is built against this gateway. */
|
|
51
|
+
lastUsed: number;
|
|
52
|
+
/**
|
|
53
|
+
* Runs currently depending on this gateway.
|
|
54
|
+
*
|
|
55
|
+
* Idle time alone is not safe to reap on: `lastUsed` moves when an agent
|
|
56
|
+
* environment is *built*, which happens once at dispatch, not when requests
|
|
57
|
+
* flow. A mission that ran for thirty minutes therefore looked idle after
|
|
58
|
+
* ten, and the proxy was pulled out from under a live run — every subsequent
|
|
59
|
+
* call failed with a refused connection, for the director and its workers
|
|
60
|
+
* alike. A gateway with holders is never reaped, however quiet it looks.
|
|
61
|
+
*/
|
|
62
|
+
holders: Set<string>;
|
|
63
|
+
restarts: number[];
|
|
64
|
+
/** Set when the gateway has crash-looped; reported instead of restarted. */
|
|
65
|
+
broken?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const running = new Map<string, Gateway>();
|
|
69
|
+
let reaper: NodeJS.Timeout | null = null;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Identity of a gateway. Mode and upstream are all that distinguish one from
|
|
73
|
+
* another — two providers pointing at the same endpoint in the same mode can
|
|
74
|
+
* share, since the credential rides on each request rather than the process.
|
|
75
|
+
*/
|
|
76
|
+
function keyFor(p: ResolvedProvider): string {
|
|
77
|
+
// Account id is part of the identity for Codex: it is the gateway's fallback
|
|
78
|
+
// when a token carries no account claim, and two logins sharing a process
|
|
79
|
+
// would then borrow the first one's.
|
|
80
|
+
return `${p.wire}|${p.upstreamUrl ?? ''}|${p.accountId ?? ''}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** An unused loopback port, chosen by the OS rather than guessed. */
|
|
84
|
+
function freePort(): Promise<number> {
|
|
85
|
+
return new Promise((resolve, reject) => {
|
|
86
|
+
const srv = net.createServer();
|
|
87
|
+
srv.on('error', reject);
|
|
88
|
+
srv.listen(0, '127.0.0.1', () => {
|
|
89
|
+
const addr = srv.address();
|
|
90
|
+
const port = typeof addr === 'object' && addr ? addr.port : 0;
|
|
91
|
+
srv.close(() => (port ? resolve(port) : reject(new Error('no free port'))));
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Resolves once something is listening on the port, or rejects on timeout. */
|
|
97
|
+
async function waitForListen(port: number, child: ChildProcess): Promise<void> {
|
|
98
|
+
const deadline = Date.now() + START_TIMEOUT_MS;
|
|
99
|
+
for (;;) {
|
|
100
|
+
if (child.exitCode !== null || child.signalCode) {
|
|
101
|
+
throw new Error(`gateway exited during startup (code ${child.exitCode ?? child.signalCode})`);
|
|
102
|
+
}
|
|
103
|
+
const up = await new Promise<boolean>((resolve) => {
|
|
104
|
+
const sock = net.connect({ port, host: '127.0.0.1' });
|
|
105
|
+
const done = (ok: boolean) => { sock.destroy(); resolve(ok); };
|
|
106
|
+
sock.once('connect', () => done(true));
|
|
107
|
+
sock.once('error', () => done(false));
|
|
108
|
+
sock.setTimeout(500, () => done(false));
|
|
109
|
+
});
|
|
110
|
+
if (up) return;
|
|
111
|
+
if (Date.now() > deadline) throw new Error(`gateway did not listen on ${port} within ${START_TIMEOUT_MS}ms`);
|
|
112
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Ensures a gateway is running for this provider and returns its base URL.
|
|
118
|
+
*
|
|
119
|
+
* Called on the dispatch path, so it throws rather than returning a broken
|
|
120
|
+
* URL: a run that cannot reach its gateway must fail with a reason, not send
|
|
121
|
+
* requests into a closed port.
|
|
122
|
+
*/
|
|
123
|
+
export async function ensureGateway(p: ResolvedProvider, holder?: string): Promise<string> {
|
|
124
|
+
if (p.wire === 'anthropic-native') throw new Error('native providers need no gateway');
|
|
125
|
+
const key = keyFor(p);
|
|
126
|
+
|
|
127
|
+
const existing = running.get(key);
|
|
128
|
+
if (existing) {
|
|
129
|
+
if (existing.broken) throw new Error(existing.broken);
|
|
130
|
+
if (existing.child.exitCode === null) {
|
|
131
|
+
existing.lastUsed = Date.now();
|
|
132
|
+
if (holder) existing.holders.add(holder);
|
|
133
|
+
return `http://127.0.0.1:${existing.port}`;
|
|
134
|
+
}
|
|
135
|
+
running.delete(key);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const mode = p.wire === 'gateway-codex' ? 'codex' as const : 'openai' as const;
|
|
139
|
+
const upstream = p.upstreamUrl ?? '';
|
|
140
|
+
const accountId = p.accountId ?? '';
|
|
141
|
+
const port = await freePort();
|
|
142
|
+
|
|
143
|
+
// Only routing goes in the env. LLM_GATEWAY_DEFAULT_MODEL is deliberately
|
|
144
|
+
// absent: the model-alias remap already happens in the agent's own env, and
|
|
145
|
+
// baking a model into a shared process would make it wrong for the next
|
|
146
|
+
// provider that reuses this gateway.
|
|
147
|
+
const child = spawn(process.execPath, [GATEWAY_CLI], {
|
|
148
|
+
env: {
|
|
149
|
+
...process.env,
|
|
150
|
+
LLM_GATEWAY_MODE: mode,
|
|
151
|
+
LLM_GATEWAY_TARGET_URL: upstream,
|
|
152
|
+
LLM_GATEWAY_PORT: String(port),
|
|
153
|
+
// The ported file defaults this to the name of the project it came from.
|
|
154
|
+
// Foreman says who it actually is: the originator header is a claim
|
|
155
|
+
// about which client is calling, and sending someone else's name — or
|
|
156
|
+
// the CLI's, to look like Codex itself — would be a lie told to a
|
|
157
|
+
// vendor. See provider-model.md §5.
|
|
158
|
+
CODEX_ORIGINATOR: process.env.CODEX_ORIGINATOR || 'foreman',
|
|
159
|
+
// Only consulted when the token carries no account claim of its own.
|
|
160
|
+
...(accountId ? { CODEX_ACCOUNT_ID: accountId } : {}),
|
|
161
|
+
// Opt-in request dumping for diagnosing a stall that only the real
|
|
162
|
+
// payload reproduces. Forwarded only when the operator set it: the
|
|
163
|
+
// files contain prompts and repository contents.
|
|
164
|
+
...(process.env.FOREMAN_GATEWAY_DUMP_DIR
|
|
165
|
+
? { FOREMAN_GATEWAY_DUMP_DIR: process.env.FOREMAN_GATEWAY_DUMP_DIR } : {}),
|
|
166
|
+
},
|
|
167
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
168
|
+
});
|
|
169
|
+
child.unref();
|
|
170
|
+
|
|
171
|
+
const gw: Gateway = {
|
|
172
|
+
key, port, mode, upstream, child, lastUsed: Date.now(), restarts: [],
|
|
173
|
+
holders: new Set(holder ? [holder] : []),
|
|
174
|
+
};
|
|
175
|
+
running.set(key, gw);
|
|
176
|
+
|
|
177
|
+
// The gateway logs one line per translated model substitution and its own
|
|
178
|
+
// startup banner. Prefix them so they are attributable in Foreman's output.
|
|
179
|
+
const log = (buf: Buffer) => {
|
|
180
|
+
for (const line of buf.toString().split('\n')) {
|
|
181
|
+
if (line.trim()) console.error(`[gateway ${mode}:${port}] ${line}`);
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
child.stdout?.on('data', log);
|
|
185
|
+
child.stderr?.on('data', log);
|
|
186
|
+
|
|
187
|
+
child.on('exit', (code, signal) => {
|
|
188
|
+
const g = running.get(key);
|
|
189
|
+
if (!g || g.child !== child) return; // already replaced
|
|
190
|
+
const now = Date.now();
|
|
191
|
+
g.restarts = [...g.restarts.filter((t) => now - t < CRASH_WINDOW_MS), now];
|
|
192
|
+
if (g.restarts.length > MAX_RESTARTS_PER_WINDOW) {
|
|
193
|
+
// A gateway that dies repeatedly is misconfigured, not unlucky. Report it
|
|
194
|
+
// instead of restarting forever behind a mission that keeps failing.
|
|
195
|
+
g.broken = `gateway for ${upstream || mode} keeps exiting (last: code ${code ?? signal})`;
|
|
196
|
+
console.error(`[gateway] ${g.broken}`);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
running.delete(key);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
await waitForListen(port, child);
|
|
204
|
+
} catch (err) {
|
|
205
|
+
child.kill();
|
|
206
|
+
running.delete(key);
|
|
207
|
+
throw new Error(`could not start the gateway: ${String(err instanceof Error ? err.message : err)}`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
startReaper();
|
|
211
|
+
return `http://127.0.0.1:${port}`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Retires gateways nothing holds and nothing has used recently.
|
|
216
|
+
*
|
|
217
|
+
* Exported so a test can run it at an arbitrary clock instead of waiting a
|
|
218
|
+
* real ten minutes — the property worth testing is *which* gateways it spares,
|
|
219
|
+
* and that is invisible on the interval's own schedule.
|
|
220
|
+
*/
|
|
221
|
+
export function reapNow(now = Date.now()): void {
|
|
222
|
+
for (const [key, g] of running) {
|
|
223
|
+
if (g.holders.size > 0) continue;
|
|
224
|
+
if (now - g.lastUsed < IDLE_MS) continue;
|
|
225
|
+
g.child.kill();
|
|
226
|
+
running.delete(key);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Stops gateways nothing has used recently. Cheap to restart on demand. */
|
|
231
|
+
function startReaper(): void {
|
|
232
|
+
if (reaper) return;
|
|
233
|
+
reaper = setInterval(() => {
|
|
234
|
+
reapNow();
|
|
235
|
+
if (running.size === 0 && reaper) {
|
|
236
|
+
clearInterval(reaper);
|
|
237
|
+
reaper = null;
|
|
238
|
+
}
|
|
239
|
+
}, 60_000);
|
|
240
|
+
reaper.unref();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Releases every gateway a run was holding. Called when the run ends, however
|
|
245
|
+
* it ends — the reaper can then retire anything nothing else needs.
|
|
246
|
+
*/
|
|
247
|
+
export function releaseGateways(holder: string): void {
|
|
248
|
+
for (const g of running.values()) {
|
|
249
|
+
if (g.holders.delete(holder)) g.lastUsed = Date.now();
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Stops every gateway. Called on server shutdown so none are orphaned. */
|
|
254
|
+
export function stopGateways(): void {
|
|
255
|
+
for (const [key, g] of running) {
|
|
256
|
+
g.child.kill();
|
|
257
|
+
running.delete(key);
|
|
258
|
+
}
|
|
259
|
+
if (reaper) {
|
|
260
|
+
clearInterval(reaper);
|
|
261
|
+
reaper = null;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Token totals every running gateway has counted for one run key.
|
|
267
|
+
*
|
|
268
|
+
* Summed across gateways because a run may hold two — a director on one
|
|
269
|
+
* provider, workers on another — and the caller wants the run's total, not a
|
|
270
|
+
* per-process breakdown. A gateway that has seen nothing for this key simply
|
|
271
|
+
* contributes nothing, so an unreachable or restarted one degrades to a lower
|
|
272
|
+
* number rather than an error: this is a live read of work in progress, and
|
|
273
|
+
* the SDK's own figure at the end of the turn remains the authority.
|
|
274
|
+
*/
|
|
275
|
+
export async function gatewayUsage(key: string): Promise<{
|
|
276
|
+
inputTokens: number; outputTokens: number; cacheReadTokens: number;
|
|
277
|
+
cacheWriteTokens: number; calls: number; costUsd?: number;
|
|
278
|
+
} | null> {
|
|
279
|
+
let out: {
|
|
280
|
+
inputTokens: number; outputTokens: number; cacheReadTokens: number;
|
|
281
|
+
cacheWriteTokens: number; calls: number; costUsd?: number;
|
|
282
|
+
} | null = null;
|
|
283
|
+
for (const g of running.values()) {
|
|
284
|
+
if (g.broken) continue;
|
|
285
|
+
const body = await fetch(`http://127.0.0.1:${g.port}/_foreman/usage`, {
|
|
286
|
+
signal: AbortSignal.timeout(1500),
|
|
287
|
+
}).then((r) => (r.ok ? r.json() : null)).catch(() => null) as Record<string, {
|
|
288
|
+
inputTokens: number; outputTokens: number; cacheReadTokens: number;
|
|
289
|
+
cacheWriteTokens: number; calls: number; costUsd?: number;
|
|
290
|
+
}> | null;
|
|
291
|
+
const t = body?.[key];
|
|
292
|
+
if (!t) continue;
|
|
293
|
+
out ??= { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, calls: 0 };
|
|
294
|
+
out.inputTokens += t.inputTokens;
|
|
295
|
+
out.outputTokens += t.outputTokens;
|
|
296
|
+
out.cacheReadTokens += t.cacheReadTokens;
|
|
297
|
+
out.cacheWriteTokens += t.cacheWriteTokens;
|
|
298
|
+
out.calls += t.calls;
|
|
299
|
+
if (typeof t.costUsd === 'number') out.costUsd = (out.costUsd ?? 0) + t.costUsd;
|
|
300
|
+
}
|
|
301
|
+
return out;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Running gateways, for preflight and diagnostics. Never includes secrets. */
|
|
305
|
+
export function gatewayStatus(): Array<{ mode: string; upstream: string; port: number; broken?: string }> {
|
|
306
|
+
return [...running.values()].map((g) => ({
|
|
307
|
+
mode: g.mode, upstream: g.upstream, port: g.port, broken: g.broken,
|
|
308
|
+
}));
|
|
309
|
+
}
|
package/src/instance.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code installs — discovery and description.
|
|
3
|
+
*
|
|
4
|
+
* A machine can host several side by side: distinct CLAUDE_CONFIG_DIRs (each
|
|
5
|
+
* with its own credentials, settings and plugins) and potentially distinct
|
|
6
|
+
* executables. This module finds them and describes them.
|
|
7
|
+
*
|
|
8
|
+
* It deliberately does NOT decide what an agent authenticates as. That is one
|
|
9
|
+
* question with one answer, and it lives in provider.ts — a Claude Code
|
|
10
|
+
* install is only one of several things a provider can be, and having two
|
|
11
|
+
* modules able to build an agent's environment is how a credential ends up
|
|
12
|
+
* somewhere it should not.
|
|
13
|
+
*/
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
|
|
18
|
+
export interface ClaudeInstance {
|
|
19
|
+
/** CLAUDE_CONFIG_DIR for the agent — selects credentials, settings, plugins. */
|
|
20
|
+
configDir?: string;
|
|
21
|
+
/** Claude Code executable; the SDK's bundled one is used when absent. */
|
|
22
|
+
executable?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Expands a leading `~` so config can be written the way people type it. */
|
|
26
|
+
function expandHome(p: string): string {
|
|
27
|
+
if (p === '~') return os.homedir();
|
|
28
|
+
if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
|
|
29
|
+
return p;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function clean(value: string | undefined): string | undefined {
|
|
33
|
+
const trimmed = value?.trim();
|
|
34
|
+
return trimmed ? expandHome(trimmed) : undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Server-wide default, from the environment. */
|
|
38
|
+
export function defaultInstance(): ClaudeInstance {
|
|
39
|
+
return {
|
|
40
|
+
configDir: clean(process.env.FOREMAN_CLAUDE_CONFIG_DIR),
|
|
41
|
+
executable: clean(process.env.FOREMAN_CLAUDE_EXECUTABLE),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* One-line description for the preflight screen and run history.
|
|
47
|
+
*
|
|
48
|
+
* Resolves through effectiveConfigDir rather than assuming ~/.claude, because a
|
|
49
|
+
* CLAUDE_CONFIG_DIR inherited from the launching shell silently selects a
|
|
50
|
+
* different account — and printing the wrong directory here is worse than
|
|
51
|
+
* printing nothing, since this line is what someone checks before spending.
|
|
52
|
+
*/
|
|
53
|
+
export function describeInstance(instance: ClaudeInstance): string {
|
|
54
|
+
const dir = effectiveConfigDir(instance);
|
|
55
|
+
const inherited = !instance.configDir ? ' (inherited)' : '';
|
|
56
|
+
return [
|
|
57
|
+
`${dir}${inherited}`,
|
|
58
|
+
instance.executable ? path.basename(instance.executable) : 'bundled executable',
|
|
59
|
+
].join(' · ');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** One Claude Code install offered in the project settings picker. */
|
|
63
|
+
export interface DiscoveredInstance {
|
|
64
|
+
/** Absolute config dir (CLAUDE_CONFIG_DIR). */
|
|
65
|
+
configDir: string;
|
|
66
|
+
/** How it was found, for the muted hint in the picker. */
|
|
67
|
+
origin: 'server default' | 'this process' | 'found on disk';
|
|
68
|
+
/** Whether that dir holds a stored subscription login. */
|
|
69
|
+
hasStoredLogin: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Config dirs worth offering: the server default, whatever this process was
|
|
74
|
+
* started with, and any `~/.claude*` directory that actually looks like a
|
|
75
|
+
* Claude Code install. Free-text entry stays available for anything missed.
|
|
76
|
+
*/
|
|
77
|
+
export async function discoverInstances(
|
|
78
|
+
probe: (dir: string) => Promise<boolean>,
|
|
79
|
+
): Promise<DiscoveredInstance[]> {
|
|
80
|
+
const home = os.homedir();
|
|
81
|
+
const origins = new Map<string, DiscoveredInstance['origin']>();
|
|
82
|
+
|
|
83
|
+
const note = (dir: string | undefined, origin: DiscoveredInstance['origin']) => {
|
|
84
|
+
const resolved = clean(dir);
|
|
85
|
+
if (resolved && !origins.has(resolved)) origins.set(resolved, origin);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
note(process.env.FOREMAN_CLAUDE_CONFIG_DIR, 'server default');
|
|
89
|
+
note(process.env.CLAUDE_CONFIG_DIR, 'this process');
|
|
90
|
+
note(path.join(home, '.claude'), 'found on disk');
|
|
91
|
+
|
|
92
|
+
const entries = await readdir(home, { withFileTypes: true }).catch(() => []);
|
|
93
|
+
for (const e of entries) {
|
|
94
|
+
if (!e.isDirectory() || !e.name.startsWith('.claude')) continue;
|
|
95
|
+
note(path.join(home, e.name), 'found on disk');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const found = await Promise.all(
|
|
99
|
+
[...origins].map(async ([configDir, origin]) => {
|
|
100
|
+
const hasStoredLogin = await probe(configDir);
|
|
101
|
+
// A dir only counts as an install if it holds a login or real settings.
|
|
102
|
+
const looksReal =
|
|
103
|
+
hasStoredLogin || (await stat(path.join(configDir, 'settings.json')).then(() => true, () => false));
|
|
104
|
+
return looksReal || origin !== 'found on disk'
|
|
105
|
+
? { configDir, origin, hasStoredLogin }
|
|
106
|
+
: null;
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
return found.filter((i): i is DiscoveredInstance => i !== null)
|
|
111
|
+
.sort((a, b) => a.configDir.localeCompare(b.configDir));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The config dir that will actually apply to an agent — the pin if there is
|
|
116
|
+
* one, otherwise whatever the server process itself resolves to.
|
|
117
|
+
*/
|
|
118
|
+
export function effectiveConfigDir(instance: ClaudeInstance): string {
|
|
119
|
+
return (
|
|
120
|
+
instance.configDir ??
|
|
121
|
+
clean(process.env.CLAUDE_CONFIG_DIR) ??
|
|
122
|
+
path.join(os.homedir(), '.claude')
|
|
123
|
+
);
|
|
124
|
+
}
|