@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/README.md CHANGED
@@ -50,7 +50,7 @@ This package selects an adapter by model family:
50
50
  Clone into a normal directory:
51
51
 
52
52
  ```bash
53
- git clone https://github.com/xifan2333/pi-anyrouter.git
53
+ git clone https://github.com/lll9p/pi-anyrouter.git
54
54
  cd pi-anyrouter
55
55
  ```
56
56
 
@@ -71,13 +71,13 @@ pi install /absolute/path/to/pi-anyrouter
71
71
  Once pushed to GitHub, install with:
72
72
 
73
73
  ```bash
74
- pi install git:github.com/xifan2333/pi-anyrouter
74
+ pi install git:github.com/lll9p/pi-anyrouter
75
75
  ```
76
76
 
77
77
  Or pin a ref/tag:
78
78
 
79
79
  ```bash
80
- pi install git:github.com/xifan2333/pi-anyrouter@<tag>
80
+ pi install git:github.com/lll9p/pi-anyrouter@<tag>
81
81
  ```
82
82
 
83
83
  ## Option C: manual extension placement
@@ -204,7 +204,7 @@ This repo is already structured as a pi package through `package.json`:
204
204
  That means users can install it with:
205
205
 
206
206
  ```bash
207
- pi install git:github.com/xifan2333/pi-anyrouter
207
+ pi install git:github.com/lll9p/pi-anyrouter
208
208
  ```
209
209
 
210
210
  ## Publish checklist
@@ -216,7 +216,7 @@ git init
216
216
  git add .
217
217
  git commit -m "feat: add AnyRouter Claude and Codex adapters"
218
218
  git branch -M main
219
- git remote add origin git@github.com:xifan2333/pi-anyrouter.git
219
+ git remote add origin git@github.com:lll9p/pi-anyrouter.git
220
220
  git push -u origin main
