@lll9p/pi-anyrouter 0.4.0 → 0.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lll9p/pi-anyrouter",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Unofficial pi provider extension for AnyRouter Claude Code and Codex Responses Lite routes",
@@ -11,17 +11,7 @@ import type {
11
11
  Tool,
12
12
  ToolResultMessage,
13
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";
14
+ import { fetchWithProxy, nextSseChunk, parseSseEvent, redactHeaders, writeDebugFile } from "./http.js";
25
15
  import {
26
16
  ANTHROPIC_BETA,
27
17
  CLAUDE_CODE_VERSION,
@@ -209,8 +199,7 @@ function applySsePayloadEvent(
209
199
  if (!payload?.type || payload.type === "ping" || payload.type === "message_stop") return;
210
200
 
211
201
  if (payload.type === "error") {
212
- const errorText = payload?.error?.message || payload?.error || payload?.message || JSON.stringify(payload);
213
- throw new Error(String(errorText));
202
+ throw new Error(String(payload?.error?.message || payload?.error || payload?.message || JSON.stringify(payload)));
214
203
  }
215
204
 
216
205
  if (payload.type === "message_start") {
@@ -327,91 +316,10 @@ function applySsePayloadEvent(
327
316
  }
328
317
  }
329
318
 
330
- // ── Streaming request ───────────────────────────────────────────────────────
331
-
332
- export async function tryStreamAnyRouterCc(
333
- url: string,
334
- body: Json,
335
- apiKey: string,
336
- model: Model<Api>,
337
- output: AssistantMessage,
338
- stream: AssistantMessageEventStream,
339
- sessionId: string,
340
- options?: SimpleStreamOptions,
341
- ) {
342
- const requestBody = { ...body, stream: true };
343
- const bodyText = JSON.stringify(requestBody);
344
- const maxRetries = Math.max(0, Number(process.env.PI_ANYROUTER_CC_MAX_RETRIES || options?.maxRetries || "10") || 0);
345
- let response: Response | undefined;
346
-
347
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
348
- // Real Claude Code keeps this at zero across its application-level retries.
349
- const headers = getClaudeCodeHeaders(apiKey, 0, sessionId);
350
- if (attempt === 0) {
351
- writeDebugFile("request", model.id, undefined, {
352
- url,
353
- headers: redactHeaders(headers),
354
- body: requestBody,
355
- transport: "sse",
356
- });
357
- }
358
-
359
- try {
360
- response = await fetchWithProxy(url, {
361
- method: "POST",
362
- signal: options?.signal,
363
- headers,
364
- body: bodyText,
365
- });
366
- } catch (error) {
367
- if (attempt < maxRetries && !options?.signal?.aborted) {
368
- await delay(getRetryDelayMs(attempt));
369
- continue;
370
- }
371
- throw error;
372
- }
373
-
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);
378
- }
379
- break;
380
- }
381
-
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;
408
- }
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");
319
+ // ── Stream consumption ──────────────────────────────────────────────────────
414
320
 
321
+ async function consumeCcStream(response: Response, output: AssistantMessage, stream: AssistantMessageEventStream, model: Model<Api>) {
322
+ if (!response.body) throw new Error("stream response body missing");
415
323
  const blockIndexByEventIndex = new Map<number, number>();
416
324
  const reader = response.body.getReader();
417
325
  const decoder = new TextDecoder();
@@ -445,11 +353,57 @@ export async function tryStreamAnyRouterCc(
445
353
  applySsePayloadEvent(payload, output, stream, model, blockIndexByEventIndex);
446
354
  }
447
355
  }
