@lll9p/pi-anyrouter 0.3.2 → 0.4.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/src/codex.ts ADDED
@@ -0,0 +1,376 @@
1
+ import {
2
+ type Api,
3
+ type AssistantMessage,
4
+ type AssistantMessageEventStream,
5
+ type Context,
6
+ calculateCost,
7
+ type ImageContent,
8
+ type Model,
9
+ type SimpleStreamOptions,
10
+ type TextContent,
11
+ type Tool,
12
+ type ToolResultMessage,
13
+ } from "@earendil-works/pi-ai";
14
+ import {
15
+ delay,
16
+ fetchWithProxy,
17
+ getRetryDelayMs,
18
+ isRetryableStatus,
19
+ nextSseChunk,
20
+ parseRetryAfterMs,
21
+ parseSseEvent,
22
+ redactHeaders,
23
+ writeDebugFile,
24
+ } from "./http.js";
25
+ import { CODEX_INSTALLATION_ID, CODEX_VERSION, type Json } from "./types.js";
26
+ import { extractRequestId, mapReasoningEffort, sanitizeText, tryParseJson } from "./utils.js";
27
+
28
+ // ── URL ─────────────────────────────────────────────────────────────────────
29
+
30
+ export function getCodexResponsesUrl(baseUrl: string) {
31
+ const normalized = baseUrl.replace(/\/+$/, "");
32
+ if (normalized.endsWith("/responses")) return normalized;
33
+ if (normalized.endsWith("/v1")) return `${normalized}/responses`;
34
+ return `${normalized}/v1/responses`;
35
+ }
36
+
37
+ // ── Message / tool conversion ───────────────────────────────────────────────
38
+
39
+ export function convertCodexMessages(context: Context) {
40
+ const input: any[] = [];
41
+ if (context.systemPrompt) {
42
+ input.push({
43
+ type: "message",
44
+ role: "developer",
45
+ content: [{ type: "input_text", text: sanitizeText(context.systemPrompt) }],
46
+ });
47
+ }
48
+
49
+ for (const msg of context.messages) {
50
+ if (msg.role === "user") {
51
+ if (typeof msg.content === "string") {
52
+ if (msg.content.trim()) {
53
+ input.push({ type: "message", role: "user", content: [{ type: "input_text", text: sanitizeText(msg.content) }] });
54
+ }
55
+ } else {
56
+ const content = msg.content.map((item) =>
57
+ item.type === "text"
58
+ ? { type: "input_text", text: sanitizeText(item.text) }
59
+ : { type: "input_image", detail: "auto", image_url: `data:${item.mimeType};base64,${item.data}` },
60
+ );
61
+ if (content.length) input.push({ type: "message", role: "user", content });
62
+ }
63
+ continue;
64
+ }
65
+
66
+ if (msg.role === "assistant") {
67
+ for (const block of msg.content) {
68
+ if (block.type === "thinking" && block.thinkingSignature) {
69
+ const reasoning = tryParseJson(block.thinkingSignature);
70
+ if (reasoning) input.push(reasoning);
71
+ } else if (block.type === "text" && block.text.trim()) {
72
+ input.push({
73
+ type: "message",
74
+ role: "assistant",
75
+ status: "completed",
76
+ content: [{ type: "output_text", text: sanitizeText(block.text), annotations: [] }],
77
+ });
78
+ } else if (block.type === "toolCall") {
79
+ const [callId, itemId] = block.id.split("|");
80
+ input.push({
81
+ type: "function_call",
82
+ ...(itemId ? { id: itemId } : {}),
83
+ call_id: callId,
84
+ name: block.name,
85
+ arguments: JSON.stringify(block.arguments),
86
+ });
87
+ }
88
+ }
89
+ continue;
90
+ }
91
+
92
+ if (msg.role === "toolResult") {
93
+ const toolMsg = msg as ToolResultMessage;
94
+ const text = toolMsg.content
95
+ .filter((item) => item.type === "text")
96
+ .map((item) => (item as TextContent).text)
97
+ .join("\n");
98
+ const images = toolMsg.content.filter((item) => item.type === "image") as ImageContent[];
99
+ const output = images.length
100
+ ? [
101
+ ...(text ? [{ type: "input_text", text: sanitizeText(text) }] : []),
102
+ ...images.map((image) => ({ type: "input_image", detail: "auto", image_url: `data:${image.mimeType};base64,${image.data}` })),
103
+ ]
104
+ : sanitizeText(text || (images.length ? "(see attached image)" : "(no tool output)"));
105
+ input.push({ type: "function_call_output", call_id: toolMsg.toolCallId.split("|")[0], output });
106
+ }
107
+ }
108
+ return input;
109
+ }
110
+
111
+ function convertCodexTools(tools: Tool[]) {
112
+ return tools.map((tool) => ({
113
+ type: "function",
114
+ name: tool.name,
115
+ description: tool.description,
116
+ parameters: tool.parameters,
117
+ strict: false,
118
+ }));
119
+ }
120
+
121
+ // ── Headers / metadata ──────────────────────────────────────────────────────
122
+
123
+ export function createCodexMetadata(sessionId: string, turnId: string) {
124
+ const windowId = `${sessionId}:0`;
125
+ const turnMetadata = JSON.stringify({
126
+ installation_id: CODEX_INSTALLATION_ID,
127
+ session_id: sessionId,
128
+ thread_id: sessionId,
129
+ turn_id: turnId,
130
+ window_id: windowId,
131
+ request_kind: "turn",
132
+ thread_source: "user",
133
+ turn_started_at_unix_ms: Date.now(),
134
+ });
135
+ return {
136
+ windowId,
137
+ turnMetadata,
138
+ clientMetadata: {
139
+ session_id: sessionId,
140
+ thread_id: sessionId,
141
+ turn_id: turnId,
142
+ "x-codex-installation-id": CODEX_INSTALLATION_ID,
143
+ "x-codex-window-id": windowId,
144
+ "x-codex-turn-metadata": turnMetadata,
145
+ },
146
+ };
147
+ }
148
+
149
+ function createCodexHeaders(apiKey: string, sessionId: string, metadata: ReturnType<typeof createCodexMetadata>) {
150
+ return {
151
+ authorization: `Bearer ${apiKey}`,
152
+ accept: "text/event-stream",
153
+ "content-type": "application/json",
154
+ originator: "codex_exec",
155
+ "user-agent": `codex_exec/${CODEX_VERSION} (Linux; x86_64) (codex_exec; ${CODEX_VERSION})`,
156
+ "x-openai-internal-codex-responses-lite": "true",
157
+ "x-codex-beta-features": "remote_compaction_v2",
158
+ "x-codex-window-id": metadata.windowId,
159
+ "x-codex-turn-metadata": metadata.turnMetadata,
160
+ "x-client-request-id": sessionId,
161
+ "session-id": sessionId,
162
+ "thread-id": sessionId,
163
+ };
164
+ }
165
+
166
+ // ── Request body builder ────────────────────────────────────────────────────
167
+
168
+ export function buildCodexRequestBody(
169
+ model: Model<Api>,
170
+ context: Context,
171
+ options: SimpleStreamOptions | undefined,
172
+ sessionId: string,
173
+ metadata: ReturnType<typeof createCodexMetadata>,
174
+ ) {
175
+ const body: Json = {
176
+ model: model.id,
177
+ input: convertCodexMessages(context),
178
+ tool_choice: "auto",
179
+ parallel_tool_calls: false,
180
+ reasoning: {
181
+ effort: mapReasoningEffort(options?.reasoning),
182
+ context: "all_turns",
183
+ },
184
+ store: false,
185
+ stream: true,
186
+ text: { verbosity: "low" },
187
+ max_output_tokens: options?.maxTokens || model.maxTokens,
188
+ include: ["reasoning.encrypted_content"],
189
+ prompt_cache_key: sessionId,
190
+ client_metadata: metadata.clientMetadata,
191
+ };
192
+ if (context.tools?.length) body.tools = convertCodexTools(context.tools);
193
+ return body;
194
+ }
195
+
196
+ // ── Usage ───────────────────────────────────────────────────────────────────
197
+
198
+ function applyCodexUsage(output: AssistantMessage, response: any, model: Model<Api>) {
199
+ const usage = response?.usage;
200
+ if (!usage) return;
201
+ const cached = usage.input_tokens_details?.cached_tokens || 0;
202
+ const cacheWrite = usage.input_tokens_details?.cache_write_tokens || 0;
203
+ output.usage.input = Math.max(0, (usage.input_tokens || 0) - cached - cacheWrite);
204
+ output.usage.output = usage.output_tokens || 0;
205
+ output.usage.cacheRead = cached;
206
+ output.usage.cacheWrite = cacheWrite;
207
+ if (usage.output_tokens_details?.reasoning_tokens != null) output.usage.reasoning = usage.output_tokens_details.reasoning_tokens;
208
+ output.usage.totalTokens = usage.total_tokens || output.usage.input + output.usage.output + cached + cacheWrite;
209
+ calculateCost(model, output.usage);
210
+ }
211
+
212
+ // ── SSE payload processing ──────────────────────────────────────────────────
213
+
214
+ function applyCodexSsePayload(payload: any, output: AssistantMessage, stream: AssistantMessageEventStream, model: Model<Api>, slots: Map<number, any>) {
215
+ const type = payload?.type;
216
+ if (!type || type === "response.in_progress" || type === "response.metadata") return;
217
+ if (type === "error") throw new Error(payload.message || JSON.stringify(payload));
218
+ if (type === "response.failed") throw new Error(payload.response?.error?.message || "Codex response failed");
219
+
220
+ if (type === "response.created") {
221
+ output.responseId = payload.response?.id || output.responseId;
222
+ return;
223
+ }
224
+
225
+ if (type === "response.output_item.added") {
226
+ const item = payload.item;
227
+ if (item?.type === "message") {
228
+ const block = { type: "text", text: "" };
229
+ output.content.push(block as any);
230
+ const contentIndex = output.content.length - 1;
231
+ slots.set(payload.output_index, { type: "text", block, contentIndex });
232
+ stream.push({ type: "text_start", contentIndex, partial: output });
233
+ } else if (item?.type === "reasoning") {
234
+ const block = { type: "thinking", thinking: "", thinkingSignature: "" };
235
+ output.content.push(block as any);
236
+ const contentIndex = output.content.length - 1;
237
+ slots.set(payload.output_index, { type: "thinking", block, contentIndex });
238
+ stream.push({ type: "thinking_start", contentIndex, partial: output });
239
+ } else if (item?.type === "function_call") {
240
+ const block = { type: "toolCall", id: `${item.call_id}|${item.id}`, name: item.name, arguments: {}, partialJson: item.arguments || "" };
241
+ output.content.push(block as any);
242
+ const contentIndex = output.content.length - 1;
243
+ slots.set(payload.output_index, { type: "toolCall", block, contentIndex });
244
+ stream.push({ type: "toolcall_start", contentIndex, partial: output });
245
+ }
246
+ return;
247
+ }
248
+
249
+ const slot = slots.get(payload.output_index);
250
+ if (type === "response.output_text.delta" && slot?.type === "text") {
251
+ slot.block.text += String(payload.delta || "");
252
+ stream.push({ type: "text_delta", contentIndex: slot.contentIndex, delta: String(payload.delta || ""), partial: output });
253
+ } else if ((type === "response.reasoning_summary_text.delta" || type === "response.reasoning_text.delta") && slot?.type === "thinking") {
254
+ slot.block.thinking += String(payload.delta || "");
255
+ stream.push({ type: "thinking_delta", contentIndex: slot.contentIndex, delta: String(payload.delta || ""), partial: output });
256
+ } else if (type === "response.function_call_arguments.delta" && slot?.type === "toolCall") {
257
+ slot.block.partialJson += String(payload.delta || "");
258
+ const parsed = tryParseJson(slot.block.partialJson);
259
+ if (parsed !== undefined) slot.block.arguments = parsed;
260
+ stream.push({ type: "toolcall_delta", contentIndex: slot.contentIndex, delta: String(payload.delta || ""), partial: output });
261
+ } else if (type === "response.function_call_arguments.done" && slot?.type === "toolCall") {
262
+ slot.block.partialJson = String(payload.arguments || slot.block.partialJson);
263
+ slot.block.arguments = tryParseJson(slot.block.partialJson) || {};
264
+ } else if (type === "response.output_item.done") {
265
+ const item = payload.item;
266
+ if (slot?.type === "text" && item?.type === "message") {
267
+ slot.block.text = item.content?.map((part: any) => part.text || part.refusal || "").join("") || slot.block.text;
268
+ stream.push({ type: "text_end", contentIndex: slot.contentIndex, content: slot.block.text, partial: output });
269
+ } else if (slot?.type === "thinking" && item?.type === "reasoning") {
270
+ slot.block.thinking =
271
+ item.summary?.map((part: any) => part.text).join("\n\n") || item.content?.map((part: any) => part.text).join("\n\n") || slot.block.thinking;
272
+ slot.block.thinkingSignature = JSON.stringify(item);
273
+ stream.push({ type: "thinking_end", contentIndex: slot.contentIndex, content: slot.block.thinking, partial: output });
274
+ } else if (slot?.type === "toolCall" && item?.type === "function_call") {
275
+ slot.block.arguments = tryParseJson(item.arguments || slot.block.partialJson) || {};
276
+ delete slot.block.partialJson;
277
+ stream.push({ type: "toolcall_end", contentIndex: slot.contentIndex, toolCall: slot.block, partial: output });
278
+ }
279
+ slots.delete(payload.output_index);
280
+ } else if (type === "response.completed" || type === "response.incomplete") {
281
+ output.responseId = payload.response?.id || output.responseId;
282
+ applyCodexUsage(output, payload.response, model);
283
+ output.stopReason = type === "response.incomplete" ? "length" : output.content.some((block) => block.type === "toolCall") ? "toolUse" : "stop";
284
+ }
285
+ }
286
+
287
+ // ── Streaming request ───────────────────────────────────────────────────────
288
+
289
+ export async function tryStreamAnyRouterCodex(
290
+ url: string,
291
+ body: Json,
292
+ apiKey: string,
293
+ model: Model<Api>,
294
+ output: AssistantMessage,
295
+ stream: AssistantMessageEventStream,
296
+ sessionId: string,
297
+ metadata: ReturnType<typeof createCodexMetadata>,
298
+ options?: SimpleStreamOptions,
299
+ ) {
300
+ const bodyText = JSON.stringify(body);
301
+ const maxRetries = Math.max(0, Number(process.env.PI_ANYROUTER_CC_MAX_RETRIES || options?.maxRetries || "10") || 0);
302
+ let response: Response | undefined;
303
+
304
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
305
+ const headers = createCodexHeaders(apiKey, sessionId, metadata);
306
+ if (attempt === 0) writeDebugFile("request", model.id, undefined, { url, headers: redactHeaders(headers), body, transport: "codex-sse" });
307
+ try {
308
+ response = await fetchWithProxy(url, { method: "POST", signal: options?.signal, headers, body: bodyText });
309
+ } catch (error) {
310
+ if (attempt < maxRetries && !options?.signal?.aborted) {
311
+ await delay(getRetryDelayMs(attempt));
312
+ continue;
313
+ }
314
+ throw error;
315
+ }
316
+
317
+ if (response.ok && (response.headers.get("content-type") || "").includes("text/event-stream")) {
318
+ if (options?.onResponse) {
319
+ await options.onResponse({ status: response.status, headers: Object.fromEntries(response.headers.entries()) }, model);
320
+ }
321
+ break;
322
+ }
323
+ const raw = await response.text();
324
+ const parsed = tryParseJson(raw) || { raw };
325
+ const requestId = extractRequestId(parsed, response.headers);
326
+ writeDebugFile("error", model.id, requestId, { status: response.status, requestId, body: parsed, raw, transport: "codex-sse", retryAttempt: attempt });
327
+ if (!response.ok && attempt < maxRetries && isRetryableStatus(response.status)) {
328
+ await delay(getRetryDelayMs(attempt, parseRetryAfterMs(response.headers.get("retry-after"))));
329
+ response = undefined;
330
+ continue;
331
+ }
332
+ throw new Error(raw || `HTTP ${response.status}`);
333
+ }
334
+
335
+ if (!response?.body) throw new Error("Codex stream response body missing");
336
+ const slots = new Map<number, any>();
337
+ const reader = response.body.getReader();
338
+ const decoder = new TextDecoder();
339
+ let buffer = "";
340
+ let terminal = false;
341
+ while (true) {
342
+ const { value, done } = await reader.read();
343
+ buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
344
+ let parsedChunk = nextSseChunk(buffer);
345
+ while (parsedChunk) {
346
+ buffer = parsedChunk.rest;
347
+ const event = parseSseEvent(parsedChunk.chunk);
348
+ if (event.data && event.data !== "[DONE]") {
349
+ const payload = tryParseJson(event.data);
350
+ if (!payload) throw new Error(`invalid Codex SSE payload: ${event.data.slice(0, 200)}`);
351
+ applyCodexSsePayload(payload, output, stream, model, slots);
352
+ if (payload.type === "response.completed" || payload.type === "response.incomplete") terminal = true;
353
+ }
354
+ parsedChunk = nextSseChunk(buffer);
355
+ }
356
+ if (done) break;
357
+ }
358
+ const tail = buffer.trim();
359
+ if (tail) {
360
+ const event = parseSseEvent(tail);
361
+ if (event.data && event.data !== "[DONE]") {
362
+ const payload = tryParseJson(event.data);
363
+ if (!payload) throw new Error(`invalid Codex SSE payload: ${event.data.slice(0, 200)}`);
364
+ applyCodexSsePayload(payload, output, stream, model, slots);
365
+ if (payload.type === "response.completed" || payload.type === "response.incomplete") terminal = true;
366
+ }
367
+ }
368
+ if (!terminal) throw new Error("Codex stream ended before a terminal response event");
369
+ writeDebugFile("response", model.id, response.headers.get("x-oneapi-request-id") || undefined, {
370
+ status: response.status,
371
+ responseId: output.responseId,
372
+ stopReason: output.stopReason,
373
+ usage: output.usage,
374
+ transport: "codex-sse",
375
+ });
376
+ }
package/src/config.ts ADDED
@@ -0,0 +1,36 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { CONFIG_PATH, type ProviderConfigFile } from "./types.js";
3
+
4
+ export function resolveConfigValue(value?: string) {
5
+ if (!value) return "";
6
+ if (value.startsWith("!")) {
7
+ throw new Error("anyrouter does not support shell-command apiKey values. Use a literal key, env var name, or PI_ANYROUTER_CC_API_KEY.");
8
+ }
9
+ return process.env[value] || value;
10
+ }
11
+
12
+ export function loadSourceProvider() {
13
+ let content = "";
14
+ try {
15
+ content = readFileSync(CONFIG_PATH, "utf8");
16
+ } catch {
17
+ throw new Error(`Config file not found: ${CONFIG_PATH}. Create ~/.pi/agent/anyrouter.json or set PI_ANYROUTER_CC_CONFIG.`);
18
+ }
19
+
20
+ let parsed: ProviderConfigFile;
21
+ try {
22
+ parsed = JSON.parse(content) as ProviderConfigFile;
23
+ } catch (error) {
24
+ throw new Error(`Invalid JSON in ${CONFIG_PATH}: ${error instanceof Error ? error.message : String(error)}`);
25
+ }
26
+
27
+ const baseUrl = process.env.PI_ANYROUTER_CC_BASE_URL || parsed.baseUrl;
28
+ const apiKey = process.env.PI_ANYROUTER_CC_API_KEY || resolveConfigValue(parsed.apiKey);
29
+ const models = parsed.models || [];
30
+
31
+ if (!baseUrl) throw new Error(`Missing baseUrl in ${CONFIG_PATH}. You can also set PI_ANYROUTER_CC_BASE_URL.`);
32
+ if (!apiKey) throw new Error(`Missing apiKey in ${CONFIG_PATH}. You can also set PI_ANYROUTER_CC_API_KEY.`);
33
+ if (!models.length) throw new Error(`No models configured in ${CONFIG_PATH}. Add at least one model entry.`);
34
+
35
+ return { baseUrl, apiKey, models };
36
+ }
package/src/http.ts ADDED
@@ -0,0 +1,113 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { ProxyAgent, fetch as undiciFetch } from "undici";
4
+ import { DEBUG_DIR, DEBUG_ENABLED, type FetchInit, type Json } from "./types.js";
5
+
6
+ // ── Headers ─────────────────────────────────────────────────────────────────
7
+
8
+ export function redactHeaders(headers: Record<string, string>) {
9
+ const redacted = { ...headers };
10
+ if (redacted.authorization) redacted.authorization = "Bearer ***";
11
+ if (redacted["x-api-key"]) redacted["x-api-key"] = "***";
12
+ return redacted;
13
+ }
14
+
15
+ // ── Proxy ───────────────────────────────────────────────────────────────────
16
+
17
+ const PROXY_AGENTS = new Map<string, ProxyAgent>();
18
+
19
+ function hostMatchesNoProxy(hostname: string, pattern: string) {
20
+ const item = pattern.trim().toLowerCase();
21
+ if (!item) return false;
22
+ if (item === "*") return true;
23
+ const host = hostname.toLowerCase();
24
+ if (item.startsWith(".")) return host === item.slice(1) || host.endsWith(item);
25
+ return host === item || host.endsWith(`.${item}`);
26
+ }
27
+
28
+ function getProxyUrl(url: string) {
29
+ const parsed = new URL(url);
30
+ const noProxy = process.env.NO_PROXY || process.env.no_proxy || "";
31
+ if (noProxy.split(",").some((item) => hostMatchesNoProxy(parsed.hostname, item))) return undefined;
32
+ if (parsed.protocol === "https:") return process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy;
33
+ return process.env.HTTP_PROXY || process.env.http_proxy;
34
+ }
35
+
36
+ function getProxyAgent(proxyUrl: string) {
37
+ let agent = PROXY_AGENTS.get(proxyUrl);
38
+ if (!agent) {
39
+ agent = new ProxyAgent(proxyUrl);
40
+ PROXY_AGENTS.set(proxyUrl, agent);
41
+ }
42
+ return agent;
43
+ }
44
+
45
+ export function fetchWithProxy(url: string, init: FetchInit) {
46
+ const proxyUrl = getProxyUrl(url);
47
+ if (!proxyUrl) return fetch(url, init);
48
+ return undiciFetch(url, { ...init, dispatcher: getProxyAgent(proxyUrl) } as any) as unknown as Promise<Response>;
49
+ }
50
+
51
+ // ── Debug ───────────────────────────────────────────────────────────────────
52
+
53
+ export function writeDebugFile(kind: "request" | "response" | "error", modelId: string, requestId: string | undefined, payload: Json) {
54
+ if (!DEBUG_ENABLED) return;
55
+ mkdirSync(DEBUG_DIR, { recursive: true });
56
+ const safeModel = modelId.replace(/[^a-zA-Z0-9._-]+/g, "_");
57
+ const safeRequestId = (requestId || "no-request-id").replace(/[^a-zA-Z0-9._-]+/g, "_");
58
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
59
+ const path = join(DEBUG_DIR, `${timestamp}-${safeModel}-${safeRequestId}-${kind}.json`);
60
+ writeFileSync(path, JSON.stringify(payload, null, 2), "utf8");
61
+ }
62
+
63
+ // ── Retry ───────────────────────────────────────────────────────────────────
64
+
65
+ export function delay(ms: number) {
66
+ return new Promise((resolve) => setTimeout(resolve, ms));
67
+ }
68
+
69
+ export function isRetryableStatus(status: number) {
70
+ return [408, 409, 429, 500, 502, 503, 504, 520, 522, 524].includes(status);
71
+ }
72
+
73
+ export function parseRetryAfterMs(value: string | null) {
74
+ if (!value) return undefined;
75
+ const seconds = Number(value);
76
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000;
77
+ const at = Date.parse(value);
78
+ if (Number.isFinite(at)) {
79
+ const delta = at - Date.now();
80
+ return delta > 0 ? delta : 0;
81
+ }
82
+ return undefined;
83
+ }
84
+
85
+ export function getRetryDelayMs(attempt: number, retryAfterMs?: number) {
86
+ if (typeof retryAfterMs === "number") return Math.max(0, Math.min(retryAfterMs, 30_000));
87
+ const base = Math.min(1000 * 2 ** attempt, 15_000);
88
+ const jitter = Math.floor(Math.random() * 250);
89
+ return base + jitter;
90
+ }
91
+
92
+ // ── SSE parsing ─────────────────────────────────────────────────────────────
93
+
94
+ export function parseSseEvent(chunk: string) {
95
+ let event = "message";
96
+ const data: string[] = [];
97
+ for (const line of chunk.split(/\r?\n/)) {
98
+ if (!line || line.startsWith(":")) continue;
99
+ if (line.startsWith("event:")) event = line.slice(6).trim();
100
+ else if (line.startsWith("data:")) data.push(line.slice(5).trimStart());
101
+ }
102
+ return { event, data: data.join("\n") };
103
+ }
104
+
105
+ export function nextSseChunk(buffer: string) {
106
+ const unix = buffer.indexOf("\n\n");
107
+ const dos = buffer.indexOf("\r\n\r\n");
108
+ if (unix === -1 && dos === -1) return undefined;
109
+ if (dos !== -1 && (unix === -1 || dos < unix)) {
110
+ return { chunk: buffer.slice(0, dos), rest: buffer.slice(dos + 4) };
111
+ }
112
+ return { chunk: buffer.slice(0, unix), rest: buffer.slice(unix + 2) };
113
+ }