221
221
  ```
222
222
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lll9p/pi-anyrouter",
3
- "version": "0.3.2",
3
+ "version": "0.4.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",
@@ -24,19 +24,24 @@
24
24
  "url": "https://github.com/lll9p/pi-anyrouter/issues"
25
25
  },
26
26
  "files": [
27
- "index.ts",
27
+ "src/",
28
28
  "README.md"
29
29
  ],
30
30
  "pi": {
31
31
  "extensions": [
32
- "./index.ts"
32
+ "./src/index.ts"
33
33
  ]
34
34
  },
35
35
  "scripts": {
36
+ "typecheck": "tsc --noEmit",
37
+ "lint": "biome check src/",
38
+ "lint:fix": "biome check --write src/",
39
+ "format": "biome format --write src/",
40
+ "check": "tsc --noEmit && biome check src/",
36
41
  "pack:dry-run": "npm pack --dry-run"
37
42
  },
38
43
  "dependencies": {
39
- "undici": "^8.3.0"
44
+ "undici": "^8.10.2"
40
45
  },
41
46
  "peerDependencies": {
42
47
  "@earendil-works/pi-ai": "*",
@@ -49,5 +54,11 @@
49
54
  "@earendil-works/pi-coding-agent": {
50
55
  "optional": true
51
56
  }
57
+ },
58
+ "devDependencies": {
59
+ "@biomejs/biome": "^2.5.12",
60
+ "@earendil-works/pi-ai": "^0.85.1",
61
+ "@earendil-works/pi-coding-agent": "^0.85.1",
62
+ "@types/node": "^26.5.0"
52
63
  }
53
64
  }
@@ -0,0 +1,531 @@
1
+ import type {
2
+ Api,
3
+ AssistantMessage,
4
+ AssistantMessageEventStream,
5
+ ImageContent,
6
+ Message,
7
+ Model,
8
+ SimpleStreamOptions,
9
+ TextContent,
10
+ ThinkingContent,
11
+ Tool,
12
+ 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 {
26
+ ANTHROPIC_BETA,
27
+ CLAUDE_CODE_VERSION,
28
+ CLAUDE_CODE_VERSION_BUILD,
29
+ CLAUDE_DEVICE_ID,
30
+ type Json,
31
+ STAINLESS_ARCH,
32
+ STAINLESS_OS,
33
+ STAINLESS_PACKAGE_VERSION,
34
+ STAINLESS_RUNTIME,
35
+ STAINLESS_RUNTIME_VERSION,
36
+ } from "./types.js";
37
+ import { extractRequestId, fromClaudeCodeName, mapStopReason, sanitizeText, toClaudeCodeName, tryParseJson, updateUsageFromAnthropic } from "./utils.js";
38
+
39
+ // ── Message / tool conversion ───────────────────────────────────────────────
40
+
41
+ function convertContentBlocks(content: (TextContent | ImageContent)[]) {
42
+ const hasImages = content.some((c) => c.type === "image");
43
+ if (!hasImages) return sanitizeText(content.map((c) => (c as TextContent).text).join("\n"));
44
+
45
+ const blocks = content.map((block) => {
46
+ if (block.type === "text") return { type: "text", text: sanitizeText(block.text) };
47
+ return { type: "image", source: { type: "base64", media_type: block.mimeType, data: block.data } };
48
+ });
49
+ if (!blocks.some((b) => b.type === "text")) blocks.unshift({ type: "text", text: "(see attached image)" });
50
+ return blocks;
51
+ }
52
+
53
+ export function convertMessages(messages: Message[]) {
54
+ const params: any[] = [];
55
+ for (let i = 0; i < messages.length; i++) {
56
+ const msg = messages[i];
57
+ if (msg.role === "user") {
58
+ if (typeof msg.content === "string") {
59
+ const text = sanitizeText(msg.content);
60
+ if (text.trim()) params.push({ role: "user", content: [{ type: "text", text }] });
61
+ } else {
62
+ const blocks = msg.content.map((item) =>
63
+ item.type === "text"
64
+ ? { type: "text", text: sanitizeText(item.text) }
65
+ : { type: "image", source: { type: "base64", media_type: item.mimeType, data: item.data } },
66
+ );
67
+ if (blocks.length > 0) params.push({ role: "user", content: blocks });
68
+ }
69
+ continue;
70
+ }
71
+
72
+ if (msg.role === "assistant") {
73
+ const blocks: any[] = [];
74
+ for (const block of msg.content) {
75
+ if (block.type === "text" && block.text.trim()) blocks.push({ type: "text", text: sanitizeText(block.text) });
76
+ else if (block.type === "thinking" && block.thinking.trim()) {
77
+ if ((block as ThinkingContent).thinkingSignature) {
78
+ blocks.push({ type: "thinking", thinking: sanitizeText(block.thinking), signature: (block as ThinkingContent).thinkingSignature });
79
+ } else {
80
+ blocks.push({ type: "text", text: sanitizeText(block.thinking) });
81
+ }
82
+ } else if (block.type === "toolCall") {
83
+ blocks.push({ type: "tool_use", id: block.id, name: toClaudeCodeName(block.name), input: block.arguments });
84
+ }
85
+ }
86
+ if (blocks.length > 0) params.push({ role: "assistant", content: blocks });
87
+ continue;
88
+ }
89
+
90
+ if (msg.role === "toolResult") {
91
+ const toolResults: any[] = [];
92
+ const pushToolResult = (toolMsg: ToolResultMessage) => {
93
+ toolResults.push({ type: "tool_result", tool_use_id: toolMsg.toolCallId, content: convertContentBlocks(toolMsg.content), is_error: toolMsg.isError });
94
+ };
95
+ pushToolResult(msg as ToolResultMessage);
96
+ let j = i + 1;
97
+ while (j < messages.length && messages[j].role === "toolResult") {
98
+ pushToolResult(messages[j] as ToolResultMessage);
99
+ j++;
100
+ }
101
+ i = j - 1;
102
+ params.push({ role: "user", content: toolResults });
103
+ }
104
+ }
105
+
106
+ if (params.length > 0) {
107
+ const last = params[params.length - 1];
108
+ if (last.role === "user" && Array.isArray(last.content) && last.content.length > 0) {
109
+ last.content[last.content.length - 1].cache_control = { type: "ephemeral" };
110
+ }
111
+ }
112
+ return params;
113
+ }
114
+
115
+ export function convertTools(tools: Tool[]) {
116
+ return tools.map((tool) => ({
117
+ name: toClaudeCodeName(tool.name),
118
+ description: tool.description,
119
+ input_schema: {
120
+ type: "object",
121
+ properties: (tool.parameters as any).properties || {},
122
+ required: (tool.parameters as any).required || [],
123
+ },
124
+ }));
125
+ }
126
+
127
+ // ── Headers / metadata ──────────────────────────────────────────────────────
128
+
129
+ export function getClaudeCodeHeaders(apiKey: string, retryCount = 0, sessionId: string) {
130
+ return {
131
+ "content-type": "application/json",
132
+ accept: "application/json",
133
+ authorization: `Bearer ${apiKey}`,
134
+ "anthropic-version": "2023-06-01",
135
+ "anthropic-dangerous-direct-browser-access": "true",
136
+ "anthropic-beta": ANTHROPIC_BETA,
137
+ "user-agent": `claude-cli/${CLAUDE_CODE_VERSION} (external, sdk-cli)`,
138
+ "x-app": "cli",
139
+ "x-claude-code-session-id": sessionId,
140
+ "x-stainless-retry-count": String(retryCount),
141
+ "x-stainless-timeout": "600",
142
+ "x-stainless-lang": "js",
143
+ "x-stainless-package-version": STAINLESS_PACKAGE_VERSION,
144
+ "x-stainless-os": STAINLESS_OS,
145
+ "x-stainless-arch": STAINLESS_ARCH,
146
+ "x-stainless-runtime": STAINLESS_RUNTIME,
147
+ "x-stainless-runtime-version": STAINLESS_RUNTIME_VERSION,
148
+ };
149
+ }
150
+
151
+ export function createClaudeCodeMetadata(sessionId: string) {
152
+ return {
153
+ user_id: JSON.stringify({
154
+ device_id: CLAUDE_DEVICE_ID,
155
+ account_uuid: "",
156
+ session_id: sessionId,
157
+ }),
158
+ };
159
+ }
160
+
161
+ export function createClaudeCodeSystem(systemPrompt: string) {
162
+ return [
163
+ { type: "text", text: `x-anthropic-billing-header: cc_version=${CLAUDE_CODE_VERSION_BUILD}; cc_entrypoint=sdk-cli;` },
164
+ { type: "text", text: "You are a Claude agent, built on Anthropic's Claude Agent SDK.", cache_control: { type: "ephemeral" } },
165
+ { type: "text", text: sanitizeText(systemPrompt), cache_control: { type: "ephemeral" } },
166
+ ];
167
+ }
168
+
169
+ // ── SSE payload processing ──────────────────────────────────────────────────
170
+
171
+ export function applyJsonResponseToOutput(response: any, output: AssistantMessage, stream: AssistantMessageEventStream, model: Model<Api>) {
172
+ updateUsageFromAnthropic(output, response?.usage || {}, model);
173
+ output.stopReason = mapStopReason(response?.stop_reason || "end_turn");
174
+
175
+ const content = Array.isArray(response?.content) ? response.content : [];
176
+ for (const block of content) {
177
+ if (block?.type === "text") {
178
+ output.content.push({ type: "text", text: "" });
179
+ const contentIndex = output.content.length - 1;
180
+ stream.push({ type: "text_start", contentIndex, partial: output });
181
+ const text = String(block.text || "");
182
+ (output.content[contentIndex] as any).text = text;
183
+ if (text) stream.push({ type: "text_delta", contentIndex, delta: text, partial: output });
184
+ stream.push({ type: "text_end", contentIndex, content: text, partial: output });
185
+ } else if (block?.type === "thinking") {
186
+ output.content.push({ type: "thinking", thinking: String(block.thinking || ""), thinkingSignature: block.signature || "" } as any);
187
+ const contentIndex = output.content.length - 1;
188
+ stream.push({ type: "thinking_start", contentIndex, partial: output });
189
+ if (block.thinking) stream.push({ type: "thinking_delta", contentIndex, delta: String(block.thinking), partial: output });
190
+ stream.push({ type: "thinking_end", contentIndex, content: String(block.thinking || ""), partial: output });
191
+ } else if (block?.type === "tool_use") {
192
+ const toolCall = { type: "toolCall" as const, id: block.id, name: fromClaudeCodeName(block.name), arguments: block.input || {} };
193
+ output.content.push(toolCall as any);
194
+ const contentIndex = output.content.length - 1;
195
+ stream.push({ type: "toolcall_start", contentIndex, partial: output });
196
+ stream.push({ type: "toolcall_delta", contentIndex, delta: JSON.stringify(toolCall.arguments), partial: output });
197
+ stream.push({ type: "toolcall_end", contentIndex, toolCall, partial: output });
198
+ }
199
+ }
200
+ }
201
+
202
+ function applySsePayloadEvent(
203
+ payload: any,
204
+ output: AssistantMessage,
205
+ stream: AssistantMessageEventStream,
206
+ model: Model<Api>,
207
+ blockIndexByEventIndex: Map<number, number>,
208
+ ) {
209
+ if (!payload?.type || payload.type === "ping" || payload.type === "message_stop") return;
210
+
211
+ if (payload.type === "error") {
212
+ const errorText = payload?.error?.message || payload?.error || payload?.message || JSON.stringify(payload);
213
+ throw new Error(String(errorText));
214
+ }
215
+
216
+ if (payload.type === "message_start") {
217
+ output.responseId = payload.message?.id || output.responseId;
218
+ updateUsageFromAnthropic(output, payload.message?.usage || {}, model);
219
+ return;
220
+ }
221
+
222
+ if (payload.type === "content_block_start") {
223
+ const block = payload.content_block;
224
+ if (block?.type === "text") {
225
+ output.content.push({ type: "text", text: "", eventIndex: payload.index } as any);
226
+ const contentIndex = output.content.length - 1;
227
+ blockIndexByEventIndex.set(payload.index, contentIndex);
228
+ stream.push({ type: "text_start", contentIndex, partial: output });
229
+ return;
230
+ }
231
+ if (block?.type === "thinking" || block?.type === "redacted_thinking") {
232
+ output.content.push({
233
+ type: "thinking",
234
+ thinking: block.type === "redacted_thinking" ? "[Reasoning redacted]" : "",
235
+ thinkingSignature: block.type === "redacted_thinking" ? String(block.data || "") : "",
236
+ redacted: block.type === "redacted_thinking" ? true : undefined,
237
+ eventIndex: payload.index,
238
+ } as any);
239
+ const contentIndex = output.content.length - 1;
240
+ blockIndexByEventIndex.set(payload.index, contentIndex);
241
+ stream.push({ type: "thinking_start", contentIndex, partial: output });
242
+ return;
243
+ }
244
+ if (block?.type === "tool_use") {
245
+ const toolCall = {
246
+ type: "toolCall" as const,
247
+ id: block.id,
248
+ name: fromClaudeCodeName(block.name),
249
+ arguments: (block.input as Json) || {},
250
+ partialJson: "",
251
+ eventIndex: payload.index,
252
+ };
253
+ output.content.push(toolCall as any);
254
+ const contentIndex = output.content.length - 1;
255
+ blockIndexByEventIndex.set(payload.index, contentIndex);
256
+ stream.push({ type: "toolcall_start", contentIndex, partial: output });
257
+ }
258
+ return;
259
+ }
260
+
261
+ if (payload.type === "content_block_delta") {
262
+ const contentIndex = blockIndexByEventIndex.get(payload.index);
263
+ if (contentIndex == null) return;
264
+ const block = output.content[contentIndex] as any;
265
+ if (!block) return;
266
+
267
+ if (payload.delta?.type === "text_delta" && block.type === "text") {
268
+ block.text += String(payload.delta.text || "");
269
+ stream.push({ type: "text_delta", contentIndex, delta: String(payload.delta.text || ""), partial: output });
270
+ return;
271
+ }
272
+ if (payload.delta?.type === "thinking_delta" && block.type === "thinking") {
273
+ block.thinking += String(payload.delta.thinking || "");
274
+ stream.push({ type: "thinking_delta", contentIndex, delta: String(payload.delta.thinking || ""), partial: output });
275
+ return;
276
+ }
277
+ if (payload.delta?.type === "input_json_delta" && block.type === "toolCall") {
278
+ block.partialJson += String(payload.delta.partial_json || "");
279
+ try {
280
+ block.arguments = JSON.parse(block.partialJson);
281
+ } catch {
282
+ // partial json is expected during streaming
283
+ }
284
+ stream.push({ type: "toolcall_delta", contentIndex, delta: String(payload.delta.partial_json || ""), partial: output });
285
+ return;
286
+ }
287
+ if (payload.delta?.type === "signature_delta" && block.type === "thinking") {
288
+ block.thinkingSignature = `${block.thinkingSignature || ""}${String(payload.delta.signature || "")}`;
289
+ }
290
+ return;
291
+ }
292
+
293
+ if (payload.type === "content_block_stop") {
294
+ const contentIndex = blockIndexByEventIndex.get(payload.index);
295
+ if (contentIndex == null) return;
296
+ const block = output.content[contentIndex] as any;
297
+ if (!block) return;
298
+
299
+ delete block.eventIndex;
300
+ blockIndexByEventIndex.delete(payload.index);
301
+
302
+ if (block.type === "text") {
303
+ stream.push({ type: "text_end", contentIndex, content: block.text, partial: output });
304
+ return;
305
+ }
306
+ if (block.type === "thinking") {
307
+ stream.push({ type: "thinking_end", contentIndex, content: block.thinking, partial: output });
308
+ return;
309
+ }
310
+ if (block.type === "toolCall") {
311
+ if (block.partialJson) {
312
+ try {
313
+ block.arguments = JSON.parse(block.partialJson);
314
+ } catch {
315
+ block.arguments = block.arguments || {};
316
+ }
317
+ }
318
+ delete block.partialJson;
319
+ stream.push({ type: "toolcall_end", contentIndex, toolCall: block, partial: output });
320
+ }
321
+ return;
322
+ }
323
+
324
+ if (payload.type === "message_delta") {
325
+ if (payload.delta?.stop_reason) output.stopReason = mapStopReason(payload.delta.stop_reason);
326
+ updateUsageFromAnthropic(output, payload.usage || {}, model);
327
+ }
328
+ }
329
+
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");
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
+
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);
434
+ }
435
+
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);
446
+ }
447
+ }
448
+
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
+ });
461
+ }
462
+
463
+ // ── JSON request ────────────────────────────────────────────────────────────
464
+
465
+ 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
+ }
495
+
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;
505
+
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
+ });
516
+
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}`);
528
+ }
529
+
530
+ throw new Error(lastErrorText || "HTTP request failed after retries");
531
+ }