356
+ return response;
357
+ }
448
358
 
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()),
359
+ // ── Streaming request ───────────────────────────────────────────────────────
360
+
361
+ export async function tryStreamAnyRouterCc(
362
+ url: string,
363
+ body: Json,
364
+ apiKey: string,
365
+ model: Model<Api>,
366
+ output: AssistantMessage,
367
+ stream: AssistantMessageEventStream,
368
+ sessionId: string,
369
+ options?: SimpleStreamOptions,
370
+ ) {
371
+ const requestBody = { ...body, stream: true };
372
+ const headers = getClaudeCodeHeaders(apiKey, 0, sessionId);
373
+ writeDebugFile("request", model.id, undefined, { url, headers: redactHeaders(headers), body: requestBody, transport: "sse" });
374
+
375
+ const response = await fetchWithProxy(url, { method: "POST", signal: options?.signal, headers, body: JSON.stringify(requestBody) });
376
+
377
+ if (!response.ok) {
378
+ const raw = await response.text();
379
+ const parsed = tryParseJson(raw) || { raw };
380
+ const requestId = extractRequestId(parsed, response.headers);
381
+ writeDebugFile("error", model.id, requestId, {
382
+ status: response.status,
383
+ statusText: response.statusText,
384
+ requestId,
385
+ headers: Object.fromEntries(response.headers.entries()),
386
+ body: parsed,
387
+ raw,
388
+ transport: "sse",
389
+ });
390
+ throw new Error(raw || `HTTP ${response.status}`);
391
+ }
392
+
393
+ const contentType = response.headers.get("content-type") || "";
394
+ if (!contentType.includes("text/event-stream")) {
395
+ throw new Error(`stream response was not SSE (content-type=${contentType || "<missing>"})`);
396
+ }
397
+
398
+ if (options?.onResponse) {
399
+ await options.onResponse({ status: response.status, headers: Object.fromEntries(response.headers.entries()) }, model);
400
+ }
401
+
402
+ const resp = await consumeCcStream(response, output, stream, model);
403
+ writeDebugFile("response", model.id, resp.headers.get("x-oneapi-request-id") || undefined, {
404
+ status: resp.status,
405
+ statusText: resp.statusText,
406
+ headers: Object.fromEntries(resp.headers.entries()),
453
407
  body: {
454
408
  responseId: output.responseId,
455
409
  stopReason: output.stopReason,
@@ -463,69 +417,32 @@ export async function tryStreamAnyRouterCc(
463
417
  // ── JSON request ────────────────────────────────────────────────────────────
464
418
 
465
419
  export async function postJson(url: string, body: Json, apiKey: string, modelId: string, sessionId: string, model: Model<Api>, options?: SimpleStreamOptions) {
466
- const maxRetries = Math.max(0, Number(process.env.PI_ANYROUTER_CC_MAX_RETRIES || options?.maxRetries || "10") || 0);
467
- const bodyText = JSON.stringify(body);
468
- let lastErrorText = "";
469
-
470
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
471
- const headers = getClaudeCodeHeaders(apiKey, attempt, sessionId);
472
- if (attempt === 0) {
473
- writeDebugFile("request", modelId, undefined, {
474
- url,
475
- headers: redactHeaders(headers),
476
- body,
477
- });
478
- }
479
-
480
- let response: Response;
481
- try {
482
- response = await fetchWithProxy(url, {
483
- method: "POST",
484
- signal: options?.signal,
485
- headers,
486
- body: bodyText,
487
- });
488
- } catch (error) {
489
- if (attempt < maxRetries) {
490
- await delay(getRetryDelayMs(attempt));
491
- continue;
492
- }
493
- throw error;
494
- }
420
+ const headers = getClaudeCodeHeaders(apiKey, 0, sessionId);
421
+ writeDebugFile("request", modelId, undefined, { url, headers: redactHeaders(headers), body });
422
+
423
+ const response = await fetchWithProxy(url, { method: "POST", signal: options?.signal, headers, body: JSON.stringify(body) });
424
+ const text = await response.text();
425
+ let parsed: any = {};
426
+ try {
427
+ parsed = text ? JSON.parse(text) : {};
428
+ } catch {
429
+ parsed = { raw: text };
430
+ }
431
+ const requestId = parsed?.error?.message?.match(/request id:\s*([^)]+)/i)?.[1] || response.headers.get("x-oneapi-request-id") || undefined;
495
432
 
496
- const text = await response.text();
497
- lastErrorText = text;
498
- let parsed: any = {};
499
- try {
500
- parsed = text ? JSON.parse(text) : {};
501
- } catch {
502
- parsed = { raw: text };
503
- }
504
- const requestId = parsed?.error?.message?.match(/request id:\s*([^)]+)/i)?.[1] || response.headers.get("x-oneapi-request-id") || undefined;
433
+ writeDebugFile(response.ok ? "response" : "error", modelId, requestId, {
434
+ status: response.status,
435
+ statusText: response.statusText,
436
+ requestId,
437
+ headers: Object.fromEntries(response.headers.entries()),
438
+ body: parsed,
439
+ raw: text,
440
+ });
505
441
 
506
- writeDebugFile(response.ok ? "response" : "error", modelId, requestId, {
507
- status: response.status,
508
- statusText: response.statusText,
509
- requestId,
510
- headers: Object.fromEntries(response.headers.entries()),
511
- body: parsed,
512
- raw: text,
513
- retryAttempt: attempt,
514
- maxRetries,
515
- });
442
+ if (!response.ok) throw new Error(text || `HTTP ${response.status}`);
516
443
 
517
- if (response.ok) {
518
- if (options?.onResponse) {
519
- await options.onResponse({ status: response.status, headers: Object.fromEntries(response.headers.entries()) }, model);
520
- }
521
- return parsed;
522
- }
523
- if (attempt < maxRetries && isRetryableStatus(response.status)) {
524
- await delay(getRetryDelayMs(attempt, parseRetryAfterMs(response.headers.get("retry-after"))));
525
- continue;
526
- }
527
- throw new Error(text || `HTTP ${response.status}`);
444
+ if (options?.onResponse) {
445
+ await options.onResponse({ status: response.status, headers: Object.fromEntries(response.headers.entries()) }, model);
528
446
  }
529
-
530
- throw new Error(lastErrorText || "HTTP request failed after retries");
447
+ return parsed;
531
448
  }
package/src/codex.ts CHANGED
@@ -11,17 +11,7 @@ import {
11
11
  type Tool,
12
12
  type ToolResultMessage,
13
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";
14
+ import { fetchWithProxy, nextSseChunk, parseSseEvent, redactHeaders, writeDebugFile } from "./http.js";
25
15
  import { CODEX_INSTALLATION_ID, CODEX_VERSION, type Json } from "./types.js";
26
16
  import { extractRequestId, mapReasoningEffort, sanitizeText, tryParseJson } from "./utils.js";
27
17
 
@@ -214,8 +204,12 @@ function applyCodexUsage(output: AssistantMessage, response: any, model: Model<A
214
204
  function applyCodexSsePayload(payload: any, output: AssistantMessage, stream: AssistantMessageEventStream, model: Model<Api>, slots: Map<number, any>) {
215
205
  const type = payload?.type;
216
206
  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");
207
+ if (type === "error") {
208
+ throw new Error(payload.error?.message || payload.message || JSON.stringify(payload));
209
+ }
210
+ if (type === "response.failed") {
211
+ throw new Error(payload.response?.error?.message || "Codex response failed");
212
+ }
219
213
 
220
214
  if (type === "response.created") {
221
215
  output.responseId = payload.response?.id || output.responseId;
@@ -284,55 +278,10 @@ function applyCodexSsePayload(payload: any, output: AssistantMessage, stream: As
284
278
  }
285
279
  }
286
280
 
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
- }
281
+ // ── Stream consumption ──────────────────────────────────────────────────────
334
282
 
335
- if (!response?.body) throw new Error("Codex stream response body missing");
283
+ async function consumeCodexStream(response: Response, output: AssistantMessage, stream: AssistantMessageEventStream, model: Model<Api>) {
284
+ if (!response.body) throw new Error("Codex stream response body missing");
336
285
  const slots = new Map<number, any>();
337
286
  const reader = response.body.getReader();
338
287
  const decoder = new TextDecoder();
@@ -366,8 +315,46 @@ export async function tryStreamAnyRouterCodex(
366
315
  }
367
316
  }
368
317
  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,
318
+ return response;
319
+ }
320
+
321
+ // ── Streaming request ───────────────────────────────────────────────────────
322
+
323
+ export async function tryStreamAnyRouterCodex(
324
+ url: string,
325
+ body: Json,
326
+ apiKey: string,
327
+ model: Model<Api>,
328
+ output: AssistantMessage,
329
+ stream: AssistantMessageEventStream,
330
+ sessionId: string,
331
+ metadata: ReturnType<typeof createCodexMetadata>,
332
+ options?: SimpleStreamOptions,
333
+ ) {
334
+ const headers = createCodexHeaders(apiKey, sessionId, metadata);
335
+ writeDebugFile("request", model.id, undefined, { url, headers: redactHeaders(headers), body, transport: "codex-sse" });
336
+
337
+ const response = await fetchWithProxy(url, { method: "POST", signal: options?.signal, headers, body: JSON.stringify(body) });
338
+
339
+ if (!response.ok) {
340
+ const raw = await response.text();
341
+ const parsed = tryParseJson(raw) || { raw };
342
+ const requestId = extractRequestId(parsed, response.headers);
343
+ writeDebugFile("error", model.id, requestId, { status: response.status, requestId, body: parsed, raw, transport: "codex-sse" });
344
+ throw new Error(raw || `HTTP ${response.status}`);
345
+ }
346
+
347
+ if (!(response.headers.get("content-type") || "").includes("text/event-stream")) {
348
+ throw new Error(`stream response was not SSE (content-type=${response.headers.get("content-type") || "<missing>"})`);
349
+ }
350
+
351
+ if (options?.onResponse) {
352
+ await options.onResponse({ status: response.status, headers: Object.fromEntries(response.headers.entries()) }, model);
353
+ }
354
+
355
+ const resp = await consumeCodexStream(response, output, stream, model);
356
+ writeDebugFile("response", model.id, resp.headers.get("x-oneapi-request-id") || undefined, {
357
+ status: resp.status,
371
358
  responseId: output.responseId,
372
359
  stopReason: output.stopReason,
373
360
  usage: output.usage,
package/src/http.ts CHANGED
@@ -60,35 +60,6 @@ export function writeDebugFile(kind: "request" | "response" | "error", modelId:
60
60
  writeFileSync(path, JSON.stringify(payload, null, 2), "utf8");
61
61
  }
62
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
63
  // ── SSE parsing ─────────────────────────────────────────────────────────────
93
64
 
94
65
  export function parseSseEvent(chunk: string) {
package/src/index.ts CHANGED
@@ -41,7 +41,7 @@ function streamAnyRouterCc(model: Model<Api>, context: Context, options?: Simple
41
41
  try {
42
42
  const source = loadSourceProvider();
43
43
  const apiKey = options?.apiKey || source.apiKey;
44
- const sessionId = randomUUID();
44
+ const sessionId = options?.sessionId || randomUUID();
45
45
 
46
46
  const configuredModel = source.models.find((item) => item.id === model.id);
47
47
  if (isCodexModel(model.id, configuredModel?.api)) {