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,690 @@
|
|
|
1
|
+
import type { ApiKeyProvider } from '../../../shared/types.ts';
|
|
2
|
+
import type { ToolCall, ToolSpec } from './api-tools.ts';
|
|
3
|
+
import { StreamError, sseEvents } from './sse.ts';
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* The two model APIs an API brain speaks, each as its documented wire protocol over fetch: Anthropic's Messages API
|
|
7
|
+
* (POST /v1/messages) and OpenAI's Responses API (POST /v1/responses), both streamed as server-sent events. The
|
|
8
|
+
* conversation is kept by Botons (OpenAI's `store` is off); the model's own answers are kept as the provider sent them,
|
|
9
|
+
* reasoning included, and passed back unchanged, except as sanitize() decides for a known secret (an API key):
|
|
10
|
+
*
|
|
11
|
+
* - Text Botons may change without breaking the protocol (an answer's text, a tool call's arguments, a refusal) has the
|
|
12
|
+
* secret replaced; a tool call whose arguments held one is marked, and the brain doesn't run it.
|
|
13
|
+
* - Signed or encrypted reasoning (Anthropic's thinking and redacted_thinking blocks, OpenAI's reasoning items) can't
|
|
14
|
+
* be changed and stay valid: one that holds a secret is left out (citations and annotations likewise).
|
|
15
|
+
* - Anything else that holds one (an id, a tool's name, a block of a kind Botons doesn't know) can be neither changed
|
|
16
|
+
* nor left out safely: the whole answer is refused (SecretInAnswer) and nothing of it is kept.
|
|
17
|
+
*
|
|
18
|
+
* Usage is taken only when the provider reported every counter its docs require, each a whole number from 0 up, and
|
|
19
|
+
* consistent with the others; anything else is unknown (null), never zero, and the request's reservation is held.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export interface UserInput {
|
|
23
|
+
text: string;
|
|
24
|
+
/** Only in the turn they came with; history keeps a note in their place. */
|
|
25
|
+
images?: { mediaType: string; base64: string }[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ToolResults {
|
|
29
|
+
results: { id: string; name: string; text: string; isError: boolean }[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type WireItem = { kind: 'user'; data: UserInput } | { kind: 'assistant'; data: unknown } | { kind: 'tools'; data: ToolResults };
|
|
33
|
+
|
|
34
|
+
export interface WireUsage {
|
|
35
|
+
/** Excluding cache reads and writes. */
|
|
36
|
+
input: number;
|
|
37
|
+
output: number;
|
|
38
|
+
cacheRead: number;
|
|
39
|
+
cacheWrite: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface WireRound {
|
|
43
|
+
text: string;
|
|
44
|
+
calls: ToolCall[];
|
|
45
|
+
stop: 'end' | 'tools' | 'max_tokens' | 'refusal' | 'paused' | 'other';
|
|
46
|
+
/** The provider's word for why it stopped, when it isn't a plain end. */
|
|
47
|
+
stopDetail: string | null;
|
|
48
|
+
/** The answer, as the provider sent it, to keep in the history. */
|
|
49
|
+
assistant: unknown;
|
|
50
|
+
usage: WireUsage | null;
|
|
51
|
+
model: string | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type WireErrorKind = 'auth' | 'forbidden' | 'quota' | 'rate_limited' | 'overloaded' | 'server' | 'bad_request' | 'not_found' | 'network' | 'stream';
|
|
55
|
+
|
|
56
|
+
export class WireError extends Error {
|
|
57
|
+
readonly kind: WireErrorKind;
|
|
58
|
+
readonly status: number | null;
|
|
59
|
+
/** The request reached the provider and may have been billed (the answer broke off, or never came). */
|
|
60
|
+
readonly sent: boolean;
|
|
61
|
+
/** What the provider said the answer used before it broke off, if it said. */
|
|
62
|
+
readonly usage: WireUsage | null;
|
|
63
|
+
readonly retryAfterMs: number | null;
|
|
64
|
+
constructor(kind: WireErrorKind, message: string, o: { status?: number | null; sent?: boolean; usage?: WireUsage | null; retryAfterMs?: number | null } = {}) {
|
|
65
|
+
super(message);
|
|
66
|
+
this.kind = kind;
|
|
67
|
+
this.status = o.status ?? null;
|
|
68
|
+
this.sent = o.sent ?? false;
|
|
69
|
+
this.usage = o.usage ?? null;
|
|
70
|
+
this.retryAfterMs = o.retryAfterMs ?? null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface SendOptions {
|
|
75
|
+
origin: string;
|
|
76
|
+
key: string;
|
|
77
|
+
signal: AbortSignal;
|
|
78
|
+
onText: (textSoFar: string) => void;
|
|
79
|
+
onActivity: (a: string) => void;
|
|
80
|
+
/** No gap between chunks longer than this. */
|
|
81
|
+
idleMs: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface Wire {
|
|
85
|
+
provider: ApiKeyProvider;
|
|
86
|
+
/** The provider id Botons pick, and the session id prefix. */
|
|
87
|
+
id: string;
|
|
88
|
+
label: string;
|
|
89
|
+
/** "anthropic" or "openai": session ids start with it. */
|
|
90
|
+
prefix: string;
|
|
91
|
+
defaultModel: string;
|
|
92
|
+
maxOutput(effort: string): number;
|
|
93
|
+
/** `noCalls`: the tools are only described (the history has calls to them); the model may call none. */
|
|
94
|
+
body(o: { model: string; effort: string; system: string; items: WireItem[]; tools: ToolSpec[]; maxOutput: number; noCalls?: boolean }): Record<string, unknown>;
|
|
95
|
+
send(body: Record<string, unknown>, o: SendOptions): Promise<WireRound>;
|
|
96
|
+
/** The answer without its reasoning (for a history whose start or tools changed); null when nothing is left. */
|
|
97
|
+
stripReasoning(assistant: unknown): unknown | null;
|
|
98
|
+
/**
|
|
99
|
+
* The answer with every known secret out of it (see the top of this file), its text and calls read again from what
|
|
100
|
+
* is left; throws SecretInAnswer when it can't be. A kept answer from the history goes through it too (`round` with
|
|
101
|
+
* only `assistant` set), so nothing kept or sent again holds a secret known now.
|
|
102
|
+
*/
|
|
103
|
+
sanitize(assistant: unknown, s: Scrub, calls?: ToolCall[]): { assistant: unknown; text: string; calls: ToolCall[]; left: number };
|
|
104
|
+
/** A refusal of reasoning made under another history: send again without it, once. */
|
|
105
|
+
reasoningRefused(err: WireError): boolean;
|
|
106
|
+
/** A body the provider may take after it refused `body` this way (once; nothing was generated), or null. */
|
|
107
|
+
adjust(body: Record<string, unknown>, err: WireError): Record<string, unknown> | null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const MAX_STREAM = 64 * 1024 * 1024;
|
|
111
|
+
const MAX_EVENT = 16 * 1024 * 1024;
|
|
112
|
+
|
|
113
|
+
const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.round(v) : 0);
|
|
114
|
+
const obj = (v: unknown): Record<string, unknown> | null => (v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : null);
|
|
115
|
+
|
|
116
|
+
/** No request reports more tokens of a kind than this; more is not a count. */
|
|
117
|
+
const MAX_TOKENS = 100_000_000;
|
|
118
|
+
const BAD = Symbol('bad');
|
|
119
|
+
/** A token counter: a whole number from 0 up; `nullable` lets null or absence read as 0 (documented as optional). */
|
|
120
|
+
function count(v: unknown, nullable: boolean): number | typeof BAD {
|
|
121
|
+
if (v === undefined || v === null) return nullable ? 0 : BAD;
|
|
122
|
+
return typeof v === 'number' && Number.isSafeInteger(v) && v >= 0 && v <= MAX_TOKENS ? v : BAD;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A secret Botons knows of (an API key), and how to take it out of text. */
|
|
126
|
+
export interface Scrub {
|
|
127
|
+
holds(text: string): boolean;
|
|
128
|
+
redact(text: string): string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The provider's answer held a secret where Botons can neither change nor leave it out. */
|
|
132
|
+
export class SecretInAnswer extends Error {}
|
|
133
|
+
|
|
134
|
+
/** Deeper than this, a value isn't walked: one that holds a secret is replaced whole (and a call with it isn't run). */
|
|
135
|
+
const MAX_DEPTH = 64;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Every string (and key) of a JSON value redacted; `changed` says whether any held a secret. Past MAX_DEPTH a value
|
|
139
|
+
* that holds one is replaced whole by its redacted text, never kept as it was.
|
|
140
|
+
*/
|
|
141
|
+
function redactDeep(v: unknown, s: Scrub, depth = 0): { value: unknown; changed: boolean } {
|
|
142
|
+
if (typeof v === 'string') {
|
|
143
|
+
if (!s.holds(v)) return { value: v, changed: false };
|
|
144
|
+
return { value: s.redact(v), changed: true };
|
|
145
|
+
}
|
|
146
|
+
if (v === null || typeof v !== 'object') return { value: v, changed: false };
|
|
147
|
+
if (depth >= MAX_DEPTH) return holdsAny(v, s) ? { value: '[too deep to keep: it held an API key]', changed: true } : { value: v, changed: false };
|
|
148
|
+
let changed = false;
|
|
149
|
+
if (Array.isArray(v)) {
|
|
150
|
+
const out = v.map((x) => {
|
|
151
|
+
const r = redactDeep(x, s, depth + 1);
|
|
152
|
+
changed ||= r.changed;
|
|
153
|
+
return r.value;
|
|
154
|
+
});
|
|
155
|
+
return { value: changed ? out : v, changed };
|
|
156
|
+
}
|
|
157
|
+
const out: Record<string, unknown> = {};
|
|
158
|
+
for (const [k, x] of Object.entries(v)) {
|
|
159
|
+
const key = s.holds(k) ? s.redact(k) : k;
|
|
160
|
+
const r = redactDeep(x, s, depth + 1);
|
|
161
|
+
changed ||= r.changed || key !== k;
|
|
162
|
+
out[key] = r.value;
|
|
163
|
+
}
|
|
164
|
+
return { value: changed ? out : v, changed };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Whether a value holds a secret anywhere; one that can't be read as JSON (a cycle) counts as holding one. */
|
|
168
|
+
function holdsAny(v: unknown, s: Scrub): boolean {
|
|
169
|
+
if (v === undefined) return false;
|
|
170
|
+
try {
|
|
171
|
+
return s.holds(JSON.stringify(v));
|
|
172
|
+
} catch {
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The last word on a sanitized answer: nothing in what is kept, nor in a call that may run, holds a secret. A call
|
|
179
|
+
* marked `secret` isn't run, but its input is checked too, so nothing of the key reaches the history through it.
|
|
180
|
+
*/
|
|
181
|
+
function checked(r: { assistant: unknown; text: string; calls: ToolCall[]; left: number }, s: Scrub) {
|
|
182
|
+
if (holdsAny(r.assistant, s) || s.holds(r.text)) throw new SecretInAnswer('the answer');
|
|
183
|
+
if (r.calls.some((c) => holdsAny(c, s))) throw new SecretInAnswer('a tool call');
|
|
184
|
+
return r;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** A refused request's fixed kind, and the provider's own words (bounded; the caller redacts the key). */
|
|
188
|
+
async function refused(res: Response, provider: string): Promise<WireError> {
|
|
189
|
+
let message = '';
|
|
190
|
+
try {
|
|
191
|
+
const text = (await res.text()).slice(0, 4000);
|
|
192
|
+
const body = obj(JSON.parse(text));
|
|
193
|
+
const e = obj(body?.error);
|
|
194
|
+
message = typeof e?.message === 'string' ? e.message : '';
|
|
195
|
+
const code = typeof e?.code === 'string' ? e.code : typeof e?.type === 'string' ? e.type : '';
|
|
196
|
+
if (code) message = `${code}: ${message}`;
|
|
197
|
+
} catch {}
|
|
198
|
+
const s = res.status;
|
|
199
|
+
const words = message.slice(0, 300) || `HTTP ${s}`;
|
|
200
|
+
const retryAfter = Number(res.headers.get('retry-after'));
|
|
201
|
+
const retryAfterMs = Number.isFinite(retryAfter) && retryAfter > 0 ? Math.min(retryAfter, 86_400) * 1000 : null;
|
|
202
|
+
const quota = /insufficient_quota|credit balance|billing|spend limit|quota/i.test(message);
|
|
203
|
+
const kind: WireErrorKind =
|
|
204
|
+
s === 401 ? 'auth' : s === 403 ? 'forbidden' : s === 404 ? 'not_found' : s === 429 ? (quota ? 'quota' : 'rate_limited') : s === 529 || /overloaded/i.test(message) ? 'overloaded' : s >= 500 ? 'server' : quota ? 'quota' : 'bad_request';
|
|
205
|
+
return new WireError(kind, `${provider} answered ${s}: ${words}`, { status: s, retryAfterMs });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function post(url: string, headers: Record<string, string>, body: Record<string, unknown>, provider: string, signal: AbortSignal): Promise<Response> {
|
|
209
|
+
let res: Response;
|
|
210
|
+
try {
|
|
211
|
+
// Never follows a redirect: the key header would go with it.
|
|
212
|
+
res = await fetch(url, { method: 'POST', headers: { ...headers, 'content-type': 'application/json', accept: 'text/event-stream' }, body: JSON.stringify(body), redirect: 'manual', signal });
|
|
213
|
+
} catch (err) {
|
|
214
|
+
if (signal.aborted) throw err;
|
|
215
|
+
// No answer at all: the request may or may not have reached it, and been billed.
|
|
216
|
+
const code = (err as { cause?: { code?: string } }).cause?.code ?? (err as Error).message;
|
|
217
|
+
// Never connected (refused, no such host): it didn't reach the provider, so nothing was billed.
|
|
218
|
+
if (/^(ECONNREFUSED|ENOTFOUND|EAI_AGAIN|ENETUNREACH|EHOSTUNREACH)$/.test(code)) throw new WireError('network', `could not reach ${provider} (${code})`, { sent: false });
|
|
219
|
+
throw new WireError('network', `no answer from ${provider} (${code}); the request may or may not have reached it`, { sent: true });
|
|
220
|
+
}
|
|
221
|
+
if (res.status !== 200) {
|
|
222
|
+
const e = res.status >= 300 && res.status < 400 ? new WireError('bad_request', `${provider} answered with a redirect (${res.status}), which is not followed`, { status: res.status }) : await refused(res, provider);
|
|
223
|
+
await res.body?.cancel().catch(() => {});
|
|
224
|
+
throw e;
|
|
225
|
+
}
|
|
226
|
+
if (!res.body) throw new WireError('stream', `${provider} sent an empty answer`, { sent: true });
|
|
227
|
+
return res;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ------------------------------------------------------------------ Anthropic
|
|
231
|
+
|
|
232
|
+
const ANTHROPIC_VERSION = '2026-10-21';
|
|
233
|
+
|
|
234
|
+
/** Reasoning blocks the Messages API binds to the conversation that made them. */
|
|
235
|
+
const THINKING = new Set(['thinking', 'redacted_thinking']);
|
|
236
|
+
|
|
237
|
+
function anthropicContent(items: WireItem[]): { role: 'user' | 'assistant'; content: unknown[] }[] {
|
|
238
|
+
const out: { role: 'user' | 'assistant'; content: unknown[] }[] = [];
|
|
239
|
+
const push = (role: 'user' | 'assistant', content: unknown[]) => {
|
|
240
|
+
if (!content.length) return;
|
|
241
|
+
const last = out.at(-1);
|
|
242
|
+
// Consecutive messages of a role are one turn: kept as one, tool results first as the API wants.
|
|
243
|
+
if (last && last.role === role) last.content.push(...content);
|
|
244
|
+
else out.push({ role, content: [...content] });
|
|
245
|
+
};
|
|
246
|
+
for (const it of items) {
|
|
247
|
+
if (it.kind === 'user') {
|
|
248
|
+
push('user', [
|
|
249
|
+
...(it.data.images ?? []).map((img) => ({ type: 'image', source: { type: 'base64', media_type: img.mediaType, data: img.base64 } })),
|
|
250
|
+
{ type: 'text', text: it.data.text || '(empty)' },
|
|
251
|
+
]);
|
|
252
|
+
} else if (it.kind === 'tools') {
|
|
253
|
+
push(
|
|
254
|
+
'user',
|
|
255
|
+
it.data.results.map((r) => ({ type: 'tool_result', tool_use_id: r.id, content: r.text || '(no output)', ...(r.isError ? { is_error: true } : {}) })),
|
|
256
|
+
);
|
|
257
|
+
} else {
|
|
258
|
+
const m = obj(it.data);
|
|
259
|
+
if (Array.isArray(m?.content)) push('assistant', m.content as unknown[]);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return out;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Anthropic's usage, as its stream reports it: message_start carries input_tokens and output_tokens (required) and the
|
|
268
|
+
* cache counts (documented as nullable); each message_delta carries output_tokens (required, cumulative) and may carry
|
|
269
|
+
* the others again (cumulative too, so never lower). cache_creation splits the writes by lifetime and must add up to
|
|
270
|
+
* cache_creation_input_tokens; a 1-hour write (Botons asks for none) or a server tool use has no rate here. Any of
|
|
271
|
+
* that wrong, or no final delta: unknown.
|
|
272
|
+
*/
|
|
273
|
+
class AnthropicUsage {
|
|
274
|
+
#u: WireUsage | null = null;
|
|
275
|
+
#final = false;
|
|
276
|
+
#bad = false;
|
|
277
|
+
|
|
278
|
+
#cache(x: Record<string, unknown>, u: WireUsage) {
|
|
279
|
+
const breakdown = obj(x.cache_creation);
|
|
280
|
+
if (x.cache_creation !== undefined && x.cache_creation !== null && !breakdown) this.#bad = true;
|
|
281
|
+
if (breakdown) {
|
|
282
|
+
const m5 = count(breakdown.ephemeral_5m_input_tokens, true);
|
|
283
|
+
const h1 = count(breakdown.ephemeral_1h_input_tokens, true);
|
|
284
|
+
if (m5 === BAD || h1 === BAD || h1 > 0 || m5 + h1 !== u.cacheWrite) this.#bad = true;
|
|
285
|
+
}
|
|
286
|
+
const tools = obj(x.server_tool_use);
|
|
287
|
+
if (tools && Object.values(tools).some((v) => v !== 0 && v !== null)) this.#bad = true;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
start(raw: unknown) {
|
|
291
|
+
const x = obj(raw);
|
|
292
|
+
if (!x || this.#u) {
|
|
293
|
+
this.#bad = true;
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
const input = count(x.input_tokens, false);
|
|
297
|
+
const output = count(x.output_tokens, false);
|
|
298
|
+
const cacheRead = count(x.cache_read_input_tokens, true);
|
|
299
|
+
const cacheWrite = count(x.cache_creation_input_tokens, true);
|
|
300
|
+
if (input === BAD || output === BAD || cacheRead === BAD || cacheWrite === BAD) {
|
|
301
|
+
this.#bad = true;
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
this.#u = { input, output, cacheRead, cacheWrite };
|
|
305
|
+
this.#cache(x, this.#u);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
delta(raw: unknown) {
|
|
309
|
+
const x = obj(raw);
|
|
310
|
+
if (!x || !this.#u) {
|
|
311
|
+
this.#bad = true;
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
const u = this.#u;
|
|
315
|
+
const output = count(x.output_tokens, false);
|
|
316
|
+
if (output === BAD || output < u.output) this.#bad = true;
|
|
317
|
+
else u.output = output;
|
|
318
|
+
for (const [field, k] of [
|
|
319
|
+
['input_tokens', 'input'],
|
|
320
|
+
['cache_read_input_tokens', 'cacheRead'],
|
|
321
|
+
['cache_creation_input_tokens', 'cacheWrite'],
|
|
322
|
+
] as const) {
|
|
323
|
+
if (x[field] === undefined || x[field] === null) continue;
|
|
324
|
+
const v = count(x[field], false);
|
|
325
|
+
if (v === BAD || v < u[k]) this.#bad = true;
|
|
326
|
+
else u[k] = v;
|
|
327
|
+
}
|
|
328
|
+
this.#cache(x, u);
|
|
329
|
+
this.#final = true;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
value(): WireUsage | null {
|
|
333
|
+
return this.#bad || !this.#final || !this.#u ? null : { ...this.#u };
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export const anthropicWire: Wire = {
|
|
338
|
+
provider: 'anthropic',
|
|
339
|
+
id: 'anthropic-api',
|
|
340
|
+
label: 'Claude (Anthropic API key)',
|
|
341
|
+
prefix: 'anthropic',
|
|
342
|
+
defaultModel: 'claude-opus-5-5',
|
|
343
|
+
// Thinking counts in max_tokens; xhigh and max need room for it.
|
|
344
|
+
maxOutput: (effort) => (effort === 'xhigh' || effort === 'max' ? 64_000 : 32_000),
|
|
345
|
+
body({ model, effort, system, items, tools, maxOutput, noCalls }) {
|
|
346
|
+
return {
|
|
347
|
+
model,
|
|
348
|
+
max_tokens: maxOutput,
|
|
349
|
+
system,
|
|
350
|
+
messages: anthropicContent(items),
|
|
351
|
+
...(tools.length ? { tools: tools.map((t) => ({ name: t.name, description: t.description, input_schema: t.schema })), tool_choice: { type: noCalls ? 'none' : 'auto' } } : {}),
|
|
352
|
+
...(effort ? { output_config: { effort } } : {}),
|
|
353
|
+
// Caches the conversation so far for the next request (the provider's automatic placement).
|
|
354
|
+
cache_control: { type: 'ephemeral' },
|
|
355
|
+
stream: true,
|
|
356
|
+
};
|
|
357
|
+
},
|
|
358
|
+
async send(body, o) {
|
|
359
|
+
const res = await post(`${o.origin}/v1/messages`, { 'x-api-key': o.key, 'anthropic-version': ANTHROPIC_VERSION }, body, 'Anthropic', o.signal);
|
|
360
|
+
const blocks: Record<string, unknown>[] = [];
|
|
361
|
+
const json = new Map<number, string>();
|
|
362
|
+
let id: string | null = null;
|
|
363
|
+
let model: string | null = null;
|
|
364
|
+
const usage = new AnthropicUsage();
|
|
365
|
+
let stop: string | null = null;
|
|
366
|
+
let stopDetail: string | null = null;
|
|
367
|
+
let done = false;
|
|
368
|
+
let text = '';
|
|
369
|
+
try {
|
|
370
|
+
for await (const ev of sseEvents(res.body!, { maxBytes: MAX_STREAM, maxEvent: MAX_EVENT, idleMs: o.idleMs, signal: o.signal })) {
|
|
371
|
+
let d: Record<string, unknown> | null;
|
|
372
|
+
try {
|
|
373
|
+
d = obj(JSON.parse(ev.data));
|
|
374
|
+
} catch {
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
if (!d) continue;
|
|
378
|
+
const type = d.type;
|
|
379
|
+
if (type === 'message_start') {
|
|
380
|
+
const m = obj(d.message);
|
|
381
|
+
id = typeof m?.id === 'string' ? m.id : null;
|
|
382
|
+
model = typeof m?.model === 'string' ? m.model : null;
|
|
383
|
+
usage.start(m?.usage);
|
|
384
|
+
} else if (type === 'content_block_start') {
|
|
385
|
+
const i = num(d.index) || 0;
|
|
386
|
+
const block = { ...(obj(d.content_block) ?? { type: 'unknown' }) };
|
|
387
|
+
if (block.type === 'tool_use') json.set(i, '');
|
|
388
|
+
if (block.type === 'text') o.onActivity('Writing');
|
|
389
|
+
if (block.type === 'thinking') o.onActivity('Thinking');
|
|
390
|
+
blocks[i] = block;
|
|
391
|
+
} else if (type === 'content_block_delta') {
|
|
392
|
+
const i = num(d.index) || 0;
|
|
393
|
+
const delta = obj(d.delta);
|
|
394
|
+
const block = blocks[i];
|
|
395
|
+
if (!block || !delta) continue;
|
|
396
|
+
if (delta.type === 'text_delta' && typeof delta.text === 'string') {
|
|
397
|
+
block.text = String(block.text ?? '') + delta.text;
|
|
398
|
+
text += delta.text;
|
|
399
|
+
o.onText(text);
|
|
400
|
+
} else if (delta.type === 'thinking_delta' && typeof delta.thinking === 'string') block.thinking = String(block.thinking ?? '') + delta.thinking;
|
|
401
|
+
else if (delta.type === 'signature_delta' && typeof delta.signature === 'string') block.signature = String(block.signature ?? '') + delta.signature;
|
|
402
|
+
else if (delta.type === 'input_json_delta' && typeof delta.partial_json === 'string') json.set(i, (json.get(i) ?? '') + delta.partial_json);
|
|
403
|
+
else if (delta.type === 'citations_delta' && delta.citation) block.citations = [...((block.citations as unknown[]) ?? []), delta.citation];
|
|
404
|
+
} else if (type === 'message_delta') {
|
|
405
|
+
const delta = obj(d.delta);
|
|
406
|
+
if (typeof delta?.stop_reason === 'string') stop = delta.stop_reason;
|
|
407
|
+
const details = obj(delta?.stop_details);
|
|
408
|
+
if (details) stopDetail = [details.category, details.explanation].filter((x) => typeof x === 'string').join(': ') || null;
|
|
409
|
+
usage.delta(d.usage);
|
|
410
|
+
} else if (type === 'message_stop') done = true;
|
|
411
|
+
else if (type === 'error') {
|
|
412
|
+
const e = obj(d.error);
|
|
413
|
+
const words = typeof e?.message === 'string' ? e.message.slice(0, 300) : 'error';
|
|
414
|
+
const kind: WireErrorKind = e?.type === 'overloaded_error' ? 'overloaded' : e?.type === 'rate_limit_error' ? 'rate_limited' : 'server';
|
|
415
|
+
throw new WireError(kind, `Anthropic stopped its answer: ${words}`, { sent: true });
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
} catch (err) {
|
|
419
|
+
if (err instanceof WireError) throw err;
|
|
420
|
+
if (o.signal.aborted) throw err;
|
|
421
|
+
throw new WireError('stream', `Anthropic's answer broke off (${err instanceof StreamError ? err.message : (err as Error).message})`, { sent: true });
|
|
422
|
+
}
|
|
423
|
+
if (!done) throw new WireError('stream', 'Anthropic’s answer ended before it was complete', { sent: true });
|
|
424
|
+
const calls: ToolCall[] = [];
|
|
425
|
+
for (const [i, raw] of json) {
|
|
426
|
+
const block = blocks[i];
|
|
427
|
+
if (!block) continue;
|
|
428
|
+
let input: unknown = {};
|
|
429
|
+
try {
|
|
430
|
+
input = raw ? JSON.parse(raw) : {};
|
|
431
|
+
} catch {
|
|
432
|
+
input = null;
|
|
433
|
+
}
|
|
434
|
+
block.input = input && typeof input === 'object' ? input : {};
|
|
435
|
+
calls.push({ id: String(block.id ?? ''), name: String(block.name ?? ''), input });
|
|
436
|
+
}
|
|
437
|
+
// An empty text block can't be sent back.
|
|
438
|
+
const content = blocks.filter((b) => b && !(b.type === 'text' && !b.text));
|
|
439
|
+
const finalText = content.flatMap((b) => (b.type === 'text' && typeof b.text === 'string' ? [b.text] : [])).join('');
|
|
440
|
+
return {
|
|
441
|
+
text: finalText,
|
|
442
|
+
calls,
|
|
443
|
+
stop: stop === 'end_turn' || stop === 'stop_sequence' ? 'end' : stop === 'tool_use' ? 'tools' : stop === 'max_tokens' ? 'max_tokens' : stop === 'refusal' ? 'refusal' : stop === 'pause_turn' ? 'paused' : 'other',
|
|
444
|
+
stopDetail: stop === 'end_turn' || stop === 'tool_use' ? null : [stop, stopDetail].filter(Boolean).join(': ') || null,
|
|
445
|
+
assistant: { id, role: 'assistant', content },
|
|
446
|
+
usage: usage.value(),
|
|
447
|
+
model,
|
|
448
|
+
};
|
|
449
|
+
},
|
|
450
|
+
stripReasoning(assistant) {
|
|
451
|
+
const m = obj(assistant);
|
|
452
|
+
const content = Array.isArray(m?.content) ? (m.content as Record<string, unknown>[]).filter((b) => !THINKING.has(String(b?.type))) : [];
|
|
453
|
+
return content.length ? { ...m, content } : null;
|
|
454
|
+
},
|
|
455
|
+
sanitize(assistant, s, sent) {
|
|
456
|
+
const m = obj(assistant) ?? {};
|
|
457
|
+
const rest = { ...m, content: undefined };
|
|
458
|
+
if (holdsAny(rest, s)) throw new SecretInAnswer('the answer’s own fields');
|
|
459
|
+
const calls: ToolCall[] = [];
|
|
460
|
+
const content: Record<string, unknown>[] = [];
|
|
461
|
+
let left = 0;
|
|
462
|
+
for (const raw of Array.isArray(m.content) ? (m.content as unknown[]) : []) {
|
|
463
|
+
const b = obj(raw);
|
|
464
|
+
if (!b) continue;
|
|
465
|
+
if (THINKING.has(String(b.type))) {
|
|
466
|
+
// Signed: changed, it would be refused; it goes whole instead.
|
|
467
|
+
if (holdsAny(b, s)) left++;
|
|
468
|
+
else content.push(b);
|
|
469
|
+
} else if (b.type === 'text') {
|
|
470
|
+
const out: Record<string, unknown> = { ...b, text: typeof b.text === 'string' ? s.redact(b.text) : b.text };
|
|
471
|
+
if (holdsAny(out.citations, s)) {
|
|
472
|
+
delete out.citations;
|
|
473
|
+
left++;
|
|
474
|
+
}
|
|
475
|
+
if (holdsAny({ ...out, text: undefined }, s)) throw new SecretInAnswer('a text block');
|
|
476
|
+
content.push(out);
|
|
477
|
+
} else if (b.type === 'tool_use') {
|
|
478
|
+
if (holdsAny(b.id, s) || holdsAny(b.name, s) || holdsAny({ ...b, input: undefined, id: undefined, name: undefined }, s)) throw new SecretInAnswer('a tool call’s id or name');
|
|
479
|
+
const r = redactDeep(b.input, s);
|
|
480
|
+
content.push(r.changed ? { ...b, input: r.value } : b);
|
|
481
|
+
// Input that wasn't JSON stays so (the block keeps {} in its place): such a call isn't run.
|
|
482
|
+
const invalid = sent?.some((c) => c.id === b.id && c.input === null);
|
|
483
|
+
calls.push({ id: String(b.id ?? ''), name: String(b.name ?? ''), input: invalid ? null : (r.value ?? null), ...(r.changed ? { secret: true } : {}) });
|
|
484
|
+
} else if (holdsAny(b, s)) throw new SecretInAnswer(`a ${String(b.type).slice(0, 40)} block`);
|
|
485
|
+
else content.push(b);
|
|
486
|
+
}
|
|
487
|
+
const text = content.flatMap((b) => (b.type === 'text' && typeof b.text === 'string' ? [b.text] : [])).join('');
|
|
488
|
+
return checked({ assistant: { ...m, content }, text, calls, left }, s);
|
|
489
|
+
},
|
|
490
|
+
reasoningRefused: (err) => err.kind === 'bad_request' && /thinking/i.test(err.message) && /signature|bound|block/i.test(err.message),
|
|
491
|
+
adjust: () => null,
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
// ------------------------------------------------------------------ OpenAI
|
|
495
|
+
|
|
496
|
+
function openaiInput(items: WireItem[]): unknown[] {
|
|
497
|
+
const out: unknown[] = [];
|
|
498
|
+
for (const it of items) {
|
|
499
|
+
if (it.kind === 'user') {
|
|
500
|
+
out.push({
|
|
501
|
+
role: 'user',
|
|
502
|
+
content: [...(it.data.images ?? []).map((img) => ({ type: 'input_image', image_url: `data:${img.mediaType};base64,${img.base64}` })), { type: 'input_text', text: it.data.text || '(empty)' }],
|
|
503
|
+
});
|
|
504
|
+
} else if (it.kind === 'tools') {
|
|
505
|
+
for (const r of it.data.results) out.push({ type: 'function_call_output', call_id: r.id, output: r.text || '(no output)' });
|
|
506
|
+
} else if (Array.isArray(it.data)) out.push(...it.data);
|
|
507
|
+
}
|
|
508
|
+
return out;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* OpenAI's usage, as the Responses API documents it: input_tokens (cached ones included), input_tokens_details.
|
|
514
|
+
* cached_tokens, output_tokens, output_tokens_details.reasoning_tokens and total_tokens, all required; cache writes
|
|
515
|
+
* (input_tokens_details.cache_write_tokens) only where reported. Every counter a whole number from 0 up, the total
|
|
516
|
+
* their sum, the cached within the input and the reasoning within the output; anything else: unknown.
|
|
517
|
+
*/
|
|
518
|
+
export function openaiUsage(raw: unknown): WireUsage | null {
|
|
519
|
+
const x = obj(raw);
|
|
520
|
+
const din = obj(x?.input_tokens_details);
|
|
521
|
+
const dout = obj(x?.output_tokens_details);
|
|
522
|
+
if (!x || !din || !dout) return null;
|
|
523
|
+
const input = count(x.input_tokens, false);
|
|
524
|
+
const output = count(x.output_tokens, false);
|
|
525
|
+
const total = count(x.total_tokens, false);
|
|
526
|
+
const cached = count(din.cached_tokens, false);
|
|
527
|
+
const written = count(din.cache_write_tokens, true);
|
|
528
|
+
const reasoning = count(dout.reasoning_tokens, false);
|
|
529
|
+
if (input === BAD || output === BAD || total === BAD || cached === BAD || written === BAD || reasoning === BAD) return null;
|
|
530
|
+
if (total !== input + output || cached + written > input || reasoning > output) return null;
|
|
531
|
+
return { input: input - cached - written, output, cacheRead: cached, cacheWrite: written };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
export const openaiWire: Wire = {
|
|
535
|
+
provider: 'openai',
|
|
536
|
+
id: 'openai-api',
|
|
537
|
+
label: 'OpenAI (API key)',
|
|
538
|
+
prefix: 'openai',
|
|
539
|
+
defaultModel: 'gpt-6-astra',
|
|
540
|
+
maxOutput: () => 32_000,
|
|
541
|
+
body({ model, effort, system, items, tools, maxOutput, noCalls }) {
|
|
542
|
+
return {
|
|
543
|
+
model,
|
|
544
|
+
instructions: system,
|
|
545
|
+
input: openaiInput(items),
|
|
546
|
+
...(tools.length ? { tools: tools.map((t) => ({ type: 'function', name: t.name, description: t.description, parameters: t.schema, strict: false })), tool_choice: noCalls ? 'none' : 'auto', ...(noCalls ? {} : { parallel_tool_calls: true }) } : {}),
|
|
547
|
+
...(effort ? { reasoning: { effort } } : {}),
|
|
548
|
+
max_output_tokens: maxOutput,
|
|
549
|
+
// The conversation is Botons'; reasoning comes back encrypted so it can be passed back.
|
|
550
|
+
store: false,
|
|
551
|
+
include: ['reasoning.encrypted_content'],
|
|
552
|
+
stream: true,
|
|
553
|
+
};
|
|
554
|
+
},
|
|
555
|
+
async send(body, o) {
|
|
556
|
+
const res = await post(`${o.origin}/v1/responses`, { authorization: `Bearer ${o.key}` }, body, 'OpenAI', o.signal);
|
|
557
|
+
let text = '';
|
|
558
|
+
let final: Record<string, unknown> | null = null;
|
|
559
|
+
const done: unknown[] = [];
|
|
560
|
+
let finals = 0;
|
|
561
|
+
try {
|
|
562
|
+
for await (const ev of sseEvents(res.body!, { maxBytes: MAX_STREAM, maxEvent: MAX_EVENT, idleMs: o.idleMs, signal: o.signal })) {
|
|
563
|
+
let d: Record<string, unknown> | null;
|
|
564
|
+
try {
|
|
565
|
+
d = obj(JSON.parse(ev.data));
|
|
566
|
+
} catch {
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
if (!d) continue;
|
|
570
|
+
const type = String(d.type ?? ev.event);
|
|
571
|
+
if (type === 'response.output_text.delta' && typeof d.delta === 'string') {
|
|
572
|
+
text += d.delta;
|
|
573
|
+
o.onActivity('Writing');
|
|
574
|
+
o.onText(text);
|
|
575
|
+
} else if (type === 'response.reasoning_summary_text.delta' || type === 'response.output_item.added') {
|
|
576
|
+
if (obj(d.item)?.type === 'reasoning' || type.startsWith('response.reasoning')) o.onActivity('Thinking');
|
|
577
|
+
} else if (type === 'response.output_item.done' && d.item) done.push(d.item);
|
|
578
|
+
else if (type === 'response.completed' || type === 'response.incomplete') {
|
|
579
|
+
// A second final answer: which one holds is unknown, so its usage is too.
|
|
580
|
+
finals++;
|
|
581
|
+
final ??= obj(d.response);
|
|
582
|
+
}
|
|
583
|
+
else if (type === 'response.failed') {
|
|
584
|
+
const r = obj(d.response);
|
|
585
|
+
const e = obj(r?.error);
|
|
586
|
+
throw new WireError('server', `OpenAI could not answer: ${[e?.code, e?.message].filter((x) => typeof x === 'string').join(': ').slice(0, 300) || 'failed'}`, { sent: true });
|
|
587
|
+
} else if (type === 'error') {
|
|
588
|
+
const words = [d.code, d.message].filter((x) => typeof x === 'string').join(': ').slice(0, 300) || 'error';
|
|
589
|
+
throw new WireError(/rate/i.test(words) ? 'rate_limited' : 'server', `OpenAI stopped its answer: ${words}`, { sent: true });
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
} catch (err) {
|
|
593
|
+
if (err instanceof WireError) throw err;
|
|
594
|
+
if (o.signal.aborted) throw err;
|
|
595
|
+
throw new WireError('stream', `OpenAI's answer broke off (${err instanceof StreamError ? err.message : (err as Error).message})`, { sent: true });
|
|
596
|
+
}
|
|
597
|
+
if (!final) throw new WireError('stream', 'OpenAI’s answer ended before it was complete', { sent: true });
|
|
598
|
+
const output = Array.isArray(final.output) ? (final.output as unknown[]) : done;
|
|
599
|
+
const calls: ToolCall[] = [];
|
|
600
|
+
let finalText = '';
|
|
601
|
+
for (const raw of output) {
|
|
602
|
+
const item = obj(raw);
|
|
603
|
+
if (item?.type === 'function_call') {
|
|
604
|
+
let input: unknown = null;
|
|
605
|
+
try {
|
|
606
|
+
input = JSON.parse(String(item.arguments ?? '{}'));
|
|
607
|
+
} catch {}
|
|
608
|
+
calls.push({ id: String(item.call_id ?? ''), name: String(item.name ?? ''), input });
|
|
609
|
+
} else if (item?.type === 'message' && Array.isArray(item.content)) {
|
|
610
|
+
for (const c of item.content as Record<string, unknown>[]) if (c?.type === 'output_text' && typeof c.text === 'string') finalText += c.text;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
const incomplete = obj(final.incomplete_details);
|
|
614
|
+
const reason = typeof incomplete?.reason === 'string' ? incomplete.reason : null;
|
|
615
|
+
const status = final.status;
|
|
616
|
+
const stop: WireRound['stop'] = calls.length ? 'tools' : status === 'completed' ? 'end' : reason === 'max_output_tokens' ? 'max_tokens' : reason === 'content_filter' ? 'refusal' : 'other';
|
|
617
|
+
return {
|
|
618
|
+
text: finalText,
|
|
619
|
+
calls,
|
|
620
|
+
stop,
|
|
621
|
+
stopDetail: status === 'completed' ? null : [status, reason].filter((x) => typeof x === 'string').join(': ') || null,
|
|
622
|
+
assistant: output,
|
|
623
|
+
usage: finals === 1 ? openaiUsage(final.usage) : null,
|
|
624
|
+
model: typeof final.model === 'string' ? final.model : null,
|
|
625
|
+
};
|
|
626
|
+
},
|
|
627
|
+
stripReasoning(assistant) {
|
|
628
|
+
const items = Array.isArray(assistant) ? assistant.filter((i) => obj(i)?.type !== 'reasoning') : [];
|
|
629
|
+
return items.length ? items : null;
|
|
630
|
+
},
|
|
631
|
+
sanitize(assistant, s) {
|
|
632
|
+
const calls: ToolCall[] = [];
|
|
633
|
+
const out: unknown[] = [];
|
|
634
|
+
let left = 0;
|
|
635
|
+
let text = '';
|
|
636
|
+
for (const raw of Array.isArray(assistant) ? assistant : []) {
|
|
637
|
+
const item = obj(raw);
|
|
638
|
+
if (!item) continue;
|
|
639
|
+
if (item.type === 'reasoning') {
|
|
640
|
+
// Encrypted, its summary with it: changed, it would be refused; it goes whole instead.
|
|
641
|
+
if (holdsAny(item, s)) left++;
|
|
642
|
+
else out.push(item);
|
|
643
|
+
} else if (item.type === 'message') {
|
|
644
|
+
if (holdsAny({ ...item, content: undefined }, s)) throw new SecretInAnswer('a message’s own fields');
|
|
645
|
+
const content: unknown[] = [];
|
|
646
|
+
for (const rc of Array.isArray(item.content) ? (item.content as unknown[]) : []) {
|
|
647
|
+
const c = obj(rc);
|
|
648
|
+
if (!c) continue;
|
|
649
|
+
const field = c.type === 'output_text' ? 'text' : c.type === 'refusal' ? 'refusal' : null;
|
|
650
|
+
if (!field) {
|
|
651
|
+
if (holdsAny(c, s)) throw new SecretInAnswer(`${String(c.type).slice(0, 40)} content`);
|
|
652
|
+
content.push(c);
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
const next: Record<string, unknown> = { ...c, [field]: typeof c[field] === 'string' ? s.redact(c[field] as string) : c[field] };
|
|
656
|
+
if (holdsAny(next.annotations, s)) {
|
|
657
|
+
next.annotations = [];
|
|
658
|
+
left++;
|
|
659
|
+
}
|
|
660
|
+
if (holdsAny({ ...next, [field]: undefined }, s)) throw new SecretInAnswer('message content');
|
|
661
|
+
if (c.type === 'output_text' && typeof next.text === 'string') text += next.text;
|
|
662
|
+
content.push(next);
|
|
663
|
+
}
|
|
664
|
+
out.push({ ...item, content });
|
|
665
|
+
} else if (item.type === 'function_call') {
|
|
666
|
+
if (holdsAny({ ...item, arguments: undefined }, s)) throw new SecretInAnswer('a tool call’s id or name');
|
|
667
|
+
let input: unknown = null;
|
|
668
|
+
try {
|
|
669
|
+
input = JSON.parse(String(item.arguments ?? '{}'));
|
|
670
|
+
} catch {}
|
|
671
|
+
const r = redactDeep(input, s);
|
|
672
|
+
let args = item.arguments;
|
|
673
|
+
let secret = r.changed;
|
|
674
|
+
if (r.changed) args = JSON.stringify(r.value);
|
|
675
|
+
else if (input === null && typeof args === 'string' && s.holds(args)) {
|
|
676
|
+
// Not JSON, so not run anyway: what it held goes.
|
|
677
|
+
args = '{}';
|
|
678
|
+
secret = true;
|
|
679
|
+
}
|
|
680
|
+
out.push(args === item.arguments ? item : { ...item, arguments: args });
|
|
681
|
+
calls.push({ id: String(item.call_id ?? ''), name: String(item.name ?? ''), input: r.value ?? null, ...(secret ? { secret: true } : {}) });
|
|
682
|
+
} else if (holdsAny(item, s)) throw new SecretInAnswer(`a ${String(item.type).slice(0, 40)} item`);
|
|
683
|
+
else out.push(item);
|
|
684
|
+
}
|
|
685
|
+
return checked({ assistant: out, text, calls, left }, s);
|
|
686
|
+
},
|
|
687
|
+
reasoningRefused: (err) => err.kind === 'bad_request' && /reasoning/i.test(err.message) && /item/i.test(err.message) && !/include/i.test(err.message),
|
|
688
|
+
// A model without reasoning may refuse to be asked for its encrypted reasoning.
|
|
689
|
+
adjust: (body, err) => (err.kind === 'bad_request' && body.include && /include|encrypted[ _]content/i.test(err.message) ? { ...body, include: undefined } : null),
|
|
690
|
+
};
|