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.
Files changed (3) hide show
  1. package/index.ts +34 -23
  2. package/notification.ts +99 -0
  3. 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 notify: ((text: string, level: "info" | "warning" | "error") => void) | undefined;
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: { notify: (t: string, l?: "info" | "warning" | "error") => void };
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
- notify = (text, level) => ctx.ui.notify(text, level);
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 (notify && currentKey) {
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
- notify(
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("after_provider_response", async (event, ctx) => {
190
+ pi.on("message_end", async (event, ctx) => {
194
191
  if (!config) return;
195
- if (event.status !== 429 && event.status !== 401 && event.status !== 403) return;
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, event.status, (key) => {
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 — clear runtime override so pi falls back to auth.json
214
- // (which has the user's original key). pi will surface the real error.
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 HTTP ${event.status}.`,
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
- notify = undefined;
240
+ uiCtx = undefined;
230
241
  activationNotified = false;
231
242
  });
232
243
 
@@ -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.2.3",
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": [