@madgagarin/pi-agentrouter 2.1.3 → 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 +5 -0
  2. package/index.ts +333 -25
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -37,7 +37,9 @@ pi install npm:@madgagarin/pi-agentrouter
37
37
  ## Features
38
38
 
39
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.
40
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`.
41
43
  - **Model Synchronization:** Automatically registers and adds active models to `enabledModels` in `settings.json` for quick selection via `Ctrl+P`.
42
44
  - **Schema Sanitization:** Automatically normalizes tool definitions (e.g. converting `required: null` to empty arrays) for strict OpenAI schema validation compatibility.
43
45
  - **WAF Diagnostics & Safe Redaction:** Intercepts upstream blocks and safely redacts older messages while preserving thinking placeholders for reasoning models.
@@ -124,6 +126,9 @@ No. Request pacing only applies when talking to `agentrouter.org` endpoints. Loc
124
126
  #### Using custom subagents (`pi-subagents`)
125
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`.
126
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
+
127
132
  ---
128
133
 
129
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");
@@ -61,7 +62,6 @@ export const KNOWN_MODEL_SPECS: Record<string, ModelSpec> = {
61
62
  compat: {
62
63
  sendSessionAffinityHeaders: true,
63
64
  requiresReasoningContentOnAssistantMessages: true,
64
- thinkingFormat: "deepseek",
65
65
  },
66
66
  cost: { input: 4.0 / 1_000_000, output: 12.0 / 1_000_000, cacheRead: 2.0 / 1_000_000, cacheWrite: 0 },
67
67
  },
@@ -75,7 +75,6 @@ export const KNOWN_MODEL_SPECS: Record<string, ModelSpec> = {
75
75
  compat: {
76
76
  sendSessionAffinityHeaders: true,
77
77
  requiresReasoningContentOnAssistantMessages: true,
78
- thinkingFormat: "deepseek",
79
78
  },
80
79
  cost: { input: 4.0 / 1_000_000, output: 12.0 / 1_000_000, cacheRead: 2.0 / 1_000_000, cacheWrite: 0 },
81
80
  },
@@ -351,6 +350,14 @@ export const CANONICAL_PI_HEADER =
351
350
  export const LANGUAGE_PREAMBLE =
352
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.]";
353
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
+
354
361
  export function sanitizeDeepSeekText(text: string): string {
355
362
  if (typeof text !== "string") return text;
356
363
  // Replace false-positive blocked Russian word in AgentRouter upstream WAF
@@ -818,6 +825,171 @@ export function applyPoisonRedaction(payload: Record<string, unknown>): void {
818
825
  }
819
826
  }
820
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
+
821
993
  export function normalizeMessagesForAgentRouter(messages: any[], isDeepSeek: boolean = false): void {
822
994
  if (!Array.isArray(messages)) return;
823
995
 
@@ -916,9 +1088,6 @@ export function normalizeMessagesForAgentRouter(messages: any[], isDeepSeek: boo
916
1088
  }
917
1089
 
918
1090
  if (isDeepSeek) {
919
- // Flatten past assistant tool_calls into text and convert tool roles into user turns.
920
- // This prevents AgentRouter's upstream Anthropic gateway from rejecting the request with:
921
- // "400: The `content[].thinking` in the thinking mode must be passed back to the API."
922
1091
  // Ensure assistant tool calls retain non-empty reasoning_content for gateway compatibility
923
1092
  if (
924
1093
  msg.role === "assistant" &&
@@ -1075,7 +1244,7 @@ export function checkAndNotifyContentBlocked(errMessage: string | undefined, ctx
1075
1244
  export async function fetchLivePricing(): Promise<ApiPricingModel[] | null> {
1076
1245
  try {
1077
1246
  const res = await fetch("https://agentrouter.org/api/pricing", {
1078
-
1247
+ headers: { "User-Agent": getPiUserAgent() },
1079
1248
  });
1080
1249
  if (!res.ok) return null;
1081
1250
  const data = await res.json();
@@ -1099,7 +1268,8 @@ export async function fetchTokenUsage(apiKey: string): Promise<number | null> {
1099
1268
  {
1100
1269
  headers: {
1101
1270
  Authorization: `Bearer ${apiKey}`,
1102
- },
1271
+ "User-Agent": getPiUserAgent(),
1272
+ },
1103
1273
  }
1104
1274
  );
1105
1275
  if (!res.ok) return null;
@@ -1177,7 +1347,134 @@ export async function probeModelQuota(modelId: string, apiKey: string, isAnthrop
1177
1347
  }
1178
1348
  }
1179
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
+
1180
1476
  export default function (pi: ExtensionAPI) {
1477
+ installAgentRouterFetchHook();
1181
1478
  function getEffectiveApiKey(): string {
1182
1479
  const cfg = loadConfig();
1183
1480
  return normalizeApiKey(process.env.AGENTROUTER_API_KEY || process.env.AGENT_ROUTER_API_KEY || cfg.apiKey || "");
@@ -1339,27 +1636,38 @@ export default function (pi: ExtensionAPI) {
1339
1636
  if (Array.isArray(payload.system)) payload.system = structuredClone(payload.system);
1340
1637
  const isDeepSeek = isDeepSeekRequest(event, ctx);
1341
1638
 
1342
- if (payload.system !== undefined) {
1343
- payload.system = enforceCanonicalRootPrompt(payload.system);
1344
- }
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
+ }
1345
1652
 
1346
- applyPoisonRedaction(payload);
1653
+ applyPoisonRedaction(payload);
1347
1654
 
1348
- const messages = Array.isArray(payload.messages) ? payload.messages : payload.input;
1349
- if (Array.isArray(messages) && messages.length > 0) {
1350
- 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);
1351
1658
 
1352
- if (isDeepSeek) {
1353
- frameUserTurnsForDeepSeek(messages);
1354
- }
1659
+ if (isDeepSeek) {
1660
+ frameUserTurnsForDeepSeek(messages);
1661
+ }
1355
1662
 
1356
- const firstMsg = messages[0];
1357
- if (firstMsg && (firstMsg.role === "system" || firstMsg.role === "developer")) {
1358
- firstMsg.role = "system";
1359
- if (typeof firstMsg.content === "string") {
1360
- firstMsg.content = enforceCanonicalRootPrompt(firstMsg.content);
1361
- } else if (Array.isArray(firstMsg.content)) {
1362
- 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
+ }
1363
1671
  }
1364
1672
  }
1365
1673
  }
@@ -1767,7 +2075,7 @@ export default function (pi: ExtensionAPI) {
1767
2075
  }
1768
2076
 
1769
2077
  ctx.ui.notify(
1770
- `[AgentRouter Plugin v2.1.3]\n` +
2078
+ `[AgentRouter Plugin v2.2.0]\n` +
1771
2079
  `- Active model: ${activeModel?.id || "none"} (${isAR ? "AgentRouter [yes]" : "Other Provider"})\n` +
1772
2080
  `- Package Priority: ${priorityStatus}\n` +
1773
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.3",
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"