pi-keyrouter 0.2.3 → 0.3.1
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/index.ts +34 -23
- package/notification.ts +99 -0
- package/package.json +2 -1
package/index.ts
CHANGED
|
@@ -27,8 +27,9 @@
|
|
|
27
27
|
// # create ~/.pi/keyrouter.json with your provider keys
|
|
28
28
|
// /reload
|
|
29
29
|
|
|
30
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
30
|
+
import type { ExtensionAPI, ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
31
31
|
import { loadConfig, configPath } from "./config.ts";
|
|
32
|
+
import { notifyRotation } from "./notification.ts";
|
|
32
33
|
import {
|
|
33
34
|
initKeyStates,
|
|
34
35
|
isAvailable,
|
|
@@ -46,7 +47,7 @@ interface ProviderRuntime {
|
|
|
46
47
|
export default function keyRouterExtension(pi: ExtensionAPI): void {
|
|
47
48
|
let config: KeyRouterConfig | undefined;
|
|
48
49
|
const runtimes = new Map<string, ProviderRuntime>();
|
|
49
|
-
let
|
|
50
|
+
let uiCtx: ExtensionUIContext | undefined;
|
|
50
51
|
let activationNotified = false;
|
|
51
52
|
|
|
52
53
|
/**
|
|
@@ -75,7 +76,7 @@ export default function keyRouterExtension(pi: ExtensionAPI): void {
|
|
|
75
76
|
*/
|
|
76
77
|
async function activate(ctx: {
|
|
77
78
|
cwd: string;
|
|
78
|
-
ui:
|
|
79
|
+
ui: ExtensionUIContext;
|
|
79
80
|
modelRegistry: { authStorage: { setRuntimeApiKey: (p: string, k: string) => void } };
|
|
80
81
|
}): Promise<void> {
|
|
81
82
|
// Load config once (reload clears it)
|
|
@@ -83,7 +84,7 @@ export default function keyRouterExtension(pi: ExtensionAPI): void {
|
|
|
83
84
|
config = loadConfig(ctx.cwd);
|
|
84
85
|
}
|
|
85
86
|
if (config.providers.length === 0) return;
|
|
86
|
-
|
|
87
|
+
uiCtx = ctx.ui;
|
|
87
88
|
|
|
88
89
|
const authStorage = ctx.modelRegistry.authStorage;
|
|
89
90
|
let newlyBootstrapped = 0;
|
|
@@ -160,8 +161,8 @@ export default function keyRouterExtension(pi: ExtensionAPI): void {
|
|
|
160
161
|
setKey(nextKey.value);
|
|
161
162
|
rt.currentIndex = nextIdx;
|
|
162
163
|
|
|
163
|
-
// Notify
|
|
164
|
-
if (
|
|
164
|
+
// Notify with yellow box widget (falls back to plain notify)
|
|
165
|
+
if (currentKey && uiCtx) {
|
|
165
166
|
const event: RotationEvent = {
|
|
166
167
|
provider: providerName,
|
|
167
168
|
fromKey: currentKey.name,
|
|
@@ -170,11 +171,7 @@ export default function keyRouterExtension(pi: ExtensionAPI): void {
|
|
|
170
171
|
status,
|
|
171
172
|
attempt: rt.keys.reduce((a, k) => a + k.failures, 0),
|
|
172
173
|
};
|
|
173
|
-
|
|
174
|
-
`🔑 keyrouter: ${event.provider} — ${event.fromKey} → ${event.toKey} ` +
|
|
175
|
-
`(HTTP ${event.status}, ${event.reason})`,
|
|
176
|
-
"warning",
|
|
177
|
-
);
|
|
174
|
+
notifyRotation(uiCtx, event);
|
|
178
175
|
}
|
|
179
176
|
return true;
|
|
180
177
|
}
|
|
@@ -190,9 +187,27 @@ export default function keyRouterExtension(pi: ExtensionAPI): void {
|
|
|
190
187
|
await activate(ctx);
|
|
191
188
|
});
|
|
192
189
|
|
|
193
|
-
pi.on("
|
|
190
|
+
pi.on("message_end", async (event, ctx) => {
|
|
194
191
|
if (!config) return;
|
|
195
|
-
|
|
192
|
+
const msg = event.message;
|
|
193
|
+
// Only intercept assistant error messages
|
|
194
|
+
if (msg.role !== "assistant" || msg.stopReason !== "error") return;
|
|
195
|
+
const errMsg = msg.errorMessage ?? "";
|
|
196
|
+
if (!errMsg) return;
|
|
197
|
+
|
|
198
|
+
// Detect error type from the message string.
|
|
199
|
+
// pi's error messages look like: "429 Usage limit reached..."
|
|
200
|
+
// or "401 Unauthorized" / "403 Forbidden".
|
|
201
|
+
let reason: "rate-limited" | "unauthorized" | null = null;
|
|
202
|
+
let status = 0;
|
|
203
|
+
if (/\b429\b|rate.?limit|too many requests/i.test(errMsg)) {
|
|
204
|
+
reason = "rate-limited";
|
|
205
|
+
status = 429;
|
|
206
|
+
} else if (/\b40[13]\b|unauthorized|forbidden/i.test(errMsg)) {
|
|
207
|
+
reason = "unauthorized";
|
|
208
|
+
status = errMsg.includes("401") ? 401 : 403;
|
|
209
|
+
}
|
|
210
|
+
if (!reason) return; // not a rotatable error
|
|
196
211
|
|
|
197
212
|
// Determine provider from current model
|
|
198
213
|
const model = ctx.model;
|
|
@@ -201,22 +216,18 @@ export default function keyRouterExtension(pi: ExtensionAPI): void {
|
|
|
201
216
|
const rt = runtimes.get(providerName);
|
|
202
217
|
if (!rt) return; // not a managed provider
|
|
203
218
|
|
|
204
|
-
const reason: "rate-limited" | "unauthorized" =
|
|
205
|
-
event.status === 429 ? "rate-limited" : "unauthorized";
|
|
206
|
-
|
|
207
219
|
const authStorage = ctx.modelRegistry.authStorage;
|
|
208
|
-
const rotated = rotate(providerName, reason,
|
|
220
|
+
const rotated = rotate(providerName, reason, status, (key) => {
|
|
209
221
|
authStorage.setRuntimeApiKey(providerName, key);
|
|
210
222
|
});
|
|
211
223
|
|
|
212
224
|
if (!rotated) {
|
|
213
|
-
// All keys exhausted —
|
|
214
|
-
|
|
215
|
-
if (notify) {
|
|
225
|
+
// All keys exhausted — let pi surface the real error.
|
|
226
|
+
if (uiCtx) {
|
|
216
227
|
const failed = rt.keys.filter((k) => k.failures > 0).map((k) => k.name);
|
|
217
|
-
notify(
|
|
228
|
+
uiCtx.notify(
|
|
218
229
|
`🔑 keyrouter: ${providerName} — all keys exhausted (${failed.join(", ")}). ` +
|
|
219
|
-
`Letting pi surface the original
|
|
230
|
+
`Letting pi surface the original error.`,
|
|
220
231
|
"error",
|
|
221
232
|
);
|
|
222
233
|
}
|
|
@@ -226,7 +237,7 @@ export default function keyRouterExtension(pi: ExtensionAPI): void {
|
|
|
226
237
|
pi.on("session_shutdown", () => {
|
|
227
238
|
runtimes.clear();
|
|
228
239
|
config = undefined;
|
|
229
|
-
|
|
240
|
+
uiCtx = undefined;
|
|
230
241
|
activationNotified = false;
|
|
231
242
|
});
|
|
232
243
|
|
package/notification.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// notification.ts — yellow Box widget for key rotation events
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Uses the same pattern as pi-soly's notification.ts:
|
|
6
|
+
// ui.setWidget(key, (tui, theme) => Component, { placement })
|
|
7
|
+
//
|
|
8
|
+
// Why yellow? Rotations are not errors (the request will succeed on the next
|
|
9
|
+
// key), so red toolErrorBg would be misleading. They're warnings, so we use
|
|
10
|
+
// yellow (\x1b[43m) which isn't in pi's ThemeBg palette but is universally
|
|
11
|
+
// supported across terminals (8-color ANSI, always available).
|
|
12
|
+
//
|
|
13
|
+
// Reset codes:
|
|
14
|
+
// \x1b[49m — reset background
|
|
15
|
+
// \x1b[39m — reset foreground (text color)
|
|
16
|
+
|
|
17
|
+
import { Box, Spacer, Text } from "@earendil-works/pi-tui";
|
|
18
|
+
import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import type { RotationEvent } from "./types.ts";
|
|
20
|
+
|
|
21
|
+
const WIDGET_KEY = "keyrouter-rotation";
|
|
22
|
+
const AUTO_CLEAR_MS = 8000;
|
|
23
|
+
|
|
24
|
+
// Standard ANSI yellow background (works in all terminals — 8-color minimum).
|
|
25
|
+
// Truecolor would be nicer but detection is terminal-specific; 43m is safe.
|
|
26
|
+
const YELLOW_BG = "\x1b[43m";
|
|
27
|
+
const BLACK_FG = "\x1b[30m";
|
|
28
|
+
const RESET_BG = "\x1b[49m";
|
|
29
|
+
const RESET_FG = "\x1b[39m";
|
|
30
|
+
const BOLD = "\x1b[1m";
|
|
31
|
+
|
|
32
|
+
/** Wrap text in a yellow box (background + black text for contrast). */
|
|
33
|
+
function yellowBg(text: string): string {
|
|
34
|
+
return `${YELLOW_BG}${BLACK_FG}${text}${RESET_BG}${RESET_FG}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Build the rotation widget. */
|
|
38
|
+
function buildRotationBox(event: RotationEvent): Box {
|
|
39
|
+
const box = new Box(1, 0, (t) => yellowBg(t));
|
|
40
|
+
|
|
41
|
+
// Title: 🔑 keyrouter: provider — fromKey → toKey
|
|
42
|
+
const title = `${BOLD}🔑 keyrouter: ${event.provider} — ${event.fromKey} → ${event.toKey}${BOLD === "\x1b[1m" ? "\x1b[22m" : ""}`;
|
|
43
|
+
box.addChild(new Text(title, 1, 0));
|
|
44
|
+
box.addChild(new Spacer(1));
|
|
45
|
+
|
|
46
|
+
// Body: reason + status
|
|
47
|
+
const reasonText =
|
|
48
|
+
event.reason === "rate-limited"
|
|
49
|
+
? `Rate-limited (HTTP ${event.status}) — rotated to next key`
|
|
50
|
+
: `Unauthorized (HTTP ${event.status}) — skipping bad key`;
|
|
51
|
+
box.addChild(new Text(reasonText, 1, 0));
|
|
52
|
+
|
|
53
|
+
// Retry hint
|
|
54
|
+
box.addChild(new Text(`pi will retry with ${event.toKey}…`, 1, 0));
|
|
55
|
+
|
|
56
|
+
return box;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Show a yellow box widget for a key rotation event.
|
|
61
|
+
* Auto-clears after 8s.
|
|
62
|
+
*/
|
|
63
|
+
export function notifyRotation(ui: ExtensionUIContext, event: RotationEvent): void {
|
|
64
|
+
try {
|
|
65
|
+
ui.setWidget(
|
|
66
|
+
WIDGET_KEY,
|
|
67
|
+
() => buildRotationBox(event),
|
|
68
|
+
{ placement: "aboveEditor" },
|
|
69
|
+
);
|
|
70
|
+
} catch {
|
|
71
|
+
// setWidget may fail if UI not available (print mode) — fall back to notify
|
|
72
|
+
try {
|
|
73
|
+
ui.notify(
|
|
74
|
+
`🔑 keyrouter: ${event.provider} — ${event.fromKey} → ${event.toKey} (HTTP ${event.status}, ${event.reason})`,
|
|
75
|
+
"warning",
|
|
76
|
+
);
|
|
77
|
+
} catch {
|
|
78
|
+
// no UI at all — silent
|
|
79
|
+
}
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
// Auto-clear after 8 seconds
|
|
83
|
+
setTimeout(() => {
|
|
84
|
+
try {
|
|
85
|
+
ui.setWidget(WIDGET_KEY, undefined);
|
|
86
|
+
} catch {
|
|
87
|
+
// session may have ended — ignore
|
|
88
|
+
}
|
|
89
|
+
}, AUTO_CLEAR_MS);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Clear the rotation widget manually. */
|
|
93
|
+
export function clearRotationWidget(ui: ExtensionUIContext): void {
|
|
94
|
+
try {
|
|
95
|
+
ui.setWidget(WIDGET_KEY, undefined);
|
|
96
|
+
} catch {
|
|
97
|
+
// ignore
|
|
98
|
+
}
|
|
99
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-keyrouter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "API key rotation for pi-coding-agent. Multiple keys per provider, automatic 429/401 fallback, max-retries guard.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"index.ts",
|
|
24
24
|
"config.ts",
|
|
25
25
|
"rotation.ts",
|
|
26
|
+
"notification.ts",
|
|
26
27
|
"types.ts"
|
|
27
28
|
],
|
|
28
29
|
"keywords": [
|