pi-multi-account 1.1.0 → 1.3.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 +45 -0
- package/README.md +5 -1
- package/index.ts +263 -113
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,49 @@ 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.3.0] - 2026-06-10
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **Manual model/account selection is now respected.** Picking a model (e.g. Opus
|
|
13
|
+
on another account) no longer gets auto-yanked onto a different provider on the
|
|
14
|
+
next rate limit — the failover stays put and tells you, until you switch with
|
|
15
|
+
`/model` or `/multi-account next`. The pin auto-releases after a successful
|
|
16
|
+
response on that provider.
|
|
17
|
+
- **No more self-resurrecting work.** All background resume timers were removed:
|
|
18
|
+
continuation now happens only synchronously inside an active turn, so Esc and
|
|
19
|
+
quitting always stop it. When every account is rate-limited the failover STOPS
|
|
20
|
+
and asks you to retry, instead of churning between exhausted accounts.
|
|
21
|
+
- **No more "Agent is already processing" / "Cannot continue from message role:
|
|
22
|
+
assistant".** Continuations are sent only when the agent is idle and not aborting.
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- Test suite (`npm test`) covering the failover edge cases: limit/401 failover,
|
|
27
|
+
all-accounts-exhausted stop, Esc/abort, manual-selection pinning, idle gating,
|
|
28
|
+
Anthropic OAuth shaping idempotency, and session shutdown. Wired into CI.
|
|
29
|
+
|
|
30
|
+
## [1.2.0] - 2026-06-10
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
|
|
34
|
+
- **Anthropic (Claude Pro/Max) OAuth now works out of the box.** OAuth login is
|
|
35
|
+
enabled on the base `anthropic` provider and on every `anthropic-account-*`
|
|
36
|
+
alias, and outgoing Anthropic OAuth requests are shaped (billing header +
|
|
37
|
+
system-prompt normalization) directly by this package. A separate
|
|
38
|
+
`pi-anthropic-auth` install is no longer required.
|
|
39
|
+
|
|
40
|
+
### Changed
|
|
41
|
+
|
|
42
|
+
- Request shaping is idempotent and only touches OAuth-marked Anthropic requests,
|
|
43
|
+
so it coexists safely with `pi-anthropic-auth` if both are installed, and leaves
|
|
44
|
+
API-key Anthropic and OpenAI Codex / Qwen requests untouched.
|
|
45
|
+
|
|
46
|
+
### Credits
|
|
47
|
+
|
|
48
|
+
- Anthropic OAuth request-shaping logic vendored from
|
|
49
|
+
[`gotgenes/pi-anthropic-auth`](https://github.com/gotgenes/pi-anthropic-auth) (MIT).
|
|
50
|
+
|
|
8
51
|
## [1.1.0] - 2026-06-10
|
|
9
52
|
|
|
10
53
|
### Fixed
|
|
@@ -55,5 +98,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
55
98
|
- Plaintext-free credential handling (SHA-256 fingerprints only); `0600`
|
|
56
99
|
config/state files.
|
|
57
100
|
|
|
101
|
+
[1.3.0]: https://github.com/Sarrius/pi-multi-account/releases/tag/v1.3.0
|
|
102
|
+
[1.2.0]: https://github.com/Sarrius/pi-multi-account/releases/tag/v1.2.0
|
|
58
103
|
[1.1.0]: https://github.com/Sarrius/pi-multi-account/releases/tag/v1.1.0
|
|
59
104
|
[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
|
|
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
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
|
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
|
// ===========================================================================
|
|
@@ -635,6 +824,11 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
635
824
|
let autoContinueTimer: ReturnType<typeof setTimeout> | undefined; // pending spaced continuation
|
|
636
825
|
let lastLeftProvider: string | undefined; // account we just failed away from (anti-ping-pong)
|
|
637
826
|
let lastLeftAt = 0;
|
|
827
|
+
// When the USER manually picks a model/account, we respect it: auto-failover will not yank
|
|
828
|
+
// them off it (that's the "I selected opus and it flipped to chatgpt" bug). selfModelSwitch
|
|
829
|
+
// marks our OWN setModel calls so the model_select event isn't mistaken for a manual pick.
|
|
830
|
+
let userSelectedProvider: string | undefined;
|
|
831
|
+
let selfModelSwitch = false;
|
|
638
832
|
// The thinking level the user intended for this turn. pi.setModel() re-clamps and
|
|
639
833
|
// persists the thinking level on every model switch, so without this it drifts
|
|
640
834
|
// downward across failovers ("thinking level keeps dropping"). We capture it before
|
|
@@ -910,11 +1104,22 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
910
1104
|
return pool.sort((a, b) => a.remaining - b.remaining || a.rotIndex - b.rotIndex).map((s) => s.model);
|
|
911
1105
|
}
|
|
912
1106
|
|
|
913
|
-
async function switchToFallback(ctx: any, reason: string, cooldownMs = config.cooldownMs) {
|
|
1107
|
+
async function switchToFallback(ctx: any, reason: string, cooldownMs = config.cooldownMs, manual = false) {
|
|
914
1108
|
if (!config.enabled) return false;
|
|
915
1109
|
const currentModel = ctx.model;
|
|
916
1110
|
if (!currentModel) return false;
|
|
917
1111
|
|
|
1112
|
+
// Respect a manual model choice: if the user just picked this provider, do NOT auto-yank
|
|
1113
|
+
// them onto another one — show the error and let them decide. Manual /multi-account next
|
|
1114
|
+
// bypasses this (manual=true).
|
|
1115
|
+
if (!manual && userSelectedProvider && currentModel.provider === userSelectedProvider) {
|
|
1116
|
+
ctx.ui.notify(
|
|
1117
|
+
`Provider failover: you selected ${currentModel.provider}/${currentModel.id} manually — staying on it (${reason.slice(0, 90)}). Use /model or /multi-account next to switch.`,
|
|
1118
|
+
"warning",
|
|
1119
|
+
);
|
|
1120
|
+
return false;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
918
1123
|
markExhausted(currentModel.provider, cooldownMs);
|
|
919
1124
|
lastLeftProvider = currentModel.provider;
|
|
920
1125
|
lastLeftAt = Date.now();
|
|
@@ -938,7 +1143,9 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
938
1143
|
const from = ref(currentModel.provider, currentModel.id);
|
|
939
1144
|
for (const fallback of candidates) {
|
|
940
1145
|
const to = ref(fallback.provider, fallback.id);
|
|
1146
|
+
selfModelSwitch = true; // our own switch — not a manual user pick
|
|
941
1147
|
const ok = await pi.setModel(fallback);
|
|
1148
|
+
selfModelSwitch = false;
|
|
942
1149
|
if (!ok) {
|
|
943
1150
|
// setModel failed → the account has no usable auth right now.
|
|
944
1151
|
ctx.ui.notify(`Provider failover: ${to} has no usable auth, dropping from rotation`, "warning");
|
|
@@ -963,35 +1170,31 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
963
1170
|
return config.continuationPrompt.replaceAll("{from}", record.from).replaceAll("{to}", record.to).replaceAll("{reason}", record.reason);
|
|
964
1171
|
}
|
|
965
1172
|
|
|
966
|
-
/**
|
|
967
|
-
|
|
1173
|
+
/**
|
|
1174
|
+
* Send our failover continuation — but ONLY when it is genuinely safe:
|
|
1175
|
+
* - the user has not aborted (Esc), and the current op isn't aborting, and
|
|
1176
|
+
* - the agent is idle (sending mid-turn throws "Agent is already processing" /
|
|
1177
|
+
* "Cannot continue from message role: assistant").
|
|
1178
|
+
* Returns whether it actually sent. No background timer is ever used, so a turn is
|
|
1179
|
+
* always active for Esc to cancel — Esc/quit therefore always stop the chain.
|
|
1180
|
+
*/
|
|
1181
|
+
function dispatchSelfContinuation(ctx: any, prompt: string): boolean {
|
|
1182
|
+
if (userAbortedChain || ctx.signal?.aborted || !ctx.isIdle()) return false;
|
|
968
1183
|
lastAutoContinueAt = Date.now();
|
|
969
1184
|
lastSentContinuationPrompt = prompt;
|
|
970
1185
|
expectingSelfContinuation = true;
|
|
971
|
-
pi.sendUserMessage(prompt
|
|
1186
|
+
pi.sendUserMessage(prompt);
|
|
1187
|
+
return true;
|
|
972
1188
|
}
|
|
973
1189
|
|
|
974
1190
|
/**
|
|
975
|
-
*
|
|
976
|
-
*
|
|
977
|
-
*
|
|
1191
|
+
* Continue after a successful failover switch — SYNCHRONOUSLY only.
|
|
1192
|
+
* Deliberately NOT a setTimeout: a deferred timer fires sendUserMessage when there is no
|
|
1193
|
+
* active turn for Esc to cancel, which is exactly how the chain escaped the user's control
|
|
1194
|
+
* and resurrected work on its own. Returns whether it sent.
|
|
978
1195
|
*/
|
|
979
|
-
function scheduleAutoContinue(ctx: any, prompt: string) {
|
|
980
|
-
|
|
981
|
-
clearTimeout(autoContinueTimer);
|
|
982
|
-
autoContinueTimer = undefined;
|
|
983
|
-
}
|
|
984
|
-
const wait = Math.max(0, MIN_AUTOCONTINUE_INTERVAL_MS - (Date.now() - lastAutoContinueAt));
|
|
985
|
-
if (wait === 0) {
|
|
986
|
-
dispatchSelfContinuation(ctx, prompt);
|
|
987
|
-
return;
|
|
988
|
-
}
|
|
989
|
-
autoContinueTimer = setTimeout(() => {
|
|
990
|
-
autoContinueTimer = undefined;
|
|
991
|
-
if (userAbortedChain || ctx.signal?.aborted) return; // user took over while we waited
|
|
992
|
-
dispatchSelfContinuation(ctx, prompt);
|
|
993
|
-
}, wait);
|
|
994
|
-
ctx.ui.notify(`Provider failover: next auto-continue in ~${Math.ceil(wait / 1000)}s (press Esc to cancel).`, "info");
|
|
1196
|
+
function scheduleAutoContinue(ctx: any, prompt: string): boolean {
|
|
1197
|
+
return dispatchSelfContinuation(ctx, prompt);
|
|
995
1198
|
}
|
|
996
1199
|
|
|
997
1200
|
function clearPendingContinuation() {
|
|
@@ -1003,96 +1206,21 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
1003
1206
|
persist();
|
|
1004
1207
|
}
|
|
1005
1208
|
|
|
1006
|
-
function nextPendingWakeDelayMs() {
|
|
1007
|
-
if (!persistedState.pendingContinuationPrompt) return undefined;
|
|
1008
|
-
const now = Date.now();
|
|
1009
|
-
const lastProbe = lastProbeMap();
|
|
1010
|
-
let bestWakeAt = Number.POSITIVE_INFINITY;
|
|
1011
|
-
for (const provider of configuredProviders()) {
|
|
1012
|
-
if (isInvalidated(provider)) continue;
|
|
1013
|
-
const exhaustedUntil = exhaustedUntilByProvider.get(provider) ?? 0;
|
|
1014
|
-
if (exhaustedUntil <= now) return 1000;
|
|
1015
|
-
const probeDueAt = (lastProbe[provider] ?? 0) + config.probeCooldownMs;
|
|
1016
|
-
bestWakeAt = Math.min(bestWakeAt, exhaustedUntil, probeDueAt);
|
|
1017
|
-
}
|
|
1018
|
-
if (!Number.isFinite(bestWakeAt)) return config.probeCooldownMs;
|
|
1019
|
-
return Math.max(1000, Math.min(bestWakeAt - now, 2_147_483_647));
|
|
1020
|
-
}
|
|
1021
|
-
|
|
1022
|
-
function schedulePendingWake(ctx?: any) {
|
|
1023
|
-
if (ctx) latestCtx = ctx;
|
|
1024
|
-
if (pendingWakeTimer) clearTimeout(pendingWakeTimer);
|
|
1025
|
-
const delayMs = nextPendingWakeDelayMs();
|
|
1026
|
-
if (delayMs === undefined) return;
|
|
1027
|
-
pendingWakeTimer = setTimeout(() => {
|
|
1028
|
-
pendingWakeTimer = undefined;
|
|
1029
|
-
void attemptPendingResume();
|
|
1030
|
-
}, delayMs);
|
|
1031
|
-
}
|
|
1032
|
-
|
|
1033
1209
|
function setPendingContinuation(ctx: any, reason: string) {
|
|
1034
|
-
//
|
|
1035
|
-
//
|
|
1036
|
-
//
|
|
1037
|
-
|
|
1038
|
-
const
|
|
1039
|
-
|
|
1040
|
-
persistedState = {
|
|
1041
|
-
...persistedState,
|
|
1042
|
-
pendingContinuationPrompt: persistedState.pendingContinuationPrompt || continuationPrompt(record),
|
|
1043
|
-
pendingSince: persistedState.pendingSince || Date.now(),
|
|
1044
|
-
pendingReason: reason,
|
|
1045
|
-
};
|
|
1210
|
+
// Every available account is rate-limited or unavailable right now. We deliberately do
|
|
1211
|
+
// NOT arm a background timer to auto-resume later: such a timer fires sendUserMessage with
|
|
1212
|
+
// no active turn for Esc to cancel and resurrects work on its own. Instead we STOP cleanly
|
|
1213
|
+
// and tell the user — they retry by sending a message when an account has recovered.
|
|
1214
|
+
const alreadyStopped = persistedState.pendingReason === reason;
|
|
1215
|
+
persistedState = { ...persistedState, pendingReason: reason, pendingContinuationPrompt: undefined, pendingSince: undefined };
|
|
1046
1216
|
persist();
|
|
1047
|
-
|
|
1048
|
-
if (alreadyPending) return;
|
|
1049
|
-
const delayMs = nextPendingWakeDelayMs();
|
|
1217
|
+
if (alreadyStopped) return;
|
|
1050
1218
|
ctx.ui.notify(
|
|
1051
|
-
`Provider failover:
|
|
1219
|
+
`Provider failover: every account is rate-limited or unavailable right now — stopped here. Send a message to retry once one recovers (check /multi-account status).`,
|
|
1052
1220
|
"warning",
|
|
1053
1221
|
);
|
|
1054
1222
|
}
|
|
1055
1223
|
|
|
1056
|
-
async function attemptPendingResume() {
|
|
1057
|
-
const ctx = latestCtx;
|
|
1058
|
-
const prompt = persistedState.pendingContinuationPrompt;
|
|
1059
|
-
if (!ctx || !prompt || !config.enabled || !config.autoContinue) return;
|
|
1060
|
-
if (userAbortedChain) {
|
|
1061
|
-
clearPendingContinuation(); // user took over — abandon the background resurrection
|
|
1062
|
-
return;
|
|
1063
|
-
}
|
|
1064
|
-
if (autoContinuesThisPrompt >= config.maxAutoContinuesPerPrompt) {
|
|
1065
|
-
clearPendingContinuation(); // task-level cap reached — stop resurrecting
|
|
1066
|
-
return;
|
|
1067
|
-
}
|
|
1068
|
-
refreshDiscovery();
|
|
1069
|
-
pruneCooldowns();
|
|
1070
|
-
const candidates = findFallbackModels(ctx, ctx.model);
|
|
1071
|
-
if (candidates.length === 0) {
|
|
1072
|
-
schedulePendingWake(ctx);
|
|
1073
|
-
return;
|
|
1074
|
-
}
|
|
1075
|
-
for (const candidate of candidates) {
|
|
1076
|
-
const to = ref(candidate.provider, candidate.id);
|
|
1077
|
-
const ok = await pi.setModel(candidate);
|
|
1078
|
-
if (!ok) {
|
|
1079
|
-
markInvalid(candidate.provider, "setModel failed on resume");
|
|
1080
|
-
continue;
|
|
1081
|
-
}
|
|
1082
|
-
restoreDesiredThinking(); // keep the user's thinking level across the switch
|
|
1083
|
-
setLastProbe(candidate.provider);
|
|
1084
|
-
clearPendingContinuation();
|
|
1085
|
-
// A genuine recovery after a real wait earns a fresh continuation budget so the
|
|
1086
|
-
// agent can keep going whenever an account recovers; rapid flapping (resume that
|
|
1087
|
-
// immediately re-limits) does NOT reset, so the cap still bounds a tight loop.
|
|
1088
|
-
if (Date.now() - lastAutoContinueAt >= config.probeCooldownMs) autoContinuesThisPrompt = 0;
|
|
1089
|
-
ctx.ui.notify(`Provider failover: resuming pending work on ${to}`, "warning");
|
|
1090
|
-
dispatchSelfContinuation(ctx, prompt);
|
|
1091
|
-
return;
|
|
1092
|
-
}
|
|
1093
|
-
schedulePendingWake(ctx);
|
|
1094
|
-
}
|
|
1095
|
-
|
|
1096
1224
|
// ----- error classification --------------------------------------------
|
|
1097
1225
|
|
|
1098
1226
|
function isAuthError(text: string) {
|
|
@@ -1139,6 +1267,8 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
1139
1267
|
exhaustedUntilByProvider.clear();
|
|
1140
1268
|
currentPromptSwitch = undefined;
|
|
1141
1269
|
autoContinuesThisPrompt = 0;
|
|
1270
|
+
userAbortedChain = false;
|
|
1271
|
+
userSelectedProvider = undefined;
|
|
1142
1272
|
if (pendingWakeTimer) {
|
|
1143
1273
|
clearTimeout(pendingWakeTimer);
|
|
1144
1274
|
pendingWakeTimer = undefined;
|
|
@@ -1151,7 +1281,8 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
1151
1281
|
return;
|
|
1152
1282
|
}
|
|
1153
1283
|
if (command === "next") {
|
|
1154
|
-
|
|
1284
|
+
userSelectedProvider = undefined; // explicit request to move — drop any manual pin
|
|
1285
|
+
await switchToFallback(ctx, "manual /multi-account next", 5 * 60 * 1000, true);
|
|
1155
1286
|
return;
|
|
1156
1287
|
}
|
|
1157
1288
|
if (command === "enable" || command === "disable") {
|
|
@@ -1186,6 +1317,14 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
1186
1317
|
pi.registerCommand(name, { description: "Manage automatic multi-account failover & rotation", handler: handleCommand });
|
|
1187
1318
|
}
|
|
1188
1319
|
|
|
1320
|
+
// ----- Anthropic OAuth out of the box -----------------------------------
|
|
1321
|
+
// Enable Claude Pro/Max OAuth login on the base `anthropic` provider and shape
|
|
1322
|
+
// every Anthropic OAuth request so subscription tokens are accepted — without
|
|
1323
|
+
// requiring a separate pi-anthropic-auth install. Idempotent, so it coexists
|
|
1324
|
+
// safely if pi-anthropic-auth is also present.
|
|
1325
|
+
pi.registerProvider("anthropic", { oauth: anthropicOAuthOverride } as any);
|
|
1326
|
+
pi.on("before_provider_request", (event: any) => shapeAnthropicOAuthPayload(event.payload));
|
|
1327
|
+
|
|
1189
1328
|
// ----- lifecycle hooks --------------------------------------------------
|
|
1190
1329
|
|
|
1191
1330
|
refreshDiscovery(true);
|
|
@@ -1258,11 +1397,21 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
1258
1397
|
refreshDiscovery(); // cheap: only re-scans when auth.json changed (new /login)
|
|
1259
1398
|
});
|
|
1260
1399
|
|
|
1400
|
+
// Detect a MANUAL model/account selection by the user (vs our own failover setModel) and
|
|
1401
|
+
// pin it, so auto-failover won't immediately yank them off it.
|
|
1402
|
+
pi.on("model_select", (event) => {
|
|
1403
|
+
if (selfModelSwitch) return; // our own failover switch — not a manual pick
|
|
1404
|
+
const model = (event as any).model;
|
|
1405
|
+
if (model?.provider) userSelectedProvider = model.provider;
|
|
1406
|
+
});
|
|
1407
|
+
|
|
1261
1408
|
pi.on("after_provider_response", async (event, ctx) => {
|
|
1262
1409
|
latestCtx = ctx;
|
|
1263
1410
|
if (!config.enabled) return;
|
|
1264
1411
|
if (userAbortedChain || ctx.signal?.aborted) return; // user is cancelling — don't fail over
|
|
1265
1412
|
const status = (event as any).status;
|
|
1413
|
+
// The user's manually-picked model just worked → resume normal auto-failover for it.
|
|
1414
|
+
if (status < 400 && ctx.model && ctx.model.provider === userSelectedProvider) userSelectedProvider = undefined;
|
|
1266
1415
|
if (status === 401) {
|
|
1267
1416
|
// Authorization is dead → drop this account, then move on.
|
|
1268
1417
|
if (ctx.model) markInvalid(ctx.model.provider, `HTTP 401`);
|
|
@@ -1339,9 +1488,10 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
1339
1488
|
}
|
|
1340
1489
|
|
|
1341
1490
|
if (currentPromptSwitch) {
|
|
1342
|
-
autoContinuesThisPrompt++;
|
|
1343
1491
|
const prompt = continuationPrompt(currentPromptSwitch);
|
|
1344
|
-
|
|
1492
|
+
// Continue synchronously and only if it actually sent (agent idle, not aborted).
|
|
1493
|
+
// Count the attempt only when we really sent, so the cap reflects real tries.
|
|
1494
|
+
if (scheduleAutoContinue(ctx, prompt)) autoContinuesThisPrompt++;
|
|
1345
1495
|
}
|
|
1346
1496
|
});
|
|
1347
1497
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-multi-account",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.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",
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
],
|
|
43
43
|
"scripts": {
|
|
44
44
|
"check": "tsc --noEmit",
|
|
45
|
+
"test": "node --test test/*.test.ts",
|
|
45
46
|
"pack:check": "npm pack --dry-run",
|
|
46
47
|
"prepublishOnly": "npm run check"
|
|
47
48
|
},
|