pi-multikey 1.2.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/index.ts ADDED
@@ -0,0 +1,181 @@
1
+ /**
2
+ * multikey — one pi provider, many API keys.
3
+ *
4
+ * Every pool in ~/.pi/agent/multikey.json is registered as a pi provider.
5
+ * Requests are spread across the pool's keys (least-loaded, least-recently-used),
6
+ * and any 429/401/403 rotates to the next key with a cooldown — including across
7
+ * concurrent subagents, since each in-flight request holds its own key lease.
8
+ *
9
+ * Manage everything with the /multikey command (better-custom style TUI).
10
+ */
11
+
12
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
13
+ import { getApiProvider, type Api } from "@earendil-works/pi-ai";
14
+ import { configPath, loadConfig, saveConfig, toProviderModels, type KeypoolConfig, type PoolConfig } from "./config.ts";
15
+ import { KeyPool } from "./pool.ts";
16
+ import { createRotatingStreamSimple } from "./stream.ts";
17
+ import { runManager, type ManagerHooks } from "./manage.ts";
18
+
19
+ type CommandContext = Parameters<Parameters<ExtensionAPI["registerCommand"]>[1]["handler"]>[1];
20
+
21
+ export default function multikey(pi: ExtensionAPI) {
22
+ let config: KeypoolConfig;
23
+ let created = false;
24
+ let migratedFrom: string | undefined;
25
+ try {
26
+ const loaded = loadConfig();
27
+ config = loaded.config;
28
+ created = loaded.created;
29
+ migratedFrom = loaded.migratedFrom;
30
+ } catch (error) {
31
+ // Never break pi startup over config problems.
32
+ console.error(`[multikey] failed to load config: ${error instanceof Error ? error.message : String(error)}`);
33
+ config = { pools: [] };
34
+ }
35
+
36
+ const pools = new Map<string, KeyPool>();
37
+ for (const pool of config.pools) pools.set(pool.id, new KeyPool(pool));
38
+
39
+ let ui: ExtensionContext["ui"] | undefined;
40
+ const notify = (message: string) => {
41
+ try {
42
+ ui?.notify(message, "info");
43
+ } catch {
44
+ // UI may be gone (reload/shutdown); notifications are best-effort.
45
+ }
46
+ };
47
+
48
+ /**
49
+ * Register a pool as a pi provider. Returns undefined on success, or a
50
+ * human-readable reason why the pool was skipped (unknown api, incomplete).
51
+ */
52
+ function registerPool(pool: PoolConfig): string | undefined {
53
+ if (pool.keys.length === 0 || pool.models.length === 0) {
54
+ return pool.keys.length === 0 ? "no API keys" : "no models";
55
+ }
56
+ const api = pool.api ?? "openai-completions";
57
+ if (!getApiProvider(api as Api)) {
58
+ // Never throw at startup over a bad api value; report it instead so
59
+ // the user gets a fix hint (and /multikey marks the pool broken).
60
+ return `unknown api type "${api}" (edit the pool and pick a valid API type)`;
61
+ }
62
+ const keyPool = pools.get(pool.id) ?? new KeyPool(pool);
63
+ keyPool.updateConfig(pool);
64
+ pools.set(pool.id, keyPool);
65
+
66
+ pi.registerProvider(pool.id, {
67
+ name: pool.name ?? pool.id,
68
+ baseUrl: pool.baseUrl,
69
+ // Real keys are injected per-request by the rotating stream function.
70
+ apiKey: "multikey-managed",
71
+ api,
72
+ headers: pool.headers,
73
+ models: toProviderModels(pool),
74
+ streamSimple: createRotatingStreamSimple(keyPool, api, notify),
75
+ });
76
+ return undefined;
77
+ }
78
+
79
+ function saveAndReregister(poolId: string) {
80
+ saveConfig(config);
81
+ const pool = config.pools.find((p) => p.id === poolId);
82
+ if (!pool) return;
83
+ if (pool.keys.length === 0 || pool.models.length === 0) {
84
+ // Nothing usable to expose; drop any previous registration.
85
+ try {
86
+ pi.unregisterProvider(poolId);
87
+ } catch {
88
+ // Not registered yet.
89
+ }
90
+ return;
91
+ }
92
+ const error = registerPool(pool);
93
+ if (error) notify(`multikey[${poolId}]: provider not registered: ${error}`);
94
+ }
95
+
96
+ function removePool(poolId: string) {
97
+ config.pools = config.pools.filter((p) => p.id !== poolId);
98
+ pools.delete(poolId);
99
+ saveConfig(config);
100
+ try {
101
+ pi.unregisterProvider(poolId);
102
+ } catch {
103
+ // Not registered yet.
104
+ }
105
+ }
106
+
107
+ function reloadFromDisk() {
108
+ const loaded = loadConfig();
109
+ config = loaded.config;
110
+ const seen = new Set<string>();
111
+ for (const pool of config.pools) {
112
+ seen.add(pool.id);
113
+ const error = registerPool(pool);
114
+ if (error) notify(`multikey[${pool.id}]: provider not registered: ${error}`);
115
+ }
116
+ for (const id of [...pools.keys()]) {
117
+ if (!seen.has(id)) removePool(id);
118
+ }
119
+ }
120
+
121
+ // Register every pool up front so models are available during startup
122
+ // and to `pi --list-models`. Broken pools are collected for a friendly
123
+ // session-start hint instead of a bare console error.
124
+ const skipped: { id: string; reason: string }[] = [];
125
+ for (const pool of config.pools) {
126
+ try {
127
+ const error = registerPool(pool);
128
+ if (error) skipped.push({ id: pool.id, reason: error });
129
+ } catch (error) {
130
+ skipped.push({ id: pool.id, reason: error instanceof Error ? error.message : String(error) });
131
+ }
132
+ }
133
+
134
+ pi.on("session_start", async (_event, ctx) => {
135
+ ui = ctx.ui;
136
+ for (const { id, reason } of skipped) {
137
+ notify(`multikey: provider "${id}" not available — ${reason}. Fix it via /multikey → Manage pools.`);
138
+ }
139
+ skipped.length = 0;
140
+ if (migratedFrom) {
141
+ notify(`multikey: migrated config from ${migratedFrom} to ${configPath()} (old file kept as backup).`);
142
+ }
143
+ if (created) {
144
+ const path = configPath();
145
+ if (config.pools.length > 0) {
146
+ const summary = config.pools.map((p) => `"${p.id}" (${p.keys.length} keys, ${p.models.length} models)`).join(", ");
147
+ notify(`multikey: created ${path} from your models.json — pool ${summary}. Manage with /multikey.`);
148
+ } else {
149
+ notify(`multikey: no multi-key providers found in models.json — run /multikey → Add pool to create one at ${path}.`);
150
+ }
151
+ }
152
+ });
153
+
154
+ const managerHandler = async (_args: unknown, ctx: CommandContext) => {
155
+ if (!ctx.hasUI) {
156
+ ctx.ui.notify("multikey: interactive management requires a TUI session", "error");
157
+ return;
158
+ }
159
+ const hooks: ManagerHooks = {
160
+ get config() {
161
+ return config;
162
+ },
163
+ pools,
164
+ saveAndReregister,
165
+ removePool,
166
+ reloadFromDisk,
167
+ notify,
168
+ };
169
+ await runManager(pi, ctx, hooks);
170
+ };
171
+
172
+ pi.registerCommand("multikey", {
173
+ description: "Many API keys per provider: 429 rotation, cooldowns, live status",
174
+ handler: managerHandler,
175
+ });
176
+ // Legacy alias from the pre-rename days — same manager.
177
+ pi.registerCommand("keypool", {
178
+ description: "Alias of /multikey",
179
+ handler: managerHandler,
180
+ });
181
+ }