@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
package/src/index.ts
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { addBackupKey, type AddBackupKeyResult } from "./add-backup-key.ts";
|
|
3
|
+
import { loadAuthCatalog, type AuthCatalog } from "./auth-catalog.ts";
|
|
4
|
+
import { runFailoverLogin } from "./failover-login.ts";
|
|
5
|
+
import {
|
|
6
|
+
backupIndexForSlot,
|
|
7
|
+
FailoverEngine,
|
|
8
|
+
type FailoverAttempt,
|
|
9
|
+
type FailoverDecision,
|
|
10
|
+
type FailureObservation,
|
|
11
|
+
} from "./failover-engine.ts";
|
|
12
|
+
import { applyNextModel } from "./model-planner.ts";
|
|
13
|
+
import {
|
|
14
|
+
formatStatus,
|
|
15
|
+
notify,
|
|
16
|
+
notifyExhausted,
|
|
17
|
+
notifyKeySwitch,
|
|
18
|
+
notifyProviderSwitch,
|
|
19
|
+
} from "./notification.ts";
|
|
20
|
+
import { createPiRuntimeAdapter, type PiRuntimeAdapter } from "./pi-runtime.ts";
|
|
21
|
+
|
|
22
|
+
export interface FailoverExtensionOptions {
|
|
23
|
+
loadCatalog?: () => AuthCatalog;
|
|
24
|
+
writeBackupKey?: (providerId: string, backupKey: string) => AddBackupKeyResult;
|
|
25
|
+
now?: () => number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface AttemptResponse extends FailoverAttempt {
|
|
29
|
+
status?: number;
|
|
30
|
+
retryAfterMs?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type FailoverExecutionResult = "applied" | "exhausted" | "none";
|
|
34
|
+
|
|
35
|
+
const OVERLOADED_TEXT = /\boverload(?:ed)?(?:[_ -]?error)?\b/i;
|
|
36
|
+
const RETRY_MESSAGE = {
|
|
37
|
+
customType: "pi-failover-retry",
|
|
38
|
+
content: "Retry the current user request now using the failover credential or provider. Do not mention this internal retry.",
|
|
39
|
+
display: false,
|
|
40
|
+
} as const;
|
|
41
|
+
|
|
42
|
+
function classifyAttemptFailure(attempt: AttemptResponse, errorMessage: string): FailureObservation {
|
|
43
|
+
const retryAfter = attempt.retryAfterMs === undefined ? {} : { retryAfterMs: attempt.retryAfterMs };
|
|
44
|
+
if (attempt.status !== undefined) {
|
|
45
|
+
if (attempt.status === 429 && OVERLOADED_TEXT.test(errorMessage)) {
|
|
46
|
+
return { kind: "overloaded", ...retryAfter };
|
|
47
|
+
}
|
|
48
|
+
return { status: attempt.status, ...retryAfter };
|
|
49
|
+
}
|
|
50
|
+
if (OVERLOADED_TEXT.test(errorMessage) || /\b529\b/i.test(errorMessage)) return { kind: "overloaded", ...retryAfter };
|
|
51
|
+
if (/\btimeout|timed out|deadline exceeded/i.test(errorMessage)) return { kind: "network", ...retryAfter };
|
|
52
|
+
if (/\bnetwork|connection (?:reset|refused|closed)|fetch failed|socket hang up/i.test(errorMessage)) {
|
|
53
|
+
return { kind: "network", ...retryAfter };
|
|
54
|
+
}
|
|
55
|
+
if (/\b40[13]\b|unauthorized|forbidden/i.test(errorMessage)) return { kind: "unauthorized", ...retryAfter };
|
|
56
|
+
if (/\b429\b|rate.?limit|too many requests/i.test(errorMessage)) return { kind: "rate-limited", ...retryAfter };
|
|
57
|
+
if (/\b50[0234]\b|temporar(?:y|ily) unavailable/i.test(errorMessage)) return { kind: "provider-error", ...retryAfter };
|
|
58
|
+
return retryAfter;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function retryAfterMilliseconds(headers: Record<string, string>, now: number): number | undefined {
|
|
62
|
+
const value = Object.entries(headers).find(([name]) => name.toLowerCase() === "retry-after")?.[1]?.trim();
|
|
63
|
+
if (!value) return undefined;
|
|
64
|
+
const seconds = Number(value);
|
|
65
|
+
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1_000;
|
|
66
|
+
const date = Date.parse(value);
|
|
67
|
+
return Number.isFinite(date) ? Math.max(0, date - now) : undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function createFailoverExtension(options: FailoverExtensionOptions = {}) {
|
|
71
|
+
const readCatalog = options.loadCatalog ?? loadAuthCatalog;
|
|
72
|
+
const writeBackupKey = options.writeBackupKey ?? addBackupKey;
|
|
73
|
+
const now = options.now ?? Date.now;
|
|
74
|
+
|
|
75
|
+
return function registerFailover(pi: ExtensionAPI): void {
|
|
76
|
+
let catalog: AuthCatalog = { enabled: false, providers: [], diagnostics: [] };
|
|
77
|
+
let engine: FailoverEngine | undefined;
|
|
78
|
+
let runtime: PiRuntimeAdapter | undefined;
|
|
79
|
+
let attempt: AttemptResponse | undefined;
|
|
80
|
+
let activeContext: ExtensionContext | undefined;
|
|
81
|
+
const possiblyOwnedProviderIds = new Set<string>();
|
|
82
|
+
let compatibilityErrorReported = false;
|
|
83
|
+
let exhaustionPending = false;
|
|
84
|
+
let deferredAttempt: FailoverAttempt | undefined;
|
|
85
|
+
|
|
86
|
+
function clearPendingExhaustion(): void {
|
|
87
|
+
exhaustionPending = false;
|
|
88
|
+
deferredAttempt = undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function nextProvider(current: { providerId: string; model: string }, unavailableProviderIds: readonly string[]) {
|
|
92
|
+
if (!activeContext) return undefined;
|
|
93
|
+
const order = catalog.providers.map((provider) => provider.provider);
|
|
94
|
+
const currentIndex = order.indexOf(current.providerId);
|
|
95
|
+
const candidates = currentIndex < 0
|
|
96
|
+
? order
|
|
97
|
+
: [...order.slice(currentIndex + 1), ...order.slice(0, currentIndex)];
|
|
98
|
+
const unavailable = new Set(unavailableProviderIds);
|
|
99
|
+
const models = activeContext.modelRegistry.getAvailable();
|
|
100
|
+
for (const providerId of candidates) {
|
|
101
|
+
if (providerId === current.providerId || unavailable.has(providerId)) continue;
|
|
102
|
+
if (!activeContext.modelRegistry.getProviderAuthStatus(providerId).configured) continue;
|
|
103
|
+
const providerModels = models.filter((model) => model.provider === providerId);
|
|
104
|
+
const model = providerModels.find((candidate) => candidate.id === current.model) ?? providerModels[0];
|
|
105
|
+
if (model) return { providerId, model: model.id };
|
|
106
|
+
}
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function rebuild(ctx: ExtensionContext): void {
|
|
111
|
+
clearPendingExhaustion();
|
|
112
|
+
activeContext = ctx;
|
|
113
|
+
catalog = readCatalog();
|
|
114
|
+
runtime = createPiRuntimeAdapter(ctx.modelRegistry);
|
|
115
|
+
if (!runtime.supported) {
|
|
116
|
+
engine = undefined;
|
|
117
|
+
if (!compatibilityErrorReported) {
|
|
118
|
+
notify(ctx, "pi-failover: disabled because this Pi runtime lacks credential override support", "error");
|
|
119
|
+
compatibilityErrorReported = true;
|
|
120
|
+
}
|
|
121
|
+
attempt = undefined;
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
engine = catalog.enabled
|
|
125
|
+
? new FailoverEngine({
|
|
126
|
+
providers: catalog.providers.map((provider) => ({
|
|
127
|
+
id: provider.provider,
|
|
128
|
+
backupKeyCount: provider.backupKeys?.length ?? 0,
|
|
129
|
+
})),
|
|
130
|
+
now,
|
|
131
|
+
nextProvider: ({ current, unavailableProviderIds }) => nextProvider(current, unavailableProviderIds),
|
|
132
|
+
})
|
|
133
|
+
: undefined;
|
|
134
|
+
attempt = undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function restoreOwnedOverrides(): Promise<void> {
|
|
138
|
+
if (!runtime) return;
|
|
139
|
+
for (const providerId of [...possiblyOwnedProviderIds]) {
|
|
140
|
+
const result = await runtime.restoreOriginalKey(providerId);
|
|
141
|
+
if (result.ok) possiblyOwnedProviderIds.delete(providerId);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function execute(initialDecision: FailoverDecision, ctx: ExtensionContext): Promise<FailoverExecutionResult> {
|
|
146
|
+
let decision = initialDecision;
|
|
147
|
+
while (decision.kind === "switch-key" || decision.kind === "switch-model") {
|
|
148
|
+
const activeDecision = decision;
|
|
149
|
+
if (activeDecision.kind === "switch-model") {
|
|
150
|
+
const applied = await applyNextModel(
|
|
151
|
+
{ modelRegistry: ctx.modelRegistry, setModel: (model) => pi.setModel(model) },
|
|
152
|
+
{
|
|
153
|
+
authOrder: [activeDecision.providerId],
|
|
154
|
+
current: { providerId: "", model: activeDecision.model },
|
|
155
|
+
unavailableProviderIds: [],
|
|
156
|
+
cooldownProviderIds: [],
|
|
157
|
+
},
|
|
158
|
+
);
|
|
159
|
+
if (!applied) {
|
|
160
|
+
decision = engine?.observeFailure({ kind: "provider-error" }) ?? { kind: "exhausted" };
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
notifyProviderSwitch(ctx, applied.providerId, applied.model);
|
|
164
|
+
}
|
|
165
|
+
if (activeDecision.keySlot === "primary") {
|
|
166
|
+
if (!runtime?.hasOwnedOverride(activeDecision.providerId)) {
|
|
167
|
+
if (possiblyOwnedProviderIds.has(activeDecision.providerId)) {
|
|
168
|
+
const result = await runtime?.restoreOriginalKey(activeDecision.providerId);
|
|
169
|
+
if (result?.ok) possiblyOwnedProviderIds.delete(activeDecision.providerId);
|
|
170
|
+
}
|
|
171
|
+
clearPendingExhaustion();
|
|
172
|
+
return "applied";
|
|
173
|
+
}
|
|
174
|
+
const result = await runtime?.restoreOriginalKey(activeDecision.providerId);
|
|
175
|
+
if (result?.ok) {
|
|
176
|
+
possiblyOwnedProviderIds.delete(activeDecision.providerId);
|
|
177
|
+
clearPendingExhaustion();
|
|
178
|
+
return "applied";
|
|
179
|
+
}
|
|
180
|
+
decision = engine?.observeFailure({ kind: "provider-error" }) ?? { kind: "exhausted" };
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
const provider = catalog.providers.find((candidate) => candidate.provider === activeDecision.providerId);
|
|
184
|
+
const backupIndex = backupIndexForSlot(activeDecision.keySlot);
|
|
185
|
+
const backupKey = backupIndex === undefined ? undefined : provider?.backupKeys?.[backupIndex];
|
|
186
|
+
if (backupKey !== undefined && runtime?.ownsBackupKey(activeDecision.providerId, backupKey)) {
|
|
187
|
+
// The backup override is already in effect from a prior turn; re-applying
|
|
188
|
+
// would only duplicate the runtime write and re-announce the switch.
|
|
189
|
+
clearPendingExhaustion();
|
|
190
|
+
return "applied";
|
|
191
|
+
}
|
|
192
|
+
possiblyOwnedProviderIds.add(activeDecision.providerId);
|
|
193
|
+
const result = backupKey === undefined
|
|
194
|
+
? { ok: false as const }
|
|
195
|
+
: await runtime?.setBackupKey(activeDecision.providerId, backupKey);
|
|
196
|
+
if (result?.ok) {
|
|
197
|
+
notifyKeySwitch(ctx, activeDecision.providerId, activeDecision.keySlot);
|
|
198
|
+
clearPendingExhaustion();
|
|
199
|
+
return "applied";
|
|
200
|
+
}
|
|
201
|
+
if (!runtime?.hasOwnedOverride(activeDecision.providerId)) {
|
|
202
|
+
possiblyOwnedProviderIds.delete(activeDecision.providerId);
|
|
203
|
+
}
|
|
204
|
+
decision = engine?.observeFailure({ kind: "unauthorized" }) ?? { kind: "exhausted" };
|
|
205
|
+
}
|
|
206
|
+
if (decision.kind === "exhausted") {
|
|
207
|
+
// Pi decides whether to auto-retry only after message_end handlers finish.
|
|
208
|
+
// Keep the active credential in place until agent_settled so a built-in
|
|
209
|
+
// retry cannot fall back to a known-bad primary credential.
|
|
210
|
+
exhaustionPending = true;
|
|
211
|
+
return "exhausted";
|
|
212
|
+
}
|
|
213
|
+
return "none";
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
pi.on("session_start", (_event, ctx) => {
|
|
217
|
+
rebuild(ctx);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
pi.on("turn_start", async (_event, ctx) => {
|
|
221
|
+
activeContext = ctx;
|
|
222
|
+
if (!engine || !ctx.model) return;
|
|
223
|
+
if (exhaustionPending) return;
|
|
224
|
+
await execute(engine.startTurn({ providerId: ctx.model.provider, model: ctx.model.id }), ctx);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
pi.on("before_provider_request", (_event, ctx) => {
|
|
228
|
+
attempt = undefined;
|
|
229
|
+
const decision = engine?.currentDecision();
|
|
230
|
+
const activeAttempt = decision && (decision.kind === "switch-key" || decision.kind === "switch-model")
|
|
231
|
+
? decision
|
|
232
|
+
: exhaustionPending
|
|
233
|
+
? deferredAttempt
|
|
234
|
+
: undefined;
|
|
235
|
+
if (ctx.model && activeAttempt?.providerId === ctx.model.provider) {
|
|
236
|
+
attempt = {
|
|
237
|
+
providerId: activeAttempt.providerId,
|
|
238
|
+
model: activeAttempt.model,
|
|
239
|
+
keySlot: activeAttempt.keySlot,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
pi.on("after_provider_response", (event) => {
|
|
245
|
+
if (!attempt) return;
|
|
246
|
+
attempt.status = event.status;
|
|
247
|
+
attempt.retryAfterMs = retryAfterMilliseconds(event.headers, now());
|
|
248
|
+
if (event.status >= 200 && event.status < 300) engine?.observeSuccess();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
pi.on("message_end", async (event, ctx) => {
|
|
252
|
+
if (event.message.role !== "assistant") return;
|
|
253
|
+
if (event.message.stopReason !== "error") {
|
|
254
|
+
attempt = undefined;
|
|
255
|
+
clearPendingExhaustion();
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (!attempt || !engine) return;
|
|
259
|
+
const failedAttempt = attempt;
|
|
260
|
+
attempt = undefined;
|
|
261
|
+
const errorMessage = event.message.errorMessage ?? "";
|
|
262
|
+
const result = await execute(
|
|
263
|
+
engine.observeFailure(classifyAttemptFailure(failedAttempt, errorMessage)),
|
|
264
|
+
ctx,
|
|
265
|
+
);
|
|
266
|
+
if (result === "exhausted") {
|
|
267
|
+
deferredAttempt = {
|
|
268
|
+
providerId: failedAttempt.providerId,
|
|
269
|
+
model: failedAttempt.model,
|
|
270
|
+
keySlot: failedAttempt.keySlot,
|
|
271
|
+
};
|
|
272
|
+
engine.resumeAttempt(deferredAttempt);
|
|
273
|
+
}
|
|
274
|
+
if (result !== "applied") return;
|
|
275
|
+
|
|
276
|
+
pi.sendMessage(RETRY_MESSAGE, { triggerTurn: true, deliverAs: "followUp" });
|
|
277
|
+
return {
|
|
278
|
+
message: {
|
|
279
|
+
...event.message,
|
|
280
|
+
content: [],
|
|
281
|
+
stopReason: "stop",
|
|
282
|
+
errorMessage: undefined,
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
pi.on("agent_settled", async () => {
|
|
288
|
+
if (!exhaustionPending) return;
|
|
289
|
+
clearPendingExhaustion();
|
|
290
|
+
await restoreOwnedOverrides();
|
|
291
|
+
if (activeContext) notifyExhausted(activeContext);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
pi.on("session_shutdown", async () => {
|
|
295
|
+
await restoreOwnedOverrides();
|
|
296
|
+
engine = undefined;
|
|
297
|
+
attempt = undefined;
|
|
298
|
+
clearPendingExhaustion();
|
|
299
|
+
activeContext = undefined;
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
pi.registerCommand("failover-status", {
|
|
303
|
+
description: "show redacted failover status",
|
|
304
|
+
handler: async (_args, ctx) => {
|
|
305
|
+
notify(ctx, formatStatus(catalog, engine?.snapshot()), "info");
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
pi.registerCommand("failover-reload", {
|
|
310
|
+
description: "reload failover configuration from auth.json",
|
|
311
|
+
handler: async (_args, ctx) => {
|
|
312
|
+
await restoreOwnedOverrides();
|
|
313
|
+
rebuild(ctx);
|
|
314
|
+
notify(ctx, catalog.enabled ? "pi-failover: reloaded auth.json" : "pi-failover: auth.json reload disabled failover", catalog.enabled ? "info" : "warning");
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
pi.registerCommand("failover-login", {
|
|
319
|
+
description: "add a backup API key to a provider's api_key credential in auth.json",
|
|
320
|
+
getArgumentCompletions: (prefix) => {
|
|
321
|
+
const items = readCatalog()
|
|
322
|
+
.providers.filter((provider) => provider.type === "api_key")
|
|
323
|
+
.map((provider) => ({ value: provider.provider, label: provider.provider }));
|
|
324
|
+
const matches = items.filter((item) => item.value.startsWith(prefix));
|
|
325
|
+
return matches.length > 0 ? matches : null;
|
|
326
|
+
},
|
|
327
|
+
handler: async (args, ctx) => {
|
|
328
|
+
await runFailoverLogin(args, {
|
|
329
|
+
hasUI: ctx.hasUI,
|
|
330
|
+
select: (title, options) => ctx.ui.select(title, options),
|
|
331
|
+
input: (title, placeholder) => ctx.ui.input(title, placeholder),
|
|
332
|
+
confirm: (title, message) => ctx.ui.confirm(title, message),
|
|
333
|
+
readCatalog,
|
|
334
|
+
writeBackupKey,
|
|
335
|
+
rebuild: async () => {
|
|
336
|
+
await restoreOwnedOverrides();
|
|
337
|
+
rebuild(ctx);
|
|
338
|
+
},
|
|
339
|
+
notify: (message, level) => notify(ctx, message, level),
|
|
340
|
+
});
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export default createFailoverExtension();
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { ProviderPlan } from "./failover-engine.ts";
|
|
2
|
+
|
|
3
|
+
export interface PlannerModel {
|
|
4
|
+
provider: string;
|
|
5
|
+
id: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ModelPlannerContext<TModel extends PlannerModel = PlannerModel> {
|
|
9
|
+
modelRegistry: {
|
|
10
|
+
getAvailable(): readonly TModel[];
|
|
11
|
+
getProviderAuthStatus(providerId: string): { configured: boolean };
|
|
12
|
+
};
|
|
13
|
+
setModel(model: TModel): boolean | Promise<boolean>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ApplyNextModelRequest {
|
|
17
|
+
authOrder: readonly (string | { provider: string })[];
|
|
18
|
+
current: ProviderPlan;
|
|
19
|
+
unavailableProviderIds: readonly string[];
|
|
20
|
+
cooldownProviderIds: readonly string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function applyNextModel<TModel extends PlannerModel>(
|
|
24
|
+
ctx: ModelPlannerContext<TModel>,
|
|
25
|
+
request: ApplyNextModelRequest,
|
|
26
|
+
): Promise<ProviderPlan | undefined> {
|
|
27
|
+
const providerIds = request.authOrder.map((entry) => typeof entry === "string" ? entry : entry.provider);
|
|
28
|
+
const currentIndex = providerIds.indexOf(request.current.providerId);
|
|
29
|
+
const ordered = currentIndex < 0
|
|
30
|
+
? providerIds
|
|
31
|
+
: [...providerIds.slice(currentIndex + 1), ...providerIds.slice(0, currentIndex)];
|
|
32
|
+
const available = ctx.modelRegistry.getAvailable();
|
|
33
|
+
const unavailable = new Set(request.unavailableProviderIds);
|
|
34
|
+
const cooling = new Set(request.cooldownProviderIds);
|
|
35
|
+
|
|
36
|
+
for (const providerId of ordered) {
|
|
37
|
+
if (unavailable.has(providerId) || cooling.has(providerId)) continue;
|
|
38
|
+
if (!ctx.modelRegistry.getProviderAuthStatus(providerId).configured) continue;
|
|
39
|
+
const providerModels = available.filter((candidate) => candidate.provider === providerId);
|
|
40
|
+
const model = providerModels.find((candidate) => candidate.id === request.current.model) ?? providerModels[0];
|
|
41
|
+
if (!model) continue;
|
|
42
|
+
try {
|
|
43
|
+
if (await ctx.setModel(model)) return { providerId, model: model.id };
|
|
44
|
+
} catch {
|
|
45
|
+
// A model may become unavailable between the registry snapshot and
|
|
46
|
+
// application. Continue the deterministic provider walk.
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { AuthCatalog } from "./auth-catalog.ts";
|
|
3
|
+
import type { FailoverSnapshot, KeySlot } from "./failover-engine.ts";
|
|
4
|
+
|
|
5
|
+
export type NotificationLevel = "info" | "warning" | "error";
|
|
6
|
+
export type NotificationContext = Pick<ExtensionContext, "hasUI" | "ui">;
|
|
7
|
+
|
|
8
|
+
export function notify(ctx: NotificationContext, message: string, level: NotificationLevel): void {
|
|
9
|
+
if (!ctx.hasUI) return;
|
|
10
|
+
try {
|
|
11
|
+
ctx.ui.notify(message, level);
|
|
12
|
+
} catch {
|
|
13
|
+
// A disappearing UI must not interrupt provider failover.
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function notifyKeySwitch(ctx: NotificationContext, providerId: string, slot: KeySlot): void {
|
|
18
|
+
notify(ctx, `pi-failover: ${providerId} switched to ${slot} credential`, "warning");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function notifyProviderSwitch(ctx: NotificationContext, providerId: string, model: string): void {
|
|
22
|
+
notify(ctx, `pi-failover: switched to ${providerId}/${model}`, "warning");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function notifyExhausted(ctx: NotificationContext): void {
|
|
26
|
+
notify(ctx, "pi-failover: all configured providers exhausted; preserving the original provider error", "error");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function formatStatus(catalog: AuthCatalog, snapshot: FailoverSnapshot | undefined): string {
|
|
30
|
+
if (!catalog.enabled || !snapshot) return "pi-failover: disabled";
|
|
31
|
+
const lines = ["pi-failover: active"];
|
|
32
|
+
if (snapshot.active) {
|
|
33
|
+
lines.push(`current: ${snapshot.active.providerId}/${snapshot.active.model}; key=${snapshot.active.keySlot}`);
|
|
34
|
+
}
|
|
35
|
+
for (const provider of snapshot.providers) {
|
|
36
|
+
const providerCooldown = provider.cooldownUntil === undefined ? "" : ` until=${provider.cooldownUntil}`;
|
|
37
|
+
const keys = provider.keys.map((key) => `${key.slot}=${key.status}${key.cooldownUntil === undefined ? "" : ` until=${key.cooldownUntil}`}`).join(", ");
|
|
38
|
+
lines.push(`${provider.providerId}: ${provider.status}${providerCooldown}; ${keys}`);
|
|
39
|
+
}
|
|
40
|
+
lines.push(`visited providers: ${snapshot.visitedProviders.join(", ") || "none"}`);
|
|
41
|
+
lines.push(`visited keys: ${snapshot.visitedKeys.map((key) => `${key.providerId}:${key.keySlot}`).join(", ") || "none"}`);
|
|
42
|
+
return lines.join("\n");
|
|
43
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
export interface PiAuthStatus {
|
|
2
|
+
configured: boolean;
|
|
3
|
+
source?: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface PiRuntimeRegistry {
|
|
7
|
+
getApiKeyForProvider(providerId: string): Promise<string | undefined>;
|
|
8
|
+
getProviderAuthStatus(providerId: string): PiAuthStatus;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type RuntimeOverrideResult =
|
|
12
|
+
| { ok: true; action: "set" | "removed" | "restored" | "unchanged" }
|
|
13
|
+
| { ok: false; reason: "unsupported" | "operation_failed" };
|
|
14
|
+
|
|
15
|
+
export interface PiRuntimeAdapter {
|
|
16
|
+
readonly supported: boolean;
|
|
17
|
+
hasOwnedOverride(providerId: string): boolean;
|
|
18
|
+
ownsBackupKey(providerId: string, backupKey: string): boolean;
|
|
19
|
+
setBackupKey(providerId: string, backupKey: string): Promise<RuntimeOverrideResult>;
|
|
20
|
+
restoreOriginalKey(providerId: string): Promise<RuntimeOverrideResult>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type RuntimeApi = {
|
|
24
|
+
setRuntimeApiKey(providerId: string, apiKey: string): void | Promise<void>;
|
|
25
|
+
removeRuntimeApiKey(providerId: string): void | Promise<void>;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type OwnedOverride =
|
|
29
|
+
| { ownedKey: string; originalSourceWasRuntime: true; originalRuntimeKey: string }
|
|
30
|
+
| { ownedKey: string; originalSourceWasRuntime: false };
|
|
31
|
+
|
|
32
|
+
const OWNERSHIP_SYMBOL = Symbol.for("pi-failover.runtime-override-ownership");
|
|
33
|
+
|
|
34
|
+
function ownershipStore(): WeakMap<object, Map<string, OwnedOverride>> {
|
|
35
|
+
const root = globalThis as typeof globalThis & Record<PropertyKey, unknown>;
|
|
36
|
+
const existing = root[OWNERSHIP_SYMBOL];
|
|
37
|
+
if (existing instanceof WeakMap) return existing as WeakMap<object, Map<string, OwnedOverride>>;
|
|
38
|
+
|
|
39
|
+
const created = new WeakMap<object, Map<string, OwnedOverride>>();
|
|
40
|
+
root[OWNERSHIP_SYMBOL] = created;
|
|
41
|
+
return created;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function runtimeApi(registry: PiRuntimeRegistry): RuntimeApi | undefined {
|
|
45
|
+
// ModelRegistry.runtime is private in Pi's declarations. Keep the compatibility
|
|
46
|
+
// escape hatch contained here; all reads use the public registry facade.
|
|
47
|
+
const runtime = (registry as PiRuntimeRegistry & { runtime?: Partial<RuntimeApi> }).runtime;
|
|
48
|
+
if (
|
|
49
|
+
!runtime ||
|
|
50
|
+
typeof runtime.setRuntimeApiKey !== "function" ||
|
|
51
|
+
typeof runtime.removeRuntimeApiKey !== "function"
|
|
52
|
+
) {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
return runtime as RuntimeApi;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function createPiRuntimeAdapter(registry: PiRuntimeRegistry): PiRuntimeAdapter {
|
|
59
|
+
const runtime = runtimeApi(registry);
|
|
60
|
+
|
|
61
|
+
async function readRuntimeState(providerId: string): Promise<{ key: string | undefined; status: PiAuthStatus }> {
|
|
62
|
+
const key = await registry.getApiKeyForProvider(providerId);
|
|
63
|
+
return { key, status: registry.getProviderAuthStatus(providerId) };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function originalStateConfirmed(state: { key: string | undefined; status: PiAuthStatus }, owned: OwnedOverride): boolean {
|
|
67
|
+
return owned.originalSourceWasRuntime
|
|
68
|
+
? state.status.source === "runtime" && state.key === owned.originalRuntimeKey
|
|
69
|
+
: state.status.source !== "runtime" && state.key !== owned.ownedKey;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function reconcileRejectedSet(
|
|
73
|
+
api: RuntimeApi,
|
|
74
|
+
providerId: string,
|
|
75
|
+
owned: OwnedOverride,
|
|
76
|
+
previousOwned: OwnedOverride | undefined,
|
|
77
|
+
byProvider: Map<string, OwnedOverride>,
|
|
78
|
+
): Promise<void> {
|
|
79
|
+
let state: { key: string | undefined; status: PiAuthStatus };
|
|
80
|
+
try {
|
|
81
|
+
state = await readRuntimeState(providerId);
|
|
82
|
+
} catch {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (previousOwned && state.status.source === "runtime" && state.key === previousOwned.ownedKey) {
|
|
87
|
+
byProvider.set(providerId, previousOwned);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (originalStateConfirmed(state, owned)) {
|
|
91
|
+
byProvider.delete(providerId);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (state.status.source !== "runtime" || state.key !== owned.ownedKey) return;
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
if (owned.originalSourceWasRuntime) {
|
|
98
|
+
await api.setRuntimeApiKey(providerId, owned.originalRuntimeKey);
|
|
99
|
+
} else {
|
|
100
|
+
await api.removeRuntimeApiKey(providerId);
|
|
101
|
+
}
|
|
102
|
+
} catch {
|
|
103
|
+
// Pi can commit compensation before synchronization rejects. Confirm
|
|
104
|
+
// effective state below before deciding whether ownership can be cleared.
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
state = await readRuntimeState(providerId);
|
|
109
|
+
if (originalStateConfirmed(state, owned)) byProvider.delete(providerId);
|
|
110
|
+
} catch {
|
|
111
|
+
// Indeterminate compensation keeps ownership for a later cleanup.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
supported: runtime !== undefined,
|
|
117
|
+
hasOwnedOverride(providerId) {
|
|
118
|
+
return runtime !== undefined && (ownershipStore().get(runtime)?.has(providerId) ?? false);
|
|
119
|
+
},
|
|
120
|
+
ownsBackupKey(providerId, backupKey) {
|
|
121
|
+
if (!runtime) return false;
|
|
122
|
+
return ownershipStore().get(runtime)?.get(providerId)?.ownedKey === backupKey;
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
async setBackupKey(providerId, backupKey) {
|
|
126
|
+
if (!runtime) return { ok: false, reason: "unsupported" };
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const status = registry.getProviderAuthStatus(providerId);
|
|
130
|
+
const currentKey = await registry.getApiKeyForProvider(providerId);
|
|
131
|
+
const store = ownershipStore();
|
|
132
|
+
const byProvider = store.get(runtime) ?? new Map<string, OwnedOverride>();
|
|
133
|
+
store.set(runtime, byProvider);
|
|
134
|
+
const existing = byProvider.get(providerId);
|
|
135
|
+
let owned: OwnedOverride;
|
|
136
|
+
if (existing && currentKey === existing.ownedKey) {
|
|
137
|
+
owned = existing.originalSourceWasRuntime
|
|
138
|
+
? { ownedKey: backupKey, originalSourceWasRuntime: true, originalRuntimeKey: existing.originalRuntimeKey }
|
|
139
|
+
: { ownedKey: backupKey, originalSourceWasRuntime: false };
|
|
140
|
+
} else if (status.source === "runtime") {
|
|
141
|
+
if (currentKey === undefined) return { ok: false, reason: "operation_failed" };
|
|
142
|
+
owned = { ownedKey: backupKey, originalSourceWasRuntime: true, originalRuntimeKey: currentKey };
|
|
143
|
+
} else {
|
|
144
|
+
owned = { ownedKey: backupKey, originalSourceWasRuntime: false };
|
|
145
|
+
}
|
|
146
|
+
byProvider.set(providerId, owned);
|
|
147
|
+
try {
|
|
148
|
+
await runtime.setRuntimeApiKey(providerId, backupKey);
|
|
149
|
+
} catch {
|
|
150
|
+
await reconcileRejectedSet(runtime, providerId, owned, existing, byProvider);
|
|
151
|
+
return { ok: false, reason: "operation_failed" };
|
|
152
|
+
}
|
|
153
|
+
return { ok: true, action: "set" };
|
|
154
|
+
} catch {
|
|
155
|
+
return { ok: false, reason: "operation_failed" };
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
async restoreOriginalKey(providerId) {
|
|
160
|
+
if (!runtime) return { ok: false, reason: "unsupported" };
|
|
161
|
+
const byProvider = ownershipStore().get(runtime);
|
|
162
|
+
const owned = byProvider?.get(providerId);
|
|
163
|
+
if (!owned) return { ok: true, action: "unchanged" };
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
const currentKey = await registry.getApiKeyForProvider(providerId);
|
|
167
|
+
if (
|
|
168
|
+
currentKey === undefined &&
|
|
169
|
+
registry.getProviderAuthStatus(providerId).source === "runtime"
|
|
170
|
+
) {
|
|
171
|
+
return { ok: false, reason: "operation_failed" };
|
|
172
|
+
}
|
|
173
|
+
if (currentKey !== owned.ownedKey) {
|
|
174
|
+
byProvider?.delete(providerId);
|
|
175
|
+
return { ok: true, action: "unchanged" };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (owned.originalSourceWasRuntime) {
|
|
179
|
+
await runtime.setRuntimeApiKey(providerId, owned.originalRuntimeKey);
|
|
180
|
+
byProvider?.delete(providerId);
|
|
181
|
+
return { ok: true, action: "restored" };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
await runtime.removeRuntimeApiKey(providerId);
|
|
185
|
+
byProvider?.delete(providerId);
|
|
186
|
+
return { ok: true, action: "removed" };
|
|
187
|
+
} catch {
|
|
188
|
+
return { ok: false, reason: "operation_failed" };
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"noEmit": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"forceConsistentCasingInFileNames": true,
|
|
10
|
+
"allowImportingTsExtensions": true
|
|
11
|
+
},
|
|
12
|
+
"include": [
|
|
13
|
+
"src/**/*.ts",
|
|
14
|
+
"tests/**/*.ts"
|
|
15
|
+
],
|
|
16
|
+
"exclude": [
|
|
17
|
+
"node_modules"
|
|
18
|
+
]
|
|
19
|
+
}
|