pi-multi-account 1.1.0 → 1.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,27 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.2.0] - 2026-06-10
9
+
10
+ ### Added
11
+
12
+ - **Anthropic (Claude Pro/Max) OAuth now works out of the box.** OAuth login is
13
+ enabled on the base `anthropic` provider and on every `anthropic-account-*`
14
+ alias, and outgoing Anthropic OAuth requests are shaped (billing header +
15
+ system-prompt normalization) directly by this package. A separate
16
+ `pi-anthropic-auth` install is no longer required.
17
+
18
+ ### Changed
19
+
20
+ - Request shaping is idempotent and only touches OAuth-marked Anthropic requests,
21
+ so it coexists safely with `pi-anthropic-auth` if both are installed, and leaves
22
+ API-key Anthropic and OpenAI Codex / Qwen requests untouched.
23
+
24
+ ### Credits
25
+
26
+ - Anthropic OAuth request-shaping logic vendored from
27
+ [`gotgenes/pi-anthropic-auth`](https://github.com/gotgenes/pi-anthropic-auth) (MIT).
28
+
8
29
  ## [1.1.0] - 2026-06-10
9
30
 
10
31
  ### Fixed
@@ -55,5 +76,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
55
76
  - Plaintext-free credential handling (SHA-256 fingerprints only); `0600`
56
77
  config/state files.
57
78
 
79
+ [1.2.0]: https://github.com/Sarrius/pi-multi-account/releases/tag/v1.2.0
58
80
  [1.1.0]: https://github.com/Sarrius/pi-multi-account/releases/tag/v1.1.0
59
81
  [1.0.0]: https://github.com/Sarrius/pi-multi-account/releases/tag/v1.0.0
package/README.md CHANGED
@@ -21,7 +21,11 @@ pi install npm:pi-multi-account
21
21
 
22
22
  Restart Pi or run `/reload` after installation.
23
23
 
24
- > **Anthropic OAuth aliases** (`anthropic-account-2`, …) require [`@gotgenes/pi-anthropic-auth`](https://www.npmjs.com/package/@gotgenes/pi-anthropic-auth) for request shaping. The base `anthropic` provider and all OpenAI Codex / Qwen accounts work without it.
24
+ > **Anthropic (Claude Pro/Max) works out of the box.** OAuth login and request
25
+ > shaping for the base `anthropic` provider and every `anthropic-account-*` alias
26
+ > are built in — no separate `pi-anthropic-auth` install is required. If you
27
+ > already have `pi-anthropic-auth`, the two coexist safely (the shaping is
28
+ > idempotent). OpenAI Codex / ChatGPT and Qwen accounts work as well.
25
29
 
26
30
  ### Recommended setting
27
31
 
package/index.ts CHANGED
@@ -15,9 +15,11 @@
15
15
  * transparently switches to the next available account/model, optionally
16
16
  * queuing a safe continuation prompt.
17
17
  *
18
- * Anthropic OAuth aliases require `@gotgenes/pi-anthropic-auth` for request
19
- * shaping (its before_provider_request hook is provider-agnostic and covers
20
- * every Anthropic OAuth alias this package registers).
18
+ * Anthropic OAuth (Claude Pro/Max) works out of the box: this package enables
19
+ * OAuth login on the base `anthropic` provider and on every `anthropic-account-*`
20
+ * alias, and shapes the outgoing requests itself (billing header + system-prompt
21
+ * normalization, vendored from gotgenes/pi-anthropic-auth, MIT). No separate
22
+ * pi-anthropic-auth install is needed; if you have one, both coexist (idempotent).
21
23
  *
22
24
  * Config: ~/.pi/agent/provider-failover.json
23
25
  * State: ~/.pi/agent/provider-failover-state.json
@@ -455,7 +457,7 @@ function codexModelDef(id: string) {
455
457
  }
456
458
 
457
459
  function registerAnthropicSlot(pi: ExtensionAPI, id: string) {
458
- if (id === ANTHROPIC_BASE) return; // base provider is native / shaped by pi-anthropic-auth
460
+ if (id === ANTHROPIC_BASE) return; // base provider: oauth + shaping registered in piMultiAccount()
459
461
  const models = DEFAULT_ANTHROPIC_MODELS.map((m) => anthropicModelDef(m, id));
460
462
  pi.registerProvider(id, {
461
463
  name: `Claude Pro/Max (${id})`,
@@ -601,6 +603,193 @@ function getAssistantErrorText(messages: any[]) {
601
603
  return "";
602
604
  }
603
605
 
606
+ // ===========================================================================
607
+ // Anthropic OAuth request shaping (vendored)
608
+ //
609
+ // Makes Claude Pro/Max (OAuth) accounts work out of the box — no separate
610
+ // pi-anthropic-auth install required. Ported from gotgenes/pi-anthropic-auth
611
+ // (MIT). The logic is idempotent: if pi-anthropic-auth is ALSO installed, both
612
+ // before_provider_request hooks run, but the second sees the request already
613
+ // shaped (billing header present, Pi preamble already replaced) and no-ops.
614
+ //
615
+ // CLAUDE_CODE_VERSION must track the current Claude Code release; if it drifts
616
+ // too far Anthropic may reject or miscount OAuth requests. Check `claude
617
+ // --version` or https://github.com/anthropics/claude-code.
618
+ // ===========================================================================
619
+
620
+ const PI_DEFAULT_PROMPT_PREFIX = "You are an expert coding assistant operating inside pi, a coding agent harness.";
621
+ const PI_DEFAULT_PROMPT_TERMINATOR =
622
+ "- Always read pi .md files completely and follow links to related docs (e.g., tui.md for TUI API details)";
623
+ const MINIMAL_ANTHROPIC_OAUTH_PROMPT_PREFIX = "You are an expert coding assistant.";
624
+ const MINIMAL_ANTHROPIC_OAUTH_PROMPT = [
625
+ MINIMAL_ANTHROPIC_OAUTH_PROMPT_PREFIX,
626
+ "Be concise and helpful.",
627
+ "Use the available tools to answer the user's request.",
628
+ "Show file paths clearly when working with files.",
629
+ ].join("\n");
630
+ const CLAUDE_CODE_IDENTITY_PREFIX = "You are Claude Code, Anthropic's official CLI";
631
+ const CLAUDE_CODE_VERSION = "2.1.150";
632
+ const BILLING_HEADER_SALT = "59cf53e54c78";
633
+ const BILLING_HEADER_POSITIONS = [4, 7, 20] as const;
634
+ const CLAUDE_CODE_ENTRYPOINT = "sdk-cli";
635
+ const PARAGRAPH_REMOVAL_ANCHORS: readonly string[] = [
636
+ "operating inside pi, a coding agent harness",
637
+ "In addition to the tools above",
638
+ "Pi documentation (read only when the user asks about pi itself",
639
+ ];
640
+ const TEXT_REPLACEMENTS: readonly { match: string; replacement: string }[] = [
641
+ {
642
+ match: "Here is some useful information about the environment you are running in:",
643
+ replacement: "Environment context you are running in:",
644
+ },
645
+ ];
646
+
647
+ type ShapeTextBlock = { type: "text"; text: string; [key: string]: unknown };
648
+ type ShapeMessageBlock = { type?: string; text?: string; [key: string]: unknown };
649
+ type ShapeMessageParam = { role?: string; content?: string | ShapeMessageBlock[]; [key: string]: unknown };
650
+ type ShapeAnthropicPayload = { model?: unknown; messages?: unknown; system?: unknown; stream?: unknown; [key: string]: unknown };
651
+
652
+ function isRecord(value: unknown): value is Record<string, unknown> {
653
+ return value !== null && typeof value === "object" && !Array.isArray(value);
654
+ }
655
+
656
+ function isAnthropicMessagesPayload(payload: unknown): payload is ShapeAnthropicPayload {
657
+ return isRecord(payload) && typeof payload.model === "string" && Array.isArray(payload.messages) && typeof payload.stream === "boolean";
658
+ }
659
+
660
+ function hasOAuthAnthropicSystemMarker(block: unknown): boolean {
661
+ if (!isRecord(block) || block.type !== "text" || typeof block.text !== "string") return false;
662
+ return (
663
+ block.text.includes(CLAUDE_CODE_IDENTITY_PREFIX) ||
664
+ block.text.includes("x-anthropic-billing-header:") ||
665
+ block.text.startsWith(MINIMAL_ANTHROPIC_OAUTH_PROMPT_PREFIX)
666
+ );
667
+ }
668
+
669
+ // Only requests that Pi already marked as OAuth (Claude Code identity block, or
670
+ // already-shaped) are touched — API-key Anthropic requests pass through untouched.
671
+ function isOAuthAnthropicPayload(payload: ShapeAnthropicPayload): boolean {
672
+ if (!Array.isArray(payload.system)) return false;
673
+ return payload.system.some(hasOAuthAnthropicSystemMarker);
674
+ }
675
+
676
+ function getFirstUserText(messages: ShapeMessageParam[]): string {
677
+ const firstUserMessage = messages.find((message) => message.role === "user");
678
+ if (!firstUserMessage) return "";
679
+ if (typeof firstUserMessage.content === "string") return firstUserMessage.content;
680
+ if (!Array.isArray(firstUserMessage.content)) return "";
681
+ const firstTextBlock = firstUserMessage.content.find((block) => block.type === "text" && typeof block.text === "string");
682
+ return typeof firstTextBlock?.text === "string" ? firstTextBlock.text : "";
683
+ }
684
+
685
+ function buildBillingHeaderValue(messages: ShapeMessageParam[]): string | undefined {
686
+ const messageText = getFirstUserText(messages);
687
+ if (!messageText) return undefined;
688
+ const cch = createHash("sha256").update(messageText).digest("hex").slice(0, 5);
689
+ const sampledCharacters = BILLING_HEADER_POSITIONS.map((index) => messageText[index] || "0").join("");
690
+ const suffix = createHash("sha256")
691
+ .update(`${BILLING_HEADER_SALT}${sampledCharacters}${CLAUDE_CODE_VERSION}`)
692
+ .digest("hex")
693
+ .slice(0, 3);
694
+ return ["x-anthropic-billing-header:", `cc_version=${CLAUDE_CODE_VERSION}.${suffix};`, `cc_entrypoint=${CLAUDE_CODE_ENTRYPOINT};`, `cch=${cch};`].join(" ");
695
+ }
696
+
697
+ function normalizeSystemBlock(block: unknown): ShapeTextBlock {
698
+ if (typeof block === "string") return { type: "text", text: block };
699
+ if (isRecord(block) && typeof block.text === "string") return { ...block, type: "text", text: block.text };
700
+ return { type: "text", text: "" };
701
+ }
702
+
703
+ function prependBillingHeader(system: unknown, messages: ShapeMessageParam[]): unknown {
704
+ const billingHeader = buildBillingHeaderValue(messages);
705
+ if (!billingHeader) return system;
706
+ const systemBlocks = Array.isArray(system) ? system.map(normalizeSystemBlock) : system == null ? [] : [normalizeSystemBlock(system)];
707
+ // Idempotent: don't add a second billing header (e.g. pi-anthropic-auth also ran).
708
+ if (systemBlocks.some((block) => block.text.includes("x-anthropic-billing-header:"))) return systemBlocks;
709
+ const billingBlock: ShapeTextBlock = { type: "text", text: billingHeader };
710
+ return [billingBlock, ...systemBlocks];
711
+ }
712
+
713
+ // The Anthropic API rejects assistant turns where non-tool_use blocks follow a
714
+ // tool_use block; Pi's serializer can produce that, so split into two turns.
715
+ function splitAssistantToolUseTrailingContent(messages: ShapeMessageParam[]): ShapeMessageParam[] {
716
+ return messages.flatMap((message) => {
717
+ if (message.role !== "assistant" || !Array.isArray(message.content)) return [message];
718
+ const firstToolUseIndex = message.content.findIndex((block) => block.type === "tool_use");
719
+ if (firstToolUseIndex === -1) return [message];
720
+ const trailingBlocks = message.content.slice(firstToolUseIndex);
721
+ if (!trailingBlocks.some((block) => block.type !== "tool_use")) return [message];
722
+ const nonToolUseBlocks = message.content.filter((block) => block.type !== "tool_use");
723
+ const toolUseBlocks = message.content.filter((block) => block.type === "tool_use");
724
+ return [
725
+ { ...message, content: nonToolUseBlocks },
726
+ { ...message, content: toolUseBlocks },
727
+ ];
728
+ });
729
+ }
730
+
731
+ function sanitizeSystemText(text: string): string {
732
+ const paragraphs = text.split(/\n\n+/);
733
+ const filtered = paragraphs.filter((paragraph) => !PARAGRAPH_REMOVAL_ANCHORS.some((anchor) => paragraph.includes(anchor)));
734
+ let result = filtered.join("\n\n");
735
+ for (const rule of TEXT_REPLACEMENTS) result = result.replaceAll(rule.match, rule.replacement);
736
+ return result.trim();
737
+ }
738
+
739
+ function findProjectContextStart(systemPrompt: string): number {
740
+ return systemPrompt.indexOf("\n\n# Project Context\n\n");
741
+ }
742
+
743
+ function shapeAnthropicOAuthSystemPrompt(systemPrompt: string): string {
744
+ const prefixIdx = systemPrompt.indexOf(PI_DEFAULT_PROMPT_PREFIX);
745
+ if (prefixIdx === -1) return systemPrompt;
746
+ const terminatorIdx = systemPrompt.indexOf(PI_DEFAULT_PROMPT_TERMINATOR, prefixIdx);
747
+ if (terminatorIdx !== -1) {
748
+ const terminatorEnd = terminatorIdx + PI_DEFAULT_PROMPT_TERMINATOR.length;
749
+ const preamble = systemPrompt.slice(prefixIdx, terminatorEnd);
750
+ const sanitized = sanitizeSystemText(preamble);
751
+ const shapedPreamble = sanitized ? `${MINIMAL_ANTHROPIC_OAUTH_PROMPT}\n\n${sanitized}` : MINIMAL_ANTHROPIC_OAUTH_PROMPT;
752
+ return systemPrompt.slice(0, prefixIdx) + shapedPreamble + systemPrompt.slice(terminatorEnd);
753
+ }
754
+ // Pi reworded its preamble terminator → fall back to slicing from project context.
755
+ const projectContextStart = findProjectContextStart(systemPrompt);
756
+ if (projectContextStart === -1) return MINIMAL_ANTHROPIC_OAUTH_PROMPT;
757
+ return `${MINIMAL_ANTHROPIC_OAUTH_PROMPT}${systemPrompt.slice(projectContextStart)}`;
758
+ }
759
+
760
+ function shapeSystemBlocks(blocks: ShapeTextBlock[]): ShapeTextBlock[] {
761
+ return blocks.map((block) => {
762
+ if (block.type !== "text" || !block.text.includes(PI_DEFAULT_PROMPT_PREFIX)) return block;
763
+ return { ...block, text: shapeAnthropicOAuthSystemPrompt(block.text) };
764
+ });
765
+ }
766
+
767
+ /** before_provider_request shaper: makes Claude Pro/Max OAuth requests acceptable. */
768
+ function shapeAnthropicOAuthPayload(payload: unknown): unknown {
769
+ if (!isAnthropicMessagesPayload(payload)) return payload;
770
+ const messages = payload.messages as ShapeMessageParam[];
771
+ if (!isOAuthAnthropicPayload(payload)) return payload; // API-key / non-OAuth → untouched
772
+ const normalizedMessages = splitAssistantToolUseTrailingContent(messages);
773
+ const shapedSystem = Array.isArray(payload.system) ? shapeSystemBlocks(payload.system as ShapeTextBlock[]) : payload.system;
774
+ const finalSystem = prependBillingHeader(shapedSystem, normalizedMessages);
775
+ return { ...payload, messages: normalizedMessages, system: finalSystem };
776
+ }
777
+
778
+ /** OAuth override enabling Claude Pro/Max login on a provider (base or alias). */
779
+ const anthropicOAuthOverride = {
780
+ name: "Anthropic (Claude Pro/Max)",
781
+ login: (callbacks: any) => loginAnthropic(callbacks),
782
+ async refreshToken(credentials: any) {
783
+ const refreshed = await refreshAnthropicToken(credentials.refresh);
784
+ return {
785
+ ...credentials,
786
+ ...refreshed,
787
+ refresh: typeof refreshed.refresh === "string" && refreshed.refresh.trim().length > 0 ? refreshed.refresh : credentials.refresh,
788
+ };
789
+ },
790
+ getApiKey: (credentials: any) => credentials.access,
791
+ };
792
+
604
793
  // ===========================================================================
605
794
  // Extension entry point
606
795
  // ===========================================================================
@@ -1186,6 +1375,14 @@ export default function piMultiAccount(pi: ExtensionAPI) {
1186
1375
  pi.registerCommand(name, { description: "Manage automatic multi-account failover & rotation", handler: handleCommand });
1187
1376
  }
1188
1377
 
1378
+ // ----- Anthropic OAuth out of the box -----------------------------------
1379
+ // Enable Claude Pro/Max OAuth login on the base `anthropic` provider and shape
1380
+ // every Anthropic OAuth request so subscription tokens are accepted — without
1381
+ // requiring a separate pi-anthropic-auth install. Idempotent, so it coexists
1382
+ // safely if pi-anthropic-auth is also present.
1383
+ pi.registerProvider("anthropic", { oauth: anthropicOAuthOverride } as any);
1384
+ pi.on("before_provider_request", (event: any) => shapeAnthropicOAuthPayload(event.payload));
1385
+
1189
1386
  // ----- lifecycle hooks --------------------------------------------------
1190
1387
 
1191
1388
  refreshDiscovery(true);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-multi-account",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Automatic multi-account failover & rotation for Pi Agent across Anthropic (Claude), OpenAI/ChatGPT Codex, and Qwen/Alibaba. Auto-discovers authenticated accounts, grows the rotation on login, and drops accounts on logout, expiry, or quota/rate-limit errors.",
5
5
  "type": "module",
6
6
  "license": "MIT",