@madgagarin/pi-agentrouter 2.1.1 → 2.1.3

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 +10 -10
  2. package/index.ts +681 -47
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -36,15 +36,15 @@ pi install npm:@madgagarin/pi-agentrouter
36
36
 
37
37
  ## Features
38
38
 
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
+ - **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.
39
41
  - **Model Synchronization:** Automatically registers and adds active models to `enabledModels` in `settings.json` for quick selection via `Ctrl+P`.
40
- - **DeepSeek Multi-Turn Tool Calling:** Preserves `reasoning_content` and handles thinking blocks across multi-step tool execution, avoiding API 400 errors.
41
42
  - **Schema Sanitization:** Automatically normalizes tool definitions (e.g. converting `required: null` to empty arrays) for strict OpenAI schema validation compatibility.
42
- - **WAF Diagnostics:** Intercepts upstream `content-blocked` responses and displays a clear notification in the terminal and UI.
43
- - **Dual Endpoint Protocols:** Supports both OpenAI (`agentrouter-openai`) and Anthropic Messages API (`agentrouter-clode`) routes for models like `deepseek-v4-flash`.
43
+ - **WAF Diagnostics & Safe Redaction:** Intercepts upstream blocks and safely redacts older messages while preserving thinking placeholders for reasoning models.
44
44
  - **Isolated Credential Storage:** Manages API keys exclusively within `agentrouter-*` provider namespaces in `auth.json` without modifying default third-party provider keys.
45
45
  - **Live Pricing & Quota Probing:** Fetches current rates from the gateway API on startup and provides `/agentrouter check` to probe model availability and track usage.
46
46
  - **Subagent Rate Pacing:** Uses a file-based lock (`~/.pi/agent/.agentrouter-pacing`) across concurrent subagents to prevent 429 rate limit errors.
47
- - **Prompt Caching Compatibility:** Preserves affinity headers and formatting required for upstream prompt cache reuse.
47
+ - **Prompt Caching Compatibility:** Preserves affinity headers and pricing for prompt caching reuse.
48
48
 
49
49
  ---
50
50
 
@@ -52,12 +52,12 @@ pi install npm:@madgagarin/pi-agentrouter
52
52
 
