pi-keyrouter 0.1.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.
- package/README.md +169 -0
- package/config.ts +83 -0
- package/fetch-wrapper.ts +228 -0
- package/index.ts +122 -0
- package/package.json +57 -0
- package/rotation.ts +132 -0
- package/types.ts +56 -0
package/README.md
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# ๐ pi-keyrouter
|
|
2
|
+
|
|
3
|
+
**API key rotation for [pi-coding-agent](https://github.com/nicobailon/pi-coding-agent).**
|
|
4
|
+
|
|
5
|
+
Multiple keys per provider ยท automatic 429/401 fallback ยท recursion-safe.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pi install npm:pi-keyrouter
|
|
9
|
+
# create ~/.pi/keyrouter.json with your keys
|
|
10
|
+
/reload
|
|
11
|
+
```
|
|
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.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## โก Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pi install npm:pi-keyrouter
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Add your provider config to `~/.pi/keyrouter.json`:
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{
|
|
27
|
+
"providers": [
|
|
28
|
+
{
|
|
29
|
+
"name": "z-ai",
|
|
30
|
+
"match": ["api.z.ai", "z.ai"],
|
|
31
|
+
"keys": [
|
|
32
|
+
{ "name": "primary", "value": "key-1-..." },
|
|
33
|
+
{ "name": "backup", "value": "key-2-..." }
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
],
|
|
37
|
+
"maxRetries": 3,
|
|
38
|
+
"cooldownMs": 60000
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`/reload` โ extension wraps `globalThis.fetch` and rotates on 429/401.
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## ๐ฏ How it works
|
|
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).
|
|
53
|
+
|
|
54
|
+
Recursion is bounded by `maxRetries` (default 3). No infinite loops.
|
|
55
|
+
|
|
56
|
+
### What gets rotated
|
|
57
|
+
|
|
58
|
+
| Status | Action |
|
|
59
|
+
|---|---|
|
|
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 |
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## ๐ Visibility
|
|
69
|
+
|
|
70
|
+
The `/keyrouter` command shows live state:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
/keyrouter
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
๐ keyrouter: active
|
|
78
|
+
z-ai (current: backup)
|
|
79
|
+
โข primary uses=12 fails=2 status=rate-limited โฑ 47s
|
|
80
|
+
โข backup uses=2 fails=0 status=ok
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Subcommands:
|
|
84
|
+
|
|
85
|
+
- `/keyrouter status` โ show snapshot (default)
|
|
86
|
+
- `/keyrouter enable` โ re-activate (if disabled)
|
|
87
|
+
- `/keyrouter disable` โ restore original fetch, stop rotating
|
|
88
|
+
- `/keyrouter reload` โ re-read config
|
|
89
|
+
|
|
90
|
+
Every key switch notifies the user with a Box widget:
|
|
91
|
+
|
|
92
|
+
> ๐ keyrouter: z-ai โ primary โ backup (HTTP 429, attempt 1)
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## ๐ง Config
|
|
97
|
+
|
|
98
|
+
`~/.pi/keyrouter.json` (or `<cwd>/.soly/keyrouter.json`, `<cwd>/.pi/keyrouter.json`).
|
|
99
|
+
|
|
100
|
+
```json5
|
|
101
|
+
{
|
|
102
|
+
"providers": [
|
|
103
|
+
{
|
|
104
|
+
"name": "display-name", // for logs (any string)
|
|
105
|
+
"match": ["api.z.ai", "z.ai"], // URL substrings (case-insensitive)
|
|
106
|
+
"keys": [
|
|
107
|
+
{ "name": "primary", "value": "key-1..." },
|
|
108
|
+
{ "name": "backup", "value": "key-2..." }
|
|
109
|
+
]
|
|
110
|
+
}
|
|
111
|
+
],
|
|
112
|
+
"maxRetries": 3, // total retries per request across all keys
|
|
113
|
+
"cooldownMs": 60000 // how long a bad key stays marked bad (1 min default)
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Multi-provider
|
|
118
|
+
|
|
119
|
+
```json
|
|
120
|
+
{
|
|
121
|
+
"providers": [
|
|
122
|
+
{ "name": "z-ai", "match": ["api.z.ai"], "keys": [...] },
|
|
123
|
+
{ "name": "openrouter", "match": ["openrouter.ai"], "keys": [...] }
|
|
124
|
+
]
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Each provider rotates independently. Cross-provider URLs are not intercepted.
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## ๐ก๏ธ Security
|
|
133
|
+
|
|
134
|
+
API keys live in plain text in `keyrouter.json`. **Don't commit it.** Options:
|
|
135
|
+
|
|
136
|
+
- Add `keyrouter.json` to `.gitignore`
|
|
137
|
+
- Use `chmod 600` on the file
|
|
138
|
+
- (Future) env var interpolation `$ENV_VAR` โ not yet implemented
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## ๐ Development
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
bun test # 34 tests
|
|
146
|
+
bun run typecheck # tsc --noEmit
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Monorepo layout:
|
|
150
|
+
|
|
151
|
+
```
|
|
152
|
+
packages/pi-keyrouter/
|
|
153
|
+
โโโ index.ts โ extension entry point
|
|
154
|
+
โโโ rotation.ts โ pure key-pick logic
|
|
155
|
+
โโโ fetch-wrapper.ts โ fetch interceptor with retry
|
|
156
|
+
โโโ config.ts โ config loader
|
|
157
|
+
โโโ types.ts โ shared types
|
|
158
|
+
โโโ 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
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## ๐ License
|
|
168
|
+
|
|
169
|
+
MIT โ same as [pi-soly](https://github.com/lowern1ght/pi-soly).
|
package/config.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// config.ts โ load key router config from disk
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Looks in this order (first hit wins):
|
|
6
|
+
// 1. <cwd>/.pi/keyrouter.json โ project override
|
|
7
|
+
// 2. <cwd>/.soly/keyrouter.json โ soly convention
|
|
8
|
+
// 3. ~/.pi/keyrouter.json โ user-level default
|
|
9
|
+
//
|
|
10
|
+
// Schema:
|
|
11
|
+
// {
|
|
12
|
+
// "providers": [
|
|
13
|
+
// {
|
|
14
|
+
// "name": "z-ai",
|
|
15
|
+
// "match": ["api.z.ai", "z.ai"],
|
|
16
|
+
// "keys": [
|
|
17
|
+
// { "name": "primary", "value": "key-1..." },
|
|
18
|
+
// { "name": "backup", "value": "key-2..." }
|
|
19
|
+
// ]
|
|
20
|
+
// }
|
|
21
|
+
// ],
|
|
22
|
+
// "maxRetries": 3,
|
|
23
|
+
// "cooldownMs": 60000
|
|
24
|
+
// }
|
|
25
|
+
|
|
26
|
+
import * as fs from "node:fs";
|
|
27
|
+
import * as os from "node:os";
|
|
28
|
+
import * as path from "node:path";
|
|
29
|
+
import type { KeyRouterConfig } from "./types.ts";
|
|
30
|
+
|
|
31
|
+
const CONFIG_FILENAMES = ["keyrouter.json"];
|
|
32
|
+
|
|
33
|
+
export function defaultConfig(): KeyRouterConfig {
|
|
34
|
+
return {
|
|
35
|
+
providers: [],
|
|
36
|
+
maxRetries: 3,
|
|
37
|
+
cooldownMs: 60_000,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function loadConfig(cwd: string, home?: string): KeyRouterConfig {
|
|
42
|
+
const homeDir = home ?? os.homedir();
|
|
43
|
+
const candidates: string[] = [];
|
|
44
|
+
for (const dir of [
|
|
45
|
+
path.join(cwd, ".soly"),
|
|
46
|
+
path.join(cwd, ".pi"),
|
|
47
|
+
cwd,
|
|
48
|
+
path.join(homeDir, ".soly"),
|
|
49
|
+
path.join(homeDir, ".pi"),
|
|
50
|
+
homeDir,
|
|
51
|
+
]) {
|
|
52
|
+
for (const name of CONFIG_FILENAMES) {
|
|
53
|
+
candidates.push(path.join(dir, name));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
for (const file of candidates) {
|
|
57
|
+
if (fs.existsSync(file)) {
|
|
58
|
+
try {
|
|
59
|
+
const raw = fs.readFileSync(file, "utf-8");
|
|
60
|
+
const parsed = JSON.parse(raw) as Partial<KeyRouterConfig>;
|
|
61
|
+
return normalize(parsed);
|
|
62
|
+
} catch {
|
|
63
|
+
// bad config โ fall through to default
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return defaultConfig();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function normalize(input: Partial<KeyRouterConfig>): KeyRouterConfig {
|
|
71
|
+
const providers = (input.providers ?? []).filter(
|
|
72
|
+
(p): p is { name: string; match: string[]; keys: { name: string; value: string }[] } =>
|
|
73
|
+
typeof p?.name === "string" &&
|
|
74
|
+
Array.isArray(p.match) &&
|
|
75
|
+
Array.isArray(p.keys) &&
|
|
76
|
+
p.keys.every((k) => typeof k?.name === "string" && typeof k?.value === "string"),
|
|
77
|
+
);
|
|
78
|
+
return {
|
|
79
|
+
providers,
|
|
80
|
+
maxRetries: typeof input.maxRetries === "number" ? input.maxRetries : 3,
|
|
81
|
+
cooldownMs: typeof input.cooldownMs === "number" ? input.cooldownMs : 60_000,
|
|
82
|
+
};
|
|
83
|
+
}
|
package/fetch-wrapper.ts
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
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
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// index.ts โ pi-keyrouter extension entry point
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Usage:
|
|
6
|
+
// pi install npm:pi-keyrouter
|
|
7
|
+
// # create ~/.pi/keyrouter.json with your provider keys
|
|
8
|
+
// /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
|
+
|
|
18
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import { loadConfig } from "./config.ts";
|
|
20
|
+
import { installKeyRouter, type KeyRouterHandle } from "./fetch-wrapper.ts";
|
|
21
|
+
|
|
22
|
+
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
|
|
32
|
+
}
|
|
33
|
+
handle = installKeyRouter(config, (event) => {
|
|
34
|
+
notify(
|
|
35
|
+
`๐ keyrouter: ${event.provider} โ ${event.fromKey} โ ${event.toKey} ` +
|
|
36
|
+
`(HTTP ${event.status}, attempt ${event.attempt})`,
|
|
37
|
+
"warning",
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function deactivate(): void {
|
|
43
|
+
if (handle) {
|
|
44
|
+
handle.disable();
|
|
45
|
+
handle = undefined;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
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
|
+
);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
pi.on("session_shutdown", () => {
|
|
63
|
+
deactivate();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
pi.registerCommand("keyrouter", {
|
|
67
|
+
description: "manage key rotation (status, enable, disable, reload)",
|
|
68
|
+
handler: async (args, ctx) => {
|
|
69
|
+
const sub = args.trim().split(/\s+/)[0] ?? "status";
|
|
70
|
+
if (sub === "status") {
|
|
71
|
+
if (!handle) {
|
|
72
|
+
ctx.ui.notify("๐ keyrouter: not active", "info");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
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
|
+
: "";
|
|
84
|
+
lines.push(
|
|
85
|
+
` โข ${k.name} uses=${k.uses} fails=${k.failures} status=${k.lastStatus}${cooldown}`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
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
|
+
if (sub === "reload") {
|
|
109
|
+
deactivate();
|
|
110
|
+
activate(currentCwd, (text, level) =>
|
|
111
|
+
(ctx.ui.notify as (t: string, l?: string) => void)(text, level),
|
|
112
|
+
);
|
|
113
|
+
ctx.ui.notify("๐ keyrouter: reloaded", "info");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
ctx.ui.notify(
|
|
117
|
+
"Usage: /keyrouter [status|enable|disable|reload]",
|
|
118
|
+
"info",
|
|
119
|
+
);
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-keyrouter",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "API key rotation for pi-coding-agent. Multiple keys per provider, automatic 429/401 fallback, max-retries guard.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "bun test",
|
|
9
|
+
"typecheck": "bun x tsc --noEmit"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {},
|
|
12
|
+
"peerDependencies": {
|
|
13
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@earendil-works/pi-coding-agent": "0.78.1",
|
|
17
|
+
"@types/node": "^25.9.1",
|
|
18
|
+
"bun-types": "^1.3.14",
|
|
19
|
+
"typescript": "^6.0.3"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"README.md",
|
|
23
|
+
"index.ts",
|
|
24
|
+
"config.ts",
|
|
25
|
+
"rotation.ts",
|
|
26
|
+
"fetch-wrapper.ts",
|
|
27
|
+
"types.ts"
|
|
28
|
+
],
|
|
29
|
+
"keywords": [
|
|
30
|
+
"pi",
|
|
31
|
+
"pi-extension",
|
|
32
|
+
"pi-package",
|
|
33
|
+
"api-key",
|
|
34
|
+
"key-rotation",
|
|
35
|
+
"rate-limit",
|
|
36
|
+
"fallback",
|
|
37
|
+
"ai-coding"
|
|
38
|
+
],
|
|
39
|
+
"license": "MIT",
|
|
40
|
+
"pi": {
|
|
41
|
+
"extensions": [
|
|
42
|
+
"./index.ts"
|
|
43
|
+
]
|
|
44
|
+
},
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"registry": "https://registry.npmjs.org/"
|
|
47
|
+
},
|
|
48
|
+
"homepage": "https://github.com/lowern1ght/pi-soly#readme",
|
|
49
|
+
"bugs": {
|
|
50
|
+
"url": "https://github.com/lowern1ght/pi-soly/issues"
|
|
51
|
+
},
|
|
52
|
+
"repository": {
|
|
53
|
+
"type": "git",
|
|
54
|
+
"url": "https://github.com/lowern1ght/pi-soly.git",
|
|
55
|
+
"directory": "packages/pi-keyrouter"
|
|
56
|
+
}
|
|
57
|
+
}
|
package/rotation.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// rotation.ts โ pure key-rotation logic
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Decides which key to use next. No side effects, no I/O. Given a list of
|
|
6
|
+
// keys + their state, picks the best available one.
|
|
7
|
+
//
|
|
8
|
+
// Strategy:
|
|
9
|
+
// 1. Start with the first key (most recent working key takes priority).
|
|
10
|
+
// 2. If it's on cooldown, find the next available key.
|
|
11
|
+
// 3. If all keys are on cooldown, return the one that becomes available
|
|
12
|
+
// soonest (may need to wait).
|
|
13
|
+
// 4. If there are no keys, return null.
|
|
14
|
+
|
|
15
|
+
import type { KeyState, RotationReason } from "./types.ts";
|
|
16
|
+
|
|
17
|
+
/** Build initial state from a list of key values. */
|
|
18
|
+
export function initKeyStates(
|
|
19
|
+
keys: ReadonlyArray<{ name: string; value: string }>,
|
|
20
|
+
): KeyState[] {
|
|
21
|
+
return keys.map((k) => ({
|
|
22
|
+
name: k.name,
|
|
23
|
+
value: k.value,
|
|
24
|
+
lastStatus: "untried",
|
|
25
|
+
cooldownUntil: 0,
|
|
26
|
+
uses: 0,
|
|
27
|
+
failures: 0,
|
|
28
|
+
}));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Returns true if the key is currently available (past cooldown). */
|
|
32
|
+
export function isAvailable(state: KeyState, now: number): boolean {
|
|
33
|
+
return state.cooldownUntil === 0 || state.cooldownUntil <= now;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Mark a key as bad for `cooldownMs`. */
|
|
37
|
+
export function markBad(
|
|
38
|
+
state: KeyState,
|
|
39
|
+
reason: RotationReason,
|
|
40
|
+
cooldownMs: number,
|
|
41
|
+
now: number,
|
|
42
|
+
): void {
|
|
43
|
+
state.lastStatus = reason === "rate-limited" ? "rate-limited" : "unauthorized";
|
|
44
|
+
state.cooldownUntil = now + cooldownMs;
|
|
45
|
+
state.failures += 1;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Mark a key as used successfully. Clears any cooldown. */
|
|
49
|
+
export function markOk(state: KeyState): void {
|
|
50
|
+
state.lastStatus = "ok";
|
|
51
|
+
state.cooldownUntil = 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Record that a key was attempted (regardless of outcome). */
|
|
55
|
+
export function recordUse(state: KeyState): void {
|
|
56
|
+
state.uses += 1;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Pick the next key to try.
|
|
61
|
+
*
|
|
62
|
+
* Priority:
|
|
63
|
+
* 1. The key at `preferredIndex` if available (used to "stick" with a key
|
|
64
|
+
* that was working โ pi doesn't change keys across requests in the same
|
|
65
|
+
* turn unless we rotate).
|
|
66
|
+
* 2. The next available key in rotation order.
|
|
67
|
+
* 3. If all on cooldown, the one that becomes available soonest.
|
|
68
|
+
*
|
|
69
|
+
* Returns the picked index, or -1 if no keys.
|
|
70
|
+
*/
|
|
71
|
+
export function pickNextKey(
|
|
72
|
+
states: KeyState[],
|
|
73
|
+
preferredIndex: number,
|
|
74
|
+
now: number,
|
|
75
|
+
): number {
|
|
76
|
+
if (states.length === 0) return -1;
|
|
77
|
+
|
|
78
|
+
// 1. Preferred if available
|
|
79
|
+
const preferred = states[preferredIndex];
|
|
80
|
+
if (preferred && isAvailable(preferred, now)) {
|
|
81
|
+
return preferredIndex;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 2. Next available in rotation order (start from preferred + 1)
|
|
85
|
+
for (let offset = 1; offset <= states.length; offset++) {
|
|
86
|
+
const idx = (preferredIndex + offset) % states.length;
|
|
87
|
+
const s = states[idx];
|
|
88
|
+
if (s && isAvailable(s, now)) {
|
|
89
|
+
return idx;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 3. All on cooldown โ pick the one that becomes available soonest
|
|
94
|
+
let bestIdx = 0;
|
|
95
|
+
let bestUntil = states[0]?.cooldownUntil ?? 0;
|
|
96
|
+
for (let i = 1; i < states.length; i++) {
|
|
97
|
+
const u = states[i]?.cooldownUntil ?? 0;
|
|
98
|
+
if (u < bestUntil) {
|
|
99
|
+
bestUntil = u;
|
|
100
|
+
bestIdx = i;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return bestIdx;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Find the provider config whose `match` substrings hit the URL. */
|
|
107
|
+
export function matchProvider<T extends { name: string; match: string[] }>(
|
|
108
|
+
providers: T[],
|
|
109
|
+
url: string,
|
|
110
|
+
): T | undefined {
|
|
111
|
+
const lower = url.toLowerCase();
|
|
112
|
+
for (const p of providers) {
|
|
113
|
+
for (const m of p.match) {
|
|
114
|
+
if (lower.includes(m.toLowerCase())) return p;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Compute the delay (ms) to wait before the soonest key becomes available.
|
|
122
|
+
* Returns 0 if at least one key is available now.
|
|
123
|
+
*/
|
|
124
|
+
export function waitForNextKey(states: KeyState[], now: number): number {
|
|
125
|
+
let minWait = Number.POSITIVE_INFINITY;
|
|
126
|
+
for (const s of states) {
|
|
127
|
+
if (isAvailable(s, now)) return 0;
|
|
128
|
+
const wait = s.cooldownUntil - now;
|
|
129
|
+
if (wait < minWait) minWait = wait;
|
|
130
|
+
}
|
|
131
|
+
return minWait === Number.POSITIVE_INFINITY ? 0 : minWait;
|
|
132
|
+
}
|
package/types.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// types.ts โ shared types for pi-keyrouter
|
|
3
|
+
// =============================================================================
|
|
4
|
+
|
|
5
|
+
/** A single API key entry. `name` is for logging; `value` is the literal key. */
|
|
6
|
+
export interface ApiKey {
|
|
7
|
+
name: string;
|
|
8
|
+
value: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Configuration for a single provider. */
|
|
12
|
+
export interface ProviderConfig {
|
|
13
|
+
/** Display name (e.g. "z-ai", "openrouter"). For logging. */
|
|
14
|
+
name: string;
|
|
15
|
+
/** URL substrings to match. If request URL contains any of these, the
|
|
16
|
+
* wrapper handles it. Match is case-insensitive. */
|
|
17
|
+
match: string[];
|
|
18
|
+
/** Ordered list of keys. First key used by default; on 429/401, rotate. */
|
|
19
|
+
keys: ApiKey[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Top-level config. */
|
|
23
|
+
export interface KeyRouterConfig {
|
|
24
|
+
providers: ProviderConfig[];
|
|
25
|
+
/** Max number of retries across all keys per request. Default 3. */
|
|
26
|
+
maxRetries: number;
|
|
27
|
+
/** How long a key is marked bad after 429 (ms). Default 60_000. */
|
|
28
|
+
cooldownMs: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Internal state for a key (not user-configurable). */
|
|
32
|
+
export interface KeyState {
|
|
33
|
+
name: string;
|
|
34
|
+
value: string;
|
|
35
|
+
/** Last status we saw from this key. */
|
|
36
|
+
lastStatus: "ok" | "rate-limited" | "unauthorized" | "untried";
|
|
37
|
+
/** Epoch ms when this key's bad-status expires. 0 = available. */
|
|
38
|
+
cooldownUntil: number;
|
|
39
|
+
/** How many times this key has been used (for diagnostics). */
|
|
40
|
+
uses: number;
|
|
41
|
+
/** How many times this key has returned 429/401. */
|
|
42
|
+
failures: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Reason we rotated to a new key. */
|
|
46
|
+
export type RotationReason = "rate-limited" | "unauthorized";
|
|
47
|
+
|
|
48
|
+
/** Event payload for `onRotate` callback. */
|
|
49
|
+
export interface RotationEvent {
|
|
50
|
+
provider: string;
|
|
51
|
+
fromKey: string;
|
|
52
|
+
toKey: string;
|
|
53
|
+
reason: RotationReason;
|
|
54
|
+
status: number;
|
|
55
|
+
attempt: number;
|
|
56
|
+
}
|