getbotons 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +505 -2
- package/installer/admin.mjs +6 -0
- package/installer/botons.mjs +430 -0
- package/installer/brain-clis.mjs +28 -0
- package/installer/linger.mjs +39 -0
- package/installer/output.mjs +27 -0
- package/installer/revision.mjs +30 -0
- package/installer/shell-path.mjs +66 -0
- package/installer/source-lock.json +2891 -0
- package/package.json +26 -4
- package/payload/LICENSE +21 -0
- package/payload/SECURITY.md +33 -0
- package/payload/installer/admin.mjs +6 -0
- package/payload/installer/botons.mjs +430 -0
- package/payload/installer/brain-clis.mjs +28 -0
- package/payload/installer/linger.mjs +39 -0
- package/payload/installer/output.mjs +27 -0
- package/payload/installer/revision.mjs +30 -0
- package/payload/installer/shell-path.mjs +66 -0
- package/payload/installer/source-lock.json +2891 -0
- package/payload/npm-shrinkwrap.json +51 -0
- package/payload/package.json +13 -0
- package/payload/scripts/backup.ts +47 -0
- package/payload/scripts/botons-backup.service +15 -0
- package/payload/scripts/botons-backup.timer +11 -0
- package/payload/scripts/botons.service +36 -0
- package/payload/scripts/check-browser.ts +90 -0
- package/payload/scripts/doctor.ts +790 -0
- package/payload/scripts/install-backup-timer.ts +52 -0
- package/payload/scripts/install-service.ts +206 -0
- package/payload/scripts/login.ts +17 -0
- package/payload/scripts/migrate-names.ts +47 -0
- package/payload/scripts/pair.ts +163 -0
- package/payload/scripts/restore.ts +108 -0
- package/payload/server/bin/botons +4 -0
- package/payload/server/bin/legacy/minion +4 -0
- package/payload/server/bin/legacy/tots +4 -0
- package/payload/server/src/acting.ts +16 -0
- package/payload/server/src/api-keys.ts +544 -0
- package/payload/server/src/api.ts +1484 -0
- package/payload/server/src/app.ts +546 -0
- package/payload/server/src/approvals.ts +873 -0
- package/payload/server/src/ask-rules.ts +181 -0
- package/payload/server/src/audit.ts +158 -0
- package/payload/server/src/auth.ts +497 -0
- package/payload/server/src/backup.ts +382 -0
- package/payload/server/src/brain-login.ts +75 -0
- package/payload/server/src/browser-cert.ts +67 -0
- package/payload/server/src/browser-handoff.ts +119 -0
- package/payload/server/src/browser-handover.ts +175 -0
- package/payload/server/src/browser-policy.ts +210 -0
- package/payload/server/src/browser-proxy.ts +580 -0
- package/payload/server/src/browser-request.ts +335 -0
- package/payload/server/src/browser-screenshot.ts +56 -0
- package/payload/server/src/browser-send.ts +155 -0
- package/payload/server/src/browser.ts +2164 -0
- package/payload/server/src/callback.ts +218 -0
- package/payload/server/src/capabilities.ts +90 -0
- package/payload/server/src/cards.ts +243 -0
- package/payload/server/src/cli/approve-mcp.ts +119 -0
- package/payload/server/src/cli/botons.ts +629 -0
- package/payload/server/src/cli/browser-mcp.ts +168 -0
- package/payload/server/src/cli/workspace-file.ts +78 -0
- package/payload/server/src/cli-homes.ts +38 -0
- package/payload/server/src/cli-install.ts +104 -0
- package/payload/server/src/cli-updates.ts +112 -0
- package/payload/server/src/codex-config-repair.ts +80 -0
- package/payload/server/src/config.ts +473 -0
- package/payload/server/src/connector-access.ts +158 -0
- package/payload/server/src/connector-launch.ts +203 -0
- package/payload/server/src/connectors.ts +947 -0
- package/payload/server/src/disk-usage.ts +62 -0
- package/payload/server/src/engine.ts +2328 -0
- package/payload/server/src/env-init.ts +5 -0
- package/payload/server/src/events.ts +46 -0
- package/payload/server/src/files.ts +528 -0
- package/payload/server/src/github-access.ts +52 -0
- package/payload/server/src/health.ts +117 -0
- package/payload/server/src/held-dir.ts +96 -0
- package/payload/server/src/hooks.ts +141 -0
- package/payload/server/src/http.ts +207 -0
- package/payload/server/src/index.ts +139 -0
- package/payload/server/src/install-type.ts +11 -0
- package/payload/server/src/instructions.ts +204 -0
- package/payload/server/src/isolation.ts +234 -0
- package/payload/server/src/legacy-upgrade.ts +668 -0
- package/payload/server/src/limits.ts +44 -0
- package/payload/server/src/lock.ts +75 -0
- package/payload/server/src/log.ts +137 -0
- package/payload/server/src/managed-clis.ts +65 -0
- package/payload/server/src/mcp-client.ts +229 -0
- package/payload/server/src/notify.ts +193 -0
- package/payload/server/src/oauth-connections.ts +650 -0
- package/payload/server/src/oauth.ts +311 -0
- package/payload/server/src/outbound.ts +142 -0
- package/payload/server/src/portable.ts +1669 -0
- package/payload/server/src/prompt.ts +676 -0
- package/payload/server/src/providers/api-brain.ts +612 -0
- package/payload/server/src/providers/api-history.ts +131 -0
- package/payload/server/src/providers/api-tools.ts +481 -0
- package/payload/server/src/providers/api-wire.ts +690 -0
- package/payload/server/src/providers/claude-cli.ts +620 -0
- package/payload/server/src/providers/cli-check.ts +265 -0
- package/payload/server/src/providers/cli-turn.ts +20 -0
- package/payload/server/src/providers/codex-cli.ts +552 -0
- package/payload/server/src/providers/codex-workspace.ts +61 -0
- package/payload/server/src/providers/failures.ts +143 -0
- package/payload/server/src/providers/index.ts +131 -0
- package/payload/server/src/providers/mcp-stdio.ts +147 -0
- package/payload/server/src/providers/mock.ts +94 -0
- package/payload/server/src/providers/process.ts +268 -0
- package/payload/server/src/providers/readiness.ts +43 -0
- package/payload/server/src/providers/sse.ts +73 -0
- package/payload/server/src/providers/types.ts +173 -0
- package/payload/server/src/rates.ts +128 -0
- package/payload/server/src/reactions.ts +266 -0
- package/payload/server/src/recent-logs.ts +489 -0
- package/payload/server/src/rehearsal.ts +593 -0
- package/payload/server/src/rehearsals.ts +303 -0
- package/payload/server/src/replay.ts +165 -0
- package/payload/server/src/sandbox.ts +767 -0
- package/payload/server/src/schedule.ts +214 -0
- package/payload/server/src/scheduler.ts +24 -0
- package/payload/server/src/schema.ts +882 -0
- package/payload/server/src/secrets.ts +46 -0
- package/payload/server/src/setup.ts +356 -0
- package/payload/server/src/skill-packages.ts +363 -0
- package/payload/server/src/skills.ts +527 -0
- package/payload/server/src/spend.ts +255 -0
- package/payload/server/src/ssrf.ts +126 -0
- package/payload/server/src/starter-gate.ts +168 -0
- package/payload/server/src/starters.ts +610 -0
- package/payload/server/src/storage.ts +32 -0
- package/payload/server/src/store.ts +3087 -0
- package/payload/server/src/tailnet.ts +132 -0
- package/payload/server/src/templates.ts +454 -0
- package/payload/server/src/trash.ts +772 -0
- package/payload/server/src/util.ts +97 -0
- package/payload/server/src/webhooks.ts +216 -0
- package/payload/server/src/work-feed.ts +473 -0
- package/payload/shared/app-version.d.mts +1 -0
- package/payload/shared/app-version.mjs +3 -0
- package/payload/shared/boton.ts +323 -0
- package/payload/shared/brand.ts +107 -0
- package/payload/shared/cards.ts +427 -0
- package/payload/shared/effort.ts +91 -0
- package/payload/shared/files.ts +104 -0
- package/payload/shared/handoff.ts +97 -0
- package/payload/shared/install-commands.ts +7 -0
- package/payload/shared/legacy-names.d.mts +35 -0
- package/payload/shared/legacy-names.mjs +244 -0
- package/payload/shared/live-view.ts +94 -0
- package/payload/shared/look-critter.ts +189 -0
- package/payload/shared/look-tater.ts +127 -0
- package/payload/shared/look.ts +200 -0
- package/payload/shared/reactions.ts +43 -0
- package/payload/shared/seeded.ts +34 -0
- package/payload/shared/services.ts +90 -0
- package/payload/shared/types.ts +3016 -0
- package/payload/shared/util.ts +68 -0
- package/payload/shared/versions.ts +44 -0
- package/payload/shared/wake.ts +164 -0
- package/payload/web/dist/apple-touch-icon.png +0 -0
- package/payload/web/dist/assets/Icons-nytVAa9g.js +1 -0
- package/payload/web/dist/assets/NewBotDialog-DXr2XhDP.js +1 -0
- package/payload/web/dist/assets/PairScreen-CMcJO7cQ.js +1 -0
- package/payload/web/dist/assets/Panel-Bvqz3I3m.js +4 -0
- package/payload/web/dist/assets/ReactionPicker-B0SKsjiy.js +1 -0
- package/payload/web/dist/assets/SettingRows-CvLomtuo.js +4 -0
- package/payload/web/dist/assets/SettingsDialog-CQ41LV-P.js +8 -0
- package/payload/web/dist/assets/Setup-C-3P7Mtn.js +1 -0
- package/payload/web/dist/assets/Skills-DXMH69kH.js +1 -0
- package/payload/web/dist/assets/StarterSetup-Ce2V1_Cs.js +5 -0
- package/payload/web/dist/assets/Steps-CKtB3GAM.js +1 -0
- package/payload/web/dist/assets/emoji-catalog-DfpPz8qw.js +540 -0
- package/payload/web/dist/assets/index-BEU7FDxV.css +1 -0
- package/payload/web/dist/assets/index-Dhwn-rf0.js +3 -0
- package/payload/web/dist/assets/react-C1iE8mnY.js +9 -0
- package/payload/web/dist/assets/reactions-rw0YeCmA.js +1 -0
- package/payload/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- package/payload/web/dist/assets/util-Cx5feSJu.js +1 -0
- package/payload/web/dist/assets/vendor-B3qdcjGo.js +14 -0
- package/payload/web/dist/favicon.svg +1 -0
- package/payload/web/dist/icon-192.png +0 -0
- package/payload/web/dist/icon-512.png +0 -0
- package/payload/web/dist/icon.svg +1 -0
- package/payload/web/dist/index.html +67 -0
- package/payload/web/dist/manifest.webmanifest +12 -0
- package/shared/legacy-names.d.mts +35 -0
- package/shared/legacy-names.mjs +244 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { cliHomesEnv } from '../cli-homes.ts';
|
|
2
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
3
|
+
import { mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import type { Readable } from 'node:stream';
|
|
6
|
+
import { log } from '../log.ts';
|
|
7
|
+
import { nowIso } from '../util.ts';
|
|
8
|
+
import type { ProviderError } from './types.ts';
|
|
9
|
+
|
|
10
|
+
/** All an agent CLI inherits from the server: no API keys, server settings, or another agent session's variables. */
|
|
11
|
+
export const INHERITED_ENV = ['HOME', 'PATH', 'USER', 'LOGNAME', 'SHELL', 'LANG', 'TERM', 'TZ', 'TMPDIR', 'CLAUDE_CONFIG_DIR', 'CODEX_HOME', 'DISABLE_AUTOUPDATER', 'DISABLE_UPDATES'];
|
|
12
|
+
const INHERITED = new Set(INHERITED_ENV);
|
|
13
|
+
|
|
14
|
+
export function agentEnv(extra: Record<string, string>, from: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
15
|
+
const env: NodeJS.ProcessEnv = {};
|
|
16
|
+
for (const [k, v] of Object.entries(from)) {
|
|
17
|
+
if (v !== undefined && (INHERITED.has(k) || k.startsWith('LC_'))) env[k] = v;
|
|
18
|
+
}
|
|
19
|
+
return { ...env, ...extra, ...cliHomesEnv(from) };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Feed each JSON line of `stream` to `onEvent`, skipping other lines. Returns a function that feeds an unterminated last line. */
|
|
23
|
+
export function jsonLines(stream: Readable, onEvent: (event: any) => void): () => void {
|
|
24
|
+
let buf = '';
|
|
25
|
+
const feed = (line: string) => {
|
|
26
|
+
if (!line) return;
|
|
27
|
+
try {
|
|
28
|
+
onEvent(JSON.parse(line));
|
|
29
|
+
} catch {}
|
|
30
|
+
};
|
|
31
|
+
stream.setEncoding('utf8');
|
|
32
|
+
stream.on('data', (chunk: string) => {
|
|
33
|
+
buf += chunk;
|
|
34
|
+
let nl: number;
|
|
35
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
36
|
+
const line = buf.slice(0, nl).trim();
|
|
37
|
+
buf = buf.slice(nl + 1);
|
|
38
|
+
feed(line);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
return () => feed(buf.trim());
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface JsonlRun {
|
|
45
|
+
code: number | null;
|
|
46
|
+
signal: NodeJS.Signals | null;
|
|
47
|
+
stderr: string;
|
|
48
|
+
/** Stopped by `signal` before the CLI said its turn was over or ran past its time. */
|
|
49
|
+
aborted: boolean;
|
|
50
|
+
/** Ran past `timeoutMs` before the CLI said its turn was over or a stop came. */
|
|
51
|
+
timedOut: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function runJsonl(
|
|
55
|
+
cmd: string,
|
|
56
|
+
args: string[],
|
|
57
|
+
opts: {
|
|
58
|
+
cwd: string;
|
|
59
|
+
env: NodeJS.ProcessEnv;
|
|
60
|
+
stdin: string;
|
|
61
|
+
signal: AbortSignal;
|
|
62
|
+
timeoutMs: number;
|
|
63
|
+
onEvent: (event: any) => void;
|
|
64
|
+
/**
|
|
65
|
+
* The event that ends the CLI's turn as a success (its result): a stop or the time limit after it only stops a process
|
|
66
|
+
* that is exiting. A failure it writes and then hangs is still timed out or stopped.
|
|
67
|
+
*/
|
|
68
|
+
isDone?: (event: any) => boolean;
|
|
69
|
+
groupDir?: string;
|
|
70
|
+
},
|
|
71
|
+
): Promise<JsonlRun> {
|
|
72
|
+
return new Promise((resolve, reject) => {
|
|
73
|
+
// Its own process group, so a stop kills the commands it started too.
|
|
74
|
+
const child = spawn(cmd, args, { cwd: opts.cwd, env: opts.env, stdio: ['pipe', 'pipe', 'pipe'], detached: true });
|
|
75
|
+
let stderr = '';
|
|
76
|
+
let aborted = false;
|
|
77
|
+
let timedOut = false;
|
|
78
|
+
// The CLI said its turn succeeded: what comes after is only it exiting.
|
|
79
|
+
let ended = false;
|
|
80
|
+
const record = opts.groupDir && child.pid ? recordGroup(opts.groupDir, child.pid, cmd) : null;
|
|
81
|
+
const forget = () => {
|
|
82
|
+
if (record) unlinkQuietly(record);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const kill = () => {
|
|
86
|
+
try {
|
|
87
|
+
if (child.pid) process.kill(-child.pid, 'SIGTERM');
|
|
88
|
+
} catch {}
|
|
89
|
+
setTimeout(() => {
|
|
90
|
+
try {
|
|
91
|
+
if (child.pid) process.kill(-child.pid, 'SIGKILL');
|
|
92
|
+
} catch {}
|
|
93
|
+
}, 3000).unref();
|
|
94
|
+
};
|
|
95
|
+
// Whichever ends the turn first is why it ended: its result, a stop, or the time limit. A stop that comes while a
|
|
96
|
+
// timed-out process is exiting doesn't make it stopped, and a result it writes once killed doesn't make it succeed.
|
|
97
|
+
const onAbort = () => {
|
|
98
|
+
if (!timedOut && !ended) aborted = true;
|
|
99
|
+
kill();
|
|
100
|
+
};
|
|
101
|
+
opts.signal.addEventListener('abort', onAbort, { once: true });
|
|
102
|
+
const timer = setTimeout(() => {
|
|
103
|
+
if (!aborted && !ended) timedOut = true;
|
|
104
|
+
kill();
|
|
105
|
+
}, opts.timeoutMs);
|
|
106
|
+
|
|
107
|
+
child.on('error', (err) => {
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
opts.signal.removeEventListener('abort', onAbort);
|
|
110
|
+
forget();
|
|
111
|
+
reject(err);
|
|
112
|
+
});
|
|
113
|
+
const flush = jsonLines(child.stdout, (ev) => {
|
|
114
|
+
if (!aborted && !timedOut && opts.isDone?.(ev)) ended = true;
|
|
115
|
+
opts.onEvent(ev);
|
|
116
|
+
});
|
|
117
|
+
child.stderr.setEncoding('utf8');
|
|
118
|
+
child.stderr.on('data', (chunk: string) => {
|
|
119
|
+
stderr = (stderr + chunk).slice(-8000);
|
|
120
|
+
});
|
|
121
|
+
child.on('close', (code, signal) => {
|
|
122
|
+
clearTimeout(timer);
|
|
123
|
+
opts.signal.removeEventListener('abort', onAbort);
|
|
124
|
+
forget();
|
|
125
|
+
flush();
|
|
126
|
+
resolve({ code, signal, stderr, aborted, timedOut });
|
|
127
|
+
});
|
|
128
|
+
child.stdin.on('error', () => {});
|
|
129
|
+
child.stdin.end(opts.stdin);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// A server that dies without draining leaves its agents' process groups running, and the next start
|
|
134
|
+
// would answer the same messages again. So each group is recorded while it runs, and the next start stops what is left.
|
|
135
|
+
|
|
136
|
+
interface GroupRecord {
|
|
137
|
+
pid: number;
|
|
138
|
+
/** The leader's start time, to tell it from a later process with its pid. */
|
|
139
|
+
startTime: string | null;
|
|
140
|
+
bootId: string | null;
|
|
141
|
+
cmd: string;
|
|
142
|
+
at: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function unlinkQuietly(path: string): void {
|
|
146
|
+
try {
|
|
147
|
+
unlinkSync(path);
|
|
148
|
+
} catch {}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function bootId(): string | null {
|
|
152
|
+
try {
|
|
153
|
+
return readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim() || null;
|
|
154
|
+
} catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function processStartTime(pid: number): string | null {
|
|
160
|
+
if (process.platform === 'linux') {
|
|
161
|
+
try {
|
|
162
|
+
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
163
|
+
// Field 22 (starttime); the fields after the command's closing paren start at field 3.
|
|
164
|
+
return stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19] ?? null;
|
|
165
|
+
} catch {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
return execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || null;
|
|
171
|
+
} catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function recordGroup(dir: string, pid: number, cmd: string): string | null {
|
|
177
|
+
const path = join(dir, `${pid}.json`);
|
|
178
|
+
try {
|
|
179
|
+
mkdirSync(dir, { recursive: true });
|
|
180
|
+
const rec: GroupRecord = { pid, startTime: processStartTime(pid), bootId: bootId(), cmd, at: nowIso() };
|
|
181
|
+
writeFileSync(path, JSON.stringify(rec));
|
|
182
|
+
return path;
|
|
183
|
+
} catch (err) {
|
|
184
|
+
log.warn('could not record an agent process group', { pid, err });
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function groupAlive(pgid: number): boolean {
|
|
190
|
+
try {
|
|
191
|
+
process.kill(-pgid, 0);
|
|
192
|
+
return true;
|
|
193
|
+
} catch {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Stop the recorded groups a previous server left running, and wait for them. A group is only signalled if it is
|
|
200
|
+
* still the same one: same boot, and its leader started when recorded (or the leader is gone but the group lives).
|
|
201
|
+
*/
|
|
202
|
+
export async function reapOrphanGroups(dir: string): Promise<number> {
|
|
203
|
+
let names: string[];
|
|
204
|
+
try {
|
|
205
|
+
names = readdirSync(dir).filter((n) => n.endsWith('.json'));
|
|
206
|
+
} catch {
|
|
207
|
+
return 0;
|
|
208
|
+
}
|
|
209
|
+
const boot = bootId();
|
|
210
|
+
const groups: GroupRecord[] = [];
|
|
211
|
+
for (const name of names) {
|
|
212
|
+
const path = join(dir, name);
|
|
213
|
+
let rec: GroupRecord;
|
|
214
|
+
try {
|
|
215
|
+
rec = JSON.parse(readFileSync(path, 'utf8')) as GroupRecord;
|
|
216
|
+
} catch {
|
|
217
|
+
unlinkQuietly(path);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
unlinkQuietly(path);
|
|
221
|
+
const pid = Number(rec.pid);
|
|
222
|
+
if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid || !groupAlive(pid)) continue;
|
|
223
|
+
if (rec.bootId && boot && rec.bootId !== boot) continue; // rebooted since
|
|
224
|
+
const started = processStartTime(pid);
|
|
225
|
+
if (started !== null ? started !== rec.startTime : !(rec.bootId && boot)) continue; // another process now, or can't tell
|
|
226
|
+
groups.push(rec);
|
|
227
|
+
}
|
|
228
|
+
if (!groups.length) return 0;
|
|
229
|
+
for (const g of groups) {
|
|
230
|
+
log.warn('stopping an agent CLI that a previous server process left running (it died without draining), before its work runs again', {
|
|
231
|
+
pid: g.pid,
|
|
232
|
+
cmd: g.cmd,
|
|
233
|
+
since: g.at,
|
|
234
|
+
});
|
|
235
|
+
try {
|
|
236
|
+
process.kill(-g.pid, 'SIGTERM');
|
|
237
|
+
} catch {}
|
|
238
|
+
}
|
|
239
|
+
const waitFor = async (ms: number) => {
|
|
240
|
+
const end = Date.now() + ms;
|
|
241
|
+
while (groups.some((g) => groupAlive(g.pid)) && Date.now() < end) await new Promise((r) => setTimeout(r, 50));
|
|
242
|
+
};
|
|
243
|
+
await waitFor(5000);
|
|
244
|
+
for (const g of groups) {
|
|
245
|
+
if (!groupAlive(g.pid)) continue;
|
|
246
|
+
try {
|
|
247
|
+
process.kill(-g.pid, 'SIGKILL');
|
|
248
|
+
} catch {}
|
|
249
|
+
}
|
|
250
|
+
await waitFor(2000);
|
|
251
|
+
const left = groups.filter((g) => groupAlive(g.pid));
|
|
252
|
+
if (left.length) log.error('agent CLIs left by a previous server process would not stop', { pids: left.map((g) => g.pid) });
|
|
253
|
+
return groups.length;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function failureMessage(label: string, run: JsonlRun, fallback: string | null): string {
|
|
257
|
+
if (run.aborted) return 'Stopped.';
|
|
258
|
+
if (run.timedOut) return `${label} timed out.`;
|
|
259
|
+
const tail = run.stderr.trim().split('\n').slice(-6).join('\n');
|
|
260
|
+
return fallback || tail || `${label} exited with code ${run.code}`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** `err` with how the CLI process ended (its last 20 stderr lines, at most 2000 characters), for the turn's log line. */
|
|
264
|
+
export function withExit(err: ProviderError, run: JsonlRun): ProviderError {
|
|
265
|
+
const tail = run.stderr.trimEnd().split('\n').slice(-20).join('\n');
|
|
266
|
+
err.exit = { exitCode: run.code, signal: run.signal, timedOut: run.timedOut, stderrTail: tail.length > 2000 ? tail.slice(-2000) : tail };
|
|
267
|
+
return err;
|
|
268
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { BrainCheckProblem, BrainReadiness, BrainState } from '../../../shared/types.ts';
|
|
2
|
+
import type { ProviderStatus } from './types.ts';
|
|
3
|
+
|
|
4
|
+
/** A check older than this says nothing about now (the checker runs a new one after 5 minutes, when asked). */
|
|
5
|
+
export const CHECK_OUTDATED_MS = 30 * 60_000;
|
|
6
|
+
|
|
7
|
+
/** What a check's fixed word means for the brain's state. */
|
|
8
|
+
const FROM_CHECK: Record<NonNullable<NonNullable<ProviderStatus['check']>['problem']>, BrainState> = {
|
|
9
|
+
'not-found': 'not-installed',
|
|
10
|
+
'not-runnable': 'unavailable',
|
|
11
|
+
'incompatible-version': 'unavailable',
|
|
12
|
+
'version-failed': 'unavailable',
|
|
13
|
+
'timed-out': 'unknown',
|
|
14
|
+
'login-unreadable': 'unknown',
|
|
15
|
+
'signed-out': 'signed-out',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* How ready `p` is, from what its last check found and the usage limit its turns hit, if any. Ready only when a check
|
|
20
|
+
* ran within CHECK_OUTDATED_MS and confirmed it installed and signed in; never a promise that a turn will work.
|
|
21
|
+
*/
|
|
22
|
+
export function brainReadiness(p: ProviderStatus, usageLimitUntil: string | null, now = Date.now(), boot = ''): BrainReadiness {
|
|
23
|
+
const generation = p.check?.generation ?? 0;
|
|
24
|
+
const at = p.checkedAt ? Date.parse(p.checkedAt) : Number.NaN;
|
|
25
|
+
const out = (state: BrainState, problem: BrainCheckProblem | null): BrainReadiness => ({ state, problem, checkedAt: p.checkedAt, usageLimitUntil, boot, generation });
|
|
26
|
+
let state: BrainState;
|
|
27
|
+
let problem: BrainCheckProblem | null;
|
|
28
|
+
if (Number.isNaN(at)) [state, problem] = ['unknown', 'not-checked'];
|
|
29
|
+
// Stamped later than now: the clock was set back since, so how old the check is can't be told (the checker runs a
|
|
30
|
+
// new one when next asked).
|
|
31
|
+
else if (at > now) [state, problem] = ['unknown', 'clock-changed'];
|
|
32
|
+
else if (now - at > CHECK_OUTDATED_MS) [state, problem] = ['unknown', 'outdated'];
|
|
33
|
+
else if (p.check?.problem) [state, problem] = [FROM_CHECK[p.check.problem], p.check.problem];
|
|
34
|
+
// A provider whose check doesn't name its problem.
|
|
35
|
+
else if (!p.installed) [state, problem] = ['not-installed', 'not-found'];
|
|
36
|
+
else if (p.loggedIn === false) [state, problem] = ['signed-out', 'signed-out'];
|
|
37
|
+
else if (!p.available) [state, problem] = ['unavailable', 'unavailable'];
|
|
38
|
+
else if (p.loggedIn !== true) [state, problem] = ['unknown', 'login-unreadable'];
|
|
39
|
+
else [state, problem] = ['ready', null];
|
|
40
|
+
// A turn that hit the limit got past the sign-in, whatever the check could tell.
|
|
41
|
+
if (usageLimitUntil && (state === 'ready' || state === 'unknown')) return out('usage-limited', 'usage-limit');
|
|
42
|
+
return out(state, problem);
|
|
43
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/** One server-sent event: its `event:` name ("message" when it has none) and its `data:` lines joined. */
|
|
2
|
+
export interface SseEvent {
|
|
3
|
+
event: string;
|
|
4
|
+
data: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export class StreamError extends Error {
|
|
8
|
+
/** The stream ended (or broke) before the provider said its answer was complete. */
|
|
9
|
+
constructor(message: string) {
|
|
10
|
+
super(message);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The events of a streamed answer, in order. Bounded: at most `maxBytes` in all and `maxEvent` per event, and no
|
|
16
|
+
* gap longer than `idleMs` between chunks (a provider that went silent ends the turn rather than hold it).
|
|
17
|
+
*/
|
|
18
|
+
export async function* sseEvents(body: ReadableStream<Uint8Array>, opts: { maxBytes: number; maxEvent: number; idleMs: number; signal: AbortSignal }): AsyncGenerator<SseEvent> {
|
|
19
|
+
const reader = body.getReader();
|
|
20
|
+
const decoder = new TextDecoder();
|
|
21
|
+
let buf = '';
|
|
22
|
+
let total = 0;
|
|
23
|
+
try {
|
|
24
|
+
for (;;) {
|
|
25
|
+
let timer: NodeJS.Timeout | undefined;
|
|
26
|
+
const idle = new Promise<never>((_, reject) => {
|
|
27
|
+
timer = setTimeout(() => reject(new StreamError(`no data for ${Math.round(opts.idleMs / 1000)} s`)), opts.idleMs);
|
|
28
|
+
});
|
|
29
|
+
let chunk: ReadableStreamReadResult<Uint8Array>;
|
|
30
|
+
try {
|
|
31
|
+
chunk = await Promise.race([reader.read(), idle]);
|
|
32
|
+
} finally {
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
}
|
|
35
|
+
if (chunk.done) break;
|
|
36
|
+
total += chunk.value.byteLength;
|
|
37
|
+
if (total > opts.maxBytes) throw new StreamError('the answer was larger than Botons reads');
|
|
38
|
+
buf += decoder.decode(chunk.value, { stream: true });
|
|
39
|
+
let at: number;
|
|
40
|
+
while ((at = boundary(buf)) >= 0) {
|
|
41
|
+
const block = buf.slice(0, at);
|
|
42
|
+
buf = buf.slice(at).replace(/^(\r?\n){2}|^\r\r/, '');
|
|
43
|
+
const ev = parse(block);
|
|
44
|
+
if (ev) yield ev;
|
|
45
|
+
}
|
|
46
|
+
if (buf.length > opts.maxEvent) throw new StreamError('one event of the answer was larger than Botons reads');
|
|
47
|
+
}
|
|
48
|
+
buf += decoder.decode();
|
|
49
|
+
const last = parse(buf);
|
|
50
|
+
if (last) yield last;
|
|
51
|
+
} finally {
|
|
52
|
+
reader.cancel().catch(() => {});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function boundary(buf: string): number {
|
|
57
|
+
const m = /\r?\n\r?\n|\r\r/.exec(buf);
|
|
58
|
+
return m ? m.index : -1;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parse(block: string): SseEvent | null {
|
|
62
|
+
let event = 'message';
|
|
63
|
+
const data: string[] = [];
|
|
64
|
+
for (const line of block.split(/\r?\n|\r/)) {
|
|
65
|
+
if (!line || line.startsWith(':')) continue;
|
|
66
|
+
const colon = line.indexOf(':');
|
|
67
|
+
const field = colon < 0 ? line : line.slice(0, colon);
|
|
68
|
+
const value = colon < 0 ? '' : line.slice(colon + 1).replace(/^ /, '');
|
|
69
|
+
if (field === 'event') event = value;
|
|
70
|
+
else if (field === 'data') data.push(value);
|
|
71
|
+
}
|
|
72
|
+
return data.length ? { event, data: data.join('\n') } : null;
|
|
73
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import type { AccessLevel, ProviderInfo, StepKind, TokenUsage, TurnStep } from '../../../shared/types.ts';
|
|
2
|
+
import type { ModelEfforts } from '../../../shared/effort.ts';
|
|
3
|
+
import type { AgentCli } from '../isolation.ts';
|
|
4
|
+
import { nowIso } from '../util.ts';
|
|
5
|
+
import type { SpendScope } from '../spend.ts';
|
|
6
|
+
import type { CliCheckProblem } from './cli-check.ts';
|
|
7
|
+
import type { FailureKind } from './failures.ts';
|
|
8
|
+
|
|
9
|
+
export interface TurnRequest {
|
|
10
|
+
cwd: string;
|
|
11
|
+
sessionId: string | null;
|
|
12
|
+
systemPrompt: string;
|
|
13
|
+
prompt: string;
|
|
14
|
+
/** "" means the provider's default. */
|
|
15
|
+
model: string;
|
|
16
|
+
/** A level of shared/effort.ts for this provider; "" (or absent) means the provider's default, set by nothing. */
|
|
17
|
+
effort?: string;
|
|
18
|
+
access: AccessLevel;
|
|
19
|
+
images?: string[];
|
|
20
|
+
/** Paths a Workspace Boton must not read; empty for Full computer access. */
|
|
21
|
+
privatePaths: string[];
|
|
22
|
+
sharedDirs?: string[];
|
|
23
|
+
/** A sandboxed turn (webhook run, Workspace access) must not write the instruction files later turns load. */
|
|
24
|
+
protectInstructions?: boolean;
|
|
25
|
+
groupDir?: string;
|
|
26
|
+
env: Record<string, string>;
|
|
27
|
+
/** Their secrets go to the agent CLI only: never to argv, the log or the prompt. */
|
|
28
|
+
mcpServers?: TurnMcpServer[];
|
|
29
|
+
/** A 0700 folder for files holding those secrets; required with mcpServers. */
|
|
30
|
+
privateDir?: string;
|
|
31
|
+
askFirst?: { ask: string[]; allow: string[]; command: string; args: string[] };
|
|
32
|
+
/**
|
|
33
|
+
* A reply only (the summary a provider move asks the old session for): the model is given no tools at all, whatever
|
|
34
|
+
* the Boton's access. Only for a provider whose `toolFree` says it can; never with mcpServers or askFirst.
|
|
35
|
+
*/
|
|
36
|
+
toolFree?: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* For a provider that bills by the token (an API key): the turn whose spending caps apply (spend.ts). The CLIs
|
|
39
|
+
* ignore it: their cost isn't Botons' to bound.
|
|
40
|
+
*/
|
|
41
|
+
spend?: SpendScope;
|
|
42
|
+
/** The `botons` CLI, for a provider whose tools Botons runs itself (`serverTools`). */
|
|
43
|
+
botonCli?: string;
|
|
44
|
+
signal: AbortSignal;
|
|
45
|
+
timeoutMs: number;
|
|
46
|
+
onActivity: (activity: string) => void;
|
|
47
|
+
/** All of the message being written so far; a new message starts over. */
|
|
48
|
+
onText?: (textSoFar: string) => void;
|
|
49
|
+
onStep?: (step: TurnStep) => void;
|
|
50
|
+
/** Each MCP tool call on one of `mcpServers` that finished, with its structured result (never display text). */
|
|
51
|
+
onToolResult?: (obs: ToolObservation) => void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ToolObservation {
|
|
55
|
+
/** The CLI's id for the call. */
|
|
56
|
+
callId: string;
|
|
57
|
+
/** The server's name in this turn (a key of mcpServers). */
|
|
58
|
+
server: string;
|
|
59
|
+
tool: string;
|
|
60
|
+
/** False for an error result, or one the CLI marks failed. */
|
|
61
|
+
ok: boolean;
|
|
62
|
+
/** The result's structuredContent, or its JSON text block when that is all the CLI passes on; else null. */
|
|
63
|
+
structured: Record<string, unknown> | null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function step(kind: StepKind, label: string, detail: string): TurnStep {
|
|
67
|
+
return { kind, label, detail, at: nowIso() };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface TurnUsage extends TokenUsage {
|
|
71
|
+
numTurns?: number;
|
|
72
|
+
model?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** How long the CLI gives a server to start and to run one tool, in seconds; the CLI's defaults when left out. */
|
|
76
|
+
export interface McpTimeouts {
|
|
77
|
+
startupTimeoutSec?: number;
|
|
78
|
+
toolTimeoutSec?: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type TurnMcpServer =
|
|
82
|
+
| ({
|
|
83
|
+
name: string;
|
|
84
|
+
kind: 'stdio';
|
|
85
|
+
command: string;
|
|
86
|
+
args: string[];
|
|
87
|
+
env: Record<string, string>;
|
|
88
|
+
cwd?: string;
|
|
89
|
+
/** What must not come back out in text, when not `env`'s values (a local connector's, in its launch file). */
|
|
90
|
+
secrets?: string[];
|
|
91
|
+
} & McpTimeouts)
|
|
92
|
+
| ({ name: string; kind: 'http'; url: string; headers: Record<string, string>; sse?: boolean } & McpTimeouts);
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* What the CLI showed of one of the turn's MCP servers. Only servers it said something about are listed: Claude Code
|
|
96
|
+
* reports each one's status and tools when the turn starts; Codex reports nothing, so only a tool call it ran shows
|
|
97
|
+
* a server started.
|
|
98
|
+
*/
|
|
99
|
+
export interface McpStartup {
|
|
100
|
+
name: string;
|
|
101
|
+
started: boolean;
|
|
102
|
+
/** The tools the CLI listed for it; null when the CLI doesn't list them. */
|
|
103
|
+
tools: string[] | null;
|
|
104
|
+
/** Why it didn't start (or may not have), as the owner and the Boton are told. */
|
|
105
|
+
reason?: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface TurnResult {
|
|
109
|
+
sessionId: string | null;
|
|
110
|
+
text: string;
|
|
111
|
+
/** Errors the CLI reported during a turn that still completed. */
|
|
112
|
+
warnings?: string[];
|
|
113
|
+
usage?: TurnUsage | null;
|
|
114
|
+
mcpStartup?: McpStartup[];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export class ProviderError extends Error {
|
|
118
|
+
/**
|
|
119
|
+
* Only when the provider no longer has the session and the run did nothing
|
|
120
|
+
* else: the engine retries in a new session, so a false positive runs the work twice.
|
|
121
|
+
*/
|
|
122
|
+
sessionLost: boolean;
|
|
123
|
+
kind: FailureKind;
|
|
124
|
+
retryAt: Date | null;
|
|
125
|
+
exit?: { exitCode: number | null; signal: string | null; timedOut: boolean; stderrTail: string };
|
|
126
|
+
usage?: TurnUsage | null;
|
|
127
|
+
mcpStartup?: McpStartup[];
|
|
128
|
+
constructor(message: string, sessionLost = false, info: { kind?: FailureKind; retryAt?: Date | null } = {}) {
|
|
129
|
+
super(message);
|
|
130
|
+
this.sessionLost = sessionLost;
|
|
131
|
+
this.kind = info.kind ?? 'other';
|
|
132
|
+
this.retryAt = info.retryAt ?? null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface Provider {
|
|
137
|
+
id: string;
|
|
138
|
+
label: string;
|
|
139
|
+
cli?: AgentCli;
|
|
140
|
+
info(): Promise<ProviderStatus>;
|
|
141
|
+
/**
|
|
142
|
+
* What info() would say from the checks already done, without running one (CliChecker.peek): null checkedAt before
|
|
143
|
+
* the first. Absent: info() itself runs nothing.
|
|
144
|
+
*/
|
|
145
|
+
snapshot?(): ProviderStatus;
|
|
146
|
+
/** Drop cached checks, so the next info() runs them again. */
|
|
147
|
+
recheck?(): void;
|
|
148
|
+
runTurn(req: TurnRequest): Promise<TurnResult>;
|
|
149
|
+
/** Which of a turn's MCP servers this CLI can be given, and why the others can't. */
|
|
150
|
+
fitMcpServers?(servers: TurnMcpServer[]): { servers: TurnMcpServer[]; skipped: { name: string; reason: string }[] };
|
|
151
|
+
askFirst?: boolean;
|
|
152
|
+
/**
|
|
153
|
+
* Its CLI can run a turn with no tools at all (TurnRequest.toolFree): every built-in tool, MCP tool, skill and
|
|
154
|
+
* command taken away, not only fenced in by a sandbox. Absent: it can't, and a toolFree turn is refused.
|
|
155
|
+
*/
|
|
156
|
+
toolFree?: boolean;
|
|
157
|
+
/** What the CLI's own model list says `model` takes; null when it doesn't list levels for it (see shared/effort.ts). */
|
|
158
|
+
modelEfforts?(model: string): ModelEfforts | null;
|
|
159
|
+
/**
|
|
160
|
+
* No CLI: Botons runs the agent loop and every tool itself (an API key brain, providers/api-brain.ts), the turn's
|
|
161
|
+
* Botons connectors included, which the server calls on the Boton's behalf.
|
|
162
|
+
*/
|
|
163
|
+
serverTools?: boolean;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* `effort` left out: the registry fills it in from shared/effort.ts. `readiness` is the registry's too, from `check`:
|
|
168
|
+
* what the provider's own check found, if it runs one.
|
|
169
|
+
*/
|
|
170
|
+
export type ProviderStatus = Omit<ProviderInfo, 'lastSuccessAt' | 'lastError' | 'effort' | 'readiness'> & {
|
|
171
|
+
effort?: ProviderInfo['effort'];
|
|
172
|
+
check?: { problem: CliCheckProblem | null; generation: number };
|
|
173
|
+
};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { TokenUsage } from '../../shared/types.ts';
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* What API-key turns cost, as Botons estimates it: the tokens each answer reports times these per-model rates. The
|
|
5
|
+
* rates are copied from the providers' own price pages (the sources below, on the date given), standard tier, no
|
|
6
|
+
* batch, regional, data-residency, fast or priority pricing (Botons asks for none of those). A model not listed here,
|
|
7
|
+
* or past a rate's `until`, has no known price: its cost is unknown, never guessed from a similar name, and a Boton
|
|
8
|
+
* or routine with a spending cap won't run on it. The estimate is not the provider's bill: the bill is the provider's.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** US dollars per million tokens. `cacheRead`/`cacheWrite` null: the page lists none (charged as input, if ever). */
|
|
12
|
+
export interface Rate {
|
|
13
|
+
input: number;
|
|
14
|
+
output: number;
|
|
15
|
+
cacheRead: number | null;
|
|
16
|
+
cacheWrite: number | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ModelRate {
|
|
20
|
+
provider: 'anthropic' | 'openai';
|
|
21
|
+
model: string;
|
|
22
|
+
rate: Rate;
|
|
23
|
+
/** A prompt (all its input tokens, cached ones included) over `over` tokens is priced at `rate` instead. */
|
|
24
|
+
long?: { over: number; rate: Rate };
|
|
25
|
+
/** The last day these rates hold (a promotion's end); after it the price is unknown. */
|
|
26
|
+
until?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const RATE_SOURCES = {
|
|
30
|
+
anthropic: { url: 'https://platform.claude.com/docs/en/about-claude/pricing', checked: '2026-11-07', cacheWrite: '5-minute cache writes (Botons asks for no longer)' },
|
|
31
|
+
openai: { url: 'https://developers.openai.com/api/docs/pricing', checked: '2026-11-07', cacheWrite: '30-minute cache writes (Botons asks for no longer)' },
|
|
32
|
+
} as const;
|
|
33
|
+
|
|
34
|
+
const r = (input: number, output: number, cacheRead: number | null, cacheWrite: number | null): Rate => ({ input, output, cacheRead, cacheWrite });
|
|
35
|
+
|
|
36
|
+
const ANTHROPIC: Record<string, Omit<ModelRate, 'provider' | 'model'>> = {
|
|
37
|
+
'claude-fable-5-1': { rate: r(10, 50, 0.25, 12.5) },
|
|
38
|
+
'claude-mythos-5-1': { rate: r(10, 50, 0.25, 12.5) },
|
|
39
|
+
'claude-fable-5': { rate: r(10, 50, 1, 12.5) },
|
|
40
|
+
'claude-opus-5-5': { rate: r(4, 20, 0.2, 5) },
|
|
41
|
+
'claude-opus-5': { rate: r(5, 25, 0.5, 6.25) },
|
|
42
|
+
'claude-opus-4-8': { rate: r(5, 25, 0.5, 6.25) },
|
|
43
|
+
'claude-opus-4-7': { rate: r(5, 25, 0.5, 6.25) },
|
|
44
|
+
'claude-opus-4-6': { rate: r(5, 25, 0.5, 6.25) },
|
|
45
|
+
'claude-sonnet-5-5': { rate: r(2, 10, 0.1, 2.5) },
|
|
46
|
+
'claude-sonnet-5': { rate: r(2, 10, 0.2, 2.5) },
|
|
47
|
+
'claude-sonnet-4-6': { rate: r(3, 15, 0.3, 3.75) },
|
|
48
|
+
'claude-haiku-5-5': { rate: r(0.1, 0.5, 0.01, 0.125), long: { over: 100_000, rate: r(0.5, 2.5, 0.05, 0.625) } },
|
|
49
|
+
'claude-haiku-4-5': { rate: r(1, 5, 0.1, 1.25) },
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const LONG_OPENAI = 272_000;
|
|
53
|
+
const OPENAI: Record<string, Omit<ModelRate, 'provider' | 'model'>> = {
|
|
54
|
+
'gpt-6-astra': { rate: r(10, 50, 1, 12.5), long: { over: LONG_OPENAI, rate: r(20, 75, 2, 25) } },
|
|
55
|
+
'gpt-6.1-sol': { rate: r(2, 10, 0.1, 2.5), long: { over: LONG_OPENAI, rate: r(4, 15, 0.2, 5) } },
|
|
56
|
+
'gpt-6.1-terra': { rate: r(1, 5, 0.05, 1.25), long: { over: LONG_OPENAI, rate: r(2, 7.5, 0.1, 2.5) } },
|
|
57
|
+
'gpt-6-luna': { rate: r(0.1, 0.5, 0.01, 0.125), long: { over: LONG_OPENAI, rate: r(0.2, 0.75, 0.02, 0.25) } },
|
|
58
|
+
'gpt-6-sol': { rate: r(2, 10, 0.2, 2.5), long: { over: LONG_OPENAI, rate: r(4, 15, 0.4, 5) } },
|
|
59
|
+
// A promotional price, until the end of 2026-11-30 as the page says.
|
|
60
|
+
'gpt-5.6-sol': { rate: r(4, 20, 0.4, 5), long: { over: LONG_OPENAI, rate: r(8, 30, 0.8, 10) }, until: '2026-11-30' },
|
|
61
|
+
'gpt-5.6-terra': { rate: r(2, 12, 0.2, 2.5), long: { over: LONG_OPENAI, rate: r(4, 18, 0.4, 5) } },
|
|
62
|
+
'gpt-5.6-luna': { rate: r(0.2, 1.2, 0.02, 0.25), long: { over: LONG_OPENAI, rate: r(0.4, 1.8, 0.04, 0.5) } },
|
|
63
|
+
'gpt-5.5': { rate: r(5, 30, 0.5, null), long: { over: LONG_OPENAI, rate: r(10, 45, 1, null) } },
|
|
64
|
+
'gpt-5.5-pro': { rate: r(30, 180, null, null), long: { over: LONG_OPENAI, rate: r(60, 270, null, null) } },
|
|
65
|
+
'gpt-5.4': { rate: r(2.5, 15, 0.25, null), long: { over: LONG_OPENAI, rate: r(5, 22.5, 0.5, null) } },
|
|
66
|
+
'gpt-5.4-pro': { rate: r(30, 180, null, null), long: { over: LONG_OPENAI, rate: r(60, 270, null, null) } },
|
|
67
|
+
'gpt-5.4-mini': { rate: r(0.75, 4.5, 0.075, null) },
|
|
68
|
+
'gpt-5.4-nano': { rate: r(0.2, 1.25, 0.02, null) },
|
|
69
|
+
'gpt-5.2': { rate: r(1.75, 14, 0.175, null) },
|
|
70
|
+
'gpt-5.2-pro': { rate: r(21, 168, null, null) },
|
|
71
|
+
'gpt-5.1': { rate: r(1.25, 10, 0.125, null) },
|
|
72
|
+
'gpt-5': { rate: r(1.25, 10, 0.125, null) },
|
|
73
|
+
'gpt-5-mini': { rate: r(0.25, 2, 0.025, null) },
|
|
74
|
+
'gpt-5-nano': { rate: r(0.05, 0.4, 0.005, null) },
|
|
75
|
+
'gpt-5-pro': { rate: r(15, 120, null, null) },
|
|
76
|
+
'gpt-4.1': { rate: r(2, 8, 0.5, null) },
|
|
77
|
+
'gpt-4.1-mini': { rate: r(0.4, 1.6, 0.1, null) },
|
|
78
|
+
'gpt-4o': { rate: r(2.5, 10, 1.25, null) },
|
|
79
|
+
'gpt-4o-mini': { rate: r(0.15, 0.6, 0.075, null) },
|
|
80
|
+
'o3-pro': { rate: r(20, 80, null, null) },
|
|
81
|
+
o3: { rate: r(2, 8, 0.5, null) },
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** The rates for exactly `model` (no prefix or snapshot matching), or null when its price isn't known. */
|
|
85
|
+
export function rateFor(provider: 'anthropic' | 'openai', model: string, now = new Date()): ModelRate | null {
|
|
86
|
+
const table = provider === 'anthropic' ? ANTHROPIC : OPENAI;
|
|
87
|
+
const found = Object.hasOwn(table, model) ? table[model] : undefined;
|
|
88
|
+
if (!found) return null;
|
|
89
|
+
if (found.until && now.toISOString().slice(0, 10) > found.until) return null;
|
|
90
|
+
return { provider, model, ...found };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const PER = 1_000_000;
|
|
94
|
+
|
|
95
|
+
/** The rate a prompt of `promptTokens` input tokens (cached ones included) pays. */
|
|
96
|
+
function rateAt(m: ModelRate, promptTokens: number): Rate {
|
|
97
|
+
return m.long && promptTokens > m.long.over ? m.long.rate : m.rate;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The estimate for one answer's usage; `inputTokens` excludes cache reads and writes (TokenUsage's meaning). */
|
|
101
|
+
export function costOf(m: ModelRate, u: Pick<TokenUsage, 'inputTokens' | 'outputTokens' | 'cacheReadTokens' | 'cacheWriteTokens'>): number {
|
|
102
|
+
const prompt = u.inputTokens + u.cacheReadTokens + u.cacheWriteTokens;
|
|
103
|
+
const rate = rateAt(m, prompt);
|
|
104
|
+
const usd = (u.inputTokens * rate.input + u.cacheReadTokens * (rate.cacheRead ?? rate.input) + u.cacheWriteTokens * (rate.cacheWrite ?? rate.input) + u.outputTokens * rate.output) / PER;
|
|
105
|
+
return round(usd);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The most one request can cost: every one of at most `inputTokens` input tokens at the dearest input rate (a cache
|
|
110
|
+
* write costs more than plain input), and `maxOutputTokens` (the cap the request sets, thinking included) at the
|
|
111
|
+
* output rate, at the long-context rate if the prompt could be long.
|
|
112
|
+
*/
|
|
113
|
+
export function mostOf(m: ModelRate, inputTokens: number, maxOutputTokens: number): number {
|
|
114
|
+
const rate = rateAt(m, inputTokens);
|
|
115
|
+
const dearest = Math.max(rate.input, rate.cacheWrite ?? 0, rate.cacheRead ?? 0);
|
|
116
|
+
return round((inputTokens * dearest + maxOutputTokens * rate.output) / PER);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The output tokens `usd` pays for at most, after `inputTokens` at the dearest input rate; 0 when it can't. */
|
|
120
|
+
export function outputFor(m: ModelRate, inputTokens: number, usd: number): number {
|
|
121
|
+
const rate = rateAt(m, inputTokens);
|
|
122
|
+
const dearest = Math.max(rate.input, rate.cacheWrite ?? 0, rate.cacheRead ?? 0);
|
|
123
|
+
const left = usd * PER - inputTokens * dearest;
|
|
124
|
+
return left <= 0 ? 0 : Math.floor(left / rate.output);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Dollars are summed as floats; rounded to a millionth of a cent so sums compare as they read. */
|
|
128
|
+
export const round = (usd: number) => Math.round(usd * 1e8) / 1e8;
|