@madgagarin/pi-agentrouter 2.1.1 → 2.1.2

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 +9 -10
  2. package/index.ts +578 -45
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -36,15 +36,14 @@ 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
40
  - **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
41
  - **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`.
42
+ - **WAF Diagnostics & Safe Redaction:** Intercepts upstream blocks and safely redacts older messages while preserving thinking placeholders for reasoning models.
44
43
  - **Isolated Credential Storage:** Manages API keys exclusively within `agentrouter-*` provider namespaces in `auth.json` without modifying default third-party provider keys.
45
44
  - **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
45
  - **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.
46
+ - **Prompt Caching Compatibility:** Preserves affinity headers and pricing for prompt caching reuse.
48
47
 
49
48
  ---
50
49
 
@@ -52,12 +51,12 @@ pi install npm:@madgagarin/pi-agentrouter
52
51
 
53
52
  Rates are fetched from the [agentrouter.org](https://agentrouter.org) gateway API ($2.00 / 1M tokens base unit):
54
53
 
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 |
54
+ | Model | Provider | Context | Output | Reasoning | Input / 1M | Output / 1M | Cache Read / 1M | Quota Policy |
55
+ | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
56
+ | `deepseek-v4-flash` | `agentrouter-openai` | 1M | 64K | Yes | $4.00 | $12.00 | $2.00 | Unlimited |
57
+ | `glm-5.3` | `agentrouter-openai` | 1M | 128K | Yes | $3.00 | $12.00 | - | Unlimited |
58
+ | `gpt-6-astra` | `agentrouter-openai` | 1M | 128K | Yes | $3.00 | $15.00 | - | Daily batch drops |
59
+ | `gpt-5.6-sol` | `agentrouter-openai` | 1M | 128K | Yes | $3.00 | $15.00 | - | Daily batch drops |
61
60
  | `claude-opus-5` | `agentrouter-clode` | 1M | 64K | Yes (Adaptive) | $6.00 | $30.00 | Daily batch drops |
62
61
  | `claude-opus-4-8` | `agentrouter-clode` | 1M | 64K | Yes (Adaptive) | $8.00 | $40.00 | Daily batch drops |
63
62
 
package/index.ts CHANGED
@@ -59,7 +59,7 @@ export const KNOWN_MODEL_SPECS: Record<string, ModelSpec> = {
59
59
  maxTokens: 65536,
60
60
  reasoning: true,
61
61
  compat: { sendSessionAffinityHeaders: true },
62
- cost: { input: 4.0 / 1_000_000, output: 12.0 / 1_000_000, cacheRead: 0, cacheWrite: 0 },
62
+ cost: { input: 4.0 / 1_000_000, output: 12.0 / 1_000_000, cacheRead: 2.0 / 1_000_000, cacheWrite: 0 },
63
63
  },
64
64
  "deepseek-v4f": {
65
65
  id: "deepseek-v4f",
@@ -69,7 +69,7 @@ export const KNOWN_MODEL_SPECS: Record<string, ModelSpec> = {
69
69
  maxTokens: 65536,
70
70
  reasoning: true,
71
71
  compat: { sendSessionAffinityHeaders: true },
72
- cost: { input: 4.0 / 1_000_000, output: 12.0 / 1_000_000, cacheRead: 0, cacheWrite: 0 },
72
+ cost: { input: 4.0 / 1_000_000, output: 12.0 / 1_000_000, cacheRead: 2.0 / 1_000_000, cacheWrite: 0 },
73
73
  },
74
74
  "glm-5.3": {
75
75
  id: "glm-5.3",
@@ -377,6 +377,19 @@ export function enforceCanonicalRootPrompt(systemPrompt: string | any[] | undefi
377
377
  return systemPrompt;
378
378
  }
379
379
 
380
+ export function isDeepSeekRequest(event: any, ctx: any): boolean {
381
+ const modelId = (
382
+ (event as any)?.model?.id ||
383
+ (ctx as any)?.model?.id ||
384
+ (event as any)?.payload?.model ||
385
+ ""
386
+ ).toLowerCase();
387
+ return modelId.includes("deepseek");
388
+ }
389
+ export const WAF_BLOCK_RE = /sensitive[_ ]words?[_ ]detected|content-blocked/i;
390
+ export const SENSITIVE_WORDS_RE = /sensitive[_ ]words?[_ ]detected/i;
391
+ export const REDACTED_NOTE = "[Message withheld by local policy]";
392
+
380
393
  export function cleanJsonSchemaObject(schema: any): void {
381
394
  if (!schema || typeof schema !== "object") return;
382
395
 
@@ -414,8 +427,351 @@ export function sanitizeOpenAiTools(tools: any[]): void {
414
427
  }
415
428
  }
416
429
 
417
- export function normalizeMessagesForAgentRouter(messages: any[]): void {
430
+ export function isRecord(v: unknown): v is Record<string, unknown> {
431
+ return typeof v === "object" && v !== null && !Array.isArray(v);
432
+ }
433
+
434
+ export const redactSet = new Set<string>();
435
+ export let escalatePending = false;
436
+ export let isSensitiveBlock = false;
437
+ export let exhausted = false;
438
+ export let sessionAnchor: string | null = null;
439
+ export let wafNotified = false;
440
+
441
+ export function resetPoisonRedactionState(): void {
442
+ redactSet.clear();
443
+ escalatePending = false;
444
+ isSensitiveBlock = false;
445
+ exhausted = false;
446
+ sessionAnchor = null;
447
+ wafNotified = false;
448
+ }
449
+
450
+ export function triggerEscalation(sensitive: boolean = false): void {
451
+ escalatePending = true;
452
+ isSensitiveBlock = sensitive;
453
+ }
454
+
455
+
456
+ export function fingerprintOf(msg: Record<string, unknown>): string {
457
+ const tc = Array.isArray(msg.tool_calls) ? JSON.stringify(msg.tool_calls).slice(0, 80) : "";
458
+ if (msg.type === "function_call" || msg.type === "function_call_output") {
459
+ return `${msg.type}:${(msg as any).call_id}:${JSON.stringify((msg as any).arguments ?? (msg as any).output ?? "").slice(0, 160)}`;
460
+ }
461
+ return `${msg.role ?? msg.type}:${JSON.stringify(msg.content ?? "").slice(0, 160)}:${tc}`;
462
+ }
463
+
464
+ export function firstUserAnchor(messages: unknown[]): string {
465
+ for (const m of messages) {
466
+ if (isRecord(m) && isHumanUserMessage(m as Record<string, unknown>)) return fingerprintOf(m);
467
+ }
468
+ return String(messages.length);
469
+ }
470
+
471
+ export function isTextBlock(block: Record<string, unknown>): boolean {
472
+ return block.type === "text" || block.type === "input_text" || block.type === "output_text";
473
+ }
474
+
475
+ export function isHumanUserMessage(msg: Record<string, unknown>): boolean {
476
+ if (msg.role !== "user") return false;
477
+ if (typeof msg.content === "string") return true;
478
+ if (Array.isArray(msg.content)) {
479
+ const hasToolResult = msg.content.some((b) => isRecord(b) && b.type === "tool_result");
480
+ const hasHumanText = msg.content.some(
481
+ (b) =>
482
+ isRecord(b) &&
483
+ (b.type === "text" || b.type === "input_text") &&
484
+ typeof b.text === "string" &&
485
+ b.text.trim().length > 0 &&
486
+ b.text !== REDACTED_NOTE
487
+ );
488
+ if (hasToolResult && !hasHumanText) return false;
489
+ return true;
490
+ }
491
+ return false;
492
+ }
493
+
494
+ export function lastHumanUserIndex(messages: unknown[]): number {
495
+ for (let i = messages.length - 1; i >= 0; i--) {
496
+ if (isRecord(messages[i]) && isHumanUserMessage(messages[i] as Record<string, unknown>)) {
497
+ return i;
498
+ }
499
+ }
500
+ return -1;
501
+ }
502
+
503
+ export function hasRedactableText(content: unknown, msg?: Record<string, unknown>): boolean {
504
+ if (msg) {
505
+ if (msg.details && (typeof msg.details === "string" || Object.keys(msg.details).length > 0)) return true;
506
+ if (typeof (msg as any).reasoning_content === "string" && (msg as any).reasoning_content.length > 0) return true;
507
+ if (typeof (msg as any).thinking === "string" && (msg as any).thinking.length > 0) return true;
508
+ }
509
+ if (msg?.type === "function_call") return typeof (msg as any).arguments === "string" && (msg as any).arguments !== "{}";
510
+ if (msg?.type === "function_call_output") return hasRedactableText((msg as any).output);
511
+ if (typeof content === "string") return content.length > 0 && content !== REDACTED_NOTE;
512
+ if (Array.isArray(content)) {
513
+ for (const b of content) {
514
+ if (!isRecord(b)) continue;
515
+ if (b.type === "thinking" || b.type === "reasoning") return true;
516
+ if (isTextBlock(b) && typeof b.text === "string" && b.text.length > 0 && b.text !== REDACTED_NOTE) return true;
517
+ if (b.type === "tool_result") {
518
+ if (typeof b.content === "string" && b.content.length > 0 && b.content !== REDACTED_NOTE) return true;
519
+ if (Array.isArray(b.content)) {
520
+ for (const c of b.content) {
521
+ if (isRecord(c) && isTextBlock(c) && typeof c.text === "string" && c.text.length > 0 && c.text !== REDACTED_NOTE) {
522
+ return true;
523
+ }
524
+ }
525
+ }
526
+ }
527
+ if (b.type === "tool_use" && isRecord(b.input) && Object.keys(b.input).length > 0) {
528
+ return true;
529
+ }
530
+ if (b.type === "toolCall" && isRecord(b.arguments) && Object.keys(b.arguments).length > 0) {
531
+ return true;
532
+ }
533
+ }
534
+ }
535
+ if (msg && Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0) {
536
+ for (const tc of msg.tool_calls) {
537
+ if (isRecord(tc) && isRecord(tc.function) && typeof tc.function.arguments === "string" && tc.function.arguments !== "{}") {
538
+ return true;
539
+ }
540
+ }
541
+ }
542
+ return false;
543
+ }
544
+
545
+ export function redactBlocks(content: unknown): void {
546
+ if (!Array.isArray(content)) return;
547
+ for (let idx = content.length - 1; idx >= 0; idx--) {
548
+ const block = content[idx];
549
+ if (!isRecord(block)) continue;
550
+
551
+ // For thinking / reasoning blocks, keep a harmless placeholder rather than deleting,
552
+ // because thinking models (like DeepSeek) strictly require thinking blocks to be present in multi-turn history
553
+ if (block.type === "thinking" || block.type === "reasoning") {
554
+ if (typeof (block as any).thinking === "string") {
555
+ (block as any).thinking = "Thinking...";
556
+ }
557
+ if (typeof (block as any).text === "string") {
558
+ (block as any).text = "Thinking...";
559
+ }
560
+ continue;
561
+ }
562
+
563
+ if (isTextBlock(block) && typeof block.text === "string") {
564
+ block.text = REDACTED_NOTE;
565
+ } else if (block.type === "tool_result") {
566
+ if (typeof block.content === "string") {
567
+ block.content = REDACTED_NOTE;
568
+ } else if (Array.isArray(block.content)) {
569
+ for (const b of block.content) {
570
+ if (isRecord(b) && isTextBlock(b) && typeof b.text === "string") {
571
+ b.text = REDACTED_NOTE;
572
+ }
573
+ }
574
+ }
575
+ } else if (block.type === "tool_use") {
576
+ block.input = {};
577
+ } else if (block.type === "toolCall") {
578
+ block.arguments = {};
579
+ }
580
+ }
581
+ }
582
+
583
+ export function isHideable(msg: Record<string, unknown>): boolean {
584
+ return (
585
+ msg.role === "user" ||
586
+ msg.role === "assistant" ||
587
+ msg.role === "tool" ||
588
+ msg.role === "toolResult" ||
589
+ msg.type === "function_call" ||
590
+ msg.type === "function_call_output"
591
+ );
592
+ }
593
+
594
+ export function redactMessageAt(messages: unknown[], i: number): void {
595
+ const msg = messages[i];
596
+ if (!isRecord(msg)) return;
597
+
598
+ // Clear tool result details / raw diffs / attachments
599
+ if ("details" in msg) delete msg.details;
600
+ // DeepSeek and reasoning models require reasoning_content/thinking to exist in thinking mode.
601
+ // Never delete them completely; keep a sanitized minimal placeholder.
602
+ if ("reasoning_content" in msg) {
603
+ (msg as any).reasoning_content = "Thinking...";
604
+ }
605
+ if ("thinking" in msg) {
606
+ (msg as any).thinking = "Thinking...";
607
+ }
608
+
609
+ if (msg.type === "function_call") {
610
+ (msg as any).arguments = "{}";
611
+ return;
612
+ }
613
+ if (msg.type === "function_call_output") {
614
+ if (typeof (msg as any).output === "string") (msg as any).output = REDACTED_NOTE;
615
+ else redactBlocks((msg as any).output);
616
+ return;
617
+ }
618
+ if (typeof msg.content === "string") {
619
+ msg.content = REDACTED_NOTE;
620
+ } else if (Array.isArray(msg.content)) {
621
+ redactBlocks(msg.content);
622
+ if (msg.content.length === 0 && !Array.isArray(msg.tool_calls)) {
623
+ msg.content = [{ type: "text", text: REDACTED_NOTE }];
624
+ }
625
+ } else if (msg.role === "tool" || msg.role === "toolResult" || msg.role === "assistant") {
626
+ msg.content = REDACTED_NOTE;
627
+ }
628
+ if (Array.isArray(msg.tool_calls)) {
629
+ for (const tc of msg.tool_calls) {
630
+ if (isRecord(tc) && isRecord(tc.function) && typeof tc.function.arguments === "string") {
631
+ tc.function.arguments = "{}";
632
+ }
633
+ }
634
+ }
635
+ }
636
+
637
+ export function applyPoisonRedaction(payload: Record<string, unknown>): void {
638
+ const messages = Array.isArray(payload.messages) ? payload.messages : (payload as any).input;
639
+ if (!Array.isArray(messages) || messages.length === 0) return;
640
+
641
+ // Prune failed assistant messages carrying error status/text in-place so dead error turns do not linger
642
+ for (let i = messages.length - 1; i >= 0; i--) {
643
+ const m = messages[i];
644
+ if (!isRecord(m)) continue;
645
+ if (m.role === "assistant") {
646
+ if (m.stopReason === "error") {
647
+ messages.splice(i, 1);
648
+ } else if (typeof m.content === "string" && WAF_BLOCK_RE.test(m.content)) {
649
+ messages.splice(i, 1);
650
+ }
651
+ }
652
+ }
653
+
654
+ const fps = messages.map((m) => (isRecord(m) ? fingerprintOf(m) : ""));
655
+
656
+ const anchor = firstUserAnchor(messages);
657
+ if (anchor !== sessionAnchor) {
658
+ const firstContact = sessionAnchor === null;
659
+ sessionAnchor = anchor;
660
+ if (!firstContact) {
661
+ redactSet.clear();
662
+ escalatePending = false;
663
+ isSensitiveBlock = false;
664
+ exhausted = false;
665
+ wafNotified = false;
666
+ }
667
+ }
668
+
669
+ if (redactSet.size > 0 && !fps.some((fp) => fp && redactSet.has(fp))) {
670
+ redactSet.clear();
671
+ }
672
+
673
+ const lastHumanUser = lastHumanUserIndex(messages);
674
+
675
+ for (let i = 0; i < messages.length; i++) {
676
+ if (i === lastHumanUser) continue;
677
+ if (!isRecord(messages[i]) || !isHideable(messages[i] as Record<string, unknown>)) continue;
678
+ if (redactSet.has(fps[i])) redactMessageAt(messages, i);
679
+ }
680
+
681
+ if (escalatePending) {
682
+ escalatePending = false;
683
+ const sensitive = isSensitiveBlock;
684
+ isSensitiveBlock = false;
685
+
686
+ if (sensitive) {
687
+ // Sensitive words detected: neutralize ALL turns (older user, tool, assistant, active tool results) except active human user
688
+ for (let i = 0; i < messages.length; i++) {
689
+ if (i === lastHumanUser) continue;
690
+ if (!isRecord(messages[i])) continue;
691
+ const m = messages[i] as Record<string, unknown>;
692
+ if (!isHideable(m)) continue;
693
+ redactSet.add(fps[i]);
694
+ if (hasRedactableText(m.content, m)) {
695
+ redactMessageAt(messages, i);
696
+ }
697
+ }
698
+ } else {
699
+ // Language ratio block: Stage 1 older user turns, Stage 2 assistant & tool
700
+ let anyRedacted = false;
701
+ for (let i = 0; i < messages.length; i++) {
702
+ if (i === lastHumanUser) continue;
703
+ if (!isRecord(messages[i])) continue;
704
+ const m = messages[i] as Record<string, unknown>;
705
+ if (m.role !== "user") continue;
706
+ if (redactSet.has(fps[i])) continue;
707
+ redactSet.add(fps[i]);
708
+ if (hasRedactableText(m.content, m)) {
709
+ redactMessageAt(messages, i);
710
+ anyRedacted = true;
711
+ }
712
+ }
713
+ if (!anyRedacted) {
714
+ for (let i = 0; i < messages.length; i++) {
715
+ if (i === lastHumanUser) continue;
716
+ if (!isRecord(messages[i])) continue;
717
+ const m = messages[i] as Record<string, unknown>;
718
+ if (!isHideable(m) || m.role === "user") continue;
719
+ if (redactSet.has(fps[i])) continue;
720
+ redactSet.add(fps[i]);
721
+ if (hasRedactableText(m.content, m)) {
722
+ redactMessageAt(messages, i);
723
+ anyRedacted = true;
724
+ }
725
+ }
726
+ }
727
+ }
728
+ }
729
+
730
+ // Exhausted if there are no more hideable messages left to redact other than lastHumanUser
731
+ exhausted = true;
732
+ for (let i = 0; i < messages.length; i++) {
733
+ if (i === lastHumanUser) continue;
734
+ if (!isRecord(messages[i]) || !isHideable(messages[i] as Record<string, unknown>)) continue;
735
+ if (!redactSet.has(fps[i])) {
736
+ exhausted = false;
737
+ break;
738
+ }
739
+ }
740
+ }
741
+
742
+ export function normalizeMessagesForAgentRouter(messages: any[], isDeepSeek: boolean = false): void {
418
743
  if (!Array.isArray(messages)) return;
744
+
745
+ // Prune poisoned/failed assistant turns globally across all models (e.g. prior 402 quota failure, network errors, empty turns)
746
+ for (let i = messages.length - 1; i >= 0; i--) {
747
+ const msg = messages[i];
748
+ if (!msg || typeof msg !== "object") continue;
749
+ if (msg.role === "assistant") {
750
+ const hasToolCalls = Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0;
751
+ const hasToolUseBlocks =
752
+ Array.isArray(msg.content) &&
753
+ msg.content.some((b: any) => b && typeof b === "object" && (b.type === "tool_use" || b.type === "tool_call"));
754
+ const hasTools = hasToolCalls || hasToolUseBlocks;
755
+ const isEmptyContent =
756
+ !msg.content ||
757
+ msg.content === "" ||
758
+ (Array.isArray(msg.content) && (
759
+ msg.content.length === 0 ||
760
+ msg.content.every((b: any) => b && typeof b === "object" && (b.type === "text" || b.type === "input_text") && (!b.text || b.text.trim() === ""))
761
+ ));
762
+
763
+ if (
764
+ msg.stopReason === "error" ||
765
+ (msg.stopReason === "aborted" && isEmptyContent) ||
766
+ (typeof msg.content === "string" && WAF_BLOCK_RE.test(msg.content)) ||
767
+ (isEmptyContent && !hasTools && !msg.reasoning_content)
768
+ ) {
769
+ messages.splice(i, 1);
770
+ }
771
+ }
772
+ }
773
+
774
+
419
775
  for (const msg of messages) {
420
776
  if (!msg || typeof msg !== "object") continue;
421
777
  if (msg.role === "developer") {
@@ -455,13 +811,157 @@ export function normalizeMessagesForAgentRouter(messages: any[]): void {
455
811
  }
456
812
 
457
813
  // 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) {
814
+ if (
815
+ Array.isArray(msg.tool_calls) &&
816
+ msg.tool_calls.length > 0 &&
817
+ (!msg.reasoning_content || (typeof msg.reasoning_content === "string" && !msg.reasoning_content.trim()))
818
+ ) {
459
819
  msg.reasoning_content = "Executing tools...";
460
820
  }
821
+
822
+ // If DeepSeek model and content is still empty without tool calls, provide fallback text
823
+ if (isDeepSeek && (!msg.content || msg.content === "") && (!Array.isArray(msg.tool_calls) || msg.tool_calls.length === 0)) {
824
+ msg.content = "[Interrupted]";
825
+ if (!msg.reasoning_content) {
826
+ msg.reasoning_content = "Interrupted response";
827
+ }
828
+ }
829
+ }
830
+
831
+ 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
+ }
847
+ }
848
+
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;
856
+ }
857
+
461
858
  }
462
859
  }
463
860
  }
464
861
 
862
+ export function cleanupDeepSeekDuplicates(): {
863
+ cleanedSettings: boolean;
864
+ cleanedCache: boolean;
865
+ cleanedModelsJson: boolean;
866
+ } {
867
+ let cleanedSettings = false;
868
+ let cleanedCache = false;
869
+ let cleanedModelsJson = false;
870
+
871
+ // 1. Clean settings.json
872
+ try {
873
+ if (fs.existsSync(SETTINGS_FILE)) {
874
+ const settings = JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8"));
875
+ let modified = false;
876
+
877
+ if (Array.isArray(settings.enabledModels)) {
878
+ const clodeIdx = settings.enabledModels.indexOf("agentrouter-clode/deepseek-v4-flash");
879
+ if (clodeIdx !== -1) {
880
+ settings.enabledModels.splice(clodeIdx, 1);
881
+ modified = true;
882
+ }
883
+ // Deduplicate enabledModels
884
+ const seen = new Set<string>();
885
+ const deduped: string[] = [];
886
+ for (const m of settings.enabledModels) {
887
+ if (!seen.has(m)) {
888
+ seen.add(m);
889
+ deduped.push(m);
890
+ } else {
891
+ modified = true;
892
+ }
893
+ }
894
+ settings.enabledModels = deduped;
895
+ }
896
+
897
+ if (settings.subagents && typeof settings.subagents === "object" && settings.subagents.agentOverrides) {
898
+ for (const override of Object.values(settings.subagents.agentOverrides)) {
899
+ if ((override as any)?.model === "agentrouter-clode/deepseek-v4-flash") {
900
+ (override as any).model = "agentrouter-openai/deepseek-v4-flash";
901
+ modified = true;
902
+ }
903
+ }
904
+ }
905
+
906
+ if (settings.defaultProvider === "agentrouter-clode" && settings.defaultModel === "deepseek-v4-flash") {
907
+ settings.defaultProvider = "agentrouter-openai";
908
+ modified = true;
909
+ }
910
+
911
+ if (modified) {
912
+ fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2), "utf-8");
913
+ cleanedSettings = true;
914
+ }
915
+ }
916
+ } catch {}
917
+
918
+ // 2. Clean MODELS_CACHE_FILE (.agentrouter-models-cache.json)
919
+ try {
920
+ if (fs.existsSync(MODELS_CACHE_FILE)) {
921
+ const cacheData = JSON.parse(fs.readFileSync(MODELS_CACHE_FILE, "utf-8"));
922
+ if (Array.isArray(cacheData)) {
923
+ let modified = false;
924
+ for (const item of cacheData) {
925
+ if (item && item.model_name === "deepseek-v4-flash") {
926
+ if (Array.isArray(item.supported_endpoint_types) && item.supported_endpoint_types.includes("anthropic")) {
927
+ item.supported_endpoint_types = item.supported_endpoint_types.filter((t: string) => t !== "anthropic");
928
+ modified = true;
929
+ }
930
+ }
931
+ }
932
+ if (modified) {
933
+ fs.writeFileSync(MODELS_CACHE_FILE, JSON.stringify(cacheData, null, 2), "utf-8");
934
+ cleanedCache = true;
935
+ }
936
+ }
937
+ }
938
+ } catch {}
939
+
940
+ // 3. Clean models.json (if present)
941
+ try {
942
+ const modelsJsonPath = path.join(process.env.HOME || "", ".pi/agent/models.json");
943
+ if (fs.existsSync(modelsJsonPath)) {
944
+ const modelsJson = JSON.parse(fs.readFileSync(modelsJsonPath, "utf-8"));
945
+ let modified = false;
946
+ if (modelsJson?.providers?.["agentrouter-clode"]?.models) {
947
+ const origLen = modelsJson.providers["agentrouter-clode"].models.length;
948
+ modelsJson.providers["agentrouter-clode"].models = modelsJson.providers["agentrouter-clode"].models.filter(
949
+ (m: any) => (typeof m === "string" ? m !== "deepseek-v4-flash" : m?.id !== "deepseek-v4-flash")
950
+ );
951
+ if (modelsJson.providers["agentrouter-clode"].models.length !== origLen) {
952
+ modified = true;
953
+ }
954
+ }
955
+ if (modified) {
956
+ fs.writeFileSync(modelsJsonPath, JSON.stringify(modelsJson, null, 2), "utf-8");
957
+ cleanedModelsJson = true;
958
+ }
959
+ }
960
+ } catch {}
961
+
962
+ return { cleanedSettings, cleanedCache, cleanedModelsJson };
963
+ }
964
+
465
965
  let lastWarnedErrorTimestamp = 0;
466
966
  export function checkAndNotifyContentBlocked(errMessage: string | undefined, ctx: any): void {
467
967
  if (!errMessage) return;
@@ -481,7 +981,7 @@ export function checkAndNotifyContentBlocked(errMessage: string | undefined, ctx
481
981
  export async function fetchLivePricing(): Promise<ApiPricingModel[] | null> {
482
982
  try {
483
983
  const res = await fetch("https://agentrouter.org/api/pricing", {
484
- headers: { "User-Agent": "pi-code" },
984
+
485
985
  });
486
986
  if (!res.ok) return null;
487
987
  const data = await res.json();
@@ -505,8 +1005,7 @@ export async function fetchTokenUsage(apiKey: string): Promise<number | null> {
505
1005
  {
506
1006
  headers: {
507
1007
  Authorization: `Bearer ${apiKey}`,
508
- "User-Agent": "pi-code",
509
- },
1008
+ },
510
1009
  }
511
1010
  );
512
1011
  if (!res.ok) return null;
@@ -532,12 +1031,12 @@ export async function probeModelQuota(modelId: string, apiKey: string, isAnthrop
532
1031
  "Content-Type": "application/json",
533
1032
  "x-api-key": apiKey,
534
1033
  "anthropic-version": "2023-06-01",
535
- "User-Agent": "pi-code",
1034
+ "User-Agent": getPiUserAgent(),
536
1035
  }
537
1036
  : {
538
1037
  "Content-Type": "application/json",
539
1038
  Authorization: `Bearer ${apiKey}`,
540
- "User-Agent": "pi-code",
1039
+ "User-Agent": getPiUserAgent(),
541
1040
  };
542
1041
 
543
1042
  const body = isAnthropic
@@ -629,17 +1128,6 @@ export default function (pi: ExtensionAPI) {
629
1128
  } else {
630
1129
  openaiModels.push(modelObj);
631
1130
  }
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
1131
  } else {
644
1132
  newModels.push(id);
645
1133
  const isAnthropic =
@@ -688,17 +1176,6 @@ export default function (pi: ExtensionAPI) {
688
1176
  } else {
689
1177
  openaiModels.push(modelObj);
690
1178
  }
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
1179
  }
703
1180
  }
704
1181
 
@@ -736,6 +1213,7 @@ export default function (pi: ExtensionAPI) {
736
1213
  return newModels;
737
1214
  }
738
1215
 
1216
+ cleanupDeepSeekDuplicates();
739
1217
  const cachedPricing = loadCachedPricing();
740
1218
  registerAgentRouterProviders(currentApiKey, cachedPricing);
741
1219
 
@@ -762,13 +1240,19 @@ export default function (pi: ExtensionAPI) {
762
1240
 
763
1241
  const payload = event.payload;
764
1242
  if (payload) {
1243
+ const isDeepSeek = isDeepSeekRequest(event, ctx);
1244
+
765
1245
  if (payload.system !== undefined) {
766
1246
  payload.system = enforceCanonicalRootPrompt(payload.system);
767
1247
  }
768
- if (Array.isArray(payload.messages) && payload.messages.length > 0) {
769
- normalizeMessagesForAgentRouter(payload.messages);
770
1248
 
771
- const firstMsg = payload.messages[0];
1249
+ applyPoisonRedaction(payload);
1250
+
1251
+ const messages = Array.isArray(payload.messages) ? payload.messages : payload.input;
1252
+ if (Array.isArray(messages) && messages.length > 0) {
1253
+ normalizeMessagesForAgentRouter(messages, isDeepSeek);
1254
+
1255
+ const firstMsg = messages[0];
772
1256
  if (firstMsg && (firstMsg.role === "system" || firstMsg.role === "developer")) {
773
1257
  firstMsg.role = "system";
774
1258
  if (typeof firstMsg.content === "string") {
@@ -787,24 +1271,70 @@ export default function (pi: ExtensionAPI) {
787
1271
  });
788
1272
 
789
1273
  pi.on("message_end", async (event, ctx) => {
790
- const model = ctx?.model;
791
- if (isAgentRouter(model?.provider, (model as any)?.baseUrl)) {
1274
+ const message = (event as any)?.message;
1275
+ if (!message) return;
1276
+
1277
+ if (message.role === "assistant" && message.stopReason !== "error") {
1278
+ wafNotified = false;
1279
+ isSensitiveBlock = false;
1280
+ }
1281
+
1282
+ const provider = message.provider ?? ctx?.model?.provider;
1283
+ const baseUrl = (message as any)?.baseUrl ?? (ctx?.model as any)?.baseUrl;
1284
+ if (isAgentRouter(provider, baseUrl)) {
792
1285
  setLastRequestEndTime(Date.now());
793
- const msg = (event as any)?.message;
794
- if (msg && msg.role === "assistant" && msg.stopReason === "error") {
795
- checkAndNotifyContentBlocked(msg.errorMessage, ctx);
1286
+ }
1287
+
1288
+ if (message.role !== "assistant" || message.stopReason !== "error") return;
1289
+ if (!isAgentRouter(provider, baseUrl)) return;
1290
+
1291
+ const errorMessage = message.errorMessage ?? "";
1292
+ if (!WAF_BLOCK_RE.test(errorMessage)) return;
1293
+
1294
+ escalatePending = true;
1295
+ if (SENSITIVE_WORDS_RE.test(errorMessage)) {
1296
+ isSensitiveBlock = true;
1297
+ }
1298
+
1299
+ if (exhausted) {
1300
+ if (!wafNotified) {
1301
+ wafNotified = true;
1302
+ const warning =
1303
+ "AgentRouter content filter keeps blocking even with earlier messages hidden. " +
1304
+ "Your latest message is likely the trigger — please rephrase or split it.";
1305
+ if (ctx?.hasUI) {
1306
+ ctx.ui.notify(warning, "warning");
1307
+ } else {
1308
+ console.warn(`\n⚠️ ${warning}\n`);
1309
+ }
796
1310
  }
1311
+ return;
797
1312
  }
1313
+
1314
+ if (!wafNotified) {
1315
+ wafNotified = true;
1316
+ const note = isSensitiveBlock
1317
+ ? "Sensitive words detected in agent activity. Neutralizing previous leftovers so subsequent chats can proceed safely."
1318
+ : "AgentRouter content filter blocked the request. Retrying automatically with earlier messages hidden.";
1319
+ if (ctx?.hasUI) {
1320
+ ctx.ui.notify(note, "warning");
1321
+ } else {
1322
+ console.warn(`\n⚠️ ${note}\n`);
1323
+ }
1324
+ }
1325
+
1326
+ return {
1327
+ message: {
1328
+ ...message,
1329
+ errorMessage: `${errorMessage} (provider returned error — retrying with earlier messages hidden)`,
1330
+ },
1331
+ };
798
1332
  });
799
1333
 
800
- pi.on("turn_end", async (event, ctx) => {
1334
+ pi.on("turn_end", async (_event, ctx) => {
801
1335
  const model = ctx?.model;
802
1336
  if (isAgentRouter(model?.provider, (model as any)?.baseUrl)) {
803
1337
  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
1338
  }
809
1339
  });
810
1340
 
@@ -817,6 +1347,8 @@ export default function (pi: ExtensionAPI) {
817
1347
 
818
1348
  pi.on("session_start", async (_event, ctx) => {
819
1349
  currentApiKey = getEffectiveApiKey();
1350
+ resetPoisonRedactionState();
1351
+ cleanupDeepSeekDuplicates();
820
1352
  updatePromptRewriteEnvForModel(ctx.model);
821
1353
  setLastRequestEndTime(Date.now());
822
1354
  syncEnabledModelsInSettings();
@@ -825,6 +1357,7 @@ export default function (pi: ExtensionAPI) {
825
1357
  if (livePricing) {
826
1358
  saveCachedPricing(livePricing);
827
1359
  const newModels = registerAgentRouterProviders(currentApiKey, livePricing);
1360
+ cleanupDeepSeekDuplicates();
828
1361
  syncEnabledModelsInSettings();
829
1362
  if (newModels.length > 0 && ctx.hasUI) {
830
1363
  ctx.ui.notify(
@@ -1133,7 +1666,7 @@ export default function (pi: ExtensionAPI) {
1133
1666
  }
1134
1667
 
1135
1668
  ctx.ui.notify(
1136
- `[AgentRouter Plugin v2.1.1]\n` +
1669
+ `[AgentRouter Plugin v2.1.2]\n` +
1137
1670
  `- Active model: ${activeModel?.id || "none"} (${isAR ? "AgentRouter [yes]" : "Other Provider"})\n` +
1138
1671
  `- Package Priority: ${priorityStatus}\n` +
1139
1672
  `- 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.2",
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"