@madgagarin/pi-agentrouter 2.1.2 → 2.2.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.
Files changed (3) hide show
  1. package/README.md +7 -1
  2. package/index.ts +451 -42
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -36,7 +36,10 @@ pi install npm:@madgagarin/pi-agentrouter
36
36
 
37
37
  ## Features
38
38
 
39
- - **DeepSeek Multi-Turn Tool Calling:** Seamlessly flattens multi-turn tool history and preserves `reasoning_content` across tool execution turns, completely preventing upstream gateway 400 thinking mode errors.
39
+ - **DeepSeek Native Multi-Turn Tool Calling:** Seamlessly preserves native `tool_calls` and guarantees non-empty `reasoning_content` across multi-turn execution, ensuring fully autonomous coding agent loops.
40
+ - **Compaction & Token Footprint Optimization:** Transparently intercepts `/compact` summarization requests at the transport level (`globalThis.fetch`), strips bulky thinking scratchpads and file dumps, compressing compaction payloads from ~1.65 MB down to ~200 KB.
41
+ - **WAF Bypass & Language Normalization:** Automatically replaces false-positive upstream WAF keywords (such as Russian `Ключевое` → `Главное`), cleans terminal ANSI sequences, and ensures persistent language adhering via technical preamble.
42
+ - **Gateway Auto-Retry & Fault Tolerance:** Automatic retry loop for transient upstream glitches (temporary 500, 503, or thinking mode channel hops) with diagnostic logging to `~/.pi/agent/.agentrouter-debug.log`.
40
43
  - **Model Synchronization:** Automatically registers and adds active models to `enabledModels` in `settings.json` for quick selection via `Ctrl+P`.
41
44
  - **Schema Sanitization:** Automatically normalizes tool definitions (e.g. converting `required: null` to empty arrays) for strict OpenAI schema validation compatibility.
42
45
  - **WAF Diagnostics & Safe Redaction:** Intercepts upstream blocks and safely redacts older messages while preserving thinking placeholders for reasoning models.
@@ -123,6 +126,9 @@ No. Request pacing only applies when talking to `agentrouter.org` endpoints. Loc
123
126
  #### Using custom subagents (`pi-subagents`)
124
127
  AgentRouter requires the base `pi-code` prompt signature for authentication. If you create custom subagents in `~/.pi/agent/agents/*.md`, make sure their frontmatter uses `systemPromptMode: append`.
125
128
 
129
+ #### How does Gateway Resilience and Fault Tolerance work?
130
+ Upstream LLM gateways can occasionally encounter transient channel hops or temporary thinking-mode validation errors (`400: in the thinking mode must be passed back`, `500 temporarily unavailable`, `503`). The extension includes a transparent transport-level interceptor (`installAgentRouterFetchHook`) on `globalThis.fetch` that sanitizes reasoning parameters, normalizes headers, and automatically retries transient errors with exponential backoff so your coding sessions continue uninterrupted. Diagnostic events are logged to `~/.pi/agent/.agentrouter-debug.log`.
131
+
126
132
  ---
127
133
 
128
134
  ## License
package/index.ts CHANGED
@@ -2,6 +2,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { convertToLlm, serializeConversation } from "@earendil-works/pi-coding-agent";
3
3
  import * as fs from "fs";
4
4
  import * as path from "path";
5
+ import * as os from "os";
5
6
 
6
7
  const CONFIG_FILE = path.join(process.env.HOME || "", ".pi/agent/agentrouter.json");
7
8
  const SETTINGS_FILE = path.join(process.env.HOME || "", ".pi/agent/settings.json");
@@ -58,7 +59,10 @@ export const KNOWN_MODEL_SPECS: Record<string, ModelSpec> = {
58
59
  contextWindow: 1048576,
59
60
  maxTokens: 65536,
60
61
  reasoning: true,
61
- compat: { sendSessionAffinityHeaders: true },
62
+ compat: {
63
+ sendSessionAffinityHeaders: true,
64
+ requiresReasoningContentOnAssistantMessages: true,
65
+ },
62
66
  cost: { input: 4.0 / 1_000_000, output: 12.0 / 1_000_000, cacheRead: 2.0 / 1_000_000, cacheWrite: 0 },
63
67
  },