53
53
  Rates are fetched from the [agentrouter.org](https://agentrouter.org) gateway API ($2.00 / 1M tokens base unit):
54
54
 
55
- | Model | Provider | Context | Output | Reasoning | Input / 1M | Output / 1M | Quota Policy |
56
- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
57
- | `deepseek-v4-flash` | `agentrouter-openai` / `agentrouter-clode` | 1M | 64K | Yes | $4.00 | $12.00 | Unlimited |
58
- | `glm-5.3` | `agentrouter-openai` | 1M | 128K | Yes | $3.00 | $12.00 | Unlimited |
59
- | `gpt-6-astra` | `agentrouter-openai` | 1M | 128K | Yes | $3.00 | $15.00 | Daily batch drops |
60
- | `gpt-5.6-sol` | `agentrouter-openai` | 1M | 128K | Yes | $3.00 | $15.00 | Daily batch drops |
55
+ | Model | Provider | Context | Output | Reasoning | Input / 1M | Output / 1M | Cache Read / 1M | Quota Policy |
56
+ | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
57
+ | `deepseek-v4-flash` | `agentrouter-openai` | 1M | 64K | Yes | $4.00 | $12.00 | $2.00 | Unlimited |
58
+ | `glm-5.3` | `agentrouter-openai` | 1M | 128K | Yes | $3.00 | $12.00 | - | Unlimited |
59
+ | `gpt-6-astra` | `agentrouter-openai` | 1M | 128K | Yes | $3.00 | $15.00 | - | Daily batch drops |
60
+ | `gpt-5.6-sol` | `agentrouter-openai` | 1M | 128K | Yes | $3.00 | $15.00 | - | Daily batch drops |
61
61
  | `claude-opus-5` | `agentrouter-clode` | 1M | 64K | Yes (Adaptive) | $6.00 | $30.00 | Daily batch drops |
62
62
  | `claude-opus-4-8` | `agentrouter-clode` | 1M | 64K | Yes (Adaptive) | $8.00 | $40.00 | Daily batch drops |
63
63
 
package/index.ts CHANGED
@@ -58,8 +58,12 @@ export const KNOWN_MODEL_SPECS: Record<string, ModelSpec> = {
58
58
  contextWindow: 1048576,
59
59
  maxTokens: 65536,
60
60
  reasoning: true,
61
- compat: { sendSessionAffinityHeaders: true },
62
- cost: { input: 4.0 / 1_000_000, output: 12.0 / 1_000_000, cacheRead: 0, cacheWrite: 0 },
61
+ compat: {
62
+ sendSessionAffinityHeaders: true,
63
+ requiresReasoningContentOnAssistantMessages: true,
64
+ thinkingFormat: "deepseek",
65
+ },
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": {
65
69
  id: "deepseek-v4f",
@@ -68,8 +72,12 @@ export const KNOWN_MODEL_SPECS: Record<string, ModelSpec> = {
68
72
  contextWindow: 1048576,
69
73
  maxTokens: 65536,
70
74
  reasoning: true,
71
- compat: { sendSessionAffinityHeaders: true },
72
- cost: { input: 4.0 / 1_000_000, output: 12.0 / 1_000_000, cacheRead: 0, cacheWrite: 0 },
75
+ compat: {
76
+ sendSessionAffinityHeaders: true,
77
+ requiresReasoningContentOnAssistantMessages: true,
78
+ thinkingFormat: "deepseek",
79
+ },
80
+ cost: { input: 4.0 / 1_000_000, output: 12.0 / 1_000_000, cacheRead: 2.0 / 1_000_000, cacheWrite: 0 },
73
81
  },
74
82
  "glm-5.3": {
75
83
  id: "glm-5.3",
@@ -340,6 +348,23 @@ export function isAgentRouter(providerName?: string, baseUrl?: string): boolean
340
348
  export const CANONICAL_PI_HEADER =
341
349
  "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
350
 
351
+ export const LANGUAGE_PREAMBLE =
352
+ "[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
+
354
+ export function sanitizeDeepSeekText(text: string): string {
355
+ if (typeof text !== "string") return text;
356
+ // Replace false-positive blocked Russian word in AgentRouter upstream WAF
357
+ return text.replace(/Ключевое/g, "Главное").replace(/ключевое/g, "главное");
358
+ }
359
+
360
+ export function cleanContent(text: string): string {
361
+ if (typeof text !== "string") return text;
362
+ return text
363
+ .replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "")
364
+ .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "")
365
+ .replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "");
366
+ }
367
+
343
368
  export function enforceCanonicalRootPrompt(systemPrompt: string | any[] | undefined): string | any[] {
344
369
  if (!systemPrompt) {
345
370
  return CANONICAL_PI_HEADER;
@@ -377,6 +402,73 @@ export function enforceCanonicalRootPrompt(systemPrompt: string | any[] | undefi
377
402
  return systemPrompt;
378
403
  }
379
404
 
405
+ export function isDeepSeekRequest(event: any, ctx: any): boolean {
406
+ const modelId = (
407
+ (event as any)?.model?.id ||
408
+ (ctx as any)?.model?.id ||
409
+ (event as any)?.payload?.model ||
410
+ ""
411
+ ).toLowerCase();
412
+ return modelId.includes("deepseek");
413
+ }
414
+ export const WAF_BLOCK_RE = /sensitive[_ ]words?[_ ]detected|content-blocked/i;
415
+ export const SENSITIVE_WORDS_RE = /sensitive[_ ]words?[_ ]detected/i;
416
+ export const REDACTED_NOTE = "[Message withheld by local policy]";
417
+
418
+ export function frameUserTurnsForDeepSeek(messages: any[]): void {
419
+ if (!Array.isArray(messages)) return;
420
+ for (const msg of messages) {
421
+ if (!msg || typeof msg !== "object") continue;
422
+ if (msg.role !== "user") continue;
423
+
424
+ if (typeof msg.content === "string") {
425
+ const cleaned = sanitizeDeepSeekText(cleanContent(msg.content));
426
+ if (cleaned === REDACTED_NOTE) {
427
+ msg.content = REDACTED_NOTE;
428
+ continue;
429
+ }
430
+ if (!cleaned.startsWith(LANGUAGE_PREAMBLE)) {
431
+ msg.content = cleaned ? `${LANGUAGE_PREAMBLE}\n\n${cleaned}` : LANGUAGE_PREAMBLE;
432
+ } else {
433
+ msg.content = cleaned;
434
+ }
435
+ } else if (Array.isArray(msg.content)) {
436
+ if (msg.content.length === 0) {
437
+ msg.content.push({ type: "text", text: LANGUAGE_PREAMBLE });
438
+ } else {
439
+ const first = msg.content[0];
440
+ if (first && typeof first === "object" && first.type === "tool_result") {
441
+ continue;
442
+ }
443
+ let added = false;
444
+ for (const block of msg.content) {
445
+ if (block && typeof block === "object" && (block.type === "text" || block.type === "input_text")) {
446
+ if (typeof block.text === "string") {
447
+ const cleaned = sanitizeDeepSeekText(cleanContent(block.text));
448
+ if (cleaned === REDACTED_NOTE) {
449
+ block.text = REDACTED_NOTE;
450
+ added = true;
451
+ break;
452
+ }
453
+ if (!cleaned.startsWith(LANGUAGE_PREAMBLE)) {
454
+ block.text = `${LANGUAGE_PREAMBLE}\n\n${cleaned}`;
455
+ } else {
456
+ block.text = cleaned;
457
+ }
458
+ added = true;
459
+ break;
460
+ }
461
+ }
462
+ }
463
+ if (!added && msg.content.length > 0 && msg.content[0].type !== "tool_result") {
464
+ msg.content.unshift({ type: "text", text: LANGUAGE_PREAMBLE });
465
+ }
466
+ }
467
+ }
468
+ }
469
+ }
470
+
471
+
380
472
  export function cleanJsonSchemaObject(schema: any): void {
381
473
  if (!schema || typeof schema !== "object") return;
382
474
 
@@ -414,8 +506,351 @@ export function sanitizeOpenAiTools(tools: any[]): void {
414
506
  }
415
507
  }
416
508
 
417
- export function normalizeMessagesForAgentRouter(messages: any[]): void {
509
+ export function isRecord(v: unknown): v is Record<string, unknown> {
510
+ return typeof v === "object" && v !== null && !Array.isArray(v);
511
+ }
512
+
513
+ export const redactSet = new Set<string>();
514
+ export let escalatePending = false;
515
+ export let isSensitiveBlock = false;
516
+ export let exhausted = false;
517
+ export let sessionAnchor: string | null = null;
518
+ export let wafNotified = false;
519
+
520
+ export function resetPoisonRedactionState(): void {
521
+ redactSet.clear();
522
+ escalatePending = false;
523
+ isSensitiveBlock = false;
524
+ exhausted = false;
525
+ sessionAnchor = null;
526
+ wafNotified = false;
527
+ }
528
+
529
+ export function triggerEscalation(sensitive: boolean = false): void {
530
+ escalatePending = true;
531
+ isSensitiveBlock = sensitive;
532
+ }
533
+
534
+
535
+ export function fingerprintOf(msg: Record<string, unknown>): string {
536
+ const tc = Array.isArray(msg.tool_calls) ? JSON.stringify(msg.tool_calls).slice(0, 80) : "";
537
+ if (msg.type === "function_call" || msg.type === "function_call_output") {
538
+ return `${msg.type}:${(msg as any).call_id}:${JSON.stringify((msg as any).arguments ?? (msg as any).output ?? "").slice(0, 160)}`;
539
+ }
540
+ return `${msg.role ?? msg.type}:${JSON.stringify(msg.content ?? "").slice(0, 160)}:${tc}`;
541
+ }
542
+
543
+ export function firstUserAnchor(messages: unknown[]): string {
544
+ for (const m of messages) {
545
+ if (isRecord(m) && isHumanUserMessage(m as Record<string, unknown>)) return fingerprintOf(m);
546
+ }
547
+ return String(messages.length);
548
+ }
549
+
550
+ export function isTextBlock(block: Record<string, unknown>): boolean {
551
+ return block.type === "text" || block.type === "input_text" || block.type === "output_text";
552
+ }
553
+
554
+ export function isHumanUserMessage(msg: Record<string, unknown>): boolean {
555
+ if (msg.role !== "user") return false;
556
+ if (typeof msg.content === "string") return true;
557
+ if (Array.isArray(msg.content)) {
558
+ const hasToolResult = msg.content.some((b) => isRecord(b) && b.type === "tool_result");
559
+ const hasHumanText = msg.content.some(
560
+ (b) =>
561
+ isRecord(b) &&
562
+ (b.type === "text" || b.type === "input_text") &&
563
+ typeof b.text === "string" &&
564
+ b.text.trim().length > 0 &&
565
+ b.text !== REDACTED_NOTE
566
+ );
567
+ if (hasToolResult && !hasHumanText) return false;
568
+ return true;
569
+ }
570
+ return false;
571
+ }
572
+
573
+ export function lastHumanUserIndex(messages: unknown[]): number {
574
+ for (let i = messages.length - 1; i >= 0; i--) {
575
+ if (isRecord(messages[i]) && isHumanUserMessage(messages[i] as Record<string, unknown>)) {
576
+ return i;
577
+ }
578
+ }
579
+ return -1;
580
+ }
581
+
582
+ export function hasRedactableText(content: unknown, msg?: Record<string, unknown>): boolean {
583
+ if (msg) {
584
+ if (msg.details && (typeof msg.details === "string" || Object.keys(msg.details).length > 0)) return true;
585
+ if (typeof (msg as any).reasoning_content === "string" && (msg as any).reasoning_content.length > 0) return true;
586
+ if (typeof (msg as any).thinking === "string" && (msg as any).thinking.length > 0) return true;
587
+ }
588
+ if (msg?.type === "function_call") return typeof (msg as any).arguments === "string" && (msg as any).arguments !== "{}";
589
+ if (msg?.type === "function_call_output") return hasRedactableText((msg as any).output);
590
+ if (typeof content === "string") return content.length > 0 && content !== REDACTED_NOTE;
591
+ if (Array.isArray(content)) {
592
+ for (const b of content) {
593
+ if (!isRecord(b)) continue;
594
+ if (b.type === "thinking" || b.type === "reasoning") return true;
595
+ if (isTextBlock(b) && typeof b.text === "string" && b.text.length > 0 && b.text !== REDACTED_NOTE) return true;
596
+ if (b.type === "tool_result") {
597
+ if (typeof b.content === "string" && b.content.length > 0 && b.content !== REDACTED_NOTE) return true;
598
+ if (Array.isArray(b.content)) {
599
+ for (const c of b.content) {
600
+ if (isRecord(c) && isTextBlock(c) && typeof c.text === "string" && c.text.length > 0 && c.text !== REDACTED_NOTE) {
601
+ return true;
602
+ }
603
+ }
604
+ }
605
+ }
606
+ if (b.type === "tool_use" && isRecord(b.input) && Object.keys(b.input).length > 0) {
607
+ return true;
608
+ }
609
+ if (b.type === "toolCall" && isRecord(b.arguments) && Object.keys(b.arguments).length > 0) {
610
+ return true;
611
+ }
612
+ }
613
+ }
614
+ if (msg && Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0) {
615
+ for (const tc of msg.tool_calls) {
616
+ if (isRecord(tc) && isRecord(tc.function) && typeof tc.function.arguments === "string" && tc.function.arguments !== "{}") {
617
+ return true;
618
+ }
619
+ }
620
+ }
621
+ return false;
622
+ }
623
+
624
+ export function redactBlocks(content: unknown): void {
625
+ if (!Array.isArray(content)) return;
626
+ for (let idx = content.length - 1; idx >= 0; idx--) {
627
+ const block = content[idx];
628
+ if (!isRecord(block)) continue;
629
+
630
+ // For thinking / reasoning blocks, keep a harmless placeholder rather than deleting,
631
+ // because thinking models (like DeepSeek) strictly require thinking blocks to be present in multi-turn history
632
+ if (block.type === "thinking" || block.type === "reasoning") {
633
+ if (typeof (block as any).thinking === "string") {
634
+ (block as any).thinking = "Thinking...";
635
+ }
636
+ if (typeof (block as any).text === "string") {
637
+ (block as any).text = "Thinking...";
638
+ }
639
+ continue;
640
+ }
641
+
642
+ if (isTextBlock(block) && typeof block.text === "string") {
643
+ block.text = REDACTED_NOTE;
644
+ } else if (block.type === "tool_result") {
645
+ if (typeof block.content === "string") {
646
+ block.content = REDACTED_NOTE;
647
+ } else if (Array.isArray(block.content)) {
648
+ for (const b of block.content) {
649
+ if (isRecord(b) && isTextBlock(b) && typeof b.text === "string") {
650
+ b.text = REDACTED_NOTE;
651
+ }
652
+ }
653
+ }
654
+ } else if (block.type === "tool_use") {
655
+ block.input = {};
656
+ } else if (block.type === "toolCall") {
657
+ block.arguments = {};
658
+ }
659
+ }
660
+ }
661
+
662
+ export function isHideable(msg: Record<string, unknown>): boolean {
663
+ return (
664
+ msg.role === "user" ||
665
+ msg.role === "assistant" ||
666
+ msg.role === "tool" ||
667
+ msg.role === "toolResult" ||
668
+ msg.type === "function_call" ||
669
+ msg.type === "function_call_output"
670
+ );
671
+ }
672
+
673
+ export function redactMessageAt(messages: unknown[], i: number): void {
674
+ const msg = messages[i];
675
+ if (!isRecord(msg)) return;
676
+
677
+ // Clear tool result details / raw diffs / attachments
678
+ if ("details" in msg) delete msg.details;
679
+ // DeepSeek and reasoning models require reasoning_content/thinking to exist in thinking mode.
680
+ // Never delete them completely; keep a sanitized minimal placeholder.
681
+ if ("reasoning_content" in msg) {
682
+ (msg as any).reasoning_content = "Thinking...";
683
+ }
684
+ if ("thinking" in msg) {
685
+ (msg as any).thinking = "Thinking...";
686
+ }
687
+
688
+ if (msg.type === "function_call") {
689
+ (msg as any).arguments = "{}";
690
+ return;
691
+ }
692
+ if (msg.type === "function_call_output") {
693
+ if (typeof (msg as any).output === "string") (msg as any).output = REDACTED_NOTE;
694
+ else redactBlocks((msg as any).output);
695
+ return;
696
+ }
697
+ if (typeof msg.content === "string") {
698
+ msg.content = REDACTED_NOTE;
699
+ } else if (Array.isArray(msg.content)) {
700
+ redactBlocks(msg.content);
701
+ if (msg.content.length === 0 && !Array.isArray(msg.tool_calls)) {
702
+ msg.content = [{ type: "text", text: REDACTED_NOTE }];
703
+ }
704
+ } else if (msg.role === "tool" || msg.role === "toolResult" || msg.role === "assistant") {
705
+ msg.content = REDACTED_NOTE;
706
+ }
707
+ if (Array.isArray(msg.tool_calls)) {
708
+ for (const tc of msg.tool_calls) {
709
+ if (isRecord(tc) && isRecord(tc.function) && typeof tc.function.arguments === "string") {
710
+ tc.function.arguments = "{}";
711
+ }
712
+ }
713
+ }
714
+ }
715
+
716
+ export function applyPoisonRedaction(payload: Record<string, unknown>): void {
717
+ const messages = Array.isArray(payload.messages) ? payload.messages : (payload as any).input;
718
+ if (!Array.isArray(messages) || messages.length === 0) return;
719
+
720
+ // Prune failed assistant messages carrying error status/text in-place so dead error turns do not linger
721
+ for (let i = messages.length - 1; i >= 0; i--) {
722
+ const m = messages[i];
723
+ if (!isRecord(m)) continue;
724
+ if (m.role === "assistant") {
725
+ if (m.stopReason === "error") {
726
+ messages.splice(i, 1);
727
+ } else if (typeof m.content === "string" && WAF_BLOCK_RE.test(m.content)) {
728
+ messages.splice(i, 1);
729
+ }
730
+ }
731
+ }
732
+
733
+ const fps = messages.map((m) => (isRecord(m) ? fingerprintOf(m) : ""));
734
+
735
+ const anchor = firstUserAnchor(messages);
736
+ if (anchor !== sessionAnchor) {
737
+ const firstContact = sessionAnchor === null;
738
+ sessionAnchor = anchor;
739
+ if (!firstContact) {
740
+ redactSet.clear();
741
+ escalatePending = false;
742
+ isSensitiveBlock = false;
743
+ exhausted = false;
744
+ wafNotified = false;
745
+ }
746
+ }
747
+
748
+ if (redactSet.size > 0 && !fps.some((fp) => fp && redactSet.has(fp))) {
749
+ redactSet.clear();
750
+ }
751
+
752
+ const lastHumanUser = lastHumanUserIndex(messages);
753
+
754
+ for (let i = 0; i < messages.length; i++) {
755
+ if (i === lastHumanUser) continue;
756
+ if (!isRecord(messages[i]) || !isHideable(messages[i] as Record<string, unknown>)) continue;
757
+ if (redactSet.has(fps[i])) redactMessageAt(messages, i);
758
+ }
759
+
760
+ if (escalatePending) {
761
+ escalatePending = false;
762
+ const sensitive = isSensitiveBlock;
763
+ isSensitiveBlock = false;
764
+
765
+ if (sensitive) {
766
+ // Sensitive words detected: neutralize ALL turns (older user, tool, assistant, active tool results) except active human user
767
+ for (let i = 0; i < messages.length; i++) {
768
+ if (i === lastHumanUser) continue;
769
+ if (!isRecord(messages[i])) continue;
770
+ const m = messages[i] as Record<string, unknown>;
771
+ if (!isHideable(m)) continue;
772
+ redactSet.add(fps[i]);
773
+ if (hasRedactableText(m.content, m)) {
774
+ redactMessageAt(messages, i);
775
+ }
776
+ }
777
+ } else {
778
+ // Language ratio block: Stage 1 older user turns, Stage 2 assistant & tool
779
+ let anyRedacted = false;
780
+ for (let i = 0; i < messages.length; i++) {
781
+ if (i === lastHumanUser) continue;
782
+ if (!isRecord(messages[i])) continue;
783
+ const m = messages[i] as Record<string, unknown>;
784
+ if (m.role !== "user") continue;
785
+ if (redactSet.has(fps[i])) continue;
786
+ redactSet.add(fps[i]);
787
+ if (hasRedactableText(m.content, m)) {
788
+ redactMessageAt(messages, i);
789
+ anyRedacted = true;
790
+ }
791
+ }
792
+ if (!anyRedacted) {
793
+ for (let i = 0; i < messages.length; i++) {
794
+ if (i === lastHumanUser) continue;
795
+ if (!isRecord(messages[i])) continue;
796
+ const m = messages[i] as Record<string, unknown>;
797
+ if (!isHideable(m) || m.role === "user") continue;
798
+ if (redactSet.has(fps[i])) continue;
799
+ redactSet.add(fps[i]);
800
+ if (hasRedactableText(m.content, m)) {
801
+ redactMessageAt(messages, i);
802
+ anyRedacted = true;
803
+ }
804
+ }
805
+ }
806
+ }
807
+ }
808
+
809
+ // Exhausted if there are no more hideable messages left to redact other than lastHumanUser
810
+ exhausted = true;
811
+ for (let i = 0; i < messages.length; i++) {
812
+ if (i === lastHumanUser) continue;
813
+ if (!isRecord(messages[i]) || !isHideable(messages[i] as Record<string, unknown>)) continue;
814
+ if (!redactSet.has(fps[i])) {
815
+ exhausted = false;
816
+ break;
817
+ }
818
+ }
819
+ }
820
+
821
+ export function normalizeMessagesForAgentRouter(messages: any[], isDeepSeek: boolean = false): void {
418
822
  if (!Array.isArray(messages)) return;
823
+
824
+ // Prune poisoned/failed assistant turns globally across all models (e.g. prior 402 quota failure, network errors, empty turns)
825
+ for (let i = messages.length - 1; i >= 0; i--) {
826
+ const msg = messages[i];
827
+ if (!msg || typeof msg !== "object") continue;
828
+ if (msg.role === "assistant") {
829
+ const hasToolCalls = Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0;
830
+ const hasToolUseBlocks =
831
+ Array.isArray(msg.content) &&
832
+ msg.content.some((b: any) => b && typeof b === "object" && (b.type === "tool_use" || b.type === "tool_call"));
833
+ const hasTools = hasToolCalls || hasToolUseBlocks;
834
+ const isEmptyContent =
835
+ !msg.content ||
836
+ msg.content === "" ||
837
+ (Array.isArray(msg.content) && (
838
+ msg.content.length === 0 ||
839
+ msg.content.every((b: any) => b && typeof b === "object" && (b.type === "text" || b.type === "input_text") && (!b.text || b.text.trim() === ""))
840
+ ));
841
+
842
+ if (
843
+ msg.stopReason === "error" ||
844
+ (msg.stopReason === "aborted" && isEmptyContent) ||
845
+ (typeof msg.content === "string" && WAF_BLOCK_RE.test(msg.content)) ||
846
+ (isEmptyContent && !hasTools && !msg.reasoning_content)
847
+ ) {
848
+ messages.splice(i, 1);
849
+ }
850
+ }
851
+ }
852
+
853
+
419
854
  for (const msg of messages) {
420
855
  if (!msg || typeof msg !== "object") continue;
421
856
  if (msg.role === "developer") {
@@ -454,14 +889,173 @@ export function normalizeMessagesForAgentRouter(messages: any[]): void {
454
889
  msg.reasoning_content = extractedThinking;
455
890
  }
456
891
 
892
+ if (msg.content === null || msg.content === undefined) {
893
+ msg.content = "";
894
+ }
895
+
896
+ if (typeof msg.content === "string" && msg.content.includes("[Tool Call]:")) {
897
+ msg.content = msg.content.replace(/\[Tool Call\]:[^\n]+(\n|$)/g, "").trim();
898
+ }
899
+
457
900
  // If assistant executed tool calls, AgentRouter DeepSeek proxy strictly requires reasoning_content
458
- if (Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0 && !msg.reasoning_content) {
901
+ if (
902
+ Array.isArray(msg.tool_calls) &&
903
+ msg.tool_calls.length > 0 &&
904
+ (!msg.reasoning_content || (typeof msg.reasoning_content === "string" && !msg.reasoning_content.trim()))
905
+ ) {
906
+ msg.reasoning_content = "Executing tools...";
907
+ }
908
+
909
+ // If DeepSeek model and content is still empty without tool calls, provide fallback text
910
+ if (isDeepSeek && (!msg.content || msg.content === "") && (!Array.isArray(msg.tool_calls) || msg.tool_calls.length === 0)) {
911
+ msg.content = "[Interrupted]";
912
+ if (!msg.reasoning_content) {
913
+ msg.reasoning_content = "Interrupted response";
914
+ }
915
+ }
916
+ }
917
+
918
+ 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
+ // Ensure assistant tool calls retain non-empty reasoning_content for gateway compatibility
923
+ if (
924
+ msg.role === "assistant" &&
925
+ Array.isArray(msg.tool_calls) &&
926
+ msg.tool_calls.length > 0 &&
927
+ (!msg.reasoning_content || (typeof msg.reasoning_content === "string" && !msg.reasoning_content.trim()))
928
+ ) {
459
929
  msg.reasoning_content = "Executing tools...";
460
930
  }
931
+
932
+ if (typeof msg.content === "string") {
933
+ msg.content = sanitizeDeepSeekText(msg.content);
934
+ } else if (Array.isArray(msg.content)) {
935
+ for (const block of msg.content) {
936
+ if (block && typeof block === "object") {
937
+ if (typeof block.text === "string") {
938
+ block.text = sanitizeDeepSeekText(block.text);
939
+ }
940
+ if (typeof block.content === "string") {
941
+ block.content = sanitizeDeepSeekText(block.content);
942
+ }
943
+ if (typeof block.thinking === "string") {
944
+ block.thinking = sanitizeDeepSeekText(block.thinking);
945
+ }
946
+ }
947
+ }
948
+ }
949
+ if (typeof msg.reasoning_content === "string") {
950
+ msg.reasoning_content = sanitizeDeepSeekText(msg.reasoning_content);
951
+ }
461
952
  }
462
953
  }
463
954
  }
464
955
 
956
+ export function cleanupDeepSeekDuplicates(): {
957
+ cleanedSettings: boolean;
958
+ cleanedCache: boolean;
959
+ cleanedModelsJson: boolean;
960
+ } {
961
+ let cleanedSettings = false;
962
+ let cleanedCache = false;
963
+ let cleanedModelsJson = false;
964
+
965
+ // 1. Clean settings.json
966
+ try {
967
+ if (fs.existsSync(SETTINGS_FILE)) {
968
+ const settings = JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8"));
969
+ let modified = false;
970
+
971
+ if (Array.isArray(settings.enabledModels)) {
972
+ const clodeIdx = settings.enabledModels.indexOf("agentrouter-clode/deepseek-v4-flash");
973
+ if (clodeIdx !== -1) {
974
+ settings.enabledModels.splice(clodeIdx, 1);
975
+ modified = true;
976
+ }
977
+ // Deduplicate enabledModels
978
+ const seen = new Set<string>();
979
+ const deduped: string[] = [];
980
+ for (const m of settings.enabledModels) {
981
+ if (!seen.has(m)) {
982
+ seen.add(m);
983
+ deduped.push(m);
984
+ } else {
985
+ modified = true;
986
+ }
987
+ }
988
+ settings.enabledModels = deduped;
989
+ }
990
+
991
+ if (settings.subagents && typeof settings.subagents === "object" && settings.subagents.agentOverrides) {
992
+ for (const override of Object.values(settings.subagents.agentOverrides)) {
993
+ if ((override as any)?.model === "agentrouter-clode/deepseek-v4-flash") {
994
+ (override as any).model = "agentrouter-openai/deepseek-v4-flash";
995
+ modified = true;
996
+ }
997
+ }
998
+ }
999
+
1000
+ if (settings.defaultProvider === "agentrouter-clode" && settings.defaultModel === "deepseek-v4-flash") {
1001
+ settings.defaultProvider = "agentrouter-openai";
1002
+ modified = true;
1003
+ }
1004
+
1005
+ if (modified) {
1006
+ fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2), "utf-8");
1007
+ cleanedSettings = true;
1008
+ }
1009
+ }
1010
+ } catch {}
1011
+
1012
+ // 2. Clean MODELS_CACHE_FILE (.agentrouter-models-cache.json)
1013
+ try {
1014
+ if (fs.existsSync(MODELS_CACHE_FILE)) {
1015
+ const cacheData = JSON.parse(fs.readFileSync(MODELS_CACHE_FILE, "utf-8"));
1016
+ if (Array.isArray(cacheData)) {
1017
+ let modified = false;
1018
+ for (const item of cacheData) {
1019
+ if (item && item.model_name === "deepseek-v4-flash") {
1020
+ if (Array.isArray(item.supported_endpoint_types) && item.supported_endpoint_types.includes("anthropic")) {
1021
+ item.supported_endpoint_types = item.supported_endpoint_types.filter((t: string) => t !== "anthropic");
1022
+ modified = true;
1023
+ }
1024
+ }
1025
+ }
1026
+ if (modified) {
1027
+ fs.writeFileSync(MODELS_CACHE_FILE, JSON.stringify(cacheData, null, 2), "utf-8");
1028
+ cleanedCache = true;
1029
+ }
1030
+ }
1031
+ }
1032
+ } catch {}
1033
+
1034
+ // 3. Clean models.json (if present)
1035
+ try {
1036
+ const modelsJsonPath = path.join(process.env.HOME || "", ".pi/agent/models.json");
1037
+ if (fs.existsSync(modelsJsonPath)) {
1038
+ const modelsJson = JSON.parse(fs.readFileSync(modelsJsonPath, "utf-8"));
1039
+ let modified = false;
1040
+ if (modelsJson?.providers?.["agentrouter-clode"]?.models) {
1041
+ const origLen = modelsJson.providers["agentrouter-clode"].models.length;
1042
+ modelsJson.providers["agentrouter-clode"].models = modelsJson.providers["agentrouter-clode"].models.filter(
1043
+ (m: any) => (typeof m === "string" ? m !== "deepseek-v4-flash" : m?.id !== "deepseek-v4-flash")
1044
+ );
1045
+ if (modelsJson.providers["agentrouter-clode"].models.length !== origLen) {
1046
+ modified = true;
1047
+ }
1048
+ }
1049
+ if (modified) {
1050
+ fs.writeFileSync(modelsJsonPath, JSON.stringify(modelsJson, null, 2), "utf-8");
1051
+ cleanedModelsJson = true;
1052
+ }
1053
+ }
1054
+ } catch {}
1055
+
1056
+ return { cleanedSettings, cleanedCache, cleanedModelsJson };
1057
+ }
1058
+
465
1059
  let lastWarnedErrorTimestamp = 0;
466
1060
  export function checkAndNotifyContentBlocked(errMessage: string | undefined, ctx: any): void {
467
1061
  if (!errMessage) return;
@@ -481,7 +1075,7 @@ export function checkAndNotifyContentBlocked(errMessage: string | undefined, ctx
481
1075
  export async function fetchLivePricing(): Promise<ApiPricingModel[] | null> {
482
1076
  try {
483
1077
  const res = await fetch("https://agentrouter.org/api/pricing", {
484
- headers: { "User-Agent": "pi-code" },
1078
+
485
1079
  });
486
1080
  if (!res.ok) return null;
487
1081
  const data = await res.json();
@@ -505,8 +1099,7 @@ export async function fetchTokenUsage(apiKey: string): Promise<number | null> {
505
1099
  {
506
1100
  headers: {
507
1101
  Authorization: `Bearer ${apiKey}`,
508
- "User-Agent": "pi-code",
509
- },
1102
+ },
510
1103
  }
511
1104
  );
512
1105
  if (!res.ok) return null;
@@ -532,12 +1125,12 @@ export async function probeModelQuota(modelId: string, apiKey: string, isAnthrop
532
1125
  "Content-Type": "application/json",
533
1126
  "x-api-key": apiKey,
534
1127
  "anthropic-version": "2023-06-01",
535
- "User-Agent": "pi-code",
1128
+ "User-Agent": getPiUserAgent(),
536
1129
  }
537
1130
  : {
538
1131
  "Content-Type": "application/json",
539
1132
  Authorization: `Bearer ${apiKey}`,
540
- "User-Agent": "pi-code",
1133
+ "User-Agent": getPiUserAgent(),
541
1134
  };
542
1135
 
543
1136
  const body = isAnthropic
@@ -629,17 +1222,6 @@ export default function (pi: ExtensionAPI) {
629
1222
  } else {
630
1223
  openaiModels.push(modelObj);
631
1224
  }
632
- if (spec.id === "deepseek-v4-flash" && item.supported_endpoint_types.includes("anthropic")) {
633
- claudeModels.push({
634
- ...modelObj,
635
- compat: {
636
- forceAdaptiveThinking: true,
637
- allowEmptySignature: true,
638
- sendSessionAffinityHeaders: true,
639
- supportsEagerToolInputStreaming: false,
640
- },
641
- });
642
- }
643
1225
  } else {
644
1226
  newModels.push(id);
645
1227
  const isAnthropic =
@@ -688,17 +1270,6 @@ export default function (pi: ExtensionAPI) {
688
1270
  } else {
689
1271
  openaiModels.push(modelObj);
690
1272
  }
691
- if (spec.id === "deepseek-v4-flash") {
692
- claudeModels.push({
693
- ...modelObj,
694
- compat: {
695
- forceAdaptiveThinking: true,
696
- allowEmptySignature: true,
697
- sendSessionAffinityHeaders: true,
698
- supportsEagerToolInputStreaming: false,
699
- },
700
- });
701
- }
702
1273
  }
703
1274
  }
704
1275
 
@@ -736,6 +1307,7 @@ export default function (pi: ExtensionAPI) {
736
1307
  return newModels;
737
1308
  }
738
1309
 
1310
+ cleanupDeepSeekDuplicates();
739
1311
  const cachedPricing = loadCachedPricing();
740
1312
  registerAgentRouterProviders(currentApiKey, cachedPricing);
741
1313
 
@@ -762,13 +1334,26 @@ export default function (pi: ExtensionAPI) {
762
1334
 
763
1335
  const payload = event.payload;
764
1336
  if (payload) {
1337
+ if (Array.isArray(payload.messages)) payload.messages = structuredClone(payload.messages);
1338
+ if (Array.isArray(payload.input)) payload.input = structuredClone(payload.input);
1339
+ if (Array.isArray(payload.system)) payload.system = structuredClone(payload.system);
1340
+ const isDeepSeek = isDeepSeekRequest(event, ctx);
1341
+
765
1342
  if (payload.system !== undefined) {
766
1343
  payload.system = enforceCanonicalRootPrompt(payload.system);
767
1344
  }
768
- if (Array.isArray(payload.messages) && payload.messages.length > 0) {
769
- normalizeMessagesForAgentRouter(payload.messages);
770
1345
 
771
- const firstMsg = payload.messages[0];
1346
+ applyPoisonRedaction(payload);
1347
+
1348
+ const messages = Array.isArray(payload.messages) ? payload.messages : payload.input;
1349
+ if (Array.isArray(messages) && messages.length > 0) {
1350
+ normalizeMessagesForAgentRouter(messages, isDeepSeek);
1351
+
1352
+ if (isDeepSeek) {
1353
+ frameUserTurnsForDeepSeek(messages);
1354
+ }
1355
+
1356
+ const firstMsg = messages[0];
772
1357
  if (firstMsg && (firstMsg.role === "system" || firstMsg.role === "developer")) {
773
1358
  firstMsg.role = "system";
774
1359
  if (typeof firstMsg.content === "string") {
@@ -787,24 +1372,70 @@ export default function (pi: ExtensionAPI) {
787
1372
  });
788
1373
 
789
1374
  pi.on("message_end", async (event, ctx) => {
790
- const model = ctx?.model;
791
- if (isAgentRouter(model?.provider, (model as any)?.baseUrl)) {
1375
+ const message = (event as any)?.message;
1376
+ if (!message) return;
1377
+
1378
+ if (message.role === "assistant" && message.stopReason !== "error") {
1379
+ wafNotified = false;
1380
+ isSensitiveBlock = false;
1381
+ }
1382
+
1383
+ const provider = message.provider ?? ctx?.model?.provider;
1384
+ const baseUrl = (message as any)?.baseUrl ?? (ctx?.model as any)?.baseUrl;
1385
+ if (isAgentRouter(provider, baseUrl)) {
792
1386
  setLastRequestEndTime(Date.now());
793
- const msg = (event as any)?.message;
794
- if (msg && msg.role === "assistant" && msg.stopReason === "error") {
795
- checkAndNotifyContentBlocked(msg.errorMessage, ctx);
1387
+ }
1388
+
1389
+ if (message.role !== "assistant" || message.stopReason !== "error") return;
1390
+ if (!isAgentRouter(provider, baseUrl)) return;
1391
+
1392
+ const errorMessage = message.errorMessage ?? "";
1393
+ if (!WAF_BLOCK_RE.test(errorMessage)) return;
1394
+
1395
+ escalatePending = true;
1396
+ if (SENSITIVE_WORDS_RE.test(errorMessage)) {
1397
+ isSensitiveBlock = true;
1398
+ }
1399
+
1400
+ if (exhausted) {
1401
+ if (!wafNotified) {
1402
+ wafNotified = true;
1403
+ const warning =
1404
+ "AgentRouter content filter keeps blocking even with earlier messages hidden. " +
1405
+ "Your latest message is likely the trigger — please rephrase or split it.";
1406
+ if (ctx?.hasUI) {
1407
+ ctx.ui.notify(warning, "warning");
1408
+ } else {
1409
+ console.warn(`\n⚠️ ${warning}\n`);
1410
+ }
796
1411
  }
1412
+ return;
797
1413
  }
1414
+
1415
+ if (!wafNotified) {
1416
+ wafNotified = true;
1417
+ const note = isSensitiveBlock
1418
+ ? "Sensitive words detected in agent activity. Neutralizing previous leftovers so subsequent chats can proceed safely."
1419
+ : "AgentRouter content filter blocked the request. Retrying automatically with earlier messages hidden.";
1420
+ if (ctx?.hasUI) {
1421
+ ctx.ui.notify(note, "warning");
1422
+ } else {
1423
+ console.warn(`\n⚠️ ${note}\n`);
1424
+ }
1425
+ }
1426
+
1427
+ return {
1428
+ message: {
1429
+ ...message,
1430
+ errorMessage: `${errorMessage} (provider returned error — retrying with earlier messages hidden)`,
1431
+ },
1432
+ };
798
1433
  });
799
1434
 
800
- pi.on("turn_end", async (event, ctx) => {
1435
+ pi.on("turn_end", async (_event, ctx) => {
801
1436
  const model = ctx?.model;
802
1437
  if (isAgentRouter(model?.provider, (model as any)?.baseUrl)) {
803
1438
  setLastRequestEndTime(Date.now());
804
- const msg = (event as any)?.message;
805
- if (msg && msg.role === "assistant" && msg.stopReason === "error") {
806
- checkAndNotifyContentBlocked(msg.errorMessage, ctx);
807
- }
808
1439
  }
809
1440
  });
810
1441
 
@@ -817,6 +1448,8 @@ export default function (pi: ExtensionAPI) {
817
1448
 
818
1449
  pi.on("session_start", async (_event, ctx) => {
819
1450
  currentApiKey = getEffectiveApiKey();
1451
+ resetPoisonRedactionState();
1452
+ cleanupDeepSeekDuplicates();
820
1453
  updatePromptRewriteEnvForModel(ctx.model);
821
1454
  setLastRequestEndTime(Date.now());
822
1455
  syncEnabledModelsInSettings();
@@ -825,6 +1458,7 @@ export default function (pi: ExtensionAPI) {
825
1458
  if (livePricing) {
826
1459
  saveCachedPricing(livePricing);
827
1460
  const newModels = registerAgentRouterProviders(currentApiKey, livePricing);
1461
+ cleanupDeepSeekDuplicates();
828
1462
  syncEnabledModelsInSettings();
829
1463
  if (newModels.length > 0 && ctx.hasUI) {
830
1464
  ctx.ui.notify(
@@ -1133,7 +1767,7 @@ export default function (pi: ExtensionAPI) {
1133
1767
  }
1134
1768
 
1135
1769
  ctx.ui.notify(
1136
- `[AgentRouter Plugin v2.1.1]\n` +
1770
+ `[AgentRouter Plugin v2.1.3]\n` +
1137
1771
  `- Active model: ${activeModel?.id || "none"} (${isAR ? "AgentRouter [yes]" : "Other Provider"})\n` +
1138
1772
  `- Package Priority: ${priorityStatus}\n` +
1139
1773
  `- API Key: ${maskedKey}\n` +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@madgagarin/pi-agentrouter",
3
- "version": "2.1.1",
3
+ "version": "2.1.3",
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"