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 +35 -21
- package/index.ts +246 -75
- package/package.json +1 -2
- package/fetch-wrapper.ts +0 -228
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 ·
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 |
|
|
61
|
-
| 429 |
|
|
62
|
-
| 401 / 403 |
|
|
63
|
-
|
|
|
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 #
|
|
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
|
|
160
|
-
├──
|
|
161
|
-
├── config.test.ts
|
|
162
|
-
└── smoke.test.ts
|
|
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 {
|
|
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
|
|
24
|
-
|
|
25
|
-
let
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
if (
|
|
31
|
-
|
|
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
|
-
|
|
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},
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
72
|
-
|
|
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
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
lines.push(
|
|
79
|
-
lines.push(` ${
|
|
80
|
-
for (const k of
|
|
81
|
-
const
|
|
82
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
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
|
-
}
|