pi-keyrouter 0.2.2 → 0.3.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.
Files changed (2) hide show
  1. package/index.ts +44 -24
  2. package/package.json +1 -1
package/index.ts CHANGED
@@ -49,16 +49,22 @@ export default function keyRouterExtension(pi: ExtensionAPI): void {
49
49
  let notify: ((text: string, level: "info" | "warning" | "error") => void) | undefined;
50
50
  let activationNotified = false;
51
51
 
52
- function ensureRuntime(providerName: string, cfg: KeyRouterConfig): ProviderRuntime | undefined {
53
- let rt = runtimes.get(providerName);
52
+ /**
53
+ * Get-or-create the runtime for a provider. Keyed by the RESOLVED
54
+ * name (the authStorage id, e.g. "zai"), but populated from the
55
+ * provider config passed in directly (avoids name-mismatch bugs).
56
+ */
57
+ function ensureRuntime(
58
+ resolvedName: string,
59
+ providerCfg: { keys: ReadonlyArray<{ name: string; value: string }> },
60
+ ): ProviderRuntime {
61
+ let rt = runtimes.get(resolvedName);
54
62
  if (rt) return rt;
55
- const providerCfg = cfg.providers.find((p) => p.name === providerName);
56
- if (!providerCfg) return undefined;
57
63
  rt = {
58
64
  keys: initKeyStates(providerCfg.keys),
59
65
  currentIndex: -1,
60
66
  };
61
- runtimes.set(providerName, rt);
67
+ runtimes.set(resolvedName, rt);
62
68
  return rt;
63
69
  }
64
70
 
@@ -82,15 +88,15 @@ export default function keyRouterExtension(pi: ExtensionAPI): void {
82
88
  const authStorage = ctx.modelRegistry.authStorage;
83
89
  let newlyBootstrapped = 0;
84
90
  for (const p of config.providers) {
85
- const providerName = resolveProviderName(p.name);
91
+ const resolvedName = resolveProviderName(p.name);
86
92
  // Skip providers we've already bootstrapped
87
- if (runtimes.has(providerName)) continue;
88
- if (bootstrap(providerName)) {
89
- const rt = runtimes.get(providerName);
93
+ if (runtimes.has(resolvedName)) continue;
94
+ if (bootstrap(resolvedName, p)) {
95
+ const rt = runtimes.get(resolvedName);
90
96
  if (rt && rt.currentIndex >= 0) {
91
97
  const key = rt.keys[rt.currentIndex];
92
98
  if (key) {
93
- authStorage.setRuntimeApiKey(providerName, key.value);
99
+ authStorage.setRuntimeApiKey(resolvedName, key.value);
94
100
  newlyBootstrapped++;
95
101
  }
96
102
  }
@@ -107,11 +113,11 @@ export default function keyRouterExtension(pi: ExtensionAPI): void {
107
113
  }
108
114
 
109
115
  /** Set the initial key for a provider on first use. */
110
- function bootstrap(providerName: string): boolean {
111
- const cfg = config;
112
- if (!cfg) return false;
113
- const rt = ensureRuntime(providerName, cfg);
114
- if (!rt) return false;
116
+ function bootstrap(
117
+ resolvedName: string,
118
+ providerCfg: { keys: ReadonlyArray<{ name: string; value: string }> },
119
+ ): boolean {
120
+ const rt = ensureRuntime(resolvedName, providerCfg);
115
121
  if (rt.currentIndex >= 0) return true; // already bootstrapped
116
122
  const idx = pickNextKey(rt.keys, 0, Date.now());
117
123
  if (idx < 0) return false;
@@ -184,9 +190,27 @@ export default function keyRouterExtension(pi: ExtensionAPI): void {
184
190
  await activate(ctx);
185
191
  });
186
192
 
187
- pi.on("after_provider_response", async (event, ctx) => {
193
+ pi.on("message_end", async (event, ctx) => {
188
194
  if (!config) return;
189
- if (event.status !== 429 && event.status !== 401 && event.status !== 403) return;
195
+ const msg = event.message;
196
+ // Only intercept assistant error messages
197
+ if (msg.role !== "assistant" || msg.stopReason !== "error") return;
198
+ const errMsg = msg.errorMessage ?? "";
199
+ if (!errMsg) return;
200
+
201
+ // Detect error type from the message string.
202
+ // pi's error messages look like: "429 Usage limit reached..."
203
+ // or "401 Unauthorized" / "403 Forbidden".
204
+ let reason: "rate-limited" | "unauthorized" | null = null;
205
+ let status = 0;
206
+ if (/\b429\b|rate.?limit|too many requests/i.test(errMsg)) {
207
+ reason = "rate-limited";
208
+ status = 429;
209
+ } else if (/\b40[13]\b|unauthorized|forbidden/i.test(errMsg)) {
210
+ reason = "unauthorized";
211
+ status = errMsg.includes("401") ? 401 : 403;
212
+ }
213
+ if (!reason) return; // not a rotatable error
190
214
 
191
215
  // Determine provider from current model
192
216
  const model = ctx.model;
@@ -195,22 +219,18 @@ export default function keyRouterExtension(pi: ExtensionAPI): void {
195
219
  const rt = runtimes.get(providerName);
196
220
  if (!rt) return; // not a managed provider
197
221
 
198
- const reason: "rate-limited" | "unauthorized" =
199
- event.status === 429 ? "rate-limited" : "unauthorized";
200
-
201
222
  const authStorage = ctx.modelRegistry.authStorage;
202
- const rotated = rotate(providerName, reason, event.status, (key) => {
223
+ const rotated = rotate(providerName, reason, status, (key) => {
203
224
  authStorage.setRuntimeApiKey(providerName, key);
204
225
  });
205
226
 
206
227
  if (!rotated) {
207
- // All keys exhausted — clear runtime override so pi falls back to auth.json
208
- // (which has the user's original key). pi will surface the real error.
228
+ // All keys exhausted — let pi surface the real error.
209
229
  if (notify) {
210
230
  const failed = rt.keys.filter((k) => k.failures > 0).map((k) => k.name);
211
231
  notify(
212
232
  `🔑 keyrouter: ${providerName} — all keys exhausted (${failed.join(", ")}). ` +
213
- `Letting pi surface the original HTTP ${event.status}.`,
233
+ `Letting pi surface the original error.`,
214
234
  "error",
215
235
  );
216
236
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-keyrouter",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
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",