pi-keyrouter 0.1.0 → 0.2.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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **API key rotation for [pi-coding-agent](https://github.com/nicobailon/pi-coding-agent).**
4
4
 
5
- Multiple keys per provider · automatic 429/401 fallback · recursion-safe.
5
+ Multiple keys per provider · automatic 429/401 fallback · native integration.
6
6
 
7
7
  ```bash
8
8
  pi install npm:pi-keyrouter
@@ -10,7 +10,7 @@ pi install npm:pi-keyrouter
10
10
  /reload
11
11
  ```
12
12
 
13
- When your model returns 429 (rate-limited) or 401 (unauthorized), the next key is picked automatically. pi sees a single successful response retries are transparent.
13
+ When your model returns 429 (rate-limited) or 401 (unauthorized), the next key is set via pi's native `authStorage.setRuntimeApiKey()`. pi's built-in retry then uses the new key automatically.
14
14
 
15
15
  ---
16
16
 
@@ -43,25 +43,40 @@ Add your provider config to `~/.pi/keyrouter.json`:
43
43
 
44
44
  ---
45
45
 
46
- ## 🎯 How it works
46
+ ## 🎯 How it works (native integration)
47
47
 
48
- 1. **Install** — extension loads, reads config, wraps `fetch`.
49
- 2. **Request** — URL matches a provider → key picked, `Authorization: Bearer <key>` set.
50
- 3. **On 429 / 401** — current key marked bad (cooldown `cooldownMs`), next key tried.
51
- 4. **On 200** — response returned, key marked OK.
52
- 5. **After `maxRetries`** — last failed response returned (so pi sees the real error).
48
+ This extension uses pi's **native API key resolution** — no fetch hacks, no header manipulation.
53
49
 
54
- Recursion is bounded by `maxRetries` (default 3). No infinite loops.
50
+ From the [pi SDK docs](https://github.com/nicobailon/pi-coding-agent):
51
+
52
+ > API key resolution priority (handled by AuthStorage):
53
+ > 1. **Runtime overrides (via `setRuntimeApiKey`, not persisted)** ← we use this
54
+ > 2. Stored credentials in `auth.json`
55
+ > 3. Environment variables
56
+ > 4. Fallback resolver
57
+
58
+ Flow:
59
+
60
+ 1. **session_start** — extension loads config, calls `authStorage.setRuntimeApiKey(provider, firstKey)` for each managed provider. Runtime override takes priority over auth.json.
61
+ 2. **Request** — pi makes the HTTP call with the runtime-overridden key.
62
+ 3. **after_provider_response (429/401/403)** — extension fires, calls `setRuntimeApiKey(provider, nextKey)`.
63
+ 4. **pi's built-in retry** — pi's retry logic (the "Retrying 3/3" you see in the UI) makes the next attempt, which now picks up the new runtime key.
64
+ 5. **Success or exhaustion** — if all keys fail, runtime override is cleared and pi surfaces the real error.
65
+
66
+ ### Why not fetch wrapping?
67
+
68
+ An earlier version wrapped `globalThis.fetch`. It didn't work because the OpenAI SDK (used by pi-ai for z.ai and others) captures the `fetch` reference at client creation time, before extensions load. The SDK kept calling the original fetch, ignoring the wrapper.
69
+
70
+ The native `setRuntimeApiKey` approach is cleaner: pi owns the HTTP layer, we only swap the key between attempts. No monkey-patching, no timing issues.
55
71
 
56
72
  ### What gets rotated
57
73
 
58
74
  | Status | Action |
59
75
  |---|---|
60
- | 200 | Return response, mark key OK |
61
- | 429 | Mark key `rate-limited` (cooldown), try next |
62
- | 401 / 403 | Mark key `unauthorized` (cooldown), try next |
63
- | 5xx / network | Don't mark key bad try next, but no cooldown |
64
- | `maxRetries` exhausted | Return last failed response |
76
+ | 200 | Key marked OK |
77
+ | 429 | Current key marked `rate-limited` (cooldown), `setRuntimeApiKey(nextKey)` |
78
+ | 401 / 403 | Current key marked `unauthorized` (cooldown), `setRuntimeApiKey(nextKey)` |
79
+ | All keys exhausted | Runtime override cleared, pi surfaces real error |
65
80
 
66
81
  ---
67
82
 
@@ -142,7 +157,7 @@ API keys live in plain text in `keyrouter.json`. **Don't commit it.** Options:
142
157
  ## 🛠 Development
143
158
 
144
159
  ```bash
145
- bun test # 34 tests
160
+ bun test # 33 tests
146
161
  bun run typecheck # tsc --noEmit
147
162
  ```
148
163
 
@@ -150,16 +165,15 @@ Monorepo layout:
150
165
 
151
166
  ```
152
167
  packages/pi-keyrouter/
153
- ├── index.ts — extension entry point
168
+ ├── index.ts — extension entry point (native setRuntimeApiKey)
154
169
  ├── rotation.ts — pure key-pick logic
155
- ├── fetch-wrapper.ts — fetch interceptor with retry
156
170
  ├── config.ts — config loader
157
171
  ├── types.ts — shared types
158
172
  └── tests/
159
- ├── rotation.test.ts — pure logic
160
- ├── fetch-wrapper.test.ts integration with mocked fetch
161
- ├── config.test.ts — config loader
162
- └── smoke.test.ts — load-time smoke test
173
+ ├── rotation.test.ts — pure logic
174
+ ├── provider-resolution.test.ts provider name mapping
175
+ ├── config.test.ts — config loader
176
+ └── smoke.test.ts — load-time smoke test
163
177
  ```
164
178
 
165
179
  ---
package/index.ts CHANGED
@@ -1,122 +1,293 @@
1
1
  // =============================================================================
2
- // index.ts — pi-keyrouter extension entry point
2
+ // index.ts — pi-keyrouter extension entry point (native setRuntimeApiKey)
3
3
  // =============================================================================
4
4
  //
5
+ // HOW IT WORKS (native integration, no fetch hacks):
6
+ //
7
+ // 1. pi makes a request with the current API key
8
+ // 2. Provider returns 429 (rate-limited) or 401/403 (unauthorized)
9
+ // 3. `after_provider_response` event fires with the HTTP status
10
+ // 4. We call ctx.modelRegistry.authStorage.setRuntimeApiKey(provider, nextKey)
11
+ // 5. pi's BUILT-IN retry logic kicks in → next attempt uses the new key
12
+ // 6. Repeat until a key succeeds or we exhaust our key pool
13
+ //
14
+ // This is the native integration point documented in the SDK:
15
+ // "API key resolution priority:
16
+ // 1. Runtime overrides (via setRuntimeApiKey, not persisted)
17
+ // 2. Stored credentials in auth.json
18
+ // 3. Environment variables
19
+ // 4. Fallback resolver"
20
+ //
21
+ // We only touch priority #1 (runtime override). auth.json is never modified.
22
+ // On session end, runtime overrides vanish (not persisted) — clean slate
23
+ // for next session, which is exactly what we want for 429 rate limits.
24
+ //
5
25
  // Usage:
6
26
  // pi install npm:pi-keyrouter
7
27
  // # create ~/.pi/keyrouter.json with your provider keys
8
28
  // /reload
9
- //
10
- // On load:
11
- // 1. Reads keyrouter config (project or user-level)
12
- // 2. Wraps globalThis.fetch with rotation logic
13
- // 3. On 429/401, retries with next key up to maxRetries
14
- // 4. Notifies user on every key switch via Box widget
15
- //
16
- // Provides /keyrouter command for status / disable / enable.
17
29
 
18
30
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
31
  import { loadConfig } from "./config.ts";
20
- import { installKeyRouter, type KeyRouterHandle } from "./fetch-wrapper.ts";
32
+ import {
33
+ initKeyStates,
34
+ isAvailable,
35
+ markBad,
36
+ pickNextKey,
37
+ } from "./rotation.ts";
38
+ import type { KeyRouterConfig, RotationEvent, KeyState } from "./types.ts";
39
+
40
+ interface ProviderRuntime {
41
+ keys: KeyState[];
42
+ /** Index of the key currently set via setRuntimeApiKey. -1 = none set yet. */
43
+ currentIndex: number;
44
+ }
21
45
 
22
46
  export default function keyRouterExtension(pi: ExtensionAPI): void {
23
- let handle: KeyRouterHandle | undefined;
24
- let enabled = true;
25
- let currentCwd = "";
26
-
27
- function activate(cwd: string, notify: (text: string, level: string) => void): void {
28
- if (handle) return;
29
- const config = loadConfig(cwd);
30
- if (config.providers.length === 0) {
31
- return; // nothing to do
47
+ let config: KeyRouterConfig | undefined;
48
+ const runtimes = new Map<string, ProviderRuntime>();
49
+ let notify: ((text: string, level: "info" | "warning" | "error") => void) | undefined;
50
+ let activationNotified = false;
51
+
52
+ function ensureRuntime(providerName: string, cfg: KeyRouterConfig): ProviderRuntime | undefined {
53
+ let rt = runtimes.get(providerName);
54
+ if (rt) return rt;
55
+ const providerCfg = cfg.providers.find((p) => p.name === providerName);
56
+ if (!providerCfg) return undefined;
57
+ rt = {
58
+ keys: initKeyStates(providerCfg.keys),
59
+ currentIndex: -1,
60
+ };
61
+ runtimes.set(providerName, rt);
62
+ return rt;
63
+ }
64
+
65
+ /**
66
+ * Activate the router: load config (once), bootstrap all providers.
67
+ * Idempotent — safe to call on every before_agent_start. Only runs
68
+ * the bootstrap the FIRST time for each provider.
69
+ */
70
+ async function activate(ctx: {
71
+ cwd: string;
72
+ ui: { notify: (t: string, l?: "info" | "warning" | "error") => void };
73
+ modelRegistry: { authStorage: { setRuntimeApiKey: (p: string, k: string) => void } };
74
+ }): Promise<void> {
75
+ // Load config once (reload clears it)
76
+ if (!config) {
77
+ config = loadConfig(ctx.cwd);
78
+ }
79
+ if (config.providers.length === 0) return;
80
+ notify = (text, level) => ctx.ui.notify(text, level);
81
+
82
+ const authStorage = ctx.modelRegistry.authStorage;
83
+ let newlyBootstrapped = 0;
84
+ for (const p of config.providers) {
85
+ const providerName = resolveProviderName(p.name);
86
+ // Skip providers we've already bootstrapped
87
+ if (runtimes.has(providerName)) continue;
88
+ if (bootstrap(providerName)) {
89
+ const rt = runtimes.get(providerName);
90
+ if (rt && rt.currentIndex >= 0) {
91
+ const key = rt.keys[rt.currentIndex];
92
+ if (key) {
93
+ authStorage.setRuntimeApiKey(providerName, key.value);
94
+ newlyBootstrapped++;
95
+ }
96
+ }
97
+ }
32
98
  }
33
- handle = installKeyRouter(config, (event) => {
99
+ // Only notify on first activation (when we bootstrapped at least one)
100
+ if (newlyBootstrapped > 0 && !activationNotified) {
101
+ activationNotified = true;
102
+ ctx.ui.notify(
103
+ `🔑 keyrouter: active (${config.providers.length} provider(s), ${config.providers.reduce((a, p) => a + p.keys.length, 0)} keys)`,
104
+ "info",
105
+ );
106
+ }
107
+ }
108
+
109
+ /** 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;
115
+ if (rt.currentIndex >= 0) return true; // already bootstrapped
116
+ const idx = pickNextKey(rt.keys, 0, Date.now());
117
+ if (idx < 0) return false;
118
+ const key = rt.keys[idx];
119
+ if (!key) return false;
120
+ // We can't call setRuntimeApiKey here (no ctx), but we mark the index
121
+ // so the first after_provider_response knows where we are.
122
+ rt.currentIndex = idx;
123
+ return true;
124
+ }
125
+
126
+ /** Rotate to the next available key. Returns true if rotated. */
127
+ function rotate(
128
+ providerName: string,
129
+ reason: "rate-limited" | "unauthorized",
130
+ status: number,
131
+ setKey: (key: string) => void,
132
+ ): boolean {
133
+ const cfg = config;
134
+ if (!cfg) return false;
135
+ const rt = runtimes.get(providerName);
136
+ if (!rt) return false;
137
+
138
+ // Mark current key as bad
139
+ const currentKey = rt.currentIndex >= 0 ? rt.keys[rt.currentIndex] : undefined;
140
+ if (currentKey) {
141
+ markBad(currentKey, reason, cfg.cooldownMs, Date.now());
142
+ }
143
+
144
+ // Find next available key (different from current)
145
+ const nextIdx = pickNextKey(rt.keys, rt.currentIndex + 1, Date.now());
146
+ if (nextIdx < 0 || nextIdx === rt.currentIndex) {
147
+ // No other key available
148
+ return false;
149
+ }
150
+ const nextKey = rt.keys[nextIdx];
151
+ if (!nextKey) return false;
152
+
153
+ // Set the new runtime key — pi's retry will use it
154
+ setKey(nextKey.value);
155
+ rt.currentIndex = nextIdx;
156
+
157
+ // Notify
158
+ if (notify && currentKey) {
159
+ const event: RotationEvent = {
160
+ provider: providerName,
161
+ fromKey: currentKey.name,
162
+ toKey: nextKey.name,
163
+ reason,
164
+ status,
165
+ attempt: rt.keys.reduce((a, k) => a + k.failures, 0),
166
+ };
34
167
  notify(
35
168
  `🔑 keyrouter: ${event.provider} — ${event.fromKey} → ${event.toKey} ` +
36
- `(HTTP ${event.status}, attempt ${event.attempt})`,
169
+ `(HTTP ${event.status}, ${event.reason})`,
37
170
  "warning",
38
171
  );
39
- });
40
- }
41
-
42
- function deactivate(): void {
43
- if (handle) {
44
- handle.disable();
45
- handle = undefined;
46
172
  }
173
+ return true;
47
174
  }
48
175
 
49
176
  pi.on("session_start", async (_event, ctx) => {
50
- currentCwd = ctx.cwd;
51
- activate(ctx.cwd, (text, level) =>
52
- (ctx.ui.notify as (t: string, l?: string) => void)(text, level),
53
- );
54
- if (handle) {
55
- ctx.ui.notify(
56
- `🔑 keyrouter: active (${loadConfig(ctx.cwd).providers.length} provider(s))`,
57
- "info",
58
- );
177
+ await activate(ctx);
178
+ });
179
+
180
+ // Lazy bootstrap: also fire on every turn. This handles /reload (which
181
+ // does NOT re-fire session_start) and config changes mid-session.
182
+ // activate() is idempotent — only bootstraps once per provider.
183
+ pi.on("before_agent_start", async (_event, ctx) => {
184
+ await activate(ctx);
185
+ });
186
+
187
+ pi.on("after_provider_response", async (event, ctx) => {
188
+ if (!config) return;
189
+ if (event.status !== 429 && event.status !== 401 && event.status !== 403) return;
190
+
191
+ // Determine provider from current model
192
+ const model = ctx.model;
193
+ if (!model) return;
194
+ const providerName = resolveProviderName(model.provider);
195
+ const rt = runtimes.get(providerName);
196
+ if (!rt) return; // not a managed provider
197
+
198
+ const reason: "rate-limited" | "unauthorized" =
199
+ event.status === 429 ? "rate-limited" : "unauthorized";
200
+
201
+ const authStorage = ctx.modelRegistry.authStorage;
202
+ const rotated = rotate(providerName, reason, event.status, (key) => {
203
+ authStorage.setRuntimeApiKey(providerName, key);
204
+ });
205
+
206
+ 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.
209
+ if (notify) {
210
+ const failed = rt.keys.filter((k) => k.failures > 0).map((k) => k.name);
211
+ notify(
212
+ `🔑 keyrouter: ${providerName} — all keys exhausted (${failed.join(", ")}). ` +
213
+ `Letting pi surface the original HTTP ${event.status}.`,
214
+ "error",
215
+ );
216
+ }
59
217
  }
60
218
  });
61
219
 
62
220
  pi.on("session_shutdown", () => {
63
- deactivate();
221
+ runtimes.clear();
222
+ config = undefined;
223
+ notify = undefined;
224
+ activationNotified = false;
64
225
  });
65
226
 
66
227
  pi.registerCommand("keyrouter", {
67
- description: "manage key rotation (status, enable, disable, reload)",
228
+ description: "manage key rotation (status, reload)",
68
229
  handler: async (args, ctx) => {
69
230
  const sub = args.trim().split(/\s+/)[0] ?? "status";
70
231
  if (sub === "status") {
71
- if (!handle) {
72
- ctx.ui.notify("🔑 keyrouter: not active", "info");
232
+ // On-demand activation in case session_start/before_agent_start
233
+ // haven't fired yet (e.g. user ran /keyrouter status right after
234
+ // /reload without sending a prompt).
235
+ if (!config || runtimes.size === 0) {
236
+ await activate(ctx);
237
+ }
238
+ if (!config || runtimes.size === 0) {
239
+ ctx.ui.notify(
240
+ "🔑 keyrouter: not active — no providers in ~/.pi/keyrouter.json " +
241
+ "(checked cwd/.soly, cwd/.pi, cwd, ~/.soly, ~/.pi, ~)",
242
+ "warning",
243
+ );
73
244
  return;
74
245
  }
75
- const snap = handle.getSnapshot();
76
- const lines: string[] = [`🔑 keyrouter: ${enabled ? "active" : "disabled"}`];
77
- for (const p of snap) {
78
- lines.push(``);
79
- lines.push(` ${p.provider} (current: ${p.current})`);
80
- for (const k of p.keys) {
81
- const cooldown = k.cooldownRemainingMs > 0
82
- ? ` ${Math.ceil(k.cooldownRemainingMs / 1000)}s`
83
- : "";
246
+ const lines: string[] = [`🔑 keyrouter: active`];
247
+ for (const [providerName, rt] of runtimes) {
248
+ const current = rt.currentIndex >= 0 ? rt.keys[rt.currentIndex] : undefined;
249
+ lines.push("");
250
+ lines.push(` ${providerName} (current: ${current?.name ?? "(none)"})`);
251
+ for (const k of rt.keys) {
252
+ const marker = k === current ? "→" : "•";
253
+ const avail = isAvailable(k, Date.now()) ? "" : " (cooldown)";
84
254
  lines.push(
85
- ` ${k.name} uses=${k.uses} fails=${k.failures} status=${k.lastStatus}${cooldown}`,
255
+ ` ${marker} ${k.name} uses=0 fails=${k.failures} status=${k.lastStatus}${avail}`,
86
256
  );
87
257
  }
88
258
  }
89
259
  ctx.ui.notify(lines.join("\n"), "info");
90
260
  return;
91
261
  }
92
- if (sub === "enable") {
93
- if (!handle) {
94
- activate(currentCwd, (text, level) =>
95
- (ctx.ui.notify as (t: string, l?: string) => void)(text, level),
96
- );
97
- }
98
- enabled = true;
99
- ctx.ui.notify("🔑 keyrouter: enabled", "info");
100
- return;
101
- }
102
- if (sub === "disable") {
103
- deactivate();
104
- enabled = false;
105
- ctx.ui.notify("🔑 keyrouter: disabled (fetch restored)", "info");
106
- return;
107
- }
108
262
  if (sub === "reload") {
109
- deactivate();
110
- activate(currentCwd, (text, level) =>
111
- (ctx.ui.notify as (t: string, l?: string) => void)(text, level),
263
+ config = loadConfig(ctx.cwd);
264
+ runtimes.clear();
265
+ activationNotified = false;
266
+ ctx.ui.notify(
267
+ `🔑 keyrouter: reloaded (${config.providers.length} provider(s))`,
268
+ "info",
112
269
  );
113
- ctx.ui.notify("🔑 keyrouter: reloaded", "info");
114
270
  return;
115
271
  }
116
- ctx.ui.notify(
117
- "Usage: /keyrouter [status|enable|disable|reload]",
118
- "info",
119
- );
272
+ ctx.ui.notify("Usage: /keyrouter [status|reload]", "info");
120
273
  },
121
274
  });
275
+ }
276
+
277
+ /**
278
+ * Resolve the internal provider name that authStorage uses.
279
+ * The keyrouter config uses display names like "z-ai" but authStorage
280
+ * uses the canonical provider id like "zai". We try a few mappings.
281
+ */
282
+ function resolveProviderName(displayName: string): string {
283
+ const lower = displayName.toLowerCase();
284
+ // Common mappings
285
+ const map: Record<string, string> = {
286
+ "z-ai": "zai",
287
+ "z.ai": "zai",
288
+ "open-router": "openrouter",
289
+ "openai": "openai",
290
+ "anthropic": "anthropic",
291
+ };
292
+ return map[lower] ?? displayName;
122
293
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-keyrouter",
3
- "version": "0.1.0",
3
+ "version": "0.2.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,7 +23,6 @@
23
23
  "index.ts",
24
24
  "config.ts",
25
25
  "rotation.ts",
26
- "fetch-wrapper.ts",
27
26
  "types.ts"
28
27
  ],
29
28
  "keywords": [
package/fetch-wrapper.ts DELETED
@@ -1,228 +0,0 @@
1
- // =============================================================================
2
- // fetch-wrapper.ts — wraps global fetch with key-rotation logic
3
- // =============================================================================
4
- //
5
- // Replaces `globalThis.fetch` with a function that:
6
- // 1. Intercepts requests whose URL matches a configured provider.
7
- // 2. Picks the best available key (rotation logic in rotation.ts).
8
- // 3. Sets the Authorization header.
9
- // 4. On 429/401, marks the key as bad and retries with the next key
10
- // (up to maxRetries).
11
- // 5. Calls onRotate on every key switch.
12
- //
13
- // On failure, the original response is returned (not a synthetic one) so
14
- // pi sees the real error if all retries fail.
15
-
16
- import {
17
- initKeyStates,
18
- isAvailable,
19
- markBad,
20
- markOk,
21
- matchProvider,
22
- pickNextKey,
23
- recordUse,
24
- waitForNextKey,
25
- } from "./rotation.ts";
26
- import type {
27
- KeyRouterConfig,
28
- KeyState,
29
- ProviderConfig,
30
- RotationEvent,
31
- } from "./types.ts";
32
-
33
- /** State tracked per provider. */
34
- interface ProviderState {
35
- config: ProviderConfig;
36
- keys: KeyState[];
37
- preferredIndex: number;
38
- }
39
-
40
- export interface KeyRouterHandle {
41
- /** Restore the original fetch and stop intercepting. */
42
- disable: () => void;
43
- /** Snapshot of current state (for /keyrouter status command). */
44
- getSnapshot: () => KeyRouterSnapshot[];
45
- }
46
-
47
- export interface KeyRouterSnapshot {
48
- provider: string;
49
- current: string;
50
- keys: Array<{
51
- name: string;
52
- uses: number;
53
- failures: number;
54
- lastStatus: string;
55
- cooldownRemainingMs: number;
56
- }>;
57
- }
58
-
59
- /**
60
- * Install the fetch wrapper. Returns a handle for disable / inspection.
61
- *
62
- * @param config — key router config
63
- * @param onRotate — called on every key switch (for UI notification)
64
- */
65
- export function installKeyRouter(
66
- config: KeyRouterConfig,
67
- onRotate: (event: RotationEvent) => void,
68
- ): KeyRouterHandle {
69
- // Capture original fetch BEFORE wrapping
70
- const originalFetch = globalThis.fetch.bind(globalThis);
71
-
72
- // Build per-provider state
73
- const providerStates = new Map<string, ProviderState>();
74
- for (const p of config.providers) {
75
- providerStates.set(p.name, {
76
- config: p,
77
- keys: initKeyStates(p.keys),
78
- preferredIndex: 0,
79
- });
80
- }
81
-
82
- async function wrappedFetch(
83
- input: string | URL | Request,
84
- init?: RequestInit,
85
- ): Promise<Response> {
86
- const url =
87
- typeof input === "string"
88
- ? input
89
- : input instanceof URL
90
- ? input.toString()
91
- : input.url;
92
- const matched = matchProvider(
93
- Array.from(providerStates.values()).map((s) => s.config),
94
- url,
95
- );
96
- if (!matched) {
97
- return originalFetch(input, init);
98
- }
99
- const state = providerStates.get(matched.name);
100
- if (!state || state.keys.length === 0) {
101
- return originalFetch(input, init);
102
- }
103
-
104
- const now = Date.now();
105
- const maxRetries = Math.max(1, config.maxRetries);
106
- let attempt = 0;
107
- let lastResponse: Response | undefined;
108
- let lastError: unknown;
109
- let lastPreferred = state.preferredIndex;
110
-
111
- while (attempt < maxRetries) {
112
- attempt += 1;
113
- const idx = pickNextKey(state.keys, lastPreferred, now);
114
- if (idx < 0) break;
115
- const key = state.keys[idx];
116
- if (!key) break;
117
-
118
- // If the chosen key is on cooldown, wait briefly
119
- if (!isAvailable(key, now)) {
120
- const wait = waitForNextKey(state.keys, now);
121
- if (wait > 0 && wait < 2000) {
122
- await new Promise((r) => setTimeout(r, wait));
123
- }
124
- }
125
-
126
- recordUse(key);
127
- const initCopy = { ...(init ?? {}) };
128
- const headers = new Headers(initCopy.headers ?? {});
129
- headers.set("Authorization", `Bearer ${key.value}`);
130
- initCopy.headers = headers;
131
-
132
- let response: Response;
133
- try {
134
- response = await originalFetch(input, initCopy);
135
- } catch (e) {
136
- lastError = e;
137
- // Network errors don't consume a retry budget
138
- // (we'll loop back and try again)
139
- lastPreferred = (idx + 1) % state.keys.length;
140
- continue;
141
- }
142
-
143
- if (response.status === 429) {
144
- markBad(key, "rate-limited", config.cooldownMs, Date.now());
145
- lastResponse = response;
146
- if (attempt >= maxRetries) break;
147
- const nextIdx = pickNextKey(state.keys, idx + 1, Date.now());
148
- if (nextIdx === idx) break;
149
- const nextKey = state.keys[nextIdx];
150
- if (nextKey) {
151
- onRotate({
152
- provider: matched.name,
153
- fromKey: key.name,
154
- toKey: nextKey.name,
155
- reason: "rate-limited",
156
- status: 429,
157
- attempt,
158
- });
159
- }
160
- state.preferredIndex = nextIdx;
161
- lastPreferred = nextIdx;
162
- continue;
163
- }
164
-
165
- if (response.status === 401 || response.status === 403) {
166
- markBad(key, "unauthorized", config.cooldownMs, Date.now());
167
- lastResponse = response;
168
- if (attempt >= maxRetries) break;
169
- const nextIdx = pickNextKey(state.keys, idx + 1, Date.now());
170
- if (nextIdx === idx) break;
171
- const nextKey = state.keys[nextIdx];
172
- if (nextKey) {
173
- onRotate({
174
- provider: matched.name,
175
- fromKey: key.name,
176
- toKey: nextKey.name,
177
- reason: "unauthorized",
178
- status: response.status,
179
- attempt,
180
- });
181
- }
182
- state.preferredIndex = nextIdx;
183
- lastPreferred = nextIdx;
184
- continue;
185
- }
186
-
187
- // Success — mark ok and return
188
- markOk(key);
189
- state.preferredIndex = idx;
190
- return response;
191
- }
192
-
193
- // All retries exhausted — return the last response if we have one
194
- if (lastResponse) return lastResponse;
195
- // Or re-throw the last network error
196
- if (lastError !== undefined) throw lastError;
197
- // Or fall through to original fetch
198
- return originalFetch(input, init);
199
- }
200
-
201
- // Install wrapper
202
- (globalThis as { fetch: typeof fetch }).fetch = wrappedFetch as typeof fetch;
203
-
204
- function getSnapshot(): KeyRouterSnapshot[] {
205
- const now = Date.now();
206
- return Array.from(providerStates.values()).map((s) => {
207
- const current = s.keys[s.preferredIndex];
208
- return {
209
- provider: s.config.name,
210
- current: current?.name ?? "(none)",
211
- keys: s.keys.map((k) => ({
212
- name: k.name,
213
- uses: k.uses,
214
- failures: k.failures,
215
- lastStatus: k.lastStatus,
216
- cooldownRemainingMs:
217
- k.cooldownUntil > now ? k.cooldownUntil - now : 0,
218
- })),
219
- };
220
- });
221
- }
222
-
223
- function disable(): void {
224
- (globalThis as { fetch: typeof fetch }).fetch = originalFetch;
225
- }
226
-
227
- return { disable, getSnapshot };
228
- }