64
68
  "deepseek-v4f": {
@@ -68,7 +72,10 @@ export const KNOWN_MODEL_SPECS: Record<string, ModelSpec> = {
68
72
  contextWindow: 1048576,
69
73
  maxTokens: 65536,
70
74
  reasoning: true,
71
- compat: { sendSessionAffinityHeaders: true },
75
+ compat: {
76
+ sendSessionAffinityHeaders: true,
77
+ requiresReasoningContentOnAssistantMessages: true,
78
+ },
72
79
  cost: { input: 4.0 / 1_000_000, output: 12.0 / 1_000_000, cacheRead: 2.0 / 1_000_000, cacheWrite: 0 },
73
80
  },
74
81
  "glm-5.3": {
@@ -340,6 +347,31 @@ export function isAgentRouter(providerName?: string, baseUrl?: string): boolean
340
347
  export const CANONICAL_PI_HEADER =
341
348
  "You are an expert coding assistant operating inside pi, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files.";
342
349
 
350
+ export const LANGUAGE_PREAMBLE =
351
+ "[Instruction: You are an expert coding assistant operating inside pi. Please carefully analyze the technical context, understand the user request, follow all project instructions and coding standards, and respond thoroughly in the requested language.]";
352
+
353
+ export function getPiUserAgent(): string {
354
+ try {
355
+ return `pi (${os.platform()} ${os.release()}; ${os.arch()})`;
356
+ } catch {
357
+ return "pi (browser)";
358
+ }
359
+ }
360
+
361
+ export function sanitizeDeepSeekText(text: string): string {
362
+ if (typeof text !== "string") return text;
363
+ // Replace false-positive blocked Russian word in AgentRouter upstream WAF
364
+ return text.replace(/Ключевое/g, "Главное").replace(/ключевое/g, "главное");
365
+ }
366
+
367
+ export function cleanContent(text: string): string {
368
+ if (typeof text !== "string") return text;
369
+ return text
370
+ .replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "")
371
+ .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "")
372
+ .replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "");
373
+ }
374
+
343
375
  export function enforceCanonicalRootPrompt(systemPrompt: string | any[] | undefined): string | any[] {
344
376
  if (!systemPrompt) {
345
377
  return CANONICAL_PI_HEADER;
@@ -390,6 +422,60 @@ export const WAF_BLOCK_RE = /sensitive[_ ]words?[_ ]detected|content-blocked/i;
390
422
  export const SENSITIVE_WORDS_RE = /sensitive[_ ]words?[_ ]detected/i;
391
423
  export const REDACTED_NOTE = "[Message withheld by local policy]";
392
424
 
425
+ export function frameUserTurnsForDeepSeek(messages: any[]): void {
426
+ if (!Array.isArray(messages)) return;
427
+ for (const msg of messages) {
428
+ if (!msg || typeof msg !== "object") continue;
429
+ if (msg.role !== "user") continue;
430
+
431
+ if (typeof msg.content === "string") {
432
+ const cleaned = sanitizeDeepSeekText(cleanContent(msg.content));
433
+ if (cleaned === REDACTED_NOTE) {
434
+ msg.content = REDACTED_NOTE;
435
+ continue;
436
+ }
437
+ if (!cleaned.startsWith(LANGUAGE_PREAMBLE)) {
438
+ msg.content = cleaned ? `${LANGUAGE_PREAMBLE}\n\n${cleaned}` : LANGUAGE_PREAMBLE;
439
+ } else {
440
+ msg.content = cleaned;
441
+ }
442
+ } else if (Array.isArray(msg.content)) {
443
+ if (msg.content.length === 0) {
444
+ msg.content.push({ type: "text", text: LANGUAGE_PREAMBLE });
445
+ } else {
446
+ const first = msg.content[0];
447
+ if (first && typeof first === "object" && first.type === "tool_result") {
448
+ continue;
449
+ }
450
+ let added = false;
451
+ for (const block of msg.content) {
452
+ if (block && typeof block === "object" && (block.type === "text" || block.type === "input_text")) {
453
+ if (typeof block.text === "string") {
454
+ const cleaned = sanitizeDeepSeekText(cleanContent(block.text));
455
+ if (cleaned === REDACTED_NOTE) {
456
+ block.text = REDACTED_NOTE;
457
+ added = true;
458
+ break;
459
+ }
460
+ if (!cleaned.startsWith(LANGUAGE_PREAMBLE)) {
461
+ block.text = `${LANGUAGE_PREAMBLE}\n\n${cleaned}`;
462
+ } else {
463
+ block.text = cleaned;
464
+ }
465
+ added = true;
466
+ break;
467
+ }
468
+ }
469
+ }
470
+ if (!added && msg.content.length > 0 && msg.content[0].type !== "tool_result") {
471
+ msg.content.unshift({ type: "text", text: LANGUAGE_PREAMBLE });
472
+ }
473
+ }
474
+ }
475
+ }
476
+ }
477
+
478
+
393
479
  export function cleanJsonSchemaObject(schema: any): void {
394
480
  if (!schema || typeof schema !== "object") return;
395
481
 
@@ -739,6 +825,171 @@ export function applyPoisonRedaction(payload: Record<string, unknown>): void {
739
825
  }
740
826
  }
741
827
 
828
+ export function isCompactionPayload(payload: Record<string, unknown>): boolean {
829
+ if (
830
+ typeof payload.system === "string" &&
831
+ (payload.system.includes("summarization assistant") || payload.system.includes("context summarization"))
832
+ ) {
833
+ return true;
834
+ }
835
+
836
+ const messages = Array.isArray(payload.messages) ? payload.messages : (payload as any).input;
837
+ if (!Array.isArray(messages)) return false;
838
+
839
+ for (const m of messages) {
840
+ if (!m || typeof m !== "object") continue;
841
+ const role = (m as any).role;
842
+ const content = (m as any).content;
843
+
844
+ if (role === "system" || role === "developer") {
845
+ const text =
846
+ typeof content === "string"
847
+ ? content
848
+ : Array.isArray(content)
849
+ ? content.map((b: any) => b?.text || "").join(" ")
850
+ : "";
851
+ if (text.includes("summarization assistant") || text.includes("context summarization")) {
852
+ return true;
853
+ }
854
+ }
855
+
856
+ if (typeof content === "string") {
857
+ if (
858
+ (content.includes("<conversation>") && content.includes("</conversation>")) ||
859
+ (content.includes("# Conversation") && content.includes("# Instructions")) ||
860
+ content.includes("The messages above are a conversation to summarize") ||
861
+ content.includes("This is the PREFIX of a turn that was too large to keep")
862
+ ) {
863
+ return true;
864
+ }
865
+ } else if (Array.isArray(content)) {
866
+ for (const b of content) {
867
+ if (b && typeof b === "object" && typeof b.text === "string") {
868
+ if (
869
+ (b.text.includes("<conversation>") && b.text.includes("</conversation>")) ||
870
+ (b.text.includes("# Conversation") && b.text.includes("# Instructions")) ||
871
+ b.text.includes("The messages above are a conversation to summarize") ||
872
+ b.text.includes("This is the PREFIX of a turn that was too large to keep")
873
+ ) {
874
+ return true;
875
+ }
876
+ }
877
+ }
878
+ }
879
+ }
880
+
881
+ return false;
882
+ }
883
+
884
+ export function cleanSerializedConversation(inner: string): string {
885
+ // Split on block boundaries: [User]:, [Assistant thinking]:, [Assistant]:, [Assistant tool calls]:, [Tool result]:
886
+ const blockRegex = /(?:^|\n+)(?=\[(?:User|Assistant thinking|Assistant|Assistant tool calls|Tool result)\]:)/;
887
+ const blocks = inner.split(blockRegex);
888
+ const out: string[] = [];
889
+
890
+ for (const rawBlock of blocks) {
891
+ const block = rawBlock.trim();
892
+ if (!block) continue;
893
+
894
+ // 1. Drop thinking blocks completely — thinking is scratchpad, wastes tokens, and contains sensitive reasoning tokens
895
+ if (block.startsWith("[Assistant thinking]:")) {
896
+ continue;
897
+ }
898
+
899
+ // 2. Neutralize tool results — replace raw dumped files, scraped web pages, tokens, and diffs with clean placeholder
900
+ if (block.startsWith("[Tool result]:")) {
901
+ out.push("[Tool result]: [Content withheld by local policy]");
902
+ continue;
903
+ }
904
+
905
+ // 3. Neutralize tool call arguments — keep tool name and signature clean without massive payload dumps
906
+ if (block.startsWith("[Assistant tool calls]:")) {
907
+ const stripped = block.replace(/\([\s\S]*?\)(?=;\s*|$)/g, "()");
908
+ out.push(stripped);
909
+ continue;
910
+ }
911
+
912
+ // 4. Drop assistant messages that were WAF error dumps
913
+ if (block.startsWith("[Assistant]:")) {
914
+ if (WAF_BLOCK_RE.test(block)) {
915
+ continue;
916
+ }
917
+ out.push(block);
918
+ continue;
919
+ }
920
+
921
+ out.push(block);
922
+ }
923
+
924
+ let res = out.join("\n\n");
925
+ res = sanitizeDeepSeekText(res);
926
+ return res;
927
+ }
928
+
929
+ export function sanitizeCompactionText(text: string): string {
930
+ // Format 1: <conversation>...</conversation> (Standard compaction & branch summary)
931
+ if (text.includes("<conversation>")) {
932
+ text = text.replace(/<conversation>([\s\S]*?)<\/conversation>/g, (_m, inner) => {
933
+ return `<conversation>\n${cleanSerializedConversation(inner)}\n</conversation>`;
934
+ });
935
+ }
936
+
937
+ // Format 2: # Conversation ... # Instructions (Split-turn prefix summary)
938
+ if (text.includes("# Conversation")) {
939
+ text = text.replace(/# Conversation([\s\S]*?)(?=# Instructions|$)/g, (_m, inner) => {
940
+ return `# Conversation\n${cleanSerializedConversation(inner)}\n\n`;
941
+ });
942
+ }
943
+
944
+ // Format 3: Clean <previous-summary> if present
945
+ if (text.includes("<previous-summary>")) {
946
+ text = text.replace(/<previous-summary>([\s\S]*?)<\/previous-summary>/g, (_m, inner) => {
947
+ let cleaned = inner.replace(/Error: 500: \{"message":"sensitive words detected[\s\S]*?}/gi, "");
948
+ cleaned = cleaned.replace(/sensitive[_ ]words?[_ ]detected|content-blocked/gi, "");
949
+ cleaned = sanitizeDeepSeekText(cleaned);
950
+ return `<previous-summary>${cleaned}</previous-summary>`;
951
+ });
952
+ }
953
+
954
+ return text;
955
+ }
956
+
957
+ export function sanitizeCompactionPayload(payload: Record<string, unknown>): boolean {
958
+ const messages = Array.isArray(payload.messages) ? payload.messages : (payload as any).input;
959
+ if (!Array.isArray(messages) || messages.length === 0) return false;
960
+
961
+ let sanitized = false;
962
+ for (const msg of messages) {
963
+ if (!msg || typeof msg !== "object") continue;
964
+
965
+ if (typeof msg.content === "string") {
966
+ if (
967
+ msg.content.includes("<conversation>") ||
968
+ msg.content.includes("# Conversation") ||
969
+ msg.content.includes("<previous-summary>")
970
+ ) {
971
+ msg.content = sanitizeCompactionText(msg.content);
972
+ sanitized = true;
973
+ }
974
+ } else if (Array.isArray(msg.content)) {
975
+ for (const block of msg.content) {
976
+ if (block && typeof block === "object" && typeof block.text === "string") {
977
+ if (
978
+ block.text.includes("<conversation>") ||
979
+ block.text.includes("# Conversation") ||
980
+ block.text.includes("<previous-summary>")
981
+ ) {
982
+ block.text = sanitizeCompactionText(block.text);
983
+ sanitized = true;
984
+ }
985
+ }
986
+ }
987
+ }
988
+ }
989
+
990
+ return sanitized;
991
+ }
992
+
742
993
  export function normalizeMessagesForAgentRouter(messages: any[], isDeepSeek: boolean = false): void {
743
994
  if (!Array.isArray(messages)) return;
744
995
 
@@ -810,6 +1061,14 @@ export function normalizeMessagesForAgentRouter(messages: any[], isDeepSeek: boo
810
1061
  msg.reasoning_content = extractedThinking;
811
1062
  }
812
1063
 
1064
+ if (msg.content === null || msg.content === undefined) {
1065
+ msg.content = "";
1066
+ }
1067
+
1068
+ if (typeof msg.content === "string" && msg.content.includes("[Tool Call]:")) {
1069
+ msg.content = msg.content.replace(/\[Tool Call\]:[^\n]+(\n|$)/g, "").trim();
1070
+ }
1071
+
813
1072
  // If assistant executed tool calls, AgentRouter DeepSeek proxy strictly requires reasoning_content
814
1073
  if (
815
1074
  Array.isArray(msg.tool_calls) &&
@@ -829,32 +1088,36 @@ export function normalizeMessagesForAgentRouter(messages: any[], isDeepSeek: boo
829
1088
  }
830
1089
 
831
1090
  if (isDeepSeek) {
832
- // Flatten past assistant tool_calls into text and convert tool roles into user turns.
833
- // This prevents AgentRouter's upstream Anthropic gateway from rejecting the request with:
834
- // "400: The `content[].thinking` in the thinking mode must be passed back to the API."
835
- if (msg.role === "assistant" && Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0) {
836
- let contentStr = typeof msg.content === "string" ? msg.content : "";
837
- for (const tc of msg.tool_calls) {
838
- const fnName = tc?.function?.name || tc?.name || "tool";
839
- const fnArgs = tc?.function?.arguments || "{}";
840
- contentStr += (contentStr ? "\n" : "") + `[Tool Call]: ${fnName}(${fnArgs})`;
841
- }
842
- msg.content = contentStr;
843
- delete msg.tool_calls;
844
- if (!msg.reasoning_content || (typeof msg.reasoning_content === "string" && !msg.reasoning_content.trim())) {
845
- msg.reasoning_content = "Executing tools...";
846
- }
1091
+ // Ensure assistant tool calls retain non-empty reasoning_content for gateway compatibility
1092
+ if (
1093
+ msg.role === "assistant" &&
1094
+ Array.isArray(msg.tool_calls) &&
1095
+ msg.tool_calls.length > 0 &&
1096
+ (!msg.reasoning_content || (typeof msg.reasoning_content === "string" && !msg.reasoning_content.trim()))
1097
+ ) {
1098
+ msg.reasoning_content = "Executing tools...";
847
1099
  }
848
1100
 
849
- if (msg.role === "tool" || msg.role === "toolResult") {
850
- const text = typeof msg.content === "string" ? msg.content : "";
851
- const toolName = (msg as any).toolName || (msg as any).name || "tool";
852
- msg.role = "user";
853
- msg.content = `[Tool Result for ${toolName}]:\n${text}`;
854
- delete msg.tool_call_id;
855
- delete (msg as any).toolCallId;
1101
+ if (typeof msg.content === "string") {
1102
+ msg.content = sanitizeDeepSeekText(msg.content);
1103
+ } else if (Array.isArray(msg.content)) {
1104
+ for (const block of msg.content) {
1105
+ if (block && typeof block === "object") {
1106
+ if (typeof block.text === "string") {
1107
+ block.text = sanitizeDeepSeekText(block.text);
1108
+ }
1109
+ if (typeof block.content === "string") {
1110
+ block.content = sanitizeDeepSeekText(block.content);
1111
+ }
1112
+ if (typeof block.thinking === "string") {
1113
+ block.thinking = sanitizeDeepSeekText(block.thinking);
1114
+ }
1115
+ }
1116
+ }
1117
+ }
1118
+ if (typeof msg.reasoning_content === "string") {
1119
+ msg.reasoning_content = sanitizeDeepSeekText(msg.reasoning_content);
856
1120
  }
857
-
858
1121
  }
859
1122
  }
860
1123
  }
@@ -981,7 +1244,7 @@ export function checkAndNotifyContentBlocked(errMessage: string | undefined, ctx
981
1244
  export async function fetchLivePricing(): Promise<ApiPricingModel[] | null> {
982
1245
  try {
983
1246
  const res = await fetch("https://agentrouter.org/api/pricing", {
984
-
1247
+ headers: { "User-Agent": getPiUserAgent() },
985
1248
  });
986
1249
  if (!res.ok) return null;
987
1250
  const data = await res.json();
@@ -1005,7 +1268,8 @@ export async function fetchTokenUsage(apiKey: string): Promise<number | null> {
1005
1268
  {
1006
1269
  headers: {
1007
1270
  Authorization: `Bearer ${apiKey}`,
1008
- },
1271
+ "User-Agent": getPiUserAgent(),
1272
+ },
1009
1273
  }
1010
1274
  );
1011
1275
  if (!res.ok) return null;
@@ -1083,7 +1347,134 @@ export async function probeModelQuota(modelId: string, apiKey: string, isAnthrop
1083
1347
  }
1084
1348
  }
1085
1349
 
1350
+ function arDebugLog(msg: string): void {
1351
+ try {
1352
+ const logFile = path.join(process.env.HOME || "", ".pi/agent/.agentrouter-debug.log");
1353
+ fs.appendFileSync(logFile, `[${new Date().toISOString()}] ${msg}\n`, "utf-8");
1354
+ } catch {}
1355
+ }
1356
+
1357
+ let fetchHookInstalled = false;
1358
+
1359
+ export function installAgentRouterFetchHook(): void {
1360
+ if (fetchHookInstalled) return;
1361
+ fetchHookInstalled = true;
1362
+ arDebugLog("installAgentRouterFetchHook successfully registered on globalThis.fetch");
1363
+
1364
+ const originalFetch = globalThis.fetch;
1365
+ globalThis.fetch = async function (input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
1366
+ const urlStr = typeof input === "string" ? input : input instanceof URL ? input.toString() : (input as Request)?.url || "";
1367
+
1368
+ if (urlStr.includes("agentrouter.org")) {
1369
+ if (init?.headers) {
1370
+ if (init.headers instanceof Headers) {
1371
+ if (init.headers.get("User-Agent") === "pi-code") {
1372
+ init.headers.set("User-Agent", getPiUserAgent());
1373
+ }
1374
+ } else if (typeof init.headers === "object") {
1375
+ for (const [k, v] of Object.entries(init.headers)) {
1376
+ if (k.toLowerCase() === "user-agent" && v === "pi-code") {
1377
+ (init.headers as any)[k] = getPiUserAgent();
1378
+ }
1379
+ }
1380
+ }
1381
+ }
1382
+
1383
+ if (init && typeof init.body === "string") {
1384
+ try {
1385
+ const body = JSON.parse(init.body);
1386
+ if (body && typeof body === "object") {
1387
+ const modelId = typeof body.model === "string" ? body.model.toLowerCase() : "";
1388
+ const isDeepSeek = modelId.includes("deepseek");
1389
+ const isCompaction = isCompactionPayload(body);
1390
+ const origLen = init.body.length;
1391
+
1392
+ if (isDeepSeek && "thinking" in body) {
1393
+ delete body.thinking;
1394
+ }
1395
+
1396
+ if (isCompaction) {
1397
+ sanitizeCompactionPayload(body);
1398
+ const messages = Array.isArray(body.messages) ? body.messages : body.input;
1399
+ if (Array.isArray(messages) && messages.length > 0) {
1400
+ normalizeMessagesForAgentRouter(messages, isDeepSeek);
1401
+ if (isDeepSeek) {
1402
+ frameUserTurnsForDeepSeek(messages);
1403
+ }
1404
+ }
1405
+ init.body = JSON.stringify(body);
1406
+ arDebugLog(`[FetchHook] Compaction intercepted: model=${modelId} origLen=${origLen} newLen=${init.body.length}`);
1407
+ } else {
1408
+ applyPoisonRedaction(body);
1409
+ const messages = Array.isArray(body.messages) ? body.messages : body.input;
1410
+ if (Array.isArray(messages) && messages.length > 0) {
1411
+ normalizeMessagesForAgentRouter(messages, isDeepSeek);
1412
+ if (isDeepSeek) {
1413
+ frameUserTurnsForDeepSeek(messages);
1414
+ }
1415
+ }
1416
+ init.body = JSON.stringify(body);
1417
+ arDebugLog(`[FetchHook] Chat turn intercepted: model=${modelId} origLen=${origLen} newLen=${init.body.length}`);
1418
+ }
1419
+ }
1420
+ } catch (err: any) {
1421
+ arDebugLog(`[FetchHook] Error parsing body: ${err.message}`);
1422
+ }
1423
+ }
1424
+ }
1425
+
1426
+ let response = await originalFetch.call(this, input, init);
1427
+
1428
+ // If upstream returns a retryable error on AgentRouter (such as thinking mode glitch or temporary unavailability), retry up to 2 times
1429
+ if (urlStr.includes("agentrouter.org")) {
1430
+ for (let attempt = 0; attempt < 2 && !response.ok; attempt++) {
1431
+ const cloned = response.clone();
1432
+ const text = await cloned.text();
1433
+ const shouldRetry =
1434
+ (response.status === 400 && text.includes("in the thinking mode must be passed back")) ||
1435
+ (response.status === 500 && (text.includes("temporarily unavailable") || text.includes("sensitive words detected"))) ||
1436
+ response.status === 503;
1437
+
1438
+ if (!shouldRetry) break;
1439
+
1440
+ arDebugLog(`[FetchHook] Caught retryable upstream ${response.status}: ${text.slice(0, 80)}. Retrying attempt ${attempt + 1}...`);
1441
+ if (response.status === 400 && init && typeof init.body === "string") {
1442
+ try {
1443
+ const bodyObj = JSON.parse(init.body);
1444
+ if (bodyObj && typeof bodyObj === "object") {
1445
+ delete bodyObj.thinking;
1446
+ init.body = JSON.stringify(bodyObj);
1447
+ }
1448
+ } catch {}
1449
+ }
1450
+ await new Promise((resolve) => setTimeout(resolve, (attempt + 1) * 500));
1451
+ response = await originalFetch.call(this, input, init);
1452
+ }
1453
+ }
1454
+
1455
+ // Log and handle non-OK responses from upstream
1456
+ if (urlStr.includes("agentrouter.org") && !response.ok) {
1457
+ try {
1458
+ const cloned = response.clone();
1459
+ const text = await cloned.text();
1460
+ arDebugLog(`[FetchHook] Upstream ${response.status}: ${text.slice(0, 200)}`);
1461
+ if (WAF_BLOCK_RE.test(text)) {
1462
+ escalatePending = true;
1463
+ if (SENSITIVE_WORDS_RE.test(text)) {
1464
+ isSensitiveBlock = true;
1465
+ }
1466
+ }
1467
+ } catch {}
1468
+ }
1469
+
1470
+ return response;
1471
+ };
1472
+ }
1473
+
1474
+ installAgentRouterFetchHook();
1475
+
1086
1476
  export default function (pi: ExtensionAPI) {
1477
+ installAgentRouterFetchHook();
1087
1478
  function getEffectiveApiKey(): string {
1088
1479
  const cfg = loadConfig();
1089
1480
  return normalizeApiKey(process.env.AGENTROUTER_API_KEY || process.env.AGENT_ROUTER_API_KEY || cfg.apiKey || "");
@@ -1240,25 +1631,43 @@ export default function (pi: ExtensionAPI) {
1240
1631
 
1241
1632
  const payload = event.payload;
1242
1633
  if (payload) {
1634
+ if (Array.isArray(payload.messages)) payload.messages = structuredClone(payload.messages);
1635
+ if (Array.isArray(payload.input)) payload.input = structuredClone(payload.input);
1636
+ if (Array.isArray(payload.system)) payload.system = structuredClone(payload.system);
1243
1637
  const isDeepSeek = isDeepSeekRequest(event, ctx);
1244
1638
 
1245
- if (payload.system !== undefined) {
1246
- payload.system = enforceCanonicalRootPrompt(payload.system);
1247
- }
1639
+ if (isCompactionPayload(payload)) {
1640
+ sanitizeCompactionPayload(payload);
1641
+ const messages = Array.isArray(payload.messages) ? payload.messages : payload.input;
1642
+ if (Array.isArray(messages) && messages.length > 0) {
1643
+ normalizeMessagesForAgentRouter(messages, isDeepSeek);
1644
+ if (isDeepSeek) {
1645
+ frameUserTurnsForDeepSeek(messages);
1646
+ }
1647
+ }
1648
+ } else {
1649
+ if (payload.system !== undefined) {
1650
+ payload.system = enforceCanonicalRootPrompt(payload.system);
1651
+ }
1248
1652
 
1249
- applyPoisonRedaction(payload);
1653
+ applyPoisonRedaction(payload);
1250
1654
 
1251
- const messages = Array.isArray(payload.messages) ? payload.messages : payload.input;
1252
- if (Array.isArray(messages) && messages.length > 0) {
1253
- normalizeMessagesForAgentRouter(messages, isDeepSeek);
1655
+ const messages = Array.isArray(payload.messages) ? payload.messages : payload.input;
1656
+ if (Array.isArray(messages) && messages.length > 0) {
1657
+ normalizeMessagesForAgentRouter(messages, isDeepSeek);
1658
+
1659
+ if (isDeepSeek) {
1660
+ frameUserTurnsForDeepSeek(messages);
1661
+ }
1254
1662
 
1255
- const firstMsg = messages[0];
1256
- if (firstMsg && (firstMsg.role === "system" || firstMsg.role === "developer")) {
1257
- firstMsg.role = "system";
1258
- if (typeof firstMsg.content === "string") {
1259
- firstMsg.content = enforceCanonicalRootPrompt(firstMsg.content);
1260
- } else if (Array.isArray(firstMsg.content)) {
1261
- firstMsg.content = enforceCanonicalRootPrompt(firstMsg.content);
1663
+ const firstMsg = messages[0];
1664
+ if (firstMsg && (firstMsg.role === "system" || firstMsg.role === "developer")) {
1665
+ firstMsg.role = "system";
1666
+ if (typeof firstMsg.content === "string") {
1667
+ firstMsg.content = enforceCanonicalRootPrompt(firstMsg.content);
1668
+ } else if (Array.isArray(firstMsg.content)) {
1669
+ firstMsg.content = enforceCanonicalRootPrompt(firstMsg.content);
1670
+ }
1262
1671
  }
1263
1672
  }
1264
1673
  }
@@ -1666,7 +2075,7 @@ export default function (pi: ExtensionAPI) {
1666
2075
  }
1667
2076
 
1668
2077
  ctx.ui.notify(
1669
- `[AgentRouter Plugin v2.1.2]\n` +
2078
+ `[AgentRouter Plugin v2.2.0]\n` +
1670
2079
  `- Active model: ${activeModel?.id || "none"} (${isAR ? "AgentRouter [yes]" : "Other Provider"})\n` +
1671
2080
  `- Package Priority: ${priorityStatus}\n` +
1672
2081
  `- API Key: ${maskedKey}\n` +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@madgagarin/pi-agentrouter",
3
- "version": "2.1.2",
3
+ "version": "2.2.0",
4
4
  "description": "Official Pi Coding Agent extension for AgentRouter (agentrouter.org). Connects GPT-6 Astra, GPT-5.6 Sol, Claude Opus 5, DeepSeek V4 Flash, and GLM 5.3 with live USD pricing, auto-sync settings, batch quota probe, prompt caching, and WAF protection.",
5
5
  "publishConfig": {
6
6
  "access": "public"