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 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
+ }
@@ -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
+ }