@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.
Files changed (65) hide show
  1. package/DESIGN.md +408 -0
  2. package/LICENSE +15 -0
  3. package/README.md +133 -0
  4. package/bin/foreman.mjs +58 -0
  5. package/package.json +68 -0
  6. package/scripts/prepare.mjs +48 -0
  7. package/skills/director/SKILL.md +65 -0
  8. package/src/anthropic-models.ts +54 -0
  9. package/src/ask.test.ts +88 -0
  10. package/src/ask.ts +95 -0
  11. package/src/attachments.test.ts +33 -0
  12. package/src/attachments.ts +60 -0
  13. package/src/cli.test.ts +27 -0
  14. package/src/cli.ts +297 -0
  15. package/src/codex.test.ts +328 -0
  16. package/src/codex.ts +196 -0
  17. package/src/cost-basis.test.ts +76 -0
  18. package/src/deck.test.ts +402 -0
  19. package/src/deck.ts +892 -0
  20. package/src/fork.test.ts +31 -0
  21. package/src/gateway/ledger.cjs +326 -0
  22. package/src/gateway/ledger.test.ts +255 -0
  23. package/src/gateway/llm-gateway.cjs +1411 -0
  24. package/src/gateway/llm-gateway.test.ts +478 -0
  25. package/src/gateway.test.ts +226 -0
  26. package/src/gateway.ts +309 -0
  27. package/src/instance.ts +124 -0
  28. package/src/models.test.ts +147 -0
  29. package/src/models.ts +158 -0
  30. package/src/notify/commands.test.ts +28 -0
  31. package/src/notify/commands.ts +73 -0
  32. package/src/notify/telegram.ts +259 -0
  33. package/src/notify.test.ts +343 -0
  34. package/src/notify.ts +495 -0
  35. package/src/ollama.test.ts +49 -0
  36. package/src/ollama.ts +49 -0
  37. package/src/openai-prices.test.ts +58 -0
  38. package/src/openai-prices.ts +106 -0
  39. package/src/orchestrator.test.ts +1147 -0
  40. package/src/orchestrator.ts +2325 -0
  41. package/src/planner.test.ts +60 -0
  42. package/src/planner.ts +505 -0
  43. package/src/policy.test.ts +411 -0
  44. package/src/policy.ts +599 -0
  45. package/src/preflight.ts +348 -0
  46. package/src/prices.test.ts +69 -0
  47. package/src/prices.ts +90 -0
  48. package/src/provider.test.ts +366 -0
  49. package/src/provider.ts +502 -0
  50. package/src/secrets.test.ts +143 -0
  51. package/src/secrets.ts +66 -0
  52. package/src/server.ts +1992 -0
  53. package/src/services.test.ts +53 -0
  54. package/src/services.ts +102 -0
  55. package/src/sse-events.test.ts +83 -0
  56. package/src/store.test.ts +119 -0
  57. package/src/store.ts +346 -0
  58. package/src/tailscale.test.ts +32 -0
  59. package/src/tailscale.ts +79 -0
  60. package/src/title.ts +138 -0
  61. package/src/types.ts +442 -0
  62. package/ui/dist/assets/index-LAj0Dy9p.css +1 -0
  63. package/ui/dist/assets/index-lcBy-uRZ.js +65 -0
  64. package/ui/dist/favicon.svg +8 -0
  65. package/ui/dist/index.html +14 -0
