pi-multikey 1.8.0 → 1.9.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 +1 -91
- package/README.zh.md +1 -61
- package/cline-auth.ts +64 -13
- package/config.ts +12 -32
- package/identity.ts +81 -69
- package/index.ts +6 -14
- package/manage.ts +68 -21
- package/package.json +3 -1
- package/presets.ts +1 -12
- package/probe.ts +9 -3
- package/stream.ts +14 -7
- package/tui.ts +63 -0
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ pi install /path/to/multikey
|
|
|
29
29
|
/multikey → Add pool… → Preset: B.AI → paste keys one per line (blank line to finish)
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
After picking the preset, the endpoint, compat, and all
|
|
32
|
+
After picking the preset, the endpoint, compat, and all 3 model definitions are wired up automatically. Models are available directly as `bai/<model-id>`, e.g. `bai/hy3`.
|
|
33
33
|
|
|
34
34
|
## Presets
|
|
35
35
|
|
|
@@ -40,83 +40,9 @@ Built-in presets decouple "model settings" from "keys". The data comes from b.ai
|
|
|
40
40
|
| hy3 | 256K / 128K | text | off · low · high |
|
|
41
41
|
| mimo-v2.5 | 1M / 128K | text+image | off · high (official: low/medium/high behave identically) |
|
|
42
42
|
| qwen3.8-flash | 1M / 131K | text+image | off · low · medium · xhigh |
|
|
43
|
-
| glm-5.3-flash | 1M / 131K | text+image | low · high · max (always thinks, no off) |
|
|
44
43
|
|
|
45
44
|
> 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.
|
|
46
45
|
|
|
47
|
-
### OpenCode Zen (free tier)
|
|
48
|
-
|
|
49
|
-
Endpoint `https://opencode.ai/zen/v1`; keys from [opencode.ai/auth](https://opencode.ai/auth) → workspace Keys. Context / max-output are the **Zen free-tier serving limits** (opencode.ai/docs/zen); the raw models are bigger — MiMo V2.5 = 1M ctx. `muse-spark-1.3-contributor-free` uses the OpenAI **Responses** API endpoint; the other five use chat completions.
|
|
50
|
-
|
|
51
|
-
Requests to this endpoint impersonate the official OpenCode client, on both probes and live requests, whenever a pool's baseUrl is `https://opencode.ai/zen/v1`:
|
|
52
|
-
|
|
53
|
-
| Header | Value | Lifetime |
|
|
54
|
-
|---|---|---|
|
|
55
|
-
| `x-opencode-client` | `tui` | constant |
|
|
56
|
-
| `User-Agent` | `opencode/0.1.50 ai-sdk/openai-compatible/3.0.41` | constant |
|
|
57
|
-
| `x-opencode-session` | `ses_` + 12 hex + 14 base62 | one per pi conversation; regenerated on `/new`, resume and fork |
|
|
58
|
-
| `x-opencode-request` | v4 UUID | one per machine, persisted as `deviceId` in `multikey.json` |
|
|
59
|
-
|
|
60
|
-
The session id reproduces OpenCode's own `Identifier.create()`: the first 12 hex chars encode `timestamp_ms * 0x1000 + counter` bitwise-NOTed (descending), so newer conversations sort first, and the counter resets whenever the millisecond changes.
|
|
61
|
-
|
|
62
|
-
| Model | ctx / max-out | Modalities | Supported thinking levels |
|
|
63
|
-
|---|---|---|---|
|
|
64
|
-
| big-pickle | 200K / 32K | text | always-on (no thinkingLevelMap, like pi's catalog) |
|
|
65
|
-
| mimo-v2.5-free | 200K / 32K | text+image | always-on (no thinkingLevelMap) |
|
|
66
|
-
| ling-3.0-flash-fin-free | 262K / 32K | text | always-on (no thinkingLevelMap) |
|
|
67
|
-
| nemotron-3-ultra-free | 1M / 128K | text | always-on (no thinkingLevelMap) |
|
|
68
|
-
| nemotron-3.5-lightning-free | 262K / 262K | text | always-on (no thinkingLevelMap) |
|
|
69
|
-
| muse-spark-1.3-contributor-free | 1M / 131K | text+image | always-on (no reasoning_options; Responses API) |
|
|
70
|
-
|
|
71
|
-
> All six models are free (zero per-token cost) for a limited time while OpenCode collects feedback; data may be used to improve the models (Nemotron free endpoints are NVIDIA trials; Muse Spark Contributor models grant Meta training permission — don't send confidential data).
|
|
72
|
-
>
|
|
73
|
-
> Removed from the preset over time as the free list changed: `deepseek-v4-flash-free` (now **paid** on Zen), `hy3-free` (no longer offered free), and `muse-spark-1.2-contributor-free` (legacy 1.3 predecessor).
|
|
74
|
-
|
|
75
|
-
### Cline Free (free tier)
|
|
76
|
-
|
|
77
|
-
Endpoint `https://api.cline.bot/api/v1` (OpenAI-compatible chat completions). Cline periodically offers free models on its usage-billing API — no static API key exists; access is tied to a **Cline account** via OAuth. The preset therefore collects a credential instead of keys:
|
|
78
|
-
|
|
79
|
-
- **Sign in with Cline (device flow)** — a WorkOS device code is shown; approve it in the browser at the given URL. The refresh token is stored in `multikey.json` and access tokens are minted/rotated automatically before each request and again on 401. This is the recommended path: pasted tokens rot, device-flow tokens don't.
|
|
80
|
-
- **Paste a Cline access token** — from `~/.cline/data/secrets.json` (or the Cline CLI's storage). Works until the token expires, then must be replaced manually.
|
|
81
|
-
|
|
82
|
-
Requests to this endpoint send the same client-identity headers the official Cline CLI sends, on probes and live requests alike:
|
|
83
|
-
|
|
84
|
-
| Header | Value |
|
|
85
|
-
|---|---|
|
|
86
|
-
| `HTTP-Referer` / `X-Title` | `https://cline.bot` / `Cline` |
|
|
87
|
-
| `X-CLIENT-TYPE` / `X-CLIENT-VERSION` | `cline-cli` / CLI version |
|
|
88
|
-
| `User-Agent` | `Cline/<version>` |
|
|
89
|
-
| `X-PLATFORM` / `X-PLATFORM-VERSION` | `cli` / CLI version |
|
|
90
|
-
| `X-CORE-VERSION` | SDK core version |
|
|
91
|
-
| `X-IS-MULTIROOT` | `false` |
|
|
92
|
-
| `X-Task-ID` | v4 UUID, one per pi conversation (regenerated on `/new`, resume, fork) |
|
|
93
|
-
|
|
94
|
-
Quota semantics differ from every other pool: Cline enforces a **daily, per-account, per-model** limit answered with `429 "Daily free limit reached on model X. Try again in 23h 59m"`. multikey classifies this as its own outcome — the key cools down until the server-reported reset time (shown as `cooldown 24h (daily limit)` in the status view) instead of the 20s 429 rotation, which would be meaningless here.
|
|
95
|
-
|
|
96
|
-
| Model | ctx / max-out | Notes |
|
|
97
|
-
|---|---|---|
|
|
98
|
-
| deepseek/deepseek-v4-flash | 1M / 131K | thinking levels not yet probed |
|
|
99
|
-
| meituan/longcat-2.0 | 1M / 131K | thinking levels not yet probed |
|
|
100
|
-
| poolside/laguna-s-2.1:free | 128K / 16K | limits unpublished; safe defaults |
|
|
101
|
-
| z-ai/glm-5.2:free | 200K / 131K | thinking levels not yet probed |
|
|
102
|
-
|
|
103
|
-
> The free lineup **rotates**: retired ids answer `"model not found"`. New models appear via `GET /models` (public) — or accept the one-time preset-sync prompt when this preset ships an updated list. Context/output numbers are best-effort (server-enforced); tune them per model in `multikey.json`.
|
|
104
|
-
>
|
|
105
|
-
> **Single-account by design.** A Cline account is meant to be used from the official IDE extension / CLI, not third-party API clients, and multi-account rotation to dodge the daily quota would violate Cline's terms. The integration exists to use *your own* account's free quota from pi; the client headers identify requests as coming from a Cline-style client. Use it accordingly.
|
|
106
|
-
|
|
107
|
-
To add a preset: append one entry to the `PRESETS` array in `presets.ts`.
|
|
108
|
-
|
|
109
|
-
### Preset sync
|
|
110
|
-
|
|
111
|
-
Pools created from a preset are tracked: `poolFromPreset` stamps a `_preset` marker (preset id + a fingerprint of the model list) into `multikey.json`.
|
|
112
|
-
|
|
113
|
-
- When a shipped preset changes (models added/removed, spec tweaks), pi asks **once** at session start: "Built-in presets changed for pool(s) … — review and align now?"
|
|
114
|
-
- **Align** replaces the pool's model list with the preset's; keys, endpoint, and settings are kept. **Keep my models** — or Esc, or even a crash mid-prompt — mutes that version: the offered fingerprint is persisted *before* the dialog shows, so the same version never re-prompts.
|
|
115
|
-
- When the preset changes **again** (new fingerprint), you're asked once more. Each version gets exactly one ask.
|
|
116
|
-
- Hand-tuned models never trigger the automatic prompt (the preset hasn't changed since your last sync); they stay reachable via `/multikey → Check preset updates…`, which is always available regardless of muting.
|
|
117
|
-
- Legacy pools (created before tracking existed) are matched by `baseUrl` and adopted into tracking the same way.
|
|
118
|
-
- The fingerprint covers the preset's **models only** — compat/API/description changes don't trigger prompts.
|
|
119
|
-
|
|
120
46
|
## Configuration
|
|
121
47
|
|
|
122
48
|
`~/.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.
|
|
@@ -178,22 +104,6 @@ The detected header style is stored as `"auth": "api-key"` only when the endpoin
|
|
|
178
104
|
|
|
179
105
|
Changes take effect immediately (the provider is re-registered) — no restart needed.
|
|
180
106
|
|
|
181
|
-
## Pointing subagents at the pool
|
|
182
|
-
|
|
183
|
-
Set `agentOverrides` in `settings.json` to the pool provider:
|
|
184
|
-
|
|
185
|
-
```json
|
|
186
|
-
"subagents": {
|
|
187
|
-
"agentOverrides": {
|
|
188
|
-
"oracle": { "model": "bai/glm-5.3-flash" },
|
|
189
|
-
"scout": { "model": "bai/hy3" },
|
|
190
|
-
"worker": { "model": "bai/mimo-v2.5" }
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
```
|
|
194
|
-
|
|
195
|
-
`defaultProvider: "bai"` works the same way.
|
|
196
|
-
|
|
197
107
|
## How it works
|
|
198
108
|
|
|
199
109
|
- The extension registers a provider via `pi.registerProvider()` with a custom `streamSimple`.
|
package/README.zh.md
CHANGED
|
@@ -27,7 +27,7 @@ pi install /path/to/multikey
|
|
|
27
27
|
/multikey → Add pool… → Preset: B.AI → 逐行粘贴 key(一行一个,留空结束)
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
-
选 preset 后 endpoint、compat、
|
|
30
|
+
选 preset 后 endpoint、compat、3 个模型的全部设定自动就位,模型通过
|
|
31
31
|
`bai/<model-id>` 直接可用,例如 `bai/hy3`。
|
|
32
32
|
|
|
33
33
|
## Presets
|
|
@@ -41,55 +41,11 @@ DeepSeek / Tencent / 小米官方文档,并对每个 thinking 档位做过实
|
|
|
41
41
|
| hy3 | 256K / 128K | text | off · low · high |
|
|
42
42
|
| mimo-v2.5 | 1M / 128K | text+image | off · high(官方:low/medium/high 行为相同) |
|
|
43
43
|
| qwen3.8-flash | 1M / 131K | text+image | off · low · medium · xhigh |
|
|
44
|
-
| glm-5.3-flash | 1M / 131K | text+image | low · high · max(始终思考,无 off) |
|
|
45
44
|
|
|
46
45
|
> 为什么必须显式写 `null`:pi 的 `getSupportedThinkingLevels` 把 `mapped === null`
|
|
47
46
|
> 视为不支持并隐藏该档,但**省略**会被当作支持并把档名原样发给 API;
|
|
48
47
|
> `xhigh` / `max` 还要求显式给出非 null 值才可用。
|
|
49
48
|
|
|
50
|
-
### OpenCode Zen(免费层)
|
|
51
|
-
|
|
52
|
-
端点 `https://opencode.ai/zen/v1`;密钥从 [opencode.ai/auth](https://opencode.ai/auth) → workspace Keys 获取。
|
|
53
|
-
上下文/最大输出为 **Zen 免费层限制**(opencode.ai/docs/zen);原始模型更大——MiMo V2.5 = 1M ctx。
|
|
54
|
-
`muse-spark-1.3-contributor-free` 使用 OpenAI **Responses** API;其余五个使用 chat completions。
|
|
55
|
-
|
|
56
|
-
只要 pool 的 baseUrl 是 `https://opencode.ai/zen/v1`,探测和真实请求都会伪装成官方 OpenCode 客户端:
|
|
57
|
-
|
|
58
|
-
| 请求头 | 值 | 生命周期 |
|
|
59
|
-
|---|---|---|
|
|
60
|
-
| `x-opencode-client` | `tui` | 常量 |
|
|
61
|
-
| `User-Agent` | `opencode/0.1.50 ai-sdk/openai-compatible/3.0.41` | 常量 |
|
|
62
|
-
| `x-opencode-session` | `ses_` + 12 位十六进制 + 14 位 base62 | 每个 pi 会话一个;`/new`、resume、fork 时重新生成 |
|
|
63
|
-
| `x-opencode-request` | v4 UUID | 每台机器一个,作为 `deviceId` 持久化在 `multikey.json` |
|
|
64
|
-
|
|
65
|
-
session id 复现了 OpenCode 自己的 `Identifier.create()`:前 12 位十六进制编码 `timestamp_ms * 0x1000 + counter` 后按位取反(降序),因此新会话排序在前;计数器仅在毫秒变化时重置。
|
|
66
|
-
|
|
67
|
-
| 模型 | ctx / max-out | 模态 | 生效 thinking 档位 |
|
|
68
|
-
|---|---|---|---|
|
|
69
|
-
| big-pickle | 200K / 32K | text | 始终思考(无 thinkingLevelMap,与 pi 内置目录一致) |
|
|
70
|
-
| mimo-v2.5-free | 200K / 32K | text+image | 始终思考(无 thinkingLevelMap) |
|
|
71
|
-
| ling-3.0-flash-fin-free | 262K / 32K | text | 始终思考(无 thinkingLevelMap) |
|
|
72
|
-
| nemotron-3-ultra-free | 1M / 128K | text | 始终思考(无 thinkingLevelMap) |
|
|
73
|
-
| nemotron-3.5-lightning-free | 262K / 262K | text | 始终思考(无 thinkingLevelMap) |
|
|
74
|
-
| muse-spark-1.3-contributor-free | 1M / 131K | text+image | 始终思考(无 reasoning_options;Responses API) |
|
|
75
|
-
|
|
76
|
-
> 以上六个模型在 OpenCode 收集反馈期间均免费(零 token 费用);数据可能用于改进模型(Nemotron 免费端点为 NVIDIA 试用;Muse Spark Contributor 模型授权 Meta 用于训练——请勿提交机密数据)。
|
|
77
|
-
>
|
|
78
|
-
> 免费列表变化后已从 preset 移除:`deepseek-v4-flash-free`(在 Zen 上已转为**付费**)、`hy3-free`(不再提供免费层)、`muse-spark-1.2-contributor-free`(1.3 的前身遗留变体)。
|
|
79
|
-
|
|
80
|
-
新增 preset:在 `presets.ts` 的 `PRESETS` 数组里加一项即可。
|
|
81
|
-
|
|
82
|
-
### Preset 同步
|
|
83
|
-
|
|
84
|
-
从 preset 创建的池会被追踪:`poolFromPreset` 会在 `multikey.json` 里写入 `_preset` 标记(preset id + 模型列表的指纹)。
|
|
85
|
-
|
|
86
|
-
- 内置 preset 变化(模型增删、参数调整)后,pi 会在会话启动时**只询问一次**:“内置 preset 已变化……是否立即对齐?”
|
|
87
|
-
- **Align** 用 preset 的模型列表替换池的模型;密钥、endpoint、其他设置保留。**Keep my models**——以及 Esc、甚至提示中途崩溃——都会静音该版本:提供的指纹在弹窗**之前**就已持久化,同一版本绝不会重复询问。
|
|
88
|
-
- preset **再次**变化(新指纹)时会再询问一次。每个版本恰好一次。
|
|
89
|
-
- 手动调过参数的池不会触发自动询问(自上次同步后 preset 没变);它们始终可以通过 `/multikey → Check preset updates…` 检查,该入口不受静音影响。
|
|
90
|
-
- 旧版本创建的池(早于追踪功能)按 `baseUrl` 匹配并以同样方式纳入追踪。
|
|
91
|
-
- 指纹只覆盖 preset 的**模型列表**——compat/API/描述变化不会触发询问。
|
|
92
|
-
|
|
93
49
|
## 配置
|
|
94
50
|
|
|
95
51
|
`~/.pi/agent/multikey.json`。首次运行时会从 `~/.pi/agent/models.json` 自动发现
|
|
@@ -152,22 +108,6 @@ JSON 后 `Reload config from disk`。
|
|
|
152
108
|
|
|
153
109
|
改动即时生效(重新注册 provider),无需重启。
|
|
154
110
|
|
|
155
|
-
## 让 subagent 用上池
|
|
156
|
-
|
|
157
|
-
`settings.json` 的 agentOverrides 改成池 provider:
|
|
158
|
-
|
|
159
|
-
```json
|
|
160
|
-
"subagents": {
|
|
161
|
-
"agentOverrides": {
|
|
162
|
-
"oracle": { "model": "bai/glm-5.3-flash" },
|
|
163
|
-
"scout": { "model": "bai/hy3" },
|
|
164
|
-
"worker": { "model": "bai/mimo-v2.5" }
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
```
|
|
168
|
-
|
|
169
|
-
`defaultProvider: "bai"` 同理。
|
|
170
|
-
|
|
171
111
|
## 工作原理
|
|
172
112
|
|
|
173
113
|
- 扩展通过 `pi.registerProvider()` 注册 provider,并提供自定义 `streamSimple`。
|
package/cline-auth.ts
CHANGED
|
@@ -71,6 +71,8 @@ function requireClineTokens(payload: ClineTokenResponse, message: string): Cline
|
|
|
71
71
|
if (!payload.success || !accessToken || !refreshToken) {
|
|
72
72
|
throw new Error(message);
|
|
73
73
|
}
|
|
74
|
+
// Return the RAW token; the workos: prefix is applied at request time by
|
|
75
|
+
// formatClineAccessToken (idempotent), so it is never doubled.
|
|
74
76
|
return { accessToken, refreshToken, expiresAt: parseExpiresAt(payload.data?.expiresAt) };
|
|
75
77
|
}
|
|
76
78
|
|
|
@@ -97,6 +99,19 @@ export async function refreshClineToken(refreshToken: string): Promise<ClineToke
|
|
|
97
99
|
return requireClineTokens(payload, "Invalid Cline token refresh response");
|
|
98
100
|
}
|
|
99
101
|
|
|
102
|
+
const WORKOS_ACCESS_TOKEN_PREFIX = "workos:";
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Cline's API requires the WorkOS access token prefixed with the literal
|
|
106
|
+
* string "workos:" in the Authorization header — the official client formats
|
|
107
|
+
* every stored access token this way before sending (formatAccessToken in
|
|
108
|
+
* cline core). A raw JWT gets a generic 401 that looks like a bad key.
|
|
109
|
+
*/
|
|
110
|
+
export function formatClineAccessToken(accessToken: string): string {
|
|
111
|
+
const token = accessToken.trim();
|
|
112
|
+
return token.toLowerCase().startsWith(WORKOS_ACCESS_TOKEN_PREFIX) ? token : `${WORKOS_ACCESS_TOKEN_PREFIX}${token}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
100
115
|
// Single-flight per refresh token: concurrent subagents hitting the same
|
|
101
116
|
// account must share one in-flight refresh instead of racing each other.
|
|
102
117
|
const inFlight = new Map<string, Promise<ClineTokenUpdate>>();
|
|
@@ -111,7 +126,12 @@ export async function ensureClineAccessToken(
|
|
|
111
126
|
): Promise<ClineTokenUpdate & { refreshed: boolean }> {
|
|
112
127
|
const force = options?.force === true;
|
|
113
128
|
if (!force && !isClineTokenStale(credential) && credential.accessToken) {
|
|
114
|
-
return {
|
|
129
|
+
return {
|
|
130
|
+
accessToken: formatClineAccessToken(credential.accessToken),
|
|
131
|
+
refreshToken: credential.refreshToken,
|
|
132
|
+
expiresAt: credential.expiresAt,
|
|
133
|
+
refreshed: false,
|
|
134
|
+
};
|
|
115
135
|
}
|
|
116
136
|
const existing = inFlight.get(credential.refreshToken);
|
|
117
137
|
if (existing) return { ...(await existing), refreshed: true };
|
|
@@ -119,7 +139,8 @@ export async function ensureClineAccessToken(
|
|
|
119
139
|
inFlight.delete(credential.refreshToken);
|
|
120
140
|
});
|
|
121
141
|
inFlight.set(credential.refreshToken, task);
|
|
122
|
-
|
|
142
|
+
const update = await task;
|
|
143
|
+
return { ...update, accessToken: formatClineAccessToken(update.accessToken), refreshed: true };
|
|
123
144
|
}
|
|
124
145
|
// ── Device flow (initial sign-in) ───────────────────────────────────────────
|
|
125
146
|
|
|
@@ -146,38 +167,64 @@ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
146
167
|
|
|
147
168
|
/**
|
|
148
169
|
* Interactive sign-in: request a device code, hand the verification URL to
|
|
149
|
-
* `onAuthInfo
|
|
150
|
-
*
|
|
170
|
+
* `onAuthInfo`, and start polling immediately — `onAuthInfo` must NOT block
|
|
171
|
+
* until dismissal (the old code awaited a modal dialog here, so polling only
|
|
172
|
+
* began after the user pressed enter and a completed browser approval sat
|
|
173
|
+
* unnoticed). Call `onAuthorized` (via the options below) once the first poll
|
|
174
|
+
* succeeds so the UI can auto-close the panel; the panel's dismissal promise
|
|
175
|
+
* is awaited before returning so the two UI layers never interleave.
|
|
151
176
|
*/
|
|
152
177
|
export async function loginClineDeviceFlow(options: {
|
|
153
|
-
/**
|
|
178
|
+
/** Show the verification URL/user code; resolve without waiting for dismissal. */
|
|
154
179
|
onAuthInfo: (info: { url: string; userCode: string }) => Promise<void>;
|
|
180
|
+
/** Called when the browser approval is confirmed — the UI can auto-close its auth panel. */
|
|
181
|
+
onAuthorized?: () => void;
|
|
155
182
|
/** Optional progress lines while polling (e.g. append to a progress panel). */
|
|
156
183
|
onProgress?: (message: string) => void;
|
|
157
184
|
}): Promise<ClineTokenUpdate> {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
185
|
+
// One retry: a transient network blip here aborts the whole sign-in before
|
|
186
|
+
// the user even sees a URL (observed: "fetch failed" after ~12s once).
|
|
187
|
+
let authResponse: Response | undefined;
|
|
188
|
+
let lastFetchError: unknown;
|
|
189
|
+
for (let attempt = 1; attempt <= 2 && !authResponse; attempt++) {
|
|
190
|
+
try {
|
|
191
|
+
authResponse = await fetch(`${WORKOS_API_BASE_URL}${WORKOS_DEVICE_AUTHORIZATION_PATH}`, {
|
|
192
|
+
method: "POST",
|
|
193
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
194
|
+
body: new URLSearchParams({ client_id: WORKOS_CLIENT_ID }),
|
|
195
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
196
|
+
});
|
|
197
|
+
} catch (error) {
|
|
198
|
+
lastFetchError = error;
|
|
199
|
+
if (attempt < 2) await sleep(1500);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (!authResponse) {
|
|
203
|
+
const detail = lastFetchError instanceof Error ? `${lastFetchError.message}${lastFetchError.cause instanceof Error ? ` (${lastFetchError.cause.message})` : ""}` : String(lastFetchError);
|
|
204
|
+
throw new Error(`Cline device authorization failed: could not reach api.workos.com (${detail})`);
|
|
205
|
+
}
|
|
164
206
|
const device = (await authResponse.json().catch(() => ({}))) as WorkOSDeviceAuthorizationResponse;
|
|
165
207
|
if (!authResponse.ok || !device.device_code || !device.user_code || !device.verification_uri) {
|
|
166
208
|
const detail = device.error_description ?? (authResponse.ok ? "invalid WorkOS response" : `HTTP ${authResponse.status}`);
|
|
167
209
|
throw new Error(`Cline device authorization failed: ${detail}`);
|
|
168
210
|
}
|
|
169
211
|
|
|
170
|
-
|
|
212
|
+
// Hand the URL to the UI but do NOT wait for the panel to be dismissed —
|
|
213
|
+
// polling must start now, or an already-completed browser approval goes
|
|
214
|
+
// unnoticed until the user dismisses the panel manually.
|
|
215
|
+
const panelDismissed = options.onAuthInfo({
|
|
171
216
|
url: device.verification_uri_complete ?? device.verification_uri,
|
|
172
217
|
userCode: device.user_code,
|
|
173
|
-
});
|
|
218
|
+
}).catch(() => {});
|
|
174
219
|
|
|
175
220
|
const expiresInSeconds = device.expires_in ?? DEVICE_AUTH_EXPIRES_IN_SECONDS;
|
|
176
221
|
const deadline = Date.now() + expiresInSeconds * 1000;
|
|
177
222
|
let intervalSeconds = Math.max(1, device.interval ?? DEVICE_AUTH_INTERVAL_SECONDS);
|
|
178
223
|
let workosTokens: { access: string; refresh: string } | undefined;
|
|
224
|
+
let pollCount = 0;
|
|
179
225
|
|
|
180
226
|
while (Date.now() <= deadline) {
|
|
227
|
+
pollCount++;
|
|
181
228
|
const pollResponse = await fetch(`${WORKOS_API_BASE_URL}${WORKOS_AUTHENTICATE_PATH}`, {
|
|
182
229
|
method: "POST",
|
|
183
230
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
@@ -194,6 +241,8 @@ export async function loginClineDeviceFlow(options: {
|
|
|
194
241
|
throw new Error("Invalid WorkOS token response");
|
|
195
242
|
}
|
|
196
243
|
workosTokens = { access: payload.access_token, refresh: payload.refresh_token };
|
|
244
|
+
options.onAuthorized?.();
|
|
245
|
+
await panelDismissed;
|
|
197
246
|
break;
|
|
198
247
|
}
|
|
199
248
|
switch (payload.error) {
|
|
@@ -206,7 +255,9 @@ export async function loginClineDeviceFlow(options: {
|
|
|
206
255
|
await sleep(intervalSeconds * 1000);
|
|
207
256
|
break;
|
|
208
257
|
case "access_denied":
|
|
258
|
+
throw new Error(`Cline authorization failed: ${payload.error_description ?? payload.error}`);
|
|
209
259
|
case "expired_token":
|
|
260
|
+
throw new Error(`Cline authorization timed out: ${payload.error_description ?? payload.error}`);
|
|
210
261
|
case "invalid_grant":
|
|
211
262
|
throw new Error(`Cline authorization failed: ${payload.error_description ?? payload.error}`);
|
|
212
263
|
default:
|
package/config.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { homedir } from "node:os";
|
|
|
11
11
|
import { dirname, join } from "node:path";
|
|
12
12
|
import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent";
|
|
13
13
|
import type { AuthStyle } from "./probe.ts";
|
|
14
|
-
import {
|
|
14
|
+
import { currentRequestId, currentSessionId, currentTaskId, turnKeyOf } from "./identity.ts";
|
|
15
15
|
|
|
16
16
|
/** Safe model defaults applied when a spec doesn't say otherwise (edit in multikey.json). */
|
|
17
17
|
export const DEFAULT_CONTEXT_WINDOW = 128_000;
|
|
@@ -109,12 +109,6 @@ export interface PoolConfig {
|
|
|
109
109
|
|
|
110
110
|
export interface KeypoolConfig {
|
|
111
111
|
pools: PoolConfig[];
|
|
112
|
-
/**
|
|
113
|
-
* Stable per-install UUID sent as `x-opencode-request` to OpenCode Zen.
|
|
114
|
-
* Generated once and reused forever, so every request from this machine
|
|
115
|
-
* carries the same device identity.
|
|
116
|
-
*/
|
|
117
|
-
deviceId?: string;
|
|
118
112
|
}
|
|
119
113
|
|
|
120
114
|
export function configPath(): string {
|
|
@@ -300,27 +294,9 @@ export function loadConfig(): { config: KeypoolConfig; created: boolean; migrate
|
|
|
300
294
|
}
|
|
301
295
|
const raw = JSON.parse(readFileSync(path, "utf-8")) as KeypoolConfig;
|
|
302
296
|
const config = normalize(raw);
|
|
303
|
-
// Adopt any stored device id into the identity module; a config predating
|
|
304
|
-
// deviceId gets one assigned by ensureDeviceId() in the caller.
|
|
305
|
-
if (config.deviceId) setDeviceId(config.deviceId);
|
|
306
297
|
return { config, created: false };
|
|
307
298
|
}
|
|
308
299
|
|
|
309
|
-
/**
|
|
310
|
-
* Make sure a persisted device id exists for configs that expose an OpenCode
|
|
311
|
-
* Zen pool, saving only when it actually changed anything. Pools that never
|
|
312
|
-
* talk to Zen leave the file untouched.
|
|
313
|
-
*/
|
|
314
|
-
export function ensureDeviceId(config: KeypoolConfig): void {
|
|
315
|
-
if (config.deviceId) {
|
|
316
|
-
setDeviceId(config.deviceId);
|
|
317
|
-
return;
|
|
318
|
-
}
|
|
319
|
-
if (!config.pools.some((pool) => isOpenCodeZenEndpoint(pool.baseUrl))) return;
|
|
320
|
-
config.deviceId = currentDeviceId();
|
|
321
|
-
saveConfig(config);
|
|
322
|
-
}
|
|
323
|
-
|
|
324
300
|
export function saveConfig(config: KeypoolConfig): void {
|
|
325
301
|
const path = configPath();
|
|
326
302
|
mkdirSync(dirname(path), { recursive: true });
|
|
@@ -335,8 +311,7 @@ export function normalize(config: KeypoolConfig): KeypoolConfig {
|
|
|
335
311
|
if (!pool.baseUrl || typeof pool.baseUrl !== "string") continue;
|
|
336
312
|
normalized.push(normalizePool(pool));
|
|
337
313
|
}
|
|
338
|
-
|
|
339
|
-
return { pools: normalized, deviceId };
|
|
314
|
+
return { pools: normalized };
|
|
340
315
|
}
|
|
341
316
|
|
|
342
317
|
/** Structural check so a malformed credential in the JSON file can't break a pool. */
|
|
@@ -448,15 +423,20 @@ export function endpointHeaders(baseUrl: string): Record<string, string> {
|
|
|
448
423
|
/**
|
|
449
424
|
* Per-request identity headers a known endpoint expects.
|
|
450
425
|
*
|
|
451
|
-
* OpenCode Zen reads `x-opencode-session`
|
|
452
|
-
* `x-opencode-request` as the
|
|
453
|
-
* at request time rather than baked into the provider
|
|
426
|
+
* OpenCode Zen reads `x-opencode-session` as the conversation id and
|
|
427
|
+
* `x-opencode-request` as the id of the user message being answered, so these
|
|
428
|
+
* must be computed at request time rather than baked into the provider
|
|
429
|
+
* registration. `messages` lets the caller key the request id to the current
|
|
430
|
+
* turn (see turnKeyOf); omit it for one-shot calls outside a conversation.
|
|
454
431
|
* Cline reads `X-Task-ID` as a per-conversation correlation id.
|
|
455
432
|
*/
|
|
456
|
-
export function endpointIdentityHeaders(baseUrl: string | undefined): Record<string, string> {
|
|
433
|
+
export function endpointIdentityHeaders(baseUrl: string | undefined, messages?: readonly unknown[]): Record<string, string> {
|
|
457
434
|
if (isClineEndpoint(baseUrl ?? "")) return { "X-Task-ID": currentTaskId() };
|
|
458
435
|
if (!isOpenCodeZenEndpoint(baseUrl ?? "")) return {};
|
|
459
|
-
return {
|
|
436
|
+
return {
|
|
437
|
+
"x-opencode-session": currentSessionId(),
|
|
438
|
+
"x-opencode-request": currentRequestId(messages ? turnKeyOf(messages) : undefined),
|
|
439
|
+
};
|
|
460
440
|
}
|
|
461
441
|
|
|
462
442
|
// ── Cline (api.cline.bot) ────────────────────────────────────────────────────
|
package/identity.ts
CHANGED
|
@@ -1,101 +1,113 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Client identity for endpoints that
|
|
2
|
+
* Client identity headers for endpoints that gate on them.
|
|
3
3
|
*
|
|
4
|
-
* OpenCode Zen
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* OpenCode Zen — opencode.ai/zen identifies the calling client with four headers,
|
|
5
|
+
* set in packages/opencode/src/session/llm/request.ts (LLMRequestPrep.prepare)
|
|
6
|
+
* whenever the provider id starts with "opencode":
|
|
7
|
+
*
|
|
8
|
+
* x-opencode-client flags.client ("tui" for the terminal client)
|
|
9
|
+
* User-Agent opencode/<InstallationVersion>
|
|
10
|
+
* x-opencode-session input.sessionID ("ses_" + Identifier.create(descending))
|
|
11
|
+
* x-opencode-request input.user.id, the id of the user message being answered
|
|
12
|
+
* ("msg_" + Identifier.create(ascending))
|
|
13
|
+
*
|
|
14
|
+
* prompt.ts resolves `lastUser` once per turn and passes that same message into
|
|
15
|
+
* every step of the agentic loop, so one user turn — including retries — reuses
|
|
16
|
+
* one request id. pi exposes neither a session id nor message ids to a custom
|
|
17
|
+
* stream function (UserMessage is just { role, content, timestamp }), so we mint
|
|
18
|
+
* ids in the same format and keep them alive for the same span opencode would.
|
|
19
|
+
*
|
|
20
|
+
* Cline — api.cline.bot correlates a conversation with `X-Task-ID`, a v4 uuid.
|
|
8
21
|
*/
|
|
9
22
|
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
/** OpenCode's identifier alphabet: 12 hex chars of time, then 14 base62 chars. */
|
|
13
|
-
const BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
14
|
-
const ID_LENGTH = 26;
|
|
15
|
-
const TIME_HEX_LENGTH = 12;
|
|
23
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
16
24
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
let lastTimestamp = 0;
|
|
20
|
-
let counter = 0;
|
|
25
|
+
/** Alphabet used by opencode's Identifier.create (packages/schema/src/identifier.ts). */
|
|
26
|
+
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
21
27
|
|
|
22
28
|
/**
|
|
23
|
-
*
|
|
24
|
-
* `timestamp_ms * 0x1000 + counter`, bitwise
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* The 48-bit window truncates today's millisecond timestamps, which is exactly
|
|
28
|
-
* what OpenCode ships — the low bits still order ids newest-first.
|
|
29
|
+
* Port of opencode's Identifier.create(): a sortable, KSUID-like 26-char suffix.
|
|
30
|
+
* The first 12 chars are the hex of `(timestamp_ms * 0x1000 + counter)`, bitwise
|
|
31
|
+
* NOT'd for descending ids so newer ids sort first; the last 14 are random base62.
|
|
32
|
+
* The counter resets whenever the millisecond changes.
|
|
29
33
|
*/
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const current = BigInt(timestamp) * 0x1000n + BigInt(counter);
|
|
38
|
-
const value = descending ? ~current : current;
|
|
39
|
-
let time = "";
|
|
40
|
-
for (let index = 0; index < 6; index++) {
|
|
41
|
-
const byte = Number((value >> BigInt(40 - 8 * index)) & 0xffn);
|
|
42
|
-
time += byte.toString(16).padStart(2, "0");
|
|
34
|
+
let lastTs = 0;
|
|
35
|
+
let count = 0;
|
|
36
|
+
export function createIdentifier(descending: boolean, ts: number = Date.now()): string {
|
|
37
|
+
if (ts !== lastTs) {
|
|
38
|
+
lastTs = ts;
|
|
39
|
+
count = 0;
|
|
43
40
|
}
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
41
|
+
const time = BigInt(ts) * 0x1000n + BigInt(count++);
|
|
42
|
+
const value = descending ? ~time & 0xffffffffffffn : time;
|
|
43
|
+
const hex = value.toString(16).padStart(12, "0");
|
|
44
|
+
let id = hex;
|
|
45
|
+
for (const byte of randomBytes(14)) id += ALPHABET[byte % ALPHABET.length];
|
|
46
|
+
return id;
|
|
49
47
|
}
|
|
50
48
|
|
|
51
|
-
/**
|
|
49
|
+
/** New session identifier, matching opencode's `SessionID.ascending()` ("ses_" + 26). */
|
|
52
50
|
export function createSessionId(): string {
|
|
53
51
|
return `ses_${createIdentifier(true)}`;
|
|
54
52
|
}
|
|
55
53
|
|
|
56
|
-
/**
|
|
57
|
-
export function
|
|
58
|
-
return
|
|
54
|
+
/** New message identifier, matching opencode's `MessageID.ascending()` ("msg_" + 26). */
|
|
55
|
+
export function createMessageId(): string {
|
|
56
|
+
return `msg_${createIdentifier(false)}`;
|
|
59
57
|
}
|
|
60
58
|
|
|
61
|
-
/**
|
|
62
|
-
|
|
63
|
-
|
|
59
|
+
/**
|
|
60
|
+
* Stable key for "the turn we are currently answering" — opencode's `lastUser`.
|
|
61
|
+
* pi rebuilds message objects between requests, so object identity is useless;
|
|
62
|
+
* the last user message's timestamp plus a digest of its content stays equal
|
|
63
|
+
* across every step of the same turn.
|
|
64
|
+
*/
|
|
65
|
+
export function turnKeyOf(messages: readonly unknown[]): string | undefined {
|
|
66
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
67
|
+
const m = messages[i] as { role?: string; timestamp?: number; content?: unknown } | undefined;
|
|
68
|
+
if (m?.role !== "user") continue;
|
|
69
|
+
const digest = createHash("sha1").update(JSON.stringify(m.content ?? "")).digest("hex");
|
|
70
|
+
return `${m.timestamp ?? 0}:${digest}`;
|
|
71
|
+
}
|
|
72
|
+
return undefined;
|
|
64
73
|
}
|
|
65
74
|
|
|
66
75
|
let sessionId: string | undefined;
|
|
67
|
-
let
|
|
76
|
+
let requestId: string | undefined;
|
|
77
|
+
let turnKey: string | undefined;
|
|
68
78
|
let taskId: string | undefined;
|
|
69
79
|
|
|
70
|
-
/**
|
|
71
|
-
export function
|
|
72
|
-
if (!sessionId) sessionId = createSessionId();
|
|
73
|
-
return sessionId;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** Drop the cached session id so the next request starts a new conversation. */
|
|
77
|
-
export function resetSessionId(): void {
|
|
80
|
+
/** Forget the current conversation (called on session_start for new/resume/fork). */
|
|
81
|
+
export function resetConversation(): void {
|
|
78
82
|
sessionId = undefined;
|
|
83
|
+
requestId = undefined;
|
|
84
|
+
turnKey = undefined;
|
|
85
|
+
taskId = undefined;
|
|
79
86
|
}
|
|
80
87
|
|
|
81
|
-
/**
|
|
82
|
-
export function
|
|
83
|
-
if (
|
|
88
|
+
/** The session id for the current conversation, created on first use. */
|
|
89
|
+
export function currentSessionId(): string {
|
|
90
|
+
if (!sessionId) sessionId = createSessionId();
|
|
91
|
+
return sessionId;
|
|
84
92
|
}
|
|
85
93
|
|
|
86
|
-
/**
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
94
|
+
/**
|
|
95
|
+
* The request id for the turn identified by `key` (opencode's `input.user.id`
|
|
96
|
+
* equivalent). A new key mints a fresh "msg_" id and the same key keeps returning
|
|
97
|
+
* it; a missing key means there is no conversation context to key on (a probe,
|
|
98
|
+
* say), so it mints a fresh id without disturbing the cached turn.
|
|
99
|
+
*/
|
|
100
|
+
export function currentRequestId(key: string | undefined): string {
|
|
101
|
+
if (key === undefined) return createMessageId();
|
|
102
|
+
if (!requestId || key !== turnKey) {
|
|
103
|
+
requestId = createMessageId();
|
|
104
|
+
turnKey = key;
|
|
105
|
+
}
|
|
106
|
+
return requestId;
|
|
90
107
|
}
|
|
91
108
|
|
|
92
|
-
/**
|
|
109
|
+
/** Cline's X-Task-ID: one v4 uuid per conversation. */
|
|
93
110
|
export function currentTaskId(): string {
|
|
94
|
-
if (!taskId) taskId =
|
|
111
|
+
if (!taskId) taskId = crypto.randomUUID();
|
|
95
112
|
return taskId;
|
|
96
113
|
}
|
|
97
|
-
|
|
98
|
-
/** Drop the cached task id so the next request starts a new conversation. */
|
|
99
|
-
export function resetTaskId(): void {
|
|
100
|
-
taskId = undefined;
|
|
101
|
-
}
|
package/index.ts
CHANGED
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
|
|
12
12
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
13
13
|
import { getApiProvider, type Api } from "@earendil-works/pi-ai";
|
|
14
|
-
import { configPath, endpointHeaders,
|
|
15
|
-
import {
|
|
14
|
+
import { configPath, endpointHeaders, loadConfig, saveConfig, toProviderModels, type KeypoolConfig, type PoolConfig } from "./config.ts";
|
|
15
|
+
import { resetConversation } from "./identity.ts";
|
|
16
16
|
import { KeyPool } from "./pool.ts";
|
|
17
17
|
import { createRotatingStreamSimple } from "./stream.ts";
|
|
18
18
|
import { runManager, maybeOfferPresetUpdates, type ManagerHooks } from "./manage.ts";
|
|
@@ -36,8 +36,6 @@ export default function multikey(pi: ExtensionAPI) {
|
|
|
36
36
|
|
|
37
37
|
const pools = new Map<string, KeyPool>();
|
|
38
38
|
for (const pool of config.pools) pools.set(pool.id, new KeyPool(pool));
|
|
39
|
-
// OpenCode Zen needs a stable per-device id; persist one when a Zen pool exists.
|
|
40
|
-
ensureDeviceId(config);
|
|
41
39
|
|
|
42
40
|
let ui: ExtensionContext["ui"] | undefined;
|
|
43
41
|
const notify = (message: string) => {
|
|
@@ -65,9 +63,6 @@ export default function multikey(pi: ExtensionAPI) {
|
|
|
65
63
|
const keyPool = pools.get(pool.id) ?? new KeyPool(pool);
|
|
66
64
|
keyPool.updateConfig(pool);
|
|
67
65
|
pools.set(pool.id, keyPool);
|
|
68
|
-
// A Zen pool needs a durable device identity; adopt one before the first
|
|
69
|
-
// request goes out (no-op once multikey.json has a deviceId).
|
|
70
|
-
ensureDeviceId(config);
|
|
71
66
|
|
|
72
67
|
pi.registerProvider(pool.id, {
|
|
73
68
|
name: pool.name ?? pool.id,
|
|
@@ -139,13 +134,10 @@ export default function multikey(pi: ExtensionAPI) {
|
|
|
139
134
|
|
|
140
135
|
pi.on("session_start", async (event, ctx) => {
|
|
141
136
|
ui = ctx.ui;
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
resetTaskId();
|
|
147
|
-
}
|
|
148
|
-
ensureDeviceId(config);
|
|
137
|
+
// One OpenCode session id / request id and one Cline task id per
|
|
138
|
+
// conversation: /new, resume and fork switch conversations, so drop the
|
|
139
|
+
// cached ids for those (but not on plain startup or an extension reload).
|
|
140
|
+
if (event.reason === "new" || event.reason === "resume" || event.reason === "fork") resetConversation();
|
|
149
141
|
for (const { id, reason } of skipped) {
|
|
150
142
|
notify(`multikey: provider "${id}" not available — ${reason}. Fix it via /multikey → Manage pools.`);
|
|
151
143
|
}
|
package/manage.ts
CHANGED
|
@@ -21,8 +21,9 @@ import {
|
|
|
21
21
|
import type { KeyPool } from "./pool.ts";
|
|
22
22
|
import { PRESETS, findPreset, poolFromPreset, presetFingerprint, diffPresetModels, describePresetDiff, type Preset, type PresetModelDiff } from "./presets.ts";
|
|
23
23
|
import { probeEndpoint, type ProbeResult, type RemoteModel } from "./probe.ts";
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
24
|
+
import { spawn } from "node:child_process";
|
|
25
|
+
import { describeClineCredential, ensureClineAccessToken, formatClineAccessToken, loginClineDeviceFlow } from "./cline-auth.ts";
|
|
26
|
+
import { inputNumber, pickMany, selectOne, showInfo, showInfoWithHandle, withProgress, type InfoPanelHandle } from "./tui.ts";
|
|
26
27
|
|
|
27
28
|
type CommandContext = Parameters<Parameters<ExtensionAPI["registerCommand"]>[1]["handler"]>[1];
|
|
28
29
|
|
|
@@ -97,6 +98,25 @@ function formatCooldown(ms: number): string {
|
|
|
97
98
|
return `${Math.ceil(ms / 3_600_000)}h`;
|
|
98
99
|
}
|
|
99
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Best-effort browser open for device-flow verification URLs. Never throws;
|
|
103
|
+
* a missing opener only means the user has to paste the URL manually (the
|
|
104
|
+
* auth panel always shows the URL as a fallback).
|
|
105
|
+
*/
|
|
106
|
+
function tryOpenBrowser(url: string): boolean {
|
|
107
|
+
const platform = process.platform;
|
|
108
|
+
const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
|
|
109
|
+
const args = platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
110
|
+
try {
|
|
111
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
112
|
+
child.on("error", () => {}); // opener missing — the panel shows the URL anyway
|
|
113
|
+
child.unref();
|
|
114
|
+
return true;
|
|
115
|
+
} catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
100
120
|
/**
|
|
101
121
|
* Collect a Cline account credential: the WorkOS device flow (recommended —
|
|
102
122
|
* refresh tokens keep the access token alive) or a manually pasted access
|
|
@@ -117,40 +137,67 @@ async function collectClineKeys(ctx: CommandContext, hooks: ManagerHooks): Promi
|
|
|
117
137
|
]);
|
|
118
138
|
if (how === "device") {
|
|
119
139
|
let credential: KeyCredential;
|
|
140
|
+
let panel: InfoPanelHandle | undefined;
|
|
120
141
|
try {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
142
|
+
// withProgress swallows task errors and resolves undefined, so capture
|
|
143
|
+
// the real error here and rethrow it below — `undefined` tokens used to
|
|
144
|
+
// crash with "cannot read properties of undefined" and mask the failure.
|
|
145
|
+
let loginError: unknown;
|
|
146
|
+
const tokens = await withProgress(ctx, "Cline sign-in", async (update) => {
|
|
147
|
+
try {
|
|
148
|
+
return await loginClineDeviceFlow({
|
|
149
|
+
onAuthInfo: async ({ url, userCode }) => {
|
|
150
|
+
const opened = tryOpenBrowser(url);
|
|
151
|
+
panel = showInfoWithHandle(ctx, "Authorize Cline", [
|
|
152
|
+
opened
|
|
153
|
+
? "A browser window should have opened — approve the sign-in there."
|
|
154
|
+
: "Open this URL in your browser and approve the sign-in:",
|
|
155
|
+
"",
|
|
156
|
+
...(opened ? ["If no window opened, paste this URL:", url] : [url]),
|
|
157
|
+
"",
|
|
158
|
+
`User code: ${userCode}`,
|
|
159
|
+
"",
|
|
160
|
+
"Sign-in continues automatically once you approve — you can dismiss this panel any time.",
|
|
161
|
+
]);
|
|
162
|
+
await panel.closed;
|
|
163
|
+
},
|
|
164
|
+
onAuthorized: () => {
|
|
165
|
+
update("Browser authorization confirmed — exchanging tokens…");
|
|
166
|
+
panel?.close();
|
|
167
|
+
},
|
|
168
|
+
onProgress: (message) => update(message),
|
|
169
|
+
});
|
|
170
|
+
} catch (error) {
|
|
171
|
+
loginError = error;
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
if (!tokens) throw loginError ?? new Error("Cline sign-in failed");
|
|
176
|
+
// Store the access token with Cline's required "workos:" prefix (same
|
|
177
|
+
// convention as the official CLI's providers.json); formatting is
|
|
178
|
+
// idempotent, so the request path can safely format again.
|
|
179
|
+
const accessToken = formatClineAccessToken(tokens.accessToken);
|
|
137
180
|
credential = {
|
|
138
181
|
kind: "cline-oauth",
|
|
139
182
|
refreshToken: tokens.refreshToken,
|
|
140
|
-
accessToken
|
|
183
|
+
accessToken,
|
|
141
184
|
expiresAt: tokens.expiresAt,
|
|
142
185
|
};
|
|
143
186
|
} catch (error) {
|
|
144
187
|
await showInfo(ctx, "Cline sign-in failed", [error instanceof Error ? error.message : String(error)]);
|
|
145
188
|
return undefined;
|
|
189
|
+
} finally {
|
|
190
|
+
panel?.close();
|
|
146
191
|
}
|
|
147
|
-
return [{ key: credential.accessToken ?? credential.refreshToken, label: "cline-account", enabled: true, credential }];
|
|
192
|
+
return [{ key: formatClineAccessToken(credential.accessToken ?? credential.refreshToken), label: "cline-account", enabled: true, credential }];
|
|
148
193
|
}
|
|
149
194
|
if (how === "paste") {
|
|
150
195
|
const raw = await ctx.ui.input("Cline access token", "paste the token");
|
|
151
196
|
const value = raw?.trim();
|
|
152
197
|
if (!value) return undefined;
|
|
153
|
-
|
|
198
|
+
// Cline's API rejects raw JWTs — the access token must carry the
|
|
199
|
+
// "workos:" prefix (formatClineAccessToken is idempotent).
|
|
200
|
+
return [{ key: formatClineAccessToken(value), label: "cline-account", enabled: true }];
|
|
154
201
|
}
|
|
155
202
|
return undefined;
|
|
156
203
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-multikey",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"description": "One pi provider backed by many API keys: automatic 429 rotation, per-request key leases for concurrent subagents, and a /multikey management TUI",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
"api-keys",
|
|
11
11
|
"rate-limit",
|
|
12
12
|
"429",
|
|
13
|
+
"cline",
|
|
14
|
+
"oauth",
|
|
13
15
|
"provider",
|
|
14
16
|
"key-pool",
|
|
15
17
|
"bai",
|
package/presets.ts
CHANGED
|
@@ -57,7 +57,7 @@ export const PRESETS: Preset[] = [
|
|
|
57
57
|
{
|
|
58
58
|
id: "b-ai",
|
|
59
59
|
name: "B.AI",
|
|
60
|
-
description: "api.b.ai — Hunyuan Hy3, MiMo V2.5, Qwen3.8
|
|
60
|
+
description: "api.b.ai — Hunyuan Hy3, MiMo V2.5, Qwen3.8 (3 models)",
|
|
61
61
|
defaultPoolId: "bai",
|
|
62
62
|
baseUrl: "https://api.b.ai/v1",
|
|
63
63
|
api: "openai-completions",
|
|
@@ -96,17 +96,6 @@ export const PRESETS: Preset[] = [
|
|
|
96
96
|
maxTokens: 131_072,
|
|
97
97
|
thinkingLevelMap: levels({ off: "none", low: "low", medium: "medium", xhigh: "xhigh" }),
|
|
98
98
|
},
|
|
99
|
-
{
|
|
100
|
-
// docs.b.ai/llmservice/models/glm-5-3-flash — always thinks (off unsupported);
|
|
101
|
-
// reasoning_effort: low/high/max, default max.
|
|
102
|
-
id: "glm-5.3-flash",
|
|
103
|
-
name: "GLM 5.3 Flash",
|
|
104
|
-
reasoning: true,
|
|
105
|
-
input: ["text", "image"],
|
|
106
|
-
contextWindow: 1_000_000,
|
|
107
|
-
maxTokens: 131_072,
|
|
108
|
-
thinkingLevelMap: levels({ low: "low", high: "high", max: "max" }),
|
|
109
|
-
},
|
|
110
99
|
],
|
|
111
100
|
},
|
|
112
101
|
{
|
package/probe.ts
CHANGED
|
@@ -144,8 +144,9 @@ export function parseModelsResponse(body: unknown): RemoteModel[] {
|
|
|
144
144
|
* Verify a key with a minimal chat completion (a few tokens at most). Returns
|
|
145
145
|
* "ok" when auth was accepted (2xx, or 4xx that clearly got past auth like a
|
|
146
146
|
* bad-model/params 400/404), "rejected" on 401/403, "error" on network trouble.
|
|
147
|
+
* `onLog` receives the server's rejection body so the TUI can show why.
|
|
147
148
|
*/
|
|
148
|
-
async function chatProbe(baseUrl: string, style: AuthStyle, key: string, modelId: string): Promise<"ok" | "rejected" | "error"> {
|
|
149
|
+
async function chatProbe(baseUrl: string, style: AuthStyle, key: string, modelId: string, onLog?: (line: string) => void): Promise<"ok" | "rejected" | "error"> {
|
|
149
150
|
try {
|
|
150
151
|
const response = await fetch(`${trimSlash(baseUrl)}/chat/completions`, {
|
|
151
152
|
method: "POST",
|
|
@@ -158,7 +159,12 @@ async function chatProbe(baseUrl: string, style: AuthStyle, key: string, modelId
|
|
|
158
159
|
body: JSON.stringify({ model: modelId, max_tokens: 4, messages: [{ role: "user", content: "ping" }] }),
|
|
159
160
|
signal: AbortSignal.timeout(CHAT_TIMEOUT_MS),
|
|
160
161
|
});
|
|
161
|
-
if (response.status === 401 || response.status === 403)
|
|
162
|
+
if (response.status === 401 || response.status === 403) {
|
|
163
|
+
const text = await response.text().catch(() => "");
|
|
164
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
165
|
+
if (flat) onLog?.(` ↳ server said: ${flat.slice(0, 160)}`);
|
|
166
|
+
return "rejected";
|
|
167
|
+
}
|
|
162
168
|
return "ok"; // 2xx, or 4xx past auth (bad model / params) — auth itself worked.
|
|
163
169
|
} catch {
|
|
164
170
|
return "error";
|
|
@@ -226,7 +232,7 @@ export async function probeEndpoint(
|
|
|
226
232
|
let verified: AuthStyle | undefined;
|
|
227
233
|
for (const style of styles) {
|
|
228
234
|
emit(`auth check: 1-token chat on "${chatModelId}" with ${style === "bearer" ? "Bearer" : "x-api-key"}…`);
|
|
229
|
-
const verdict = await chatProbe(baseUrl, style, key, chatModelId);
|
|
235
|
+
const verdict = await chatProbe(baseUrl, style, key, chatModelId, emit);
|
|
230
236
|
if (verdict === "ok") {
|
|
231
237
|
verified = style;
|
|
232
238
|
emit(` accepted ✓ (style: ${style === "bearer" ? "Authorization: Bearer" : "x-api-key"})`);
|
package/stream.ts
CHANGED
|
@@ -22,8 +22,8 @@ import {
|
|
|
22
22
|
type SimpleStreamOptions,
|
|
23
23
|
} from "@earendil-works/pi-ai";
|
|
24
24
|
import type { KeyOutcome, KeyPool, Lease } from "./pool.ts";
|
|
25
|
-
import { endpointIdentityHeaders } from "./config.ts";
|
|
26
|
-
import { ensureClineAccessToken } from "./cline-auth.ts";
|
|
25
|
+
import { endpointIdentityHeaders, isClineEndpoint } from "./config.ts";
|
|
26
|
+
import { ensureClineAccessToken, formatClineAccessToken } from "./cline-auth.ts";
|
|
27
27
|
|
|
28
28
|
const RATE_LIMIT_RE = /\b429\b|rate\s*limit|too many requests|quota\s*(exceed|limit)|requests per minute|requests per day/i;
|
|
29
29
|
const INVALID_KEY_RE = /\b40[13]\b|unauthorized|forbidden|invalid\s*(api\s*)?key|incorrect\s*(api\s*)?key|authentication/i;
|
|
@@ -91,7 +91,9 @@ export function createRotatingStreamSimple(pool: KeyPool, apiName: string, notif
|
|
|
91
91
|
pool.applyClineCredential(lease, fresh);
|
|
92
92
|
onConfigDirty?.();
|
|
93
93
|
}
|
|
94
|
-
|
|
94
|
+
// Cline's API requires the "workos:" prefix; formatClineAccessToken
|
|
95
|
+
// is idempotent, so this also repairs keys stored before the fix.
|
|
96
|
+
apiKey = formatClineAccessToken(fresh.accessToken);
|
|
95
97
|
} catch (error) {
|
|
96
98
|
// The stale token may still work; if not, the 401 path below
|
|
97
99
|
// force-refreshes once before giving up on this key.
|
|
@@ -99,20 +101,25 @@ export function createRotatingStreamSimple(pool: KeyPool, apiName: string, notif
|
|
|
99
101
|
`multikey[${pool.config.id}]: token refresh failed on ${lease.label} (${error instanceof Error ? error.message : String(error)}) — trying stored token`,
|
|
100
102
|
);
|
|
101
103
|
}
|
|
104
|
+
} else if (isClineEndpoint(pool.config.baseUrl)) {
|
|
105
|
+
// Static (pasted) key on the Cline endpoint: same prefix rule applies.
|
|
106
|
+
apiKey = formatClineAccessToken(apiKey);
|
|
102
107
|
}
|
|
103
108
|
|
|
104
109
|
try {
|
|
105
110
|
const captured: CapturedResponse = { status: 0 };
|
|
106
|
-
// Identity headers (session /
|
|
107
|
-
// here rather than baked into the provider registration.
|
|
108
|
-
// options.headers last, so these win over the static
|
|
111
|
+
// Identity headers (session / request) are per-request, so they are
|
|
112
|
+
// merged here rather than baked into the provider registration.
|
|
113
|
+
// Providers apply options.headers last, so these win over the static
|
|
114
|
+
// pool headers. The message list keys the request id to the current
|
|
115
|
+
// turn, so retries of one turn share it like opencode's lastUser does.
|
|
109
116
|
const identityBaseUrl = model.baseUrl || pool.config.baseUrl;
|
|
110
117
|
const attemptOptions: SimpleStreamOptions = {
|
|
111
118
|
...options,
|
|
112
119
|
apiKey,
|
|
113
120
|
headers: {
|
|
114
121
|
...options?.headers,
|
|
115
|
-
...endpointIdentityHeaders(identityBaseUrl),
|
|
122
|
+
...endpointIdentityHeaders(identityBaseUrl, context.messages),
|
|
116
123
|
...(authStyle === "api-key" ? { "x-api-key": apiKey } : {}),
|
|
117
124
|
},
|
|
118
125
|
onResponse: (response) => {
|
package/tui.ts
CHANGED
|
@@ -161,6 +161,69 @@ export async function showInfo(ctx: CommandContext, title: string, lines: string
|
|
|
161
161
|
});
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
export interface InfoPanelHandle {
|
|
165
|
+
/** Resolves when the panel closes (user enter/esc, or close()). */
|
|
166
|
+
closed: Promise<void>;
|
|
167
|
+
/** Close the panel programmatically; no-op if the user already dismissed it. */
|
|
168
|
+
close(): void;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Non-blocking variant of {@link showInfo}: returns a handle immediately so the
|
|
173
|
+
* caller can auto-close the panel when something finishes (e.g. an OAuth device
|
|
174
|
+
* flow detects browser approval) while the user can still dismiss it manually.
|
|
175
|
+
* Dismissing the panel never cancels whatever is running behind it.
|
|
176
|
+
*/
|
|
177
|
+
export function showInfoWithHandle(ctx: CommandContext, title: string, lines: string[]): InfoPanelHandle {
|
|
178
|
+
let resolveClosed: (() => void) | undefined;
|
|
179
|
+
const closed = new Promise<void>((resolve) => {
|
|
180
|
+
resolveClosed = resolve;
|
|
181
|
+
});
|
|
182
|
+
let doneFn: (() => void) | undefined;
|
|
183
|
+
let settled = false;
|
|
184
|
+
const finish = () => {
|
|
185
|
+
if (settled) return;
|
|
186
|
+
settled = true;
|
|
187
|
+
try {
|
|
188
|
+
doneFn?.();
|
|
189
|
+
} catch {
|
|
190
|
+
// Panel may already be gone; the closed promise is what matters.
|
|
191
|
+
}
|
|
192
|
+
resolveClosed?.();
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
void ctx.ui.custom<void>((tui, theme, _kb, done) => {
|
|
196
|
+
doneFn = done;
|
|
197
|
+
let cachedLines: string[] | undefined;
|
|
198
|
+
return {
|
|
199
|
+
render(width: number) {
|
|
200
|
+
if (cachedLines) return cachedLines;
|
|
201
|
+
const w = Math.max(10, width);
|
|
202
|
+
const out: string[] = [];
|
|
203
|
+
const add = (line = "") => out.push(truncateToWidth(line, w));
|
|
204
|
+
const border = theme.fg("accent", "─".repeat(w));
|
|
205
|
+
add(border);
|
|
206
|
+
add(` ${theme.fg("accent", theme.bold(title))}`);
|
|
207
|
+
add();
|
|
208
|
+
for (const line of lines) add(` ${line}`);
|
|
209
|
+
add();
|
|
210
|
+
add(theme.fg("dim", " esc/enter dismiss (keeps running)"));
|
|
211
|
+
add(border);
|
|
212
|
+
cachedLines = out;
|
|
213
|
+
return out;
|
|
214
|
+
},
|
|
215
|
+
invalidate() {
|
|
216
|
+
cachedLines = undefined;
|
|
217
|
+
},
|
|
218
|
+
handleInput(data: string) {
|
|
219
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.enter)) finish();
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
return { closed, close: finish };
|
|
225
|
+
}
|
|
226
|
+
|
|
164
227
|
/** Simple toggle list (multi-select), returns selected values or null on cancel. */
|
|
165
228
|
export async function pickMany(
|
|
166
229
|
ctx: CommandContext,
|