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/LICENSE +21 -0
- package/README.md +163 -0
- package/README.zh.md +146 -0
- package/config.ts +341 -0
- package/index.ts +181 -0
- package/manage.ts +871 -0
- package/package.json +44 -0
- package/pool.ts +180 -0
- package/presets.ts +143 -0
- package/probe.ts +264 -0
- package/stream.ts +237 -0
- package/tui.ts +349 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pi-keypool contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# pi-multikey
|
|
2
|
+
|
|
3
|
+
[中文](./README.zh.md)
|
|
4
|
+
|
|
5
|
+
A pi extension that bundles multiple API keys into a single **key pool**, exposing only **one provider** to pi.
|
|
6
|
+
|
|
7
|
+
It solves three pain points:
|
|
8
|
+
|
|
9
|
+
1. **No more copying your provider config per key** — models (contextWindow / modalities / thinkingLevelMap / compat) are configured once; swapping or adding keys never touches the model definitions.
|
|
10
|
+
2. **Automatic 429 key rotation** — on a failed request it immediately retries with the next key, and the failed key goes into cooldown (honoring `retry-after`). No manual provider switching.
|
|
11
|
+
3. **Concurrent subagents share keys automatically** — every in-flight request holds a key lease, picked by "fewest in use + least recently used", so when the main agent spawns multiple subagents they naturally land on different keys.
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# Option 1: git (recommended, no npm account needed)
|
|
17
|
+
pi install git:github.com/kslamph/multikey@v1.2.0
|
|
18
|
+
|
|
19
|
+
# Option 2: npm
|
|
20
|
+
pi install npm:pi-multikey
|
|
21
|
+
|
|
22
|
+
# Option 3: local directory
|
|
23
|
+
pi install /path/to/multikey
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Quick start (B.AI preset)
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
/multikey → Add pool… → Preset: B.AI → paste keys one per line (blank line to finish)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
After picking the preset, the endpoint, compat, and all 6 model definitions are wired up automatically. Models are available directly as `bai/<model-id>`, e.g. `bai/deepseek-v4-flash`.
|
|
33
|
+
|
|
34
|
+
## Presets
|
|
35
|
+
|
|
36
|
+
Built-in presets decouple "model settings" from "keys". The data comes from b.ai model cards and DeepSeek / Tencent / Xiaomi official docs, with each thinking level probed empirically; unsupported levels are written as `null` so the UI hides them.
|
|
37
|
+
|
|
38
|
+
| Model | ctx / max-out | Modalities | Supported thinking levels |
|
|
39
|
+
|---|---|---|---|
|
|
40
|
+
| deepseek-v4-flash | 1M / 384K | text | off · low · high · max |
|
|
41
|
+
| deepseek-v4-flash-vision-exp | 1M / 384K | text+image | off · low · high · max |
|
|
42
|
+
| hy3 | 256K / 128K | text | off · low · high |
|
|
43
|
+
| mimo-v2.5 | 1M / 128K | text+image | off · high (official: low/medium/high behave identically) |
|
|
44
|
+
| qwen3.8-flash | 1M / 131K | text+image | off · low · medium · xhigh |
|
|
45
|
+
| glm-5.3-flash | 1M / 131K | text+image | low · high · max (always thinks, no off) |
|
|
46
|
+
|
|
47
|
+
> Why `null` must be explicit: pi's `getSupportedThinkingLevels` treats `mapped === null` as unsupported and hides that level, but **omitting** it is treated as supported and the level name is sent to the API verbatim; `xhigh` / `max` additionally require an explicit non-null value to be usable.
|
|
48
|
+
|
|
49
|
+
To add a preset: append one entry to the `PRESETS` array in `presets.ts`.
|
|
50
|
+
|
|
51
|
+
## Configuration
|
|
52
|
+
|
|
53
|
+
`~/.pi/agent/multikey.json`. On first run it auto-discovers mergeable pools from `~/.pi/agent/models.json` (≥2 providers sharing a baseUrl = you copying the provider per key), and also picks up providers pointing at `api.b.ai`; if nothing is found it generates an empty config.
|
|
54
|
+
|
|
55
|
+
```jsonc
|
|
56
|
+
{
|
|
57
|
+
"pools": [
|
|
58
|
+
{
|
|
59
|
+
"id": "bai", // provider id in pi → bai/deepseek-v4-flash
|
|
60
|
+
"name": "B.AI (Key Pool)",
|
|
61
|
+
"baseUrl": "https://api.b.ai/v1",
|
|
62
|
+
"api": "openai-completions",
|
|
63
|
+
"auth": "bearer", // optional: "bearer" (default) or "api-key" (x-api-key header)
|
|
64
|
+
"compat": { ... }, // provider-level defaults, merged into every model
|
|
65
|
+
"cooldownMs": 20000, // 429 cooldown
|
|
66
|
+
"invalidKeyCooldownMs": 600000, // 401/403 cooldown
|
|
67
|
+
"keys": [
|
|
68
|
+
{ "key": "sk-...", "label": "key-1", "enabled": true },
|
|
69
|
+
{ "key": "sk-...", "label": "key-2", "enabled": true }
|
|
70
|
+
],
|
|
71
|
+
"models": [ "…preset or hand-configured model definitions…" ]
|
|
72
|
+
}
|
|
73
|
+
]
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
To add nvidia / opencode etc. later: `/multikey` → `Add pool…` (Custom), or edit the JSON directly and `Reload config from disk`.
|
|
78
|
+
|
|
79
|
+
### Adding a custom pool (no questions about API types)
|
|
80
|
+
|
|
81
|
+
The custom wizard only asks for the essentials — **provider id, base URL, key(s)**. It then probes the endpoint:
|
|
82
|
+
|
|
83
|
+
1. It fetches `<baseUrl>/models` (and `<baseUrl>/v1/models` as a fallback) with `Authorization: Bearer`; on 401/403 it retries with `x-api-key`.
|
|
84
|
+
2. `/models` is public on some gateways, so it also sends a tiny 1-token chat request to verify the key. If both header styles are rejected there but a dummy key passes, the endpoint simply doesn't check keys (open endpoint) and the pool is saved with the default Bearer auth.
|
|
85
|
+
3. You multi-select the models to add straight from the server's list. Context window / input modes / max output found in the model metadata are adopted; everything else gets safe defaults (128k context, text input, 16k max output, zero cost).
|
|
86
|
+
4. Optionally tune the common params (context size, input modes, max output) per model — or skip and edit them later via the Models menu. Anything advanced (thinking maps, compat, cost) you edit in `multikey.json` and hit *Reload config from disk*.
|
|
87
|
+
|
|
88
|
+
The detected header style is stored as `"auth": "api-key"` only when the endpoint proved to want `x-api-key`; the default is Bearer. The pool is saved **only after** this completes, so a cancelled wizard never leaves a half-configured provider behind.
|
|
89
|
+
|
|
90
|
+
## Management UI
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
/multikey
|
|
94
|
+
├─ Status live status: in-flight / cooldown / 429 count per key
|
|
95
|
+
├─ Manage pools… pools with an unknown api type are marked ⚠ broken; incomplete pools (incomplete)
|
|
96
|
+
│ ├─ Keys… add keys one per line; delete / edit / disable
|
|
97
|
+
│ ├─ Models… fetch from /models (multi-select) or add manually; edit contextWindow,
|
|
98
|
+
│ │ maxTokens, modalities, reasoning, thinkingLevelMap, compat, cost
|
|
99
|
+
│ ├─ Endpoint & settings… baseUrl, api type, auth style, cooldown durations, headers
|
|
100
|
+
│ └─ Delete pool
|
|
101
|
+
├─ Add pool…
|
|
102
|
+
│ ├─ Preset: B.AI all model settings preloaded; paste keys (verified by a probe) and you're done
|
|
103
|
+
│ └─ Custom… id + base URL + keys, then auto-probe, model multi-select, safe defaults
|
|
104
|
+
└─ Reload config from disk
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Changes take effect immediately (the provider is re-registered) — no restart needed.
|
|
108
|
+
|
|
109
|
+
## Pointing subagents at the pool
|
|
110
|
+
|
|
111
|
+
Set `agentOverrides` in `settings.json` to the pool provider:
|
|
112
|
+
|
|
113
|
+
```json
|
|
114
|
+
"subagents": {
|
|
115
|
+
"agentOverrides": {
|
|
116
|
+
"oracle": { "model": "bai/glm-5.3-flash" },
|
|
117
|
+
"scout": { "model": "bai/hy3" },
|
|
118
|
+
"worker": { "model": "bai/mimo-v2.5" }
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`defaultProvider: "bai"` works the same way.
|
|
124
|
+
|
|
125
|
+
## How it works
|
|
126
|
+
|
|
127
|
+
- The extension registers a provider via `pi.registerProvider()` with a custom `streamSimple`.
|
|
128
|
+
- Each request leases one key from the pool (`options.apiKey` overrides), and once the HTTP response headers arrive:
|
|
129
|
+
- 429 → that key is cooled down (default 20s, honoring `retry-after`) and the request immediately retries with the next key (no duplicated output);
|
|
130
|
+
- 401/403 → that key gets a long cooldown (default 10 minutes) and the request retries with the next key;
|
|
131
|
+
- other errors → handed back to pi's own retry mechanism.
|
|
132
|
+
- Only when every key is exhausted does it surface the 429 upward, letting pi's own backoff retry as a safety net (by then the earliest cooldown has usually expired).
|
|
133
|
+
|
|
134
|
+
## Releasing to npm
|
|
135
|
+
|
|
136
|
+
A GitHub Action (`.github/workflows/release.yml`) auto-publishes to npm on tag push.
|
|
137
|
+
|
|
138
|
+
1. Bump the version in `package.json` (and commit).
|
|
139
|
+
2. Tag the commit — the tag must match the version, e.g. `v1.0.1`:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
git tag v1.0.1
|
|
143
|
+
git push origin v1.0.1
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
3. The workflow verifies tag/version match, dry-runs `npm pack`, then publishes with
|
|
147
|
+
[npm provenance](https://docs.npmjs.com/generating-provenance-statements).
|
|
148
|
+
|
|
149
|
+
Setup (once):
|
|
150
|
+
|
|
151
|
+
- Add an npm **automation** token with `publish` scope as the `NPM_TOKEN` secret
|
|
152
|
+
in the repo's GitHub **Settings → Secrets and variables → Actions**.
|
|
153
|
+
(An automation token, not your login token, so 2FA never blocks the action.)
|
|
154
|
+
- The repo and package must be **public** for provenance to work.
|
|
155
|
+
- Pre-release tags like `v1.0.1-beta.1` publish to the `next` dist-tag instead of `latest`.
|
|
156
|
+
|
|
157
|
+
## Security note
|
|
158
|
+
|
|
159
|
+
Keys are stored in plaintext at `~/.pi/agent/multikey.json`; recommended:
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
chmod 600 ~/.pi/agent/multikey.json
|
|
163
|
+
```
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# pi-multikey
|
|
2
|
+
|
|
3
|
+
一个 pi 扩展:把多个 API key 组成一个"密钥池",对外只暴露**一个 provider**。
|
|
4
|
+
|
|
5
|
+
解决三个痛点:
|
|
6
|
+
|
|
7
|
+
1. **不用为每个 key 复制一份 provider 配置** —— 模型(contextWindow / 模态 / thinkingLevelMap / compat)只配置一次,换 key、加 key 都不动模型定义。
|
|
8
|
+
2. **429 自动换 key** —— 请求失败立刻用下一个 key 重试,失败的 key 进入冷却(尊重 `retry-after`),无需手工切换 provider。
|
|
9
|
+
3. **并发 subagent 自动分摊 key** —— 每个进行中的请求持有一个 key lease,选择策略是"在用数最少 + 最久未用",所以主 agent 同时开多个 subagent 时,它们天然落在不同的 key 上。
|
|
10
|
+
|
|
11
|
+
## 安装
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# 方式一:git(推荐,无需 npm 账号)
|
|
15
|
+
pi install git:github.com/kslamph/multikey@v1.2.0
|
|
16
|
+
|
|
17
|
+
# 方式二:npm(scoped 包,发布时始终带 --access public)
|
|
18
|
+
pi install npm:pi-multikey
|
|
19
|
+
|
|
20
|
+
# 方式三:本地目录
|
|
21
|
+
pi install /path/to/multikey
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## 快速开始(B.AI preset)
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
/multikey → Add pool… → Preset: B.AI → 逐行粘贴 key(一行一个,留空结束)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
选 preset 后 endpoint、compat、6 个模型的全部设定自动就位,模型通过
|
|
31
|
+
`bai/<model-id>` 直接可用,例如 `bai/deepseek-v4-flash`。
|
|
32
|
+
|
|
33
|
+
## Presets
|
|
34
|
+
|
|
35
|
+
内置 preset 把"模型设定"与"密钥"解耦。数据来自 b.ai model cards、
|
|
36
|
+
DeepSeek / Tencent / 小米官方文档,并对每个 thinking 档位做过实测探测;
|
|
37
|
+
不支持的档位写为 `null`,UI 不显示。
|
|
38
|
+
|
|
39
|
+
| 模型 | ctx / max-out | 模态 | 生效 thinking 档位 |
|
|
40
|
+
|---|---|---|---|
|
|
41
|
+
| deepseek-v4-flash | 1M / 384K | text | off · low · high · max |
|
|
42
|
+
| deepseek-v4-flash-vision-exp | 1M / 384K | text+image | off · low · high · max |
|
|
43
|
+
| hy3 | 256K / 128K | text | off · low · high |
|
|
44
|
+
| mimo-v2.5 | 1M / 128K | text+image | off · high(官方:low/medium/high 行为相同) |
|
|
45
|
+
| qwen3.8-flash | 1M / 131K | text+image | off · low · medium · xhigh |
|
|
46
|
+
| glm-5.3-flash | 1M / 131K | text+image | low · high · max(始终思考,无 off) |
|
|
47
|
+
|
|
48
|
+
> 为什么必须显式写 `null`:pi 的 `getSupportedThinkingLevels` 把 `mapped === null`
|
|
49
|
+
> 视为不支持并隐藏该档,但**省略**会被当作支持并把档名原样发给 API;
|
|
50
|
+
> `xhigh` / `max` 还要求显式给出非 null 值才可用。
|
|
51
|
+
|
|
52
|
+
新增 preset:在 `presets.ts` 的 `PRESETS` 数组里加一项即可。
|
|
53
|
+
|
|
54
|
+
## 配置
|
|
55
|
+
|
|
56
|
+
`~/.pi/agent/multikey.json`。首次运行时会从 `~/.pi/agent/models.json` 自动发现
|
|
57
|
+
可合并的池(同一 baseUrl 出现 ≥2 个 provider = 你在按 key 复制 provider),
|
|
58
|
+
也会收录指向 `api.b.ai` 的 provider;什么都没发现则生成空配置。
|
|
59
|
+
|
|
60
|
+
```jsonc
|
|
61
|
+
{
|
|
62
|
+
"pools": [
|
|
63
|
+
{
|
|
64
|
+
"id": "bai", // pi 里的 provider id → bai/deepseek-v4-flash
|
|
65
|
+
"name": "B.AI (Key Pool)",
|
|
66
|
+
"baseUrl": "https://api.b.ai/v1",
|
|
67
|
+
"api": "openai-completions",
|
|
68
|
+
"auth": "bearer", // 可选:"bearer"(默认)或 "api-key"(x-api-key 头)
|
|
69
|
+
"compat": { ... }, // provider 级默认,合并进每个模型
|
|
70
|
+
"cooldownMs": 20000, // 429 冷却
|
|
71
|
+
"invalidKeyCooldownMs": 600000, // 401/403 冷却
|
|
72
|
+
"keys": [
|
|
73
|
+
{ "key": "sk-...", "label": "key-1", "enabled": true },
|
|
74
|
+
{ "key": "sk-...", "label": "key-2", "enabled": true }
|
|
75
|
+
],
|
|
76
|
+
"models": [ "…preset 或手动配置的模型定义…" ]
|
|
77
|
+
}
|
|
78
|
+
]
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
以后要加 nvidia / opencode 等:`/multikey` → `Add pool…`(Custom),或直接编辑
|
|
83
|
+
JSON 后 `Reload config from disk`。
|
|
84
|
+
|
|
85
|
+
### 添加自定义池(不再询问 API 类型)
|
|
86
|
+
|
|
87
|
+
自定义向导只问最基本的三项:**provider id、Base URL、key**。随后自动探测端点:
|
|
88
|
+
|
|
89
|
+
1. 用 `Authorization: Bearer` 请求 `<baseUrl>/models`(会自动尝试 `<baseUrl>/v1/models`),若返回 401/403 再换 `x-api-key` 重试。
|
|
90
|
+
2. 有些网关的 `/models` 是公开的,因此还会发一个 1 token 的迷你 chat 请求验证 key。若两种头都被拒但假 key 能通过,说明是免鉴权的开放端点,按默认 Bearer 保存。
|
|
91
|
+
3. 直接从服务端返回的模型列表中**多选**要添加的模型。元数据里的上下文长度 / 输入模态 / 最大输出会被采用,其余一律安全默认值(128k 上下文、text 输入、16k 最大输出、成本 0)。
|
|
92
|
+
4. 可选:逐模型微调常用参数(上下文、输入模态、最大输出),或跳过以后在 Models 菜单里改。高级字段(thinking 映射、compat、cost)直接编辑 `multikey.json` 后 `Reload config from disk`。
|
|
93
|
+
|
|
94
|
+
探测出的认证头风格只在端点确实要求 `x-api-key` 时才会存为 `"auth": "api-key"`,默认 Bearer。整池**最后一次性写入**,中途取消不会留下半成品 provider。
|
|
95
|
+
|
|
96
|
+
## 管理界面
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
/multikey
|
|
100
|
+
├─ Status 实时状态:每把 key 的 in-flight / 冷却 / 429 计数
|
|
101
|
+
├─ Manage pools… api 类型非法的池会标 ⚠ broken;未完成的池标 (incomplete)
|
|
102
|
+
│ ├─ Keys… 一行一个添加 key;删 / 改 / 禁用
|
|
103
|
+
│ ├─ Models… 从 /models 拉取多选添加,或手动添加;编辑 contextWindow、
|
|
104
|
+
│ │ maxTokens、模态、reasoning、thinkingLevelMap、compat、cost
|
|
105
|
+
│ ├─ Endpoint & settings… baseUrl、api 类型、认证风格、冷却时长、headers
|
|
106
|
+
│ └─ Delete pool
|
|
107
|
+
├─ Add pool…
|
|
108
|
+
│ ├─ Preset: B.AI 预置全部模型设定,粘贴 key(自动校验)即可用
|
|
109
|
+
│ └─ Custom… 只填 id + Base URL + key,随后自动探测、多选模型、安全默认值
|
|
110
|
+
└─ Reload config from disk
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
改动即时生效(重新注册 provider),无需重启。
|
|
114
|
+
|
|
115
|
+
## 让 subagent 用上池
|
|
116
|
+
|
|
117
|
+
`settings.json` 的 agentOverrides 改成池 provider:
|
|
118
|
+
|
|
119
|
+
```json
|
|
120
|
+
"subagents": {
|
|
121
|
+
"agentOverrides": {
|
|
122
|
+
"oracle": { "model": "bai/glm-5.3-flash" },
|
|
123
|
+
"scout": { "model": "bai/hy3" },
|
|
124
|
+
"worker": { "model": "bai/mimo-v2.5" }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
`defaultProvider: "bai"` 同理。
|
|
130
|
+
|
|
131
|
+
## 工作原理
|
|
132
|
+
|
|
133
|
+
- 扩展通过 `pi.registerProvider()` 注册 provider,并提供自定义 `streamSimple`。
|
|
134
|
+
- 每次请求从池中取一把 key(`options.apiKey` 覆盖),收到 HTTP 响应头后:
|
|
135
|
+
- 429 → 该 key 冷却(默认 20s,尊重 `retry-after`),立即换 key 重试(不产生任何重复输出);
|
|
136
|
+
- 401/403 → 该 key 长冷却(默认 10 分钟),换 key 重试;
|
|
137
|
+
- 其他错误 → 原样交给 pi 的重试机制。
|
|
138
|
+
- 所有 key 都耗尽时才向上抛 429,由 pi 自身的 backoff 重试兜底(此时最早的冷却多半已结束)。
|
|
139
|
+
|
|
140
|
+
## 安全提示
|
|
141
|
+
|
|
142
|
+
key 明文保存在 `~/.pi/agent/multikey.json`,建议:
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
chmod 600 ~/.pi/agent/multikey.json
|
|
146
|
+
```
|
package/config.ts
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* multikey config: load / save / normalize / seed
|
|
3
|
+
*
|
|
4
|
+
* Config lives at ~/.pi/agent/multikey.json. Each "pool" becomes one pi provider
|
|
5
|
+
* whose requests are spread across multiple API keys with automatic 429 rotation.
|
|
6
|
+
* (Pools created before the rename live in keypool.json and are migrated once.)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import type { AuthStyle } from "./probe.ts";
|
|
14
|
+
|
|
15
|
+
/** Safe model defaults applied when a spec doesn't say otherwise (edit in multikey.json). */
|
|
16
|
+
export const DEFAULT_CONTEXT_WINDOW = 128_000;
|
|
17
|
+
export const DEFAULT_MAX_TOKENS = 16_384;
|
|
18
|
+
export const DEFAULT_INPUT: ("text" | "image")[] = ["text"];
|
|
19
|
+
|
|
20
|
+
export const KNOWN_API_TYPES = [
|
|
21
|
+
"openai-completions",
|
|
22
|
+
"openai-responses",
|
|
23
|
+
"anthropic-messages",
|
|
24
|
+
"google-generative-ai",
|
|
25
|
+
"mistral-conversations",
|
|
26
|
+
"openai-codex-responses",
|
|
27
|
+
"azure-openai-responses",
|
|
28
|
+
"google-vertex",
|
|
29
|
+
"bedrock-converse-stream",
|
|
30
|
+
"pi-messages",
|
|
31
|
+
] as const;
|
|
32
|
+
|
|
33
|
+
export interface PoolKeyConfig {
|
|
34
|
+
/** The literal API key. */
|
|
35
|
+
key: string;
|
|
36
|
+
/** Optional human label, e.g. "main", "spare-1". */
|
|
37
|
+
label?: string;
|
|
38
|
+
/** Disabled keys are never selected. Default: true. */
|
|
39
|
+
enabled?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface PoolModelConfig {
|
|
43
|
+
id: string;
|
|
44
|
+
name?: string;
|
|
45
|
+
api?: string;
|
|
46
|
+
baseUrl?: string;
|
|
47
|
+
reasoning?: boolean;
|
|
48
|
+
thinkingLevelMap?: Record<string, string | null>;
|
|
49
|
+
input?: ("text" | "image")[];
|
|
50
|
+
cost?: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
|
51
|
+
contextWindow?: number;
|
|
52
|
+
maxTokens?: number;
|
|
53
|
+
compat?: Record<string, unknown>;
|
|
54
|
+
headers?: Record<string, string>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface PoolConfig {
|
|
58
|
+
/** Provider id in pi, e.g. "bai". Model refs become "<id>/<modelId>". */
|
|
59
|
+
id: string;
|
|
60
|
+
name?: string;
|
|
61
|
+
baseUrl: string;
|
|
62
|
+
/** Streaming API type, e.g. "openai-completions". Default: openai-completions. */
|
|
63
|
+
api?: string;
|
|
64
|
+
/** Auth header style. Default: bearer (Authorization: Bearer). "api-key" sends x-api-key instead. */
|
|
65
|
+
auth?: AuthStyle;
|
|
66
|
+
/** Provider-level compat defaults merged into every model. */
|
|
67
|
+
compat?: Record<string, unknown>;
|
|
68
|
+
headers?: Record<string, string>;
|
|
69
|
+
/** Cooldown after a 429 on a key. Default 20000ms. */
|
|
70
|
+
cooldownMs?: number;
|
|
71
|
+
/** Cooldown after a 401/403 (bad key). Default 600000ms. */
|
|
72
|
+
invalidKeyCooldownMs?: number;
|
|
73
|
+
keys: PoolKeyConfig[];
|
|
74
|
+
models: PoolModelConfig[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface KeypoolConfig {
|
|
78
|
+
pools: PoolConfig[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function configPath(): string {
|
|
82
|
+
return process.env.MULTIKEY_CONFIG ?? process.env.KEYPOOL_CONFIG ?? join(homedir(), ".pi", "agent", "multikey.json");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Pre-rename default location; only consulted for migration when no env override is set. */
|
|
86
|
+
function legacyConfigPath(): string | undefined {
|
|
87
|
+
if (process.env.MULTIKEY_CONFIG || process.env.KEYPOOL_CONFIG) return undefined;
|
|
88
|
+
return join(homedir(), ".pi", "agent", "keypool.json");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const DEFAULT_COOLDOWN_MS = 20_000;
|
|
92
|
+
const DEFAULT_INVALID_KEY_COOLDOWN_MS = 600_000;
|
|
93
|
+
/**
|
|
94
|
+
* First-run seed: discover pools from ~/.pi/agent/models.json instead of shipping
|
|
95
|
+
* any baked-in credentials. A pool is created for every group of providers that
|
|
96
|
+
* share one baseUrl (the "I duplicated a provider per key" pattern) plus any
|
|
97
|
+
* provider already pointing at api.b.ai.
|
|
98
|
+
*/
|
|
99
|
+
|
|
100
|
+
/** Builtin provider ids we must not shadow with a pool registration. */
|
|
101
|
+
const BUILTIN_ID_DENYLIST = new Set([
|
|
102
|
+
"anthropic",
|
|
103
|
+
"openai",
|
|
104
|
+
"openai-codex",
|
|
105
|
+
"azure-openai",
|
|
106
|
+
"google",
|
|
107
|
+
"google-vertex",
|
|
108
|
+
"amazon-bedrock",
|
|
109
|
+
"mistral",
|
|
110
|
+
"groq",
|
|
111
|
+
"openrouter",
|
|
112
|
+
"xai",
|
|
113
|
+
"deepseek",
|
|
114
|
+
"moonshot",
|
|
115
|
+
"zai",
|
|
116
|
+
"minimax",
|
|
117
|
+
"github-copilot",
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
interface RawModelJsonProvider {
|
|
121
|
+
baseUrl?: string;
|
|
122
|
+
apiKey?: string;
|
|
123
|
+
api?: string;
|
|
124
|
+
name?: string;
|
|
125
|
+
compat?: Record<string, unknown>;
|
|
126
|
+
headers?: Record<string, string>;
|
|
127
|
+
models?: PoolModelConfig[];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function hostOf(url: string): string {
|
|
131
|
+
try {
|
|
132
|
+
return new URL(url).host.toLowerCase();
|
|
133
|
+
} catch {
|
|
134
|
+
return "";
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Resolve a models.json config value: literal or $ENV/${ENV}. Skips `!command` (never shell out during seed). */
|
|
139
|
+
function resolveConfigValue(value: string): string | undefined {
|
|
140
|
+
if (value.startsWith("!")) return undefined;
|
|
141
|
+
const names: string[] = [];
|
|
142
|
+
const pattern = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g;
|
|
143
|
+
let match = pattern.exec(value);
|
|
144
|
+
while (match) {
|
|
145
|
+
names.push(match[1] ?? match[2]!);
|
|
146
|
+
match = pattern.exec(value);
|
|
147
|
+
}
|
|
148
|
+
if (names.length === 0) return value.trim() || undefined;
|
|
149
|
+
let resolved = value;
|
|
150
|
+
for (const name of names) {
|
|
151
|
+
const envValue = process.env[name];
|
|
152
|
+
if (!envValue) return undefined;
|
|
153
|
+
resolved = resolved.replace(new RegExp(`\\$\\{?${name}\\}?`, "g"), envValue);
|
|
154
|
+
}
|
|
155
|
+
return resolved.trim() || undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function mergeModel(base: PoolModelConfig | undefined, extra: PoolModelConfig): PoolModelConfig {
|
|
159
|
+
if (!base) return { ...extra };
|
|
160
|
+
const merged: PoolModelConfig = { ...base };
|
|
161
|
+
for (const [key, value] of Object.entries(extra)) {
|
|
162
|
+
if (value === undefined) continue;
|
|
163
|
+
const current = (base as unknown as Record<string, unknown>)[key];
|
|
164
|
+
if (current === undefined || current === null) (merged as unknown as Record<string, unknown>)[key] = value;
|
|
165
|
+
}
|
|
166
|
+
// Prefer a definition that actually declares the sizing fields over a stub.
|
|
167
|
+
if (base.contextWindow === undefined && extra.contextWindow !== undefined) merged.contextWindow = extra.contextWindow;
|
|
168
|
+
if (base.maxTokens === undefined && extra.maxTokens !== undefined) merged.maxTokens = extra.maxTokens;
|
|
169
|
+
return merged;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function discoverPools(): PoolConfig[] {
|
|
173
|
+
const modelsPath = join(homedir(), ".pi", "agent", "models.json");
|
|
174
|
+
if (!existsSync(modelsPath)) return [];
|
|
175
|
+
|
|
176
|
+
let providers: Record<string, RawModelJsonProvider>;
|
|
177
|
+
try {
|
|
178
|
+
const parsed = JSON.parse(readFileSync(modelsPath, "utf-8")) as {
|
|
179
|
+
providers?: Record<string, RawModelJsonProvider>;
|
|
180
|
+
};
|
|
181
|
+
providers = parsed.providers ?? {};
|
|
182
|
+
} catch {
|
|
183
|
+
return [];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Group by baseUrl.
|
|
187
|
+
const groups = new Map<string, { id: string; cfg: RawModelJsonProvider }[]>();
|
|
188
|
+
for (const [id, cfg] of Object.entries(providers)) {
|
|
189
|
+
if (!cfg || typeof cfg.baseUrl !== "string" || !cfg.baseUrl.trim()) continue;
|
|
190
|
+
const key = cfg.baseUrl.trim();
|
|
191
|
+
const bucket = groups.get(key) ?? [];
|
|
192
|
+
bucket.push({ id, cfg });
|
|
193
|
+
groups.set(key, bucket);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const pools: PoolConfig[] = [];
|
|
197
|
+
for (const [baseUrl, bucket] of groups) {
|
|
198
|
+
const isBai = hostOf(baseUrl) === "api.b.ai";
|
|
199
|
+
if (!isBai && bucket.length < 2) continue;
|
|
200
|
+
|
|
201
|
+
// Collect distinct keys.
|
|
202
|
+
const keys: PoolKeyConfig[] = [];
|
|
203
|
+
const seenKeys = new Set<string>();
|
|
204
|
+
for (const entry of bucket) {
|
|
205
|
+
if (typeof entry.cfg.apiKey !== "string") continue;
|
|
206
|
+
const resolved = resolveConfigValue(entry.cfg.apiKey);
|
|
207
|
+
if (!resolved || seenKeys.has(resolved)) continue;
|
|
208
|
+
seenKeys.add(resolved);
|
|
209
|
+
keys.push({ key: resolved, label: `${entry.id}`, enabled: true });
|
|
210
|
+
}
|
|
211
|
+
if (keys.length === 0) continue;
|
|
212
|
+
|
|
213
|
+
// Union of models across the duplicated providers.
|
|
214
|
+
const modelById = new Map<string, PoolModelConfig>();
|
|
215
|
+
for (const entry of bucket) {
|
|
216
|
+
for (const model of entry.cfg.models ?? []) {
|
|
217
|
+
if (!model || typeof model.id !== "string") continue;
|
|
218
|
+
modelById.set(model.id, mergeModel(modelById.get(model.id), model));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const primary = bucket[0]!;
|
|
223
|
+
let poolId = primary.id;
|
|
224
|
+
if (BUILTIN_ID_DENYLIST.has(poolId.toLowerCase())) poolId = `${poolId}-pool`;
|
|
225
|
+
if (pools.some((p) => p.id === poolId)) poolId = `${poolId}-2`;
|
|
226
|
+
|
|
227
|
+
pools.push({
|
|
228
|
+
id: poolId,
|
|
229
|
+
name: `${primary.cfg.name ?? primary.id} (Key Pool)`,
|
|
230
|
+
baseUrl,
|
|
231
|
+
api: bucket.find((entry) => entry.cfg.api)?.cfg.api ?? "openai-completions",
|
|
232
|
+
compat: primary.cfg.compat,
|
|
233
|
+
headers: primary.cfg.headers,
|
|
234
|
+
cooldownMs: DEFAULT_COOLDOWN_MS,
|
|
235
|
+
invalidKeyCooldownMs: DEFAULT_INVALID_KEY_COOLDOWN_MS,
|
|
236
|
+
keys,
|
|
237
|
+
models: [...modelById.values()],
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
return pools;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function seedConfig(): KeypoolConfig {
|
|
244
|
+
return { pools: discoverPools() };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function loadConfig(): { config: KeypoolConfig; created: boolean; migratedFrom?: string } {
|
|
248
|
+
const path = configPath();
|
|
249
|
+
if (!existsSync(path)) {
|
|
250
|
+
const legacy = legacyConfigPath();
|
|
251
|
+
if (legacy && existsSync(legacy)) {
|
|
252
|
+
// One-time rename migration: adopt the old keypool.json as-is.
|
|
253
|
+
const raw = JSON.parse(readFileSync(legacy, "utf-8")) as KeypoolConfig;
|
|
254
|
+
const config = normalize(raw);
|
|
255
|
+
saveConfig(config);
|
|
256
|
+
return { config, created: false, migratedFrom: legacy };
|
|
257
|
+
}
|
|
258
|
+
const config = seedConfig();
|
|
259
|
+
saveConfig(config);
|
|
260
|
+
return { config, created: true };
|
|
261
|
+
}
|
|
262
|
+
const raw = JSON.parse(readFileSync(path, "utf-8")) as KeypoolConfig;
|
|
263
|
+
return { config: normalize(raw), created: false };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function saveConfig(config: KeypoolConfig): void {
|
|
267
|
+
const path = configPath();
|
|
268
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
269
|
+
writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function normalize(config: KeypoolConfig): KeypoolConfig {
|
|
273
|
+
const pools = Array.isArray(config.pools) ? config.pools : [];
|
|
274
|
+
const normalized: PoolConfig[] = [];
|
|
275
|
+
for (const pool of pools) {
|
|
276
|
+
if (!pool || typeof pool.id !== "string" || !pool.id.trim()) continue;
|
|
277
|
+
if (!pool.baseUrl || typeof pool.baseUrl !== "string") continue;
|
|
278
|
+
normalized.push(normalizePool(pool));
|
|
279
|
+
}
|
|
280
|
+
return { pools: normalized };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function normalizePool(pool: PoolConfig): PoolConfig {
|
|
284
|
+
const keys = (Array.isArray(pool.keys) ? pool.keys : [])
|
|
285
|
+
.filter((k) => k && typeof k.key === "string" && k.key.trim())
|
|
286
|
+
.map((k) => ({
|
|
287
|
+
key: k.key.trim(),
|
|
288
|
+
label: typeof k.label === "string" && k.label.trim() ? k.label.trim() : undefined,
|
|
289
|
+
enabled: k.enabled !== false,
|
|
290
|
+
}));
|
|
291
|
+
const models = (Array.isArray(pool.models) ? pool.models : []).filter((m) => m && typeof m.id === "string" && m.id.trim());
|
|
292
|
+
return {
|
|
293
|
+
id: pool.id.trim(),
|
|
294
|
+
name: pool.name ?? pool.id,
|
|
295
|
+
baseUrl: pool.baseUrl,
|
|
296
|
+
api: pool.api ?? "openai-completions",
|
|
297
|
+
auth: pool.auth === "api-key" ? "api-key" : undefined,
|
|
298
|
+
compat: pool.compat,
|
|
299
|
+
headers: pool.headers,
|
|
300
|
+
cooldownMs: typeof pool.cooldownMs === "number" && pool.cooldownMs >= 0 ? pool.cooldownMs : DEFAULT_COOLDOWN_MS,
|
|
301
|
+
invalidKeyCooldownMs:
|
|
302
|
+
typeof pool.invalidKeyCooldownMs === "number" && pool.invalidKeyCooldownMs >= 0
|
|
303
|
+
? pool.invalidKeyCooldownMs
|
|
304
|
+
: DEFAULT_INVALID_KEY_COOLDOWN_MS,
|
|
305
|
+
keys,
|
|
306
|
+
models,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Convert a pool config into pi ProviderModelConfig-style definitions (provider-level compat merged in). */
|
|
311
|
+
export function toProviderModels(pool: PoolConfig): ProviderModelConfig[] {
|
|
312
|
+
return pool.models.map(
|
|
313
|
+
(m): ProviderModelConfig => ({
|
|
314
|
+
id: m.id,
|
|
315
|
+
name: m.name ?? m.id,
|
|
316
|
+
api: m.api,
|
|
317
|
+
baseUrl: m.baseUrl,
|
|
318
|
+
reasoning: m.reasoning ?? true,
|
|
319
|
+
thinkingLevelMap: m.thinkingLevelMap as ProviderModelConfig["thinkingLevelMap"],
|
|
320
|
+
input: m.input ?? DEFAULT_INPUT,
|
|
321
|
+
cost: m.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
322
|
+
contextWindow: m.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
|
|
323
|
+
maxTokens: m.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
324
|
+
compat: mergeCompat(pool.compat, m.compat) as ProviderModelConfig["compat"],
|
|
325
|
+
}),
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function mergeCompat(
|
|
330
|
+
base: Record<string, unknown> | undefined,
|
|
331
|
+
override: Record<string, unknown> | undefined,
|
|
332
|
+
): Record<string, unknown> | undefined {
|
|
333
|
+
if (!base && !override) return undefined;
|
|
334
|
+
return { ...(base ?? {}), ...(override ?? {}) };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Mask a key for display: sk-1234…abcd */
|
|
338
|
+
export function maskKey(key: string): string {
|
|
339
|
+
if (key.length <= 10) return "…";
|
|
340
|
+
return `${key.slice(0, 6)}…${key.slice(-4)}`;
|
|
341
|
+
}
|