pi-keyrouter 0.3.0 → 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 +11 -14
  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
  }
@@ -226,9 +223,9 @@ export default function keyRouterExtension(pi: ExtensionAPI): void {
226
223
 
227
224
  if (!rotated) {
228
225
  // All keys exhausted — let pi surface the real error.
229
- if (notify) {
226
+ if (uiCtx) {
230
227
  const failed = rt.keys.filter((k) => k.failures > 0).map((k) => k.name);
231
- notify(
228
+ uiCtx.notify(
232
229
  `🔑 keyrouter: ${providerName} — all keys exhausted (${failed.join(", ")}). ` +
233
230
  `Letting pi surface the original error.`,
234
231
  "error",
@@ -240,7 +237,7 @@ export default function keyRouterExtension(pi: ExtensionAPI): void {
240
237
  pi.on("session_shutdown", () => {
241
238
  runtimes.clear();
242
239
  config = undefined;
243
- notify = undefined;
240
+ uiCtx = undefined;
244
241
  activationNotified = false;
245
242
  });
246
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.3.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": [