@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lll9p/pi-anyrouter",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Unofficial pi provider extension for AnyRouter Claude Code and Codex Responses Lite routes",
@@ -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
- throw new Error(String(errorText));
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
- const contentType = response.headers.get("content-type") || "";
375
- if (response.ok && contentType.includes("text/event-stream")) {
376
- if (options?.onResponse) {
377
- await options.onResponse({ status: response.status, headers: Object.fromEntries(response.headers.entries()) }, model);
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
- break;
444
+ throw new Error(raw || `HTTP ${response.status}`);
380
445
  }
381
446
 
382
- const raw = await response.text();
383
- const parsed = tryParseJson(raw) || { raw };
384
- const requestId = extractRequestId(parsed, response.headers);
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
- let parsedChunk = nextSseChunk(buffer);
425
- while (parsedChunk) {
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 (done) break;
437
- }
438
-
439
- const tail = buffer.trim();
440
- if (tail) {
441
- const event = parseSseEvent(tail);
442
- if (event.data && event.data !== "[DONE]") {
443
- const payload = tryParseJson(event.data);
444
- if (!payload) throw new Error(`invalid SSE payload: ${event.data.slice(0, 200)}`);
445
- applySsePayloadEvent(payload, output, stream, model, blockIndexByEventIndex);
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
- writeDebugFile("response", model.id, response.headers.get("x-oneapi-request-id") || undefined, {
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") throw new Error(payload.message || JSON.stringify(payload));
218
- if (type === "response.failed") throw new Error(payload.response?.error?.message || "Codex response failed");
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
- 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);
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
- break;
381
+ throw new Error(raw || `HTTP ${response.status}`);
322
382
  }
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;
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
- 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);
388
+ if (options?.onResponse) {
389
+ await options.onResponse({ status: response.status, headers: Object.fromEntries(response.headers.entries()) }, model);
355
390
  }
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;
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
- 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
- });
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
  }