@@ -0,0 +1,348 @@
1
+ /**
2
+ * Startup preflight — turns the ways a fresh install can be wrong into one
3
+ * readable screen instead of a stack trace (or, worse, silence).
4
+ *
5
+ * On credentials: Foreman runs on EITHER a Claude subscription OR an API key —
6
+ * both are supported, and both report real per-run cost, so budgets bind either
7
+ * way. What matters is that you know which one is active, because it determines
8
+ * who pays. The check reports the active mode and only blocks when there is no
9
+ * credential at all. Set FOREMAN_AUTH_MODE=api-key|subscription to assert the
10
+ * one you intend; startup then fails on a mismatch rather than quietly billing
11
+ * the other. Which provider a given project actually bills is a separate axis
12
+ * — see provider.ts.
13
+ */
14
+ import net from 'node:net';
15
+ import { execFile } from 'node:child_process';
16
+ import os from 'node:os';
17
+ import path from 'node:path';
18
+ import { access, mkdir, readFile } from 'node:fs/promises';
19
+ import { constants } from 'node:fs';
20
+ import { defaultInstance, describeInstance, effectiveConfigDir } from './instance.js';
21
+ import { discoverOllama, ollamaHost } from './ollama.js';
22
+ import { tailnetUrl, type Tailnet } from './tailscale.js';
23
+ import { codexHome, codexModels, readCodexAuth } from './codex.js';
24
+ import { createRequire } from 'node:module';
25
+ import { fileURLToPath } from 'node:url';
26
+
27
+ export type CheckStatus = 'ok' | 'warn' | 'error';
28
+
29
+ export interface Check {
30
+ name: string;
31
+ status: CheckStatus;
32
+ detail: string;
33
+ /** Shown beneath a warn/error as the thing to actually do. */
34
+ fix?: string;
35
+ }
36
+
37
+ /**
38
+ * Every place Claude Code may hold a subscription credential. Both are checked:
39
+ * CLAUDE_CONFIG_DIR relocates the file but not the macOS Keychain item, so a
40
+ * machine can easily have one and not the other.
41
+ */
42
+ function claudeCredentialsPaths(): string[] {
43
+ const dirs = [process.env.CLAUDE_CONFIG_DIR, path.join(os.homedir(), '.claude')].filter(
44
+ (d): d is string => Boolean(d),
45
+ );
46
+ return [...new Set(dirs)].map((d) => path.join(d, '.credentials.json'));
47
+ }
48
+
49
+ /** Presence only — never reads the secret. macOS stores Claude Code auth here. */
50
+ function keychainHasCredentials(): Promise<boolean> {
51
+ if (process.platform !== 'darwin') return Promise.resolve(false);
52
+ return new Promise((resolve) => {
53
+ execFile('security', ['find-generic-password', '-s', 'Claude Code-credentials'], (err) =>
54
+ resolve(!err),
55
+ );
56
+ });
57
+ }
58
+
59
+ /** Does this specific config dir hold a stored Claude Code login? */
60
+ export async function dirHasCredentials(configDir: string): Promise<boolean> {
61
+ return exists(path.join(configDir, '.credentials.json'));
62
+ }
63
+
64
+ /** Machine-wide Keychain login (macOS). Not tied to any one config dir. */
65
+ export const hasKeychainCredentials = keychainHasCredentials;
66
+
67
+ /** Who a config dir is signed in as. Reads only non-secret identity fields. */
68
+ export interface AccountInfo { email?: string; org?: string }
69
+
70
+ export async function readAccount(configDir: string): Promise<AccountInfo> {
71
+ try {
72
+ const raw = await readFile(path.join(configDir, '.claude.json'), 'utf8');
73
+ const acct = (JSON.parse(raw) as { oauthAccount?: Record<string, string> }).oauthAccount;
74
+ if (!acct) return {};
75
+ return { email: acct.emailAddress, org: acct.organizationName };
76
+ } catch {
77
+ return {};
78
+ }
79
+ }
80
+
81
+ async function exists(p: string): Promise<boolean> {
82
+ return access(p, constants.F_OK).then(() => true, () => false);
83
+ }
84
+
85
+ export type AuthMode = 'api-key' | 'subscription' | 'cloud' | 'none';
86
+
87
+ /** Which credential the SDK will actually pick up, and where it came from. */
88
+ export async function detectAuth(): Promise<{ mode: AuthMode; source: string; account?: AccountInfo }> {
89
+ if (process.env.ANTHROPIC_API_KEY) return { mode: 'api-key', source: 'ANTHROPIC_API_KEY' };
90
+ if (process.env.ANTHROPIC_AUTH_TOKEN) return { mode: 'api-key', source: 'ANTHROPIC_AUTH_TOKEN' };
91
+ if (process.env.CLAUDE_CODE_USE_BEDROCK) return { mode: 'cloud', source: 'Bedrock' };
92
+ if (process.env.CLAUDE_CODE_USE_VERTEX) return { mode: 'cloud', source: 'Vertex' };
93
+ if (process.env.CLAUDE_CODE_OAUTH_TOKEN) {
94
+ return { mode: 'subscription', source: 'CLAUDE_CODE_OAUTH_TOKEN' };
95
+ }
96
+
97
+ // The dir the AGENT will use — CLAUDE_CODE_CONFIG_DIR inherited from the
98
+ // launching shell counts. Reporting any other dir would name an account that
99
+ // is not the one being billed.
100
+ const dir = effectiveConfigDir({});
101
+ const account = await readAccount(dir);
102
+ const stored = (await exists(path.join(dir, '.credentials.json'))) || (await keychainHasCredentials());
103
+ if (stored || account.email) return { mode: 'subscription', source: dir, account };
104
+
105
+ return { mode: 'none', source: '' };
106
+ }
107
+
108
+ const MODE_LABEL: Record<AuthMode, string> = {
109
+ 'api-key': 'API key',
110
+ subscription: 'Claude subscription',
111
+ cloud: 'cloud provider',
112
+ none: 'none',
113
+ };
114
+
115
+ async function checkAuth(): Promise<Check> {
116
+ const name = 'Credentials';
117
+ const { mode, source, account } = await detectAuth();
118
+
119
+ if (mode === 'none') {
120
+ return {
121
+ name,
122
+ status: 'error',
123
+ detail: 'none found — no missions can run',
124
+ fix: 'Log in with Claude Code, or: export ANTHROPIC_API_KEY=sk-ant-...',
125
+ };
126
+ }
127
+
128
+ const expected = process.env.FOREMAN_AUTH_MODE;
129
+ if (expected && expected !== mode) {
130
+ return {
131
+ name,
132
+ status: 'error',
133
+ detail: `expected ${expected}, but ${MODE_LABEL[mode]} is active (${source})`,
134
+ fix:
135
+ expected === 'api-key'
136
+ ? 'export ANTHROPIC_API_KEY=sk-ant-... (an unset key silently falls back to your Claude Code login)'
137
+ : 'unset ANTHROPIC_API_KEY to use the subscription, or drop FOREMAN_AUTH_MODE',
138
+ };
139
+ }
140
+
141
+ const who = account?.email ? `${account.email}${account.org ? ` · ${account.org}` : ''}` : source;
142
+ return { name, status: 'ok', detail: `${MODE_LABEL[mode]} — ${who}` };
143
+ }
144
+
145
+ function checkInstance(): Check {
146
+ return { name: 'Claude Code', status: 'ok', detail: describeInstance(defaultInstance()) };
147
+ }
148
+
149
+ /**
150
+ * A local Ollama, if one is running.
151
+ *
152
+ * Absent is the common case and not a problem, so this reports nothing at all
153
+ * rather than a reassuring "not found" — the preflight screen exists to show
154
+ * what would stop a mission, and an unused capability is not that. When one IS
155
+ * running it is worth a line, because it means models are available with no
156
+ * configuration and the operator should know they are on offer.
157
+ */
158
+ async function checkOllama(): Promise<Check | null> {
159
+ const models = await discoverOllama(1200);
160
+ if (!models) return null;
161
+ const local = models.filter((m) => !m.remote).length;
162
+ const cloud = models.length - local;
163
+ const parts = [
164
+ local ? `${local} local` : null,
165
+ cloud ? `${cloud} cloud` : null,
166
+ ].filter(Boolean).join(' · ');
167
+ return {
168
+ name: 'Ollama',
169
+ status: models.length ? 'ok' : 'warn',
170
+ detail: models.length ? `${ollamaHost()} — ${parts}` : `${ollamaHost()} — running, no models pulled`,
171
+ fix: models.length ? undefined : 'ollama pull <model>, or use a :cloud model',
172
+ };
173
+ }
174
+
175
+ /**
176
+ * A Codex install, if there is one.
177
+ *
178
+ * Silent when Codex is not installed, for the same reason as Ollama. But an
179
+ * install with no login IS worth a warning rather than silence: the operator
180
+ * put Codex there on purpose, so a project pinned to it will fail, and the fix
181
+ * is one command.
182
+ */
183
+ async function checkCodex(): Promise<Check | null> {
184
+ const home = codexHome();
185
+ const auth = await readCodexAuth(home).catch(() => null);
186
+ const installed = await exists(path.join(home, 'auth.json'))
187
+ || await exists(path.join(home, 'config.toml'));
188
+ if (!installed) return null;
189
+
190
+ if (!auth) {
191
+ return {
192
+ name: 'Codex',
193
+ status: 'warn',
194
+ detail: `${home} — installed, not signed in`,
195
+ fix: 'codex login (Foreman reads that login; it never mints its own token)',
196
+ };
197
+ }
198
+ const models = await codexModels(home);
199
+ const how = auth.OPENAI_API_KEY ? 'API key' : 'ChatGPT subscription';
200
+ return {
201
+ name: 'Codex',
202
+ status: 'ok',
203
+ detail: `${home} — ${how}${models.length ? ` · ${models.length} models` : ''}`,
204
+ };
205
+ }
206
+
207
+ async function checkPort(port: number): Promise<Check> {
208
+ const name = `Port ${port}`;
209
+ const inUse = await new Promise<boolean>((resolve) => {
210
+ const probe = net.createServer();
211
+ probe.once('error', (err: NodeJS.ErrnoException) => resolve(err.code === 'EADDRINUSE'));
212
+ probe.once('listening', () => probe.close(() => resolve(false)));
213
+ probe.listen(port, '127.0.0.1');
214
+ });
215
+
216
+ return inUse
217
+ ? {
218
+ name,
219
+ status: 'error',
220
+ detail: 'already in use',
221
+ fix: `Another Foreman may be running. Stop it, or: PORT=${port + 1} npm start`,
222
+ }
223
+ : { name, status: 'ok', detail: 'free' };
224
+ }
225
+
226
+ async function checkHome(root: string): Promise<Check> {
227
+ const name = 'Data directory';
228
+ try {
229
+ await mkdir(root, { recursive: true });
230
+ await access(root, constants.W_OK);
231
+ return { name, status: 'ok', detail: root };
232
+ } catch (err) {
233
+ return {
234
+ name,
235
+ status: 'error',
236
+ detail: `${root} is not writable (${String(err)})`,
237
+ fix: 'Fix permissions, or point elsewhere: FOREMAN_HOME=/path/to/dir npm start',
238
+ };
239
+ }
240
+ }
241
+
242
+ /**
243
+ * The browser missions get when "browser" is on. The Playwright MCP defaults
244
+ * to the `chrome` channel — the Google Chrome already on the machine — which
245
+ * is why a fresh install needs no browser download. `FOREMAN_BROWSER` picks
246
+ * another channel or Playwright's own Chromium (installed with
247
+ * `npx playwright install chromium`).
248
+ */
249
+ async function checkBrowser(): Promise<Check> {
250
+ const name = 'Browser';
251
+ const want = (process.env.FOREMAN_BROWSER || 'chrome').toLowerCase();
252
+ const candidates: Record<string, string[]> = {
253
+ chrome: process.platform === 'darwin'
254
+ ? ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', path.join(os.homedir(), 'Applications/Google Chrome.app/Contents/MacOS/Google Chrome')]
255
+ : process.platform === 'win32'
256
+ ? [
257
+ path.join(process.env.ProgramFiles ?? 'C:\\Program Files', 'Google', 'Chrome', 'Application', 'chrome.exe'),
258
+ path.join(process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)', 'Google', 'Chrome', 'Application', 'chrome.exe'),
259
+ path.join(process.env.LOCALAPPDATA ?? '', 'Google', 'Chrome', 'Application', 'chrome.exe'),
260
+ ]
261
+ : ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/opt/google/chrome/chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser', '/snap/bin/chromium'],
262
+ msedge: process.platform === 'darwin' ? ['/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge']
263
+ : process.platform === 'win32' ? [path.join(process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)', 'Microsoft', 'Edge', 'Application', 'msedge.exe')]
264
+ : ['/usr/bin/microsoft-edge', '/usr/bin/microsoft-edge-stable'],
265
+ firefox: process.platform === 'darwin' ? ['/Applications/Firefox.app/Contents/MacOS/firefox'] : ['/usr/bin/firefox'],
266
+ };
267
+ let found: string | null = null;
268
+ if (want === 'chromium') {
269
+ // Playwright's own build, wherever the MCP's playwright-core says it lives.
270
+ try {
271
+ const req = createRequire(fileURLToPath(new URL('../node_modules/@playwright/mcp/cli.js', import.meta.url)));
272
+ const { chromium } = req('playwright-core') as { chromium: { executablePath(): string } };
273
+ const p = chromium.executablePath();
274
+ if (await exists(p)) found = p;
275
+ } catch { /* no playwright-core to ask */ }
276
+ return found
277
+ ? { name, status: 'ok', detail: `Playwright Chromium — ${found}` }
278
+ : { name, status: 'warn', detail: 'FOREMAN_BROWSER=chromium but Playwright Chromium is not installed; browser missions will fail', fix: 'npx playwright install chromium' };
279
+ }
280
+ for (const p of candidates[want] ?? []) if (await exists(p)) { found = p; break; }
281
+ return found
282
+ ? { name, status: 'ok', detail: `${want === 'chrome' ? 'Google Chrome' : want} — ${found}` }
283
+ : { name, status: 'warn', detail: `no ${want === 'chrome' ? 'Google Chrome' : want} found; missions with browser on will fail`, fix: 'Install Google Chrome, or: npx playwright install chromium && FOREMAN_BROWSER=chromium foreman' };
284
+ }
285
+
286
+ /** Where the phone can reach this. Says so plainly either way — the answer decides which links work. */
287
+ function checkTailnet(t: Tailnet | null, port: number): Check {
288
+ return t
289
+ ? { name: 'Tailscale', status: 'ok', detail: `${tailnetUrl(t, port)} — listening there too; phone links use it` }
290
+ : { name: 'Tailscale', status: 'ok', detail: 'not running — localhost only; phone links need a public URL in Settings → Notifications' };
291
+ }
292
+
293
+ async function checkUi(distDir: string): Promise<Check> {
294
+ const name = 'Dashboard';
295
+ return (await exists(path.join(distDir, 'index.html')))
296
+ ? { name, status: 'ok', detail: 'built' }
297
+ : {
298
+ name,
299
+ status: 'warn',
300
+ detail: 'ui/dist not built — the API works, the dashboard does not',
301
+ fix: 'npm run ui:build',
302
+ };
303
+ }
304
+
305
+ /**
306
+ * Runs every check. Pure: callers decide how to report and whether to exit.
307
+ */
308
+ export async function preflight(opts: {
309
+ port: number;
310
+ foremanHome: string;
311
+ distDir: string;
312
+ /** Detected by the server before preflight; null when not on a tailnet. */
313
+ tailnet?: Tailnet | null;
314
+ }): Promise<Check[]> {
315
+ const checks = await Promise.all([
316
+ Promise.resolve(checkTailnet(opts.tailnet ?? null, opts.port)),
317
+ checkAuth(),
318
+ checkInstance(),
319
+ checkOllama(),
320
+ checkCodex(),
321
+ checkPort(opts.port),
322
+ checkBrowser(),
323
+ checkHome(opts.foremanHome),
324
+ checkUi(opts.distDir),
325
+ ]);
326
+ // A null is a check that had nothing worth saying — see checkOllama().
327
+ return checks.filter((c): c is Check => c !== null);
328
+ }
329
+
330
+ const GLYPH: Record<CheckStatus, string> = { ok: '✓', warn: '!', error: '✗' };
331
+
332
+ /** Prints the checklist. Returns true when nothing blocks startup. */
333
+ export function reportPreflight(checks: Check[]): boolean {
334
+ const width = Math.max(...checks.map((c) => c.name.length));
335
+ console.log('Foreman preflight');
336
+ for (const c of checks) {
337
+ console.log(` ${GLYPH[c.status]} ${c.name.padEnd(width)} ${c.detail}`);
338
+ if (c.fix && c.status !== 'ok') console.log(` ${c.fix}`);
339
+ }
340
+
341
+ const failed = checks.filter((c) => c.status === 'error');
342
+ if (failed.length) {
343
+ console.log(`\nNot starting — ${failed.length} blocking problem(s) above.`);
344
+ return false;
345
+ }
346
+ console.log('');
347
+ return true;
348
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Prices, and where they are allowed to come from.
3
+ *
4
+ * The rule these defend is not arithmetic, it is provenance: a dollar figure
5
+ * on a gateway run may only ever come from the endpoint that sends the bill.
6
+ * Foreman showed a fabricated figure once — Anthropic's rates on Ollama
7
+ * tokens — and interrupted a finished mission at "125% of budget" over it.
8
+ */
9
+ import test from 'node:test';
10
+ import assert from 'node:assert/strict';
11
+ import { parsePricing, priceUsage } from './prices.js';
12
+
13
+ const usage = (over: Partial<Record<string, number>> = {}) => ({
14
+ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, ...over,
15
+ });
16
+
17
+ test('an OpenRouter pricing object parses, cache rates included', () => {
18
+ // The exact shape the live endpoint returns: rates as strings, per token.
19
+ assert.deepEqual(parsePricing({
20
+ prompt: '0.000002', completion: '0.00001',
21
+ input_cache_read: '0.0000002', input_cache_write: '0.0000025',
22
+ web_search: '0.01',
23
+ }), { input: 0.000002, output: 0.00001, cacheRead: 0.0000002, cacheWrite: 0.0000025 });
24
+ });
25
+
26
+ test('a model published at zero is priced at zero, not treated as unpriced', () => {
27
+ // OpenRouter's `:free` variants. Zero is a real answer from the biller.
28
+ assert.deepEqual(parsePricing({ prompt: '0', completion: '0' }), { input: 0, output: 0 });
29
+ });
30
+
31
+ test('a half-published price is no price at all', () => {
32
+ // A figure missing its output rate is confidently too low, and a number
33
+ // that is wrong is worse than none: "unpriced" sends someone to their
34
+ // vendor dashboard, a wrong total tells them not to bother.
35
+ assert.equal(parsePricing({ prompt: '0.000002' }), null);
36
+ assert.equal(parsePricing({ completion: '0.00001' }), null);
37
+ assert.equal(parsePricing({}), null);
38
+ assert.equal(parsePricing(null), null);
39
+ assert.equal(parsePricing('cheap'), null);
40
+ });
41
+
42
+ test('unparseable or negative rates are unpublished, never zero', () => {
43
+ // Reading a bad rate as 0 would price a paid model at nothing — the exact
44
+ // class of silent, confident understatement this module exists to avoid.
45
+ assert.equal(parsePricing({ prompt: 'free', completion: '0.1' }), null);
46
+ assert.equal(parsePricing({ prompt: '-1', completion: '0.1' }), null);
47
+ assert.equal(parsePricing({ prompt: NaN, completion: 1 }), null);
48
+ });
49
+
50
+ test('cost is the sum of each token class at its own rate', () => {
51
+ const price = { input: 0.000002, output: 0.00001, cacheRead: 0.0000002, cacheWrite: 0.0000025 };
52
+ const cost = priceUsage(price, usage({
53
+ inputTokens: 1000, outputTokens: 500, cacheReadTokens: 10_000, cacheWriteTokens: 2000,
54
+ }));
55
+ // 0.002 + 0.005 + 0.002 + 0.005
56
+ assert.ok(Math.abs(cost - 0.014) < 1e-9, `got ${cost}`);
57
+ });
58
+
59
+ test('unpublished cache rates fall back to the input rate', () => {
60
+ const price = { input: 0.000002, output: 0.00001 };
61
+ assert.equal(
62
+ priceUsage(price, usage({ cacheReadTokens: 1000 })),
63
+ priceUsage(price, usage({ inputTokens: 1000 })),
64
+ );
65
+ });
66
+
67
+ test('no tokens, no cost', () => {
68
+ assert.equal(priceUsage({ input: 1, output: 1 }, usage()), 0);
69
+ });
package/src/prices.ts ADDED
@@ -0,0 +1,90 @@
1
+ /**
2
+ * What a token actually costs, from the endpoint that will actually bill it.
3
+ *
4
+ * The obvious way to price a gateway run is a table of vendor rates shipped
5
+ * inside Foreman. This deliberately is not that. A table written from memory
6
+ * is a fabricated number wearing a dollar sign, and the whole reason the cost
7
+ * basis exists is that Foreman had been showing exactly that — Anthropic's
8
+ * rates applied to somebody else's tokens — and once killed a working mission
9
+ * over it.
10
+ *
11
+ * So prices come from the endpoint. OpenRouter publishes per-token rates for
12
+ * every model on `/v1/models`, including separate cache-read and cache-write
13
+ * rates, and it is the party that sends the bill — there is no more
14
+ * authoritative source, and nothing to keep in sync. An endpoint that
15
+ * publishes nothing stays `unpriced`, which is a true statement about what
16
+ * Foreman knows. It is not a gap to be filled in with a guess.
17
+ */
18
+
19
+ /**
20
+ * Rates in USD per token — the unit the source publishes, kept as-is.
21
+ *
22
+ * Per *million* tokens is how humans read pricing pages, and converting here
23
+ * would mean two places to get a factor of a million wrong. The conversion
24
+ * belongs in whatever renders it, once.
25
+ */
26
+ export interface ModelPrice {
27
+ input: number;
28
+ output: number;
29
+ /** Publishing these separately is common; absent means "same as input". */
30
+ cacheRead?: number;
31
+ cacheWrite?: number;
32
+ }
33
+
34
+ /** Token counts, as the orchestrator accumulates them. */
35
+ interface Usage {
36
+ inputTokens: number;
37
+ outputTokens: number;
38
+ cacheReadTokens: number;
39
+ cacheWriteTokens: number;
40
+ }
41
+
42
+ const num = (v: unknown): number | null => {
43
+ // Rates arrive as strings ("0.00001"), which is how a source avoids float
44
+ // formatting; anything that does not parse is treated as unpublished rather
45
+ // than as zero, because a zero rate silently prices a paid model at nothing.
46
+ const n = typeof v === 'string' ? Number(v) : typeof v === 'number' ? v : NaN;
47
+ return Number.isFinite(n) && n >= 0 ? n : null;
48
+ };
49
+
50
+ /**
51
+ * An OpenRouter-style `pricing` object, or null if it does not carry usable
52
+ * per-token rates.
53
+ *
54
+ * Both input and output must be present. A half-published price would produce
55
+ * a figure that is confidently too low, which is worse than showing none —
56
+ * "unpriced" sends someone to their vendor dashboard, a wrong number does not.
57
+ */
58
+ export function parsePricing(raw: unknown): ModelPrice | null {
59
+ if (!raw || typeof raw !== 'object') return null;
60
+ const p = raw as Record<string, unknown>;
61
+ const input = num(p.prompt);
62
+ const output = num(p.completion);
63
+ if (input === null || output === null) return null;
64
+ // A genuinely free model publishes 0/0; that is a real answer and priced at
65
+ // zero is exactly right for it.
66
+ const price: ModelPrice = { input, output };
67
+ const cacheRead = num(p.input_cache_read);
68
+ const cacheWrite = num(p.input_cache_write);
69
+ if (cacheRead !== null) price.cacheRead = cacheRead;
70
+ if (cacheWrite !== null) price.cacheWrite = cacheWrite;
71
+ return price;
72
+ }
73
+
74
+ /**
75
+ * What a batch of tokens costs, cache-aware.
76
+ *
77
+ * Cached reads and writes are billed at their own rates where the endpoint
78
+ * publishes them, and at the input rate where it does not. That fallback
79
+ * overstates a cache read (which is normally the cheapest token there is) and
80
+ * understates a cache write — but only for endpoints that declined to say, and
81
+ * both errors are bounded by the input rate rather than unbounded.
82
+ */
83
+ export function priceUsage(price: ModelPrice, usage: Usage): number {
84
+ return (
85
+ usage.inputTokens * price.input +
86
+ usage.outputTokens * price.output +
87
+ usage.cacheReadTokens * (price.cacheRead ?? price.input) +
88
+ usage.cacheWriteTokens * (price.cacheWrite ?? price.input)
89
+ );
90
+ }