@hu3rror/pi-failover 0.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/LICENSE +22 -0
- package/README.md +146 -0
- package/README.zh-CN.md +146 -0
- package/package.json +68 -0
- package/src/add-backup-key.ts +126 -0
- package/src/auth-catalog.ts +112 -0
- package/src/failover-engine.ts +314 -0
- package/src/failover-login.ts +108 -0
- package/src/index.ts +346 -0
- package/src/model-planner.ts +51 -0
- package/src/notification.ts +43 -0
- package/src/pi-runtime.ts +192 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
export type FailureKind =
|
|
2
|
+
| "unauthorized"
|
|
3
|
+
| "rate-limited"
|
|
4
|
+
| "overloaded"
|
|
5
|
+
| "provider-error"
|
|
6
|
+
| "network";
|
|
7
|
+
|
|
8
|
+
export interface FailureObservation {
|
|
9
|
+
status?: number;
|
|
10
|
+
kind?: FailureKind;
|
|
11
|
+
retryAfterMs?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type KeySlot = "primary" | "backup" | `backup-${number}`;
|
|
15
|
+
|
|
16
|
+
export interface FailoverAttempt {
|
|
17
|
+
providerId: string;
|
|
18
|
+
model: string;
|
|
19
|
+
keySlot: KeySlot;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface KeyStatusSnapshot {
|
|
23
|
+
slot: KeySlot;
|
|
24
|
+
status: "healthy" | "disabled" | "cooling";
|
|
25
|
+
cooldownUntil?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ProviderStatusSnapshot {
|
|
29
|
+
providerId: string;
|
|
30
|
+
status: "healthy" | "cooling";
|
|
31
|
+
cooldownUntil?: number;
|
|
32
|
+
keys: KeyStatusSnapshot[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface FailoverSnapshot {
|
|
36
|
+
providers: ProviderStatusSnapshot[];
|
|
37
|
+
active?: { providerId: string; model: string; keySlot: KeySlot };
|
|
38
|
+
visitedProviders: string[];
|
|
39
|
+
visitedKeys: Array<{ providerId: string; keySlot: KeySlot }>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ProviderPlan {
|
|
43
|
+
providerId: string;
|
|
44
|
+
model: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ProviderFallbackRequest {
|
|
48
|
+
current: ProviderPlan;
|
|
49
|
+
unavailableProviderIds: readonly string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface FailoverProvider {
|
|
53
|
+
id: string;
|
|
54
|
+
backupKeyCount?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface FailoverEngineOptions {
|
|
58
|
+
providers: readonly FailoverProvider[];
|
|
59
|
+
now: () => number;
|
|
60
|
+
nextProvider: (request: ProviderFallbackRequest) => ProviderPlan | undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type FailoverDecision =
|
|
64
|
+
| { kind: "switch-key" | "switch-model"; providerId: string; model: string; keySlot: KeySlot }
|
|
65
|
+
| { kind: "none" | "exhausted" };
|
|
66
|
+
|
|
67
|
+
interface KeyState {
|
|
68
|
+
slot: KeySlot;
|
|
69
|
+
disabled: boolean;
|
|
70
|
+
cooldownUntil: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface ProviderState {
|
|
74
|
+
id: string;
|
|
75
|
+
keys: KeyState[];
|
|
76
|
+
cooldownUntil: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface ActiveAttempt {
|
|
80
|
+
plan: ProviderPlan;
|
|
81
|
+
keySlot: KeySlot;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const KEY_RATE_LIMIT_COOLDOWN_MS = 60_000;
|
|
85
|
+
const PROVIDER_COOLDOWN_MS = 30_000;
|
|
86
|
+
|
|
87
|
+
export function backupSlot(index: number): KeySlot {
|
|
88
|
+
return index === 0 ? "backup" : `backup-${index + 1}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function backupIndexForSlot(slot: KeySlot): number | undefined {
|
|
92
|
+
if (slot === "primary") return undefined;
|
|
93
|
+
if (slot === "backup") return 0;
|
|
94
|
+
const ordinal = Number(slot.slice("backup-".length));
|
|
95
|
+
return Number.isInteger(ordinal) && ordinal >= 2 ? ordinal - 1 : undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export class FailoverEngine {
|
|
99
|
+
private readonly providers: ProviderState[];
|
|
100
|
+
private readonly now: () => number;
|
|
101
|
+
private readonly nextProvider: FailoverEngineOptions["nextProvider"];
|
|
102
|
+
private visitedKeys = new Set<string>();
|
|
103
|
+
private visitedProviders = new Set<string>();
|
|
104
|
+
private active?: ActiveAttempt;
|
|
105
|
+
private decision: FailoverDecision = { kind: "none" };
|
|
106
|
+
|
|
107
|
+
constructor(options: FailoverEngineOptions) {
|
|
108
|
+
this.providers = options.providers.map((provider) => ({
|
|
109
|
+
id: provider.id,
|
|
110
|
+
keys: [
|
|
111
|
+
{ slot: "primary" as const, disabled: false, cooldownUntil: 0 },
|
|
112
|
+
...Array.from({ length: provider.backupKeyCount ?? 0 }, (_, index) => ({
|
|
113
|
+
slot: backupSlot(index),
|
|
114
|
+
disabled: false,
|
|
115
|
+
cooldownUntil: 0,
|
|
116
|
+
})),
|
|
117
|
+
],
|
|
118
|
+
cooldownUntil: 0,
|
|
119
|
+
}));
|
|
120
|
+
this.now = options.now;
|
|
121
|
+
this.nextProvider = options.nextProvider;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
startTurn(initial: ProviderPlan): FailoverDecision {
|
|
125
|
+
this.visitedKeys = new Set();
|
|
126
|
+
this.visitedProviders = new Set();
|
|
127
|
+
this.active = undefined;
|
|
128
|
+
|
|
129
|
+
return this.selectPlan(initial, "switch-key") ?? this.selectFallback(initial);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
observeFailure(observation: FailureObservation): FailoverDecision {
|
|
133
|
+
if (!this.active) return this.setDecision({ kind: "none" });
|
|
134
|
+
|
|
135
|
+
switch (classifyFailure(observation)) {
|
|
136
|
+
case "unauthorized":
|
|
137
|
+
{
|
|
138
|
+
const key = this.activeKey();
|
|
139
|
+
if (key) key.disabled = true;
|
|
140
|
+
}
|
|
141
|
+
return this.rotateKeyOrFallback();
|
|
142
|
+
case "rate-limited":
|
|
143
|
+
this.activeKey()!.cooldownUntil = this.now() + cooldownFor(observation, KEY_RATE_LIMIT_COOLDOWN_MS);
|
|
144
|
+
return this.rotateKeyOrFallback();
|
|
145
|
+
case "overloaded":
|
|
146
|
+
case "provider-error":
|
|
147
|
+
case "network":
|
|
148
|
+
this.activeProvider()!.cooldownUntil = this.now() + cooldownFor(observation, PROVIDER_COOLDOWN_MS);
|
|
149
|
+
return this.selectFallback(this.active.plan);
|
|
150
|
+
case "other":
|
|
151
|
+
return this.setDecision({ kind: "none" });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
observeSuccess(): void {
|
|
156
|
+
if (!this.active) return;
|
|
157
|
+
const provider = this.activeProvider();
|
|
158
|
+
const key = this.activeKey();
|
|
159
|
+
if (!provider || !key) return;
|
|
160
|
+
|
|
161
|
+
provider.cooldownUntil = 0;
|
|
162
|
+
key.cooldownUntil = 0;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
currentDecision(): FailoverDecision {
|
|
166
|
+
return this.decision;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
resumeAttempt(attempt: FailoverAttempt): boolean {
|
|
170
|
+
const provider = this.provider(attempt.providerId);
|
|
171
|
+
const key = provider?.keys.find((candidate) => candidate.slot === attempt.keySlot);
|
|
172
|
+
if (!provider || !key) return false;
|
|
173
|
+
this.active = {
|
|
174
|
+
plan: { providerId: attempt.providerId, model: attempt.model },
|
|
175
|
+
keySlot: attempt.keySlot,
|
|
176
|
+
};
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
snapshot(): FailoverSnapshot {
|
|
181
|
+
const now = this.now();
|
|
182
|
+
return {
|
|
183
|
+
providers: this.providers.map((provider) => ({
|
|
184
|
+
providerId: provider.id,
|
|
185
|
+
...(provider.cooldownUntil > now
|
|
186
|
+
? { status: "cooling" as const, cooldownUntil: provider.cooldownUntil }
|
|
187
|
+
: { status: "healthy" as const }),
|
|
188
|
+
keys: provider.keys.map((key) => ({
|
|
189
|
+
slot: key.slot,
|
|
190
|
+
...(key.disabled
|
|
191
|
+
? { status: "disabled" as const }
|
|
192
|
+
: key.cooldownUntil > now
|
|
193
|
+
? { status: "cooling" as const, cooldownUntil: key.cooldownUntil }
|
|
194
|
+
: { status: "healthy" as const }),
|
|
195
|
+
})),
|
|
196
|
+
})),
|
|
197
|
+
...(this.active ? { active: { providerId: this.active.plan.providerId, model: this.active.plan.model, keySlot: this.active.keySlot } } : {}),
|
|
198
|
+
visitedProviders: [...this.visitedProviders],
|
|
199
|
+
visitedKeys: [...this.visitedKeys].map((value) => {
|
|
200
|
+
const separator = value.lastIndexOf(":");
|
|
201
|
+
return { providerId: value.slice(0, separator), keySlot: value.slice(separator + 1) as KeySlot };
|
|
202
|
+
}),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private rotateKeyOrFallback(): FailoverDecision {
|
|
207
|
+
const active = this.active;
|
|
208
|
+
if (!active) return this.setDecision({ kind: "none" });
|
|
209
|
+
const provider = this.activeProvider();
|
|
210
|
+
const key = provider && this.nextAvailableKey(provider);
|
|
211
|
+
if (!provider || !key) return this.selectFallback(active.plan);
|
|
212
|
+
|
|
213
|
+
return this.activate(active.plan, key, "switch-key");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private selectFallback(current: ProviderPlan): FailoverDecision {
|
|
217
|
+
const considered = new Set<string>();
|
|
218
|
+
for (let remaining = this.providers.length; remaining > 0; remaining -= 1) {
|
|
219
|
+
const plan = this.nextProvider({
|
|
220
|
+
current,
|
|
221
|
+
unavailableProviderIds: [...new Set([...this.visitedProviders, ...considered])],
|
|
222
|
+
});
|
|
223
|
+
if (!plan) break;
|
|
224
|
+
considered.add(plan.providerId);
|
|
225
|
+
const decision = this.selectPlan(plan, "switch-model");
|
|
226
|
+
if (decision) return decision;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return this.setDecision({ kind: "exhausted" });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private selectPlan(plan: ProviderPlan, kind: "switch-key" | "switch-model"): FailoverDecision | undefined {
|
|
233
|
+
const provider = this.provider(plan.providerId);
|
|
234
|
+
if (!provider || this.visitedProviders.has(provider.id) || provider.cooldownUntil > this.now()) return undefined;
|
|
235
|
+
|
|
236
|
+
const key = this.nextAvailableKey(provider);
|
|
237
|
+
if (!key) return undefined;
|
|
238
|
+
|
|
239
|
+
this.visitedProviders.add(provider.id);
|
|
240
|
+
return this.activate(plan, key, kind);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private activate(plan: ProviderPlan, key: KeyState, kind: "switch-key" | "switch-model"): FailoverDecision {
|
|
244
|
+
this.visitedKeys.add(this.keyId(plan.providerId, key.slot));
|
|
245
|
+
this.active = { plan, keySlot: key.slot };
|
|
246
|
+
return this.setDecision({ kind, providerId: plan.providerId, model: plan.model, keySlot: key.slot });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private nextAvailableKey(provider: ProviderState): KeyState | undefined {
|
|
250
|
+
const now = this.now();
|
|
251
|
+
return provider.keys.find(
|
|
252
|
+
(key) => !this.visitedKeys.has(this.keyId(provider.id, key.slot)) && !key.disabled && key.cooldownUntil <= now,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private activeProvider(): ProviderState | undefined {
|
|
257
|
+
return this.active && this.provider(this.active.plan.providerId);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private activeKey(): KeyState | undefined {
|
|
261
|
+
const provider = this.activeProvider();
|
|
262
|
+
return provider?.keys.find((key) => key.slot === this.active?.keySlot);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private provider(id: string): ProviderState | undefined {
|
|
266
|
+
return this.providers.find((provider) => provider.id === id);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private keyId(providerId: string, keySlot: KeySlot): string {
|
|
270
|
+
return `${providerId}:${keySlot}`;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private setDecision(decision: FailoverDecision): FailoverDecision {
|
|
274
|
+
this.decision = decision;
|
|
275
|
+
return decision;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function classifyFailure(observation: FailureObservation): FailureKind | "other" {
|
|
280
|
+
switch (observation.status) {
|
|
281
|
+
case 401:
|
|
282
|
+
case 403:
|
|
283
|
+
return "unauthorized";
|
|
284
|
+
case 429:
|
|
285
|
+
return "rate-limited";
|
|
286
|
+
case 529:
|
|
287
|
+
return "overloaded";
|
|
288
|
+
case 500:
|
|
289
|
+
case 502:
|
|
290
|
+
case 503:
|
|
291
|
+
case 504:
|
|
292
|
+
return "provider-error";
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
switch (observation.kind) {
|
|
296
|
+
case "overloaded":
|
|
297
|
+
return "overloaded";
|
|
298
|
+
case "provider-error":
|
|
299
|
+
case "network":
|
|
300
|
+
return observation.kind;
|
|
301
|
+
case "unauthorized":
|
|
302
|
+
return "unauthorized";
|
|
303
|
+
case "rate-limited":
|
|
304
|
+
return "rate-limited";
|
|
305
|
+
default:
|
|
306
|
+
return "other";
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function cooldownFor(observation: FailureObservation, fallback: number): number {
|
|
311
|
+
return typeof observation.retryAfterMs === "number" && Number.isFinite(observation.retryAfterMs)
|
|
312
|
+
? Math.max(0, observation.retryAfterMs)
|
|
313
|
+
: fallback;
|
|
314
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { AddBackupKeyRejection, AddBackupKeyResult } from "./add-backup-key.ts";
|
|
2
|
+
import { isLiteralBackupKey, type AuthCatalog } from "./auth-catalog.ts";
|
|
3
|
+
import type { NotificationLevel } from "./notification.ts";
|
|
4
|
+
|
|
5
|
+
export interface FailoverLoginDependencies {
|
|
6
|
+
hasUI: boolean;
|
|
7
|
+
select: (title: string, options: string[]) => Promise<string | undefined>;
|
|
8
|
+
input: (title: string, placeholder?: string) => Promise<string | undefined>;
|
|
9
|
+
confirm: (title: string, message: string) => Promise<boolean>;
|
|
10
|
+
readCatalog: () => AuthCatalog;
|
|
11
|
+
writeBackupKey: (providerId: string, backupKey: string) => AddBackupKeyResult;
|
|
12
|
+
rebuild: () => void | Promise<void>;
|
|
13
|
+
notify: (message: string, level: NotificationLevel) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Interactive `/failover-login` flow.
|
|
18
|
+
*
|
|
19
|
+
* An optional `[provider]` argument preselects the target; without one the user
|
|
20
|
+
* picks from the current `api_key` providers. The backup key always comes from an
|
|
21
|
+
* input dialog (never the command line), a confirmation gates the write, and the
|
|
22
|
+
* catalog is rebuilt on success so the new key is immediately usable.
|
|
23
|
+
* Notifications never include the key content.
|
|
24
|
+
*/
|
|
25
|
+
export async function runFailoverLogin(args: string, deps: FailoverLoginDependencies): Promise<void> {
|
|
26
|
+
if (!deps.hasUI) {
|
|
27
|
+
deps.notify("pi-failover: /failover-login requires an interactive session", "error");
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const requested = args.trim();
|
|
32
|
+
const catalog = deps.readCatalog();
|
|
33
|
+
const apiKeyProviders = catalog.providers.filter((provider) => provider.type === "api_key");
|
|
34
|
+
|
|
35
|
+
let providerId: string;
|
|
36
|
+
if (requested !== "") {
|
|
37
|
+
const match = catalog.providers.find((provider) => provider.provider === requested);
|
|
38
|
+
if (match === undefined) {
|
|
39
|
+
deps.notify(`pi-failover: unknown provider ${requested}`, "error");
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (match.type !== "api_key") {
|
|
43
|
+
deps.notify(`pi-failover: ${requested} is an oauth provider; backup keys apply only to api_key credentials`, "error");
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
providerId = requested;
|
|
47
|
+
} else {
|
|
48
|
+
if (apiKeyProviders.length === 0) {
|
|
49
|
+
deps.notify("pi-failover: no api_key providers configured", "error");
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const chosen = await deps.select(
|
|
53
|
+
"pi-failover: choose an api_key provider",
|
|
54
|
+
apiKeyProviders.map((provider) => provider.provider),
|
|
55
|
+
);
|
|
56
|
+
if (chosen === undefined) return;
|
|
57
|
+
providerId = chosen;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const backupKey = await deps.input("pi-failover: backup API key", "paste the backup key");
|
|
61
|
+
if (backupKey === undefined) return;
|
|
62
|
+
if (!isLiteralBackupKey(backupKey)) {
|
|
63
|
+
deps.notify("pi-failover: rejected backup key: must be non-empty and cannot start with ! or contain ${ or $VAR", "error");
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const confirmed = await deps.confirm(
|
|
68
|
+
`pi-failover: add a backup key to ${providerId}?`,
|
|
69
|
+
"The key will be written to auth.json and the failover catalog rebuilt.",
|
|
70
|
+
);
|
|
71
|
+
if (!confirmed) {
|
|
72
|
+
deps.notify("pi-failover: cancelled; auth.json unchanged", "info");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const result = deps.writeBackupKey(providerId, backupKey);
|
|
77
|
+
if (!result.ok) {
|
|
78
|
+
deps.notify(`pi-failover: backup key not added for ${providerId}: ${describeRejection(result.reason)}`, "error");
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
await deps.rebuild();
|
|
83
|
+
const count = result.backupKeys.length;
|
|
84
|
+
const noun = count === 1 ? "backup key" : "backup keys";
|
|
85
|
+
deps.notify(
|
|
86
|
+
result.changed
|
|
87
|
+
? `pi-failover: added a backup key to ${providerId} (${count} ${noun} configured)`
|
|
88
|
+
: `pi-failover: ${providerId} already has that backup key (${count} ${noun} configured)`,
|
|
89
|
+
"info",
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function describeRejection(reason: AddBackupKeyRejection): string {
|
|
94
|
+
switch (reason) {
|
|
95
|
+
case "invalid-key":
|
|
96
|
+
return "invalid key";
|
|
97
|
+
case "unknown-provider":
|
|
98
|
+
return "provider not found in auth.json";
|
|
99
|
+
case "oauth-provider":
|
|
100
|
+
return "provider is oauth";
|
|
101
|
+
case "unreadable":
|
|
102
|
+
return "auth.json unreadable";
|
|
103
|
+
case "malformed":
|
|
104
|
+
return "auth.json malformed";
|
|
105
|
+
case "locked":
|
|
106
|
+
return "auth.json locked by another process";
|
|
107
|
+
}
|
|
108
|
+
}
|