@lll9p/pi-anyrouter 0.4.0 → 0.4.1
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/package.json +1 -1
- package/src/claude-code.ts +109 -80
- package/src/codex.ts +94 -55
- package/src/http.ts +17 -0
package/package.json
CHANGED
package/src/claude-code.ts
CHANGED
|
@@ -15,10 +15,12 @@ import {
|
|
|
15
15
|
delay,
|
|
16
16
|
fetchWithProxy,
|
|
17
17
|
getRetryDelayMs,
|
|
18
|
+
isRetryableErrorType,
|
|
18
19
|
isRetryableStatus,
|
|
19
20
|
nextSseChunk,
|
|
20
21
|
parseRetryAfterMs,
|
|
21
22
|
parseSseEvent,
|
|
23
|
+
RetryableStreamError,
|
|
22
24
|
redactHeaders,
|
|
23
25
|
writeDebugFile,
|
|
24
26
|
} from "./http.js";
|
|
@@ -209,8 +211,11 @@ function applySsePayloadEvent(
|
|
|
209
211
|
if (!payload?.type || payload.type === "ping" || payload.type === "message_stop") return;
|
|
210
212
|
|
|
211
213
|
if (payload.type === "error") {
|
|
214
|
+
const errorType = payload?.error?.type || payload?.error?.code;
|
|
212
215
|
const errorText = payload?.error?.message || payload?.error || payload?.message || JSON.stringify(payload);
|
|
213
|
-
|
|
216
|
+
const message = String(errorText);
|
|
217
|
+
if (isRetryableErrorType(errorType)) throw new RetryableStreamError(message);
|
|
218
|
+
throw new Error(message);
|
|
214
219
|
}
|
|
215
220
|
|
|
216
221
|
if (payload.type === "message_start") {
|
|
@@ -327,6 +332,46 @@ function applySsePayloadEvent(
|
|
|
327
332
|
}
|
|
328
333
|
}
|
|
329
334
|
|
|
335
|
+
// ── Stream consumption ──────────────────────────────────────────────────────
|
|
336
|
+
|
|
337
|
+
async function consumeCcStream(response: Response, output: AssistantMessage, stream: AssistantMessageEventStream, model: Model<Api>) {
|
|
338
|
+
if (!response.body) throw new Error("stream response body missing");
|
|
339
|
+
const blockIndexByEventIndex = new Map<number, number>();
|
|
340
|
+
const reader = response.body.getReader();
|
|
341
|
+
const decoder = new TextDecoder();
|
|
342
|
+
let buffer = "";
|
|
343
|
+
|
|
344
|
+
while (true) {
|
|
345
|
+
const { value, done } = await reader.read();
|
|
346
|
+
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
|
|
347
|
+
|
|
348
|
+
let parsedChunk = nextSseChunk(buffer);
|
|
349
|
+
while (parsedChunk) {
|
|
350
|
+
buffer = parsedChunk.rest;
|
|
351
|
+
const event = parseSseEvent(parsedChunk.chunk);
|
|
352
|
+
if (event.data) {
|
|
353
|
+
const payload = tryParseJson(event.data);
|
|
354
|
+
if (!payload && event.data !== "[DONE]") throw new Error(`invalid SSE payload: ${event.data.slice(0, 200)}`);
|
|
355
|
+
if (payload) applySsePayloadEvent(payload, output, stream, model, blockIndexByEventIndex);
|
|
356
|
+
}
|
|
357
|
+
parsedChunk = nextSseChunk(buffer);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (done) break;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const tail = buffer.trim();
|
|
364
|
+
if (tail) {
|
|
365
|
+
const event = parseSseEvent(tail);
|
|
366
|
+
if (event.data && event.data !== "[DONE]") {
|
|
367
|
+
const payload = tryParseJson(event.data);
|
|
368
|
+
if (!payload) throw new Error(`invalid SSE payload: ${event.data.slice(0, 200)}`);
|
|
369
|
+
applySsePayloadEvent(payload, output, stream, model, blockIndexByEventIndex);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return response;
|
|
373
|
+
}
|
|
374
|
+
|
|
330
375
|
// ── Streaming request ───────────────────────────────────────────────────────
|
|
331
376
|
|
|
332
377
|
export async function tryStreamAnyRouterCc(
|
|
@@ -342,10 +387,8 @@ export async function tryStreamAnyRouterCc(
|
|
|
342
387
|
const requestBody = { ...body, stream: true };
|
|
343
388
|
const bodyText = JSON.stringify(requestBody);
|
|
344
389
|
const maxRetries = Math.max(0, Number(process.env.PI_ANYROUTER_CC_MAX_RETRIES || options?.maxRetries || "10") || 0);
|
|
345
|
-
let response: Response | undefined;
|
|
346
390
|
|
|
347
391
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
348
|
-
// Real Claude Code keeps this at zero across its application-level retries.
|
|
349
392
|
const headers = getClaudeCodeHeaders(apiKey, 0, sessionId);
|
|
350
393
|
if (attempt === 0) {
|
|
351
394
|
writeDebugFile("request", model.id, undefined, {
|
|
@@ -356,6 +399,7 @@ export async function tryStreamAnyRouterCc(
|
|
|
356
399
|
});
|
|
357
400
|
}
|
|
358
401
|
|
|
402
|
+
let response: Response;
|
|
359
403
|
try {
|
|
360
404
|
response = await fetchWithProxy(url, {
|
|
361
405
|
method: "POST",
|
|
@@ -371,93 +415,78 @@ export async function tryStreamAnyRouterCc(
|
|
|
371
415
|
throw error;
|
|
372
416
|
}
|
|
373
417
|
|
|
374
|
-
|
|
375
|
-
if (response.ok
|
|
376
|
-
|
|
377
|
-
|
|
418
|
+
// HTTP-level error → retry if retryable status
|
|
419
|
+
if (!response.ok) {
|
|
420
|
+
const raw = await response.text();
|
|
421
|
+
const parsed = tryParseJson(raw) || { raw };
|
|
422
|
+
const requestId = extractRequestId(parsed, response.headers);
|
|
423
|
+
writeDebugFile("error", model.id, requestId, {
|
|
424
|
+
status: response.status,
|
|
425
|
+
statusText: response.statusText,
|
|
426
|
+
requestId,
|
|
427
|
+
headers: Object.fromEntries(response.headers.entries()),
|
|
428
|
+
body: parsed,
|
|
429
|
+
raw,
|
|
430
|
+
transport: "sse",
|
|
431
|
+
retryAttempt: attempt,
|
|
432
|
+
maxRetries,
|
|
433
|
+
});
|
|
434
|
+
if (attempt < maxRetries && isRetryableStatus(response.status)) {
|
|
435
|
+
const retryBlockIndex = output.content.length;
|
|
436
|
+
const retryText = `⏳ ${response.status} — retrying (${attempt + 1}/${maxRetries})…`;
|
|
437
|
+
output.content.push({ type: "text", text: retryText } as any);
|
|
438
|
+
stream.push({ type: "text_start", contentIndex: retryBlockIndex, partial: output });
|
|
439
|
+
stream.push({ type: "text_delta", contentIndex: retryBlockIndex, delta: retryText, partial: output });
|
|
440
|
+
stream.push({ type: "text_end", contentIndex: retryBlockIndex, content: retryText, partial: output });
|
|
441
|
+
await delay(getRetryDelayMs(attempt, parseRetryAfterMs(response.headers.get("retry-after"))));
|
|
442
|
+
continue;
|
|
378
443
|
}
|
|
379
|
-
|
|
444
|
+
throw new Error(raw || `HTTP ${response.status}`);
|
|
380
445
|
}
|
|
381
446
|
|
|
382
|
-
const
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
writeDebugFile(response.ok ? "response" : "error", model.id, requestId, {
|
|
386
|
-
status: response.status,
|
|
387
|
-
statusText: response.statusText,
|
|
388
|
-
requestId,
|
|
389
|
-
headers: Object.fromEntries(response.headers.entries()),
|
|
390
|
-
body: parsed,
|
|
391
|
-
raw,
|
|
392
|
-
transport: "sse",
|
|
393
|
-
retryAttempt: attempt,
|
|
394
|
-
maxRetries,
|
|
395
|
-
});
|
|
396
|
-
|
|
397
|
-
if (!response.ok && attempt < maxRetries && isRetryableStatus(response.status)) {
|
|
398
|
-
// Push visible retry feedback so pi's UI shows activity instead of a frozen "working" status.
|
|
399
|
-
const retryBlockIndex = output.content.length;
|
|
400
|
-
const retryText = `⏳ ${response.status} — retrying (${attempt + 1}/${maxRetries})…`;
|
|
401
|
-
output.content.push({ type: "text", text: retryText } as any);
|
|
402
|
-
stream.push({ type: "text_start", contentIndex: retryBlockIndex, partial: output });
|
|
403
|
-
stream.push({ type: "text_delta", contentIndex: retryBlockIndex, delta: retryText, partial: output });
|
|
404
|
-
stream.push({ type: "text_end", contentIndex: retryBlockIndex, content: retryText, partial: output });
|
|
405
|
-
await delay(getRetryDelayMs(attempt, parseRetryAfterMs(response.headers.get("retry-after"))));
|
|
406
|
-
response = undefined;
|
|
407
|
-
continue;
|
|
447
|
+
const contentType = response.headers.get("content-type") || "";
|
|
448
|
+
if (!contentType.includes("text/event-stream")) {
|
|
449
|
+
if (response.ok) throw new Error(`stream response was not SSE (content-type=${contentType || "<missing>"})`);
|
|
408
450
|
}
|
|
409
|
-
if (response.ok) throw new Error(`stream response was not SSE (content-type=${contentType || "<missing>"})`);
|
|
410
|
-
throw new Error(raw || `HTTP ${response.status}`);
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
if (!response?.body) throw new Error("stream response body missing");
|
|
414
|
-
|
|
415
|
-
const blockIndexByEventIndex = new Map<number, number>();
|
|
416
|
-
const reader = response.body.getReader();
|
|
417
|
-
const decoder = new TextDecoder();
|
|
418
|
-
let buffer = "";
|
|
419
|
-
|
|
420
|
-
while (true) {
|
|
421
|
-
const { value, done } = await reader.read();
|
|
422
|
-
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
|
|
423
451
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
buffer = parsedChunk.rest;
|
|
427
|
-
const event = parseSseEvent(parsedChunk.chunk);
|
|
428
|
-
if (event.data) {
|
|
429
|
-
const payload = tryParseJson(event.data);
|
|
430
|
-
if (!payload && event.data !== "[DONE]") throw new Error(`invalid SSE payload: ${event.data.slice(0, 200)}`);
|
|
431
|
-
if (payload) applySsePayloadEvent(payload, output, stream, model, blockIndexByEventIndex);
|
|
432
|
-
}
|
|
433
|
-
parsedChunk = nextSseChunk(buffer);
|
|
452
|
+
if (options?.onResponse) {
|
|
453
|
+
await options.onResponse({ status: response.status, headers: Object.fromEntries(response.headers.entries()) }, model);
|
|
434
454
|
}
|
|
435
455
|
|
|
436
|
-
if
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
456
|
+
// Consume the SSE stream — retry on RetryableStreamError if no content was emitted
|
|
457
|
+
const contentLenBefore = output.content.length;
|
|
458
|
+
try {
|
|
459
|
+
const resp = await consumeCcStream(response, output, stream, model);
|
|
460
|
+
writeDebugFile("response", model.id, resp.headers.get("x-oneapi-request-id") || undefined, {
|
|
461
|
+
status: resp.status,
|
|
462
|
+
statusText: resp.statusText,
|
|
463
|
+
headers: Object.fromEntries(resp.headers.entries()),
|
|
464
|
+
body: {
|
|
465
|
+
responseId: output.responseId,
|
|
466
|
+
stopReason: output.stopReason,
|
|
467
|
+
usage: output.usage,
|
|
468
|
+
contentBlocks: output.content.length,
|
|
469
|
+
},
|
|
470
|
+
transport: "sse",
|
|
471
|
+
});
|
|
472
|
+
return;
|
|
473
|
+
} catch (error) {
|
|
474
|
+
if (error instanceof RetryableStreamError && output.content.length === contentLenBefore && attempt < maxRetries && !options?.signal?.aborted) {
|
|
475
|
+
writeDebugFile("error", model.id, undefined, { phase: "sse-stream-retry", errorMessage: error.message, retryAttempt: attempt, transport: "sse" });
|
|
476
|
+
const retryBlockIndex = output.content.length;
|
|
477
|
+
const retryText = `⏳ SSE error — retrying (${attempt + 1}/${maxRetries})…`;
|
|
478
|
+
output.content.push({ type: "text", text: retryText } as any);
|
|
479
|
+
stream.push({ type: "text_start", contentIndex: retryBlockIndex, partial: output });
|
|
480
|
+
stream.push({ type: "text_delta", contentIndex: retryBlockIndex, delta: retryText, partial: output });
|
|
481
|
+
stream.push({ type: "text_end", contentIndex: retryBlockIndex, content: retryText, partial: output });
|
|
482
|
+
await delay(getRetryDelayMs(attempt, error.retryAfterMs));
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
throw error;
|
|
446
486
|
}
|
|
447
487
|
}
|
|
448
488
|
|
|
449
|
-
|
|
450
|
-
status: response.status,
|
|
451
|
-
statusText: response.statusText,
|
|
452
|
-
headers: Object.fromEntries(response.headers.entries()),
|
|
453
|
-
body: {
|
|
454
|
-
responseId: output.responseId,
|
|
455
|
-
stopReason: output.stopReason,
|
|
456
|
-
usage: output.usage,
|
|
457
|
-
contentBlocks: output.content.length,
|
|
458
|
-
},
|
|
459
|
-
transport: "sse",
|
|
460
|
-
});
|
|
489
|
+
throw new Error("CC request failed after retries");
|
|
461
490
|
}
|
|
462
491
|
|
|
463
492
|
// ── JSON request ────────────────────────────────────────────────────────────
|
package/src/codex.ts
CHANGED
|
@@ -15,10 +15,12 @@ import {
|
|
|
15
15
|
delay,
|
|
16
16
|
fetchWithProxy,
|
|
17
17
|
getRetryDelayMs,
|
|
18
|
+
isRetryableErrorType,
|
|
18
19
|
isRetryableStatus,
|
|
19
20
|
nextSseChunk,
|
|
20
21
|
parseRetryAfterMs,
|
|
21
22
|
parseSseEvent,
|
|
23
|
+
RetryableStreamError,
|
|
22
24
|
redactHeaders,
|
|
23
25
|
writeDebugFile,
|
|
24
26
|
} from "./http.js";
|
|
@@ -214,8 +216,19 @@ function applyCodexUsage(output: AssistantMessage, response: any, model: Model<A
|
|
|
214
216
|
function applyCodexSsePayload(payload: any, output: AssistantMessage, stream: AssistantMessageEventStream, model: Model<Api>, slots: Map<number, any>) {
|
|
215
217
|
const type = payload?.type;
|
|
216
218
|
if (!type || type === "response.in_progress" || type === "response.metadata") return;
|
|
217
|
-
if (type === "error")
|
|
218
|
-
|
|
219
|
+
if (type === "error") {
|
|
220
|
+
const errorType = payload.error?.type || payload.error?.code;
|
|
221
|
+
const message = payload.error?.message || payload.message || JSON.stringify(payload);
|
|
222
|
+
if (isRetryableErrorType(errorType)) throw new RetryableStreamError(message);
|
|
223
|
+
throw new Error(message);
|
|
224
|
+
}
|
|
225
|
+
if (type === "response.failed") {
|
|
226
|
+
const err = payload.response?.error;
|
|
227
|
+
const message = err?.message || "Codex response failed";
|
|
228
|
+
const errorType = err?.type || err?.code;
|
|
229
|
+
if (isRetryableErrorType(errorType)) throw new RetryableStreamError(message);
|
|
230
|
+
throw new Error(message);
|
|
231
|
+
}
|
|
219
232
|
|
|
220
233
|
if (type === "response.created") {
|
|
221
234
|
output.responseId = payload.response?.id || output.responseId;
|
|
@@ -284,6 +297,46 @@ function applyCodexSsePayload(payload: any, output: AssistantMessage, stream: As
|
|
|
284
297
|
}
|
|
285
298
|
}
|
|
286
299
|
|
|
300
|
+
// ── Stream consumption ──────────────────────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
async function consumeCodexStream(response: Response, output: AssistantMessage, stream: AssistantMessageEventStream, model: Model<Api>) {
|
|
303
|
+
if (!response.body) throw new Error("Codex stream response body missing");
|
|
304
|
+
const slots = new Map<number, any>();
|
|
305
|
+
const reader = response.body.getReader();
|
|
306
|
+
const decoder = new TextDecoder();
|
|
307
|
+
let buffer = "";
|
|
308
|
+
let terminal = false;
|
|
309
|
+
while (true) {
|
|
310
|
+
const { value, done } = await reader.read();
|
|
311
|
+
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
|
|
312
|
+
let parsedChunk = nextSseChunk(buffer);
|
|
313
|
+
while (parsedChunk) {
|
|
314
|
+
buffer = parsedChunk.rest;
|
|
315
|
+
const event = parseSseEvent(parsedChunk.chunk);
|
|
316
|
+
if (event.data && event.data !== "[DONE]") {
|
|
317
|
+
const payload = tryParseJson(event.data);
|
|
318
|
+
if (!payload) throw new Error(`invalid Codex SSE payload: ${event.data.slice(0, 200)}`);
|
|
319
|
+
applyCodexSsePayload(payload, output, stream, model, slots);
|
|
320
|
+
if (payload.type === "response.completed" || payload.type === "response.incomplete") terminal = true;
|
|
321
|
+
}
|
|
322
|
+
parsedChunk = nextSseChunk(buffer);
|
|
323
|
+
}
|
|
324
|
+
if (done) break;
|
|
325
|
+
}
|
|
326
|
+
const tail = buffer.trim();
|
|
327
|
+
if (tail) {
|
|
328
|
+
const event = parseSseEvent(tail);
|
|
329
|
+
if (event.data && event.data !== "[DONE]") {
|
|
330
|
+
const payload = tryParseJson(event.data);
|
|
331
|
+
if (!payload) throw new Error(`invalid Codex SSE payload: ${event.data.slice(0, 200)}`);
|
|
332
|
+
applyCodexSsePayload(payload, output, stream, model, slots);
|
|
333
|
+
if (payload.type === "response.completed" || payload.type === "response.incomplete") terminal = true;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (!terminal) throw new Error("Codex stream ended before a terminal response event");
|
|
337
|
+
return response;
|
|
338
|
+
}
|
|
339
|
+
|
|
287
340
|
// ── Streaming request ───────────────────────────────────────────────────────
|
|
288
341
|
|
|
289
342
|
export async function tryStreamAnyRouterCodex(
|
|
@@ -299,11 +352,12 @@ export async function tryStreamAnyRouterCodex(
|
|
|
299
352
|
) {
|
|
300
353
|
const bodyText = JSON.stringify(body);
|
|
301
354
|
const maxRetries = Math.max(0, Number(process.env.PI_ANYROUTER_CC_MAX_RETRIES || options?.maxRetries || "10") || 0);
|
|
302
|
-
let response: Response | undefined;
|
|
303
355
|
|
|
304
356
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
305
357
|
const headers = createCodexHeaders(apiKey, sessionId, metadata);
|
|
306
358
|
if (attempt === 0) writeDebugFile("request", model.id, undefined, { url, headers: redactHeaders(headers), body, transport: "codex-sse" });
|
|
359
|
+
|
|
360
|
+
let response: Response;
|
|
307
361
|
try {
|
|
308
362
|
response = await fetchWithProxy(url, { method: "POST", signal: options?.signal, headers, body: bodyText });
|
|
309
363
|
} catch (error) {
|
|
@@ -314,63 +368,48 @@ export async function tryStreamAnyRouterCodex(
|
|
|
314
368
|
throw error;
|
|
315
369
|
}
|
|
316
370
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
371
|
+
// HTTP-level error → retry if retryable status
|
|
372
|
+
if (!response.ok) {
|
|
373
|
+
const raw = await response.text();
|
|
374
|
+
const parsed = tryParseJson(raw) || { raw };
|
|
375
|
+
const requestId = extractRequestId(parsed, response.headers);
|
|
376
|
+
writeDebugFile("error", model.id, requestId, { status: response.status, requestId, body: parsed, raw, transport: "codex-sse", retryAttempt: attempt });
|
|
377
|
+
if (attempt < maxRetries && isRetryableStatus(response.status)) {
|
|
378
|
+
await delay(getRetryDelayMs(attempt, parseRetryAfterMs(response.headers.get("retry-after"))));
|
|
379
|
+
continue;
|
|
320
380
|
}
|
|
321
|
-
|
|
381
|
+
throw new Error(raw || `HTTP ${response.status}`);
|
|
322
382
|
}
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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;
|
|
383
|
+
|
|
384
|
+
if (!(response.headers.get("content-type") || "").includes("text/event-stream")) {
|
|
385
|
+
throw new Error(`stream response was not SSE (content-type=${response.headers.get("content-type") || "<missing>"})`);
|
|
331
386
|
}
|
|
332
|
-
throw new Error(raw || `HTTP ${response.status}`);
|
|
333
|
-
}
|
|
334
387
|
|
|
335
|
-
|
|
336
|
-
|
|
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);
|
|
388
|
+
if (options?.onResponse) {
|
|
389
|
+
await options.onResponse({ status: response.status, headers: Object.fromEntries(response.headers.entries()) }, model);
|
|
355
390
|
}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
391
|
+
|
|
392
|
+
// Consume the SSE stream — retry on RetryableStreamError if no content was emitted
|
|
393
|
+
const contentLenBefore = output.content.length;
|
|
394
|
+
try {
|
|
395
|
+
const resp = await consumeCodexStream(response, output, stream, model);
|
|
396
|
+
writeDebugFile("response", model.id, resp.headers.get("x-oneapi-request-id") || undefined, {
|
|
397
|
+
status: resp.status,
|
|
398
|
+
responseId: output.responseId,
|
|
399
|
+
stopReason: output.stopReason,
|
|
400
|
+
usage: output.usage,
|
|
401
|
+
transport: "codex-sse",
|
|
402
|
+
});
|
|
403
|
+
return;
|
|
404
|
+
} catch (error) {
|
|
405
|
+
if (error instanceof RetryableStreamError && output.content.length === contentLenBefore && attempt < maxRetries && !options?.signal?.aborted) {
|
|
406
|
+
writeDebugFile("error", model.id, undefined, { phase: "sse-stream-retry", errorMessage: error.message, retryAttempt: attempt, transport: "codex-sse" });
|
|
407
|
+
await delay(getRetryDelayMs(attempt, error.retryAfterMs));
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
throw error;
|
|
366
411
|
}
|
|
367
412
|
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
status: response.status,
|
|
371
|
-
responseId: output.responseId,
|
|
372
|
-
stopReason: output.stopReason,
|
|
373
|
-
usage: output.usage,
|
|
374
|
-
transport: "codex-sse",
|
|
375
|
-
});
|
|
413
|
+
|
|
414
|
+
throw new Error("Codex request failed after retries");
|
|
376
415
|
}
|
package/src/http.ts
CHANGED
|
@@ -62,6 +62,23 @@ export function writeDebugFile(kind: "request" | "response" | "error", modelId:
|
|
|
62
62
|
|
|
63
63
|
// ── Retry ───────────────────────────────────────────────────────────────────
|
|
64
64
|
|
|
65
|
+
/** Thrown when an SSE stream delivers a retryable error (e.g. rate_limit) before any content was emitted. */
|
|
66
|
+
export class RetryableStreamError extends Error {
|
|
67
|
+
constructor(
|
|
68
|
+
message: string,
|
|
69
|
+
public readonly retryAfterMs?: number,
|
|
70
|
+
) {
|
|
71
|
+
super(message);
|
|
72
|
+
this.name = "RetryableStreamError";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const RETRYABLE_ERROR_TYPES = new Set(["too_many_requests", "rate_limit_exceeded", "server_error", "overloaded_error", "api_error"]);
|
|
77
|
+
|
|
78
|
+
export function isRetryableErrorType(errorType: string | undefined): boolean {
|
|
79
|
+
return !!errorType && RETRYABLE_ERROR_TYPES.has(errorType);
|
|
80
|
+
}
|
|
81
|
+
|
|
65
82
|
export function delay(ms: number) {
|
|
66
83
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
67
84
|
}
|