pi-multikey 1.7.1 → 1.8.1
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 +8 -43
- package/README.zh.md +3 -45
- package/cline-auth.ts +293 -0
- package/config.ts +121 -12
- package/identity.ts +113 -0
- package/index.ts +25 -3
- package/manage.ts +337 -13
- package/package.json +5 -1
- package/pool.ts +49 -6
- package/presets.ts +114 -55
- package/probe.ts +17 -6
- package/stream.ts +105 -6
- 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 4 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
|
|
|
@@ -37,8 +37,6 @@ Built-in presets decouple "model settings" from "keys". The data comes from b.ai
|
|
|
37
37
|
|
|
38
38
|
| Model | ctx / max-out | Modalities | Supported thinking levels |
|
|
39
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
40
|
| hy3 | 256K / 128K | text | off · low · high |
|
|
43
41
|
| mimo-v2.5 | 1M / 128K | text+image | off · high (official: low/medium/high behave identically) |
|
|
44
42
|
| qwen3.8-flash | 1M / 131K | text+image | off · low · medium · xhigh |
|
|
@@ -46,27 +44,6 @@ Built-in presets decouple "model settings" from "keys". The data comes from b.ai
|
|
|
46
44
|
|
|
47
45
|
> 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
46
|
|
|
49
|
-
### OpenCode Zen (free tier)
|
|
50
|
-
|
|
51
|
-
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** (consistent across models.dev `opencode` provider + pi's built-in opencode catalog); the raw models are bigger — MiMo V2.5 = 1M ctx, Hy3 = 262K ctx. `muse-spark-1.2-contributor-free` uses the OpenAI **Responses** API endpoint; the other seven use chat completions.
|
|
52
|
-
|
|
53
|
-
Requests to this endpoint automatically carry the `User-Agent` header that opencode.ai expects (`opencode/1.15.0 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.13`) — appended whenever a pool's baseUrl is `https://opencode.ai/zen/v1`, on both probes and live requests.
|
|
54
|
-
|
|
55
|
-
| Model | ctx / max-out | Modalities | Supported thinking levels |
|
|
56
|
-
|---|---|---|---|
|
|
57
|
-
| big-pickle | 200K / 32K | text | always-on (no thinkingLevelMap, like pi's catalog) |
|
|
58
|
-
| deepseek-v4-flash-free | 200K / 128K | text | off · low · high · max (mirrors b.ai's deepseek-v4-flash) |
|
|
59
|
-
| mimo-v2.5-free | 200K / 32K | text+image | always-on (no thinkingLevelMap) |
|
|
60
|
-
| hy3-free | 190K / 64K | text | low · medium · high (no off) |
|
|
61
|
-
| ling-3.0-flash-fin-free | 256K / 32K | text | always-on (no thinkingLevelMap) |
|
|
62
|
-
| nemotron-3-ultra-free | 1M / 128K | text | always-on (no thinkingLevelMap) |
|
|
63
|
-
| nemotron-3.5-lightning-free | 256K / 256K | text | always-on (no thinkingLevelMap) |
|
|
64
|
-
| muse-spark-1.2-contributor-free | 1M / 128K | text+image | minimal · low · medium · high · xhigh (no off, Responses API) |
|
|
65
|
-
|
|
66
|
-
> All eight 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 — don't send confidential data).
|
|
67
|
-
|
|
68
|
-
To add a preset: append one entry to the `PRESETS` array in `presets.ts`.
|
|
69
|
-
|
|
70
47
|
## Configuration
|
|
71
48
|
|
|
72
49
|
`~/.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.
|
|
@@ -75,7 +52,7 @@ To add a preset: append one entry to the `PRESETS` array in `presets.ts`.
|
|
|
75
52
|
{
|
|
76
53
|
"pools": [
|
|
77
54
|
{
|
|
78
|
-
"id": "bai", // provider id in pi → bai/
|
|
55
|
+
"id": "bai", // provider id in pi → bai/hy3
|
|
79
56
|
"name": "B.AI (Key Pool)",
|
|
80
57
|
"baseUrl": "https://api.b.ai/v1",
|
|
81
58
|
"api": "openai-completions",
|
|
@@ -85,7 +62,9 @@ To add a preset: append one entry to the `PRESETS` array in `presets.ts`.
|
|
|
85
62
|
"invalidKeyCooldownMs": 600000, // 401/403 cooldown
|
|
86
63
|
"keys": [
|
|
87
64
|
{ "key": "sk-...", "label": "key-1", "enabled": true },
|
|
88
|
-
{ "key": "sk-...", "label": "key-2", "enabled": true }
|
|
65
|
+
{ "key": "sk-...", "label": "key-2", "enabled": true },
|
|
66
|
+
{ "key": "<access token>", "label": "cline-account", "enabled": true,
|
|
67
|
+
"credential": { "kind": "cline-oauth", "refreshToken": "...", "accessToken": "...", "expiresAt": 1735689600000 } }
|
|
89
68
|
],
|
|
90
69
|
"models": [ "…preset or hand-configured model definitions…" ]
|
|
91
70
|
}
|
|
@@ -126,29 +105,15 @@ The detected header style is stored as `"auth": "api-key"` only when the endpoin
|
|
|
126
105
|
|
|
127
106
|
Changes take effect immediately (the provider is re-registered) — no restart needed.
|
|
128
107
|
|
|
129
|
-
## Pointing subagents at the pool
|
|
130
|
-
|
|
131
|
-
Set `agentOverrides` in `settings.json` to the pool provider:
|
|
132
|
-
|
|
133
|
-
```json
|
|
134
|
-
"subagents": {
|
|
135
|
-
"agentOverrides": {
|
|
136
|
-
"oracle": { "model": "bai/glm-5.3-flash" },
|
|
137
|
-
"scout": { "model": "bai/hy3" },
|
|
138
|
-
"worker": { "model": "bai/mimo-v2.5" }
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
```
|
|
142
|
-
|
|
143
|
-
`defaultProvider: "bai"` works the same way.
|
|
144
|
-
|
|
145
108
|
## How it works
|
|
146
109
|
|
|
147
110
|
- The extension registers a provider via `pi.registerProvider()` with a custom `streamSimple`.
|
|
148
111
|
- Each request leases one key from the pool (`options.apiKey` overrides), and once the HTTP response headers arrive:
|
|
149
112
|
- 429 → that key is cooled down (default 20s, honoring `retry-after`) and the request immediately retries with the next key (no duplicated output);
|
|
150
|
-
-
|
|
113
|
+
- Cline daily free limit (429 + `"free limit reached on model"` in the body) → that key cools down until the server-reported reset time (hours, not seconds) and the request retries with the next key;
|
|
114
|
+
- 401/403 → that key gets a long cooldown (default 10 minutes) and the request retries with the next key; for OAuth-backed keys (Cline), a 401 first forces one token refresh + same-key retry before any cooldown;
|
|
151
115
|
- other errors → handed back to pi's own retry mechanism.
|
|
116
|
+
- OAuth-backed keys (Cline accounts) resolve a fresh access token from their stored refresh token before every request (single-flight per account, so concurrent subagents share one refresh), and every rotation of the refresh token is persisted back to `multikey.json`.
|
|
152
117
|
- 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).
|
|
153
118
|
|
|
154
119
|
## Security note
|
package/README.zh.md
CHANGED
|
@@ -27,8 +27,8 @@ pi install /path/to/multikey
|
|
|
27
27
|
/multikey → Add pool… → Preset: B.AI → 逐行粘贴 key(一行一个,留空结束)
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
-
选 preset 后 endpoint、compat、
|
|
31
|
-
`bai/<model-id>` 直接可用,例如 `bai/
|
|
30
|
+
选 preset 后 endpoint、compat、4 个模型的全部设定自动就位,模型通过
|
|
31
|
+
`bai/<model-id>` 直接可用,例如 `bai/hy3`。
|
|
32
32
|
|
|
33
33
|
## Presets
|
|
34
34
|
|
|
@@ -38,8 +38,6 @@ DeepSeek / Tencent / 小米官方文档,并对每个 thinking 档位做过实
|
|
|
38
38
|
|
|
39
39
|
| 模型 | ctx / max-out | 模态 | 生效 thinking 档位 |
|
|
40
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
41
|
| hy3 | 256K / 128K | text | off · low · high |
|
|
44
42
|
| mimo-v2.5 | 1M / 128K | text+image | off · high(官方:low/medium/high 行为相同) |
|
|
45
43
|
| qwen3.8-flash | 1M / 131K | text+image | off · low · medium · xhigh |
|
|
@@ -49,30 +47,6 @@ DeepSeek / Tencent / 小米官方文档,并对每个 thinking 档位做过实
|
|
|
49
47
|
> 视为不支持并隐藏该档,但**省略**会被当作支持并把档名原样发给 API;
|
|
50
48
|
> `xhigh` / `max` 还要求显式给出非 null 值才可用。
|
|
51
49
|
|
|
52
|
-
### OpenCode Zen(免费层)
|
|
53
|
-
|
|
54
|
-
端点 `https://opencode.ai/zen/v1`;密钥从 [opencode.ai/auth](https://opencode.ai/auth) → workspace Keys 获取。
|
|
55
|
-
上下文/最大输出为 **Zen 免费层限制**(models.dev `opencode` provider 与 pi 内置 opencode 目录一致);
|
|
56
|
-
原始模型更大——MiMo V2.5 = 1M ctx,Hy3 = 262K ctx。
|
|
57
|
-
`muse-spark-1.2-contributor-free` 使用 OpenAI **Responses** API;其余七个使用 chat completions。
|
|
58
|
-
|
|
59
|
-
对该端点的请求会自动携带 opencode.ai 期望的 `User-Agent` 头(`opencode/1.15.0 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.13`)——只要 pool 的 baseUrl 是 `https://opencode.ai/zen/v1`,探测和真实请求都会附加。
|
|
60
|
-
|
|
61
|
-
| 模型 | ctx / max-out | 模态 | 生效 thinking 档位 |
|
|
62
|
-
|---|---|---|---|
|
|
63
|
-
| big-pickle | 200K / 32K | text | 始终思考(无 thinkingLevelMap,与 pi 内置目录一致) |
|
|
64
|
-
| deepseek-v4-flash-free | 200K / 128K | text | off · low · high · max(沿用 b.ai preset 的 deepseek-v4-flash) |
|
|
65
|
-
| mimo-v2.5-free | 200K / 32K | text+image | 始终思考(无 thinkingLevelMap) |
|
|
66
|
-
| hy3-free | 190K / 64K | text | low · medium · high(无 off) |
|
|
67
|
-
| ling-3.0-flash-fin-free | 256K / 32K | text | 始终思考(无 thinkingLevelMap) |
|
|
68
|
-
| nemotron-3-ultra-free | 1M / 128K | text | 始终思考(无 thinkingLevelMap) |
|
|
69
|
-
| nemotron-3.5-lightning-free | 256K / 256K | text | 始终思考(无 thinkingLevelMap) |
|
|
70
|
-
| muse-spark-1.2-contributor-free | 1M / 128K | text+image | minimal · low · medium · high · xhigh(无 off,Responses API) |
|
|
71
|
-
|
|
72
|
-
> 以上八个模型在 OpenCode 收集反馈期间均免费(零 token 费用);数据可能用于改进模型(Nemotron 免费端点为 NVIDIA 试用——请勿提交机密数据)。
|
|
73
|
-
|
|
74
|
-
新增 preset:在 `presets.ts` 的 `PRESETS` 数组里加一项即可。
|
|
75
|
-
|
|
76
50
|
## 配置
|
|
77
51
|
|
|
78
52
|
`~/.pi/agent/multikey.json`。首次运行时会从 `~/.pi/agent/models.json` 自动发现
|
|
@@ -83,7 +57,7 @@ DeepSeek / Tencent / 小米官方文档,并对每个 thinking 档位做过实
|
|
|
83
57
|
{
|
|
84
58
|
"pools": [
|
|
85
59
|
{
|
|
86
|
-
"id": "bai", // pi 里的 provider id → bai/
|
|
60
|
+
"id": "bai", // pi 里的 provider id → bai/hy3
|
|
87
61
|
"name": "B.AI (Key Pool)",
|
|
88
62
|
"baseUrl": "https://api.b.ai/v1",
|
|
89
63
|
"api": "openai-completions",
|
|
@@ -135,22 +109,6 @@ JSON 后 `Reload config from disk`。
|
|
|
135
109
|
|
|
136
110
|
改动即时生效(重新注册 provider),无需重启。
|
|
137
111
|
|
|
138
|
-
## 让 subagent 用上池
|
|
139
|
-
|
|
140
|
-
`settings.json` 的 agentOverrides 改成池 provider:
|
|
141
|
-
|
|
142
|
-
```json
|
|
143
|
-
"subagents": {
|
|
144
|
-
"agentOverrides": {
|
|
145
|
-
"oracle": { "model": "bai/glm-5.3-flash" },
|
|
146
|
-
"scout": { "model": "bai/hy3" },
|
|
147
|
-
"worker": { "model": "bai/mimo-v2.5" }
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
```
|
|
151
|
-
|
|
152
|
-
`defaultProvider: "bai"` 同理。
|
|
153
|
-
|
|
154
112
|
## 工作原理
|
|
155
113
|
|
|
156
114
|
- 扩展通过 `pi.registerProvider()` 注册 provider,并提供自定义 `streamSimple`。
|
package/cline-auth.ts
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cline account authentication: WorkOS device flow + access token refresh.
|
|
3
|
+
*
|
|
4
|
+
* Cline's free models are tied to a Cline account (no static API keys), so a
|
|
5
|
+
* pool key can carry a `credential: { kind: "cline-oauth", refreshToken }`
|
|
6
|
+
* instead of a long-lived secret. Endpoints/port/response shapes are ported
|
|
7
|
+
* from the cline SDK (sdk/packages/core/src/auth/cline.ts, production env).
|
|
8
|
+
*
|
|
9
|
+
* Flow:
|
|
10
|
+
* 1. POST api.workos.com/user_management/authorize/device → device + user code
|
|
11
|
+
* 2. User opens the verification URL in a browser and approves
|
|
12
|
+
* 3. Poll api.workos.com/user_management/authenticate → WorkOS tokens
|
|
13
|
+
* 4. POST api.cline.bot/api/v1/auth/register → Cline tokens
|
|
14
|
+
* 5. Refresh: POST api.cline.bot/api/v1/auth/refresh
|
|
15
|
+
*
|
|
16
|
+
* Tokens rotate on every refresh (the refresh token in the response replaces
|
|
17
|
+
* the old one), so callers must persist the result — stream.ts does this via
|
|
18
|
+
* KeyPool.applyClineCredential + the config save hook.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { KeyCredential } from "./config.ts";
|
|
22
|
+
|
|
23
|
+
const WORKOS_API_BASE_URL = "https://api.workos.com";
|
|
24
|
+
const WORKOS_DEVICE_AUTHORIZATION_PATH = "/user_management/authorize/device";
|
|
25
|
+
const WORKOS_AUTHENTICATE_PATH = "/user_management/authenticate";
|
|
26
|
+
/** WorkOS client id of the official Cline CLI (production environment). */
|
|
27
|
+
const WORKOS_CLIENT_ID = "client_01K3A541FN8TA3EPPHTD2325AR";
|
|
28
|
+
|
|
29
|
+
const CLINE_API_BASE_URL = "https://api.cline.bot";
|
|
30
|
+
const CLINE_REGISTER_PATH = "/api/v1/auth/register";
|
|
31
|
+
const CLINE_REFRESH_PATH = "/api/v1/auth/refresh";
|
|
32
|
+
|
|
33
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
34
|
+
const DEVICE_AUTH_EXPIRES_IN_SECONDS = 300;
|
|
35
|
+
const DEVICE_AUTH_INTERVAL_SECONDS = 5;
|
|
36
|
+
/** Refresh this long before expiry so requests never race the clock. */
|
|
37
|
+
export const REFRESH_BUFFER_MS = 5 * 60_000;
|
|
38
|
+
|
|
39
|
+
/** Headers sent to Cline API auth endpoints (same client identity as chat requests). */
|
|
40
|
+
function clineAuthHeaders(): Record<string, string> {
|
|
41
|
+
return { "Content-Type": "application/json", "User-Agent": "Cline/3.0.61" };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ClineTokenUpdate {
|
|
45
|
+
/** New access token (stored as the key's `key` value). */
|
|
46
|
+
accessToken: string;
|
|
47
|
+
/** Replacement refresh token; WorkOS/Cline rotate it on every refresh. */
|
|
48
|
+
refreshToken: string;
|
|
49
|
+
/** Access token expiry in epoch ms, when the server reports one. */
|
|
50
|
+
expiresAt?: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface ClineTokenResponse {
|
|
54
|
+
success: boolean;
|
|
55
|
+
data?: {
|
|
56
|
+
accessToken?: string;
|
|
57
|
+
refreshToken?: string;
|
|
58
|
+
expiresAt?: string;
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function parseExpiresAt(value: string | undefined): number | undefined {
|
|
63
|
+
if (!value) return undefined;
|
|
64
|
+
const ms = Date.parse(value);
|
|
65
|
+
return Number.isFinite(ms) ? ms : undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function requireClineTokens(payload: ClineTokenResponse, message: string): ClineTokenUpdate {
|
|
69
|
+
const accessToken = payload.data?.accessToken;
|
|
70
|
+
const refreshToken = payload.data?.refreshToken;
|
|
71
|
+
if (!payload.success || !accessToken || !refreshToken) {
|
|
72
|
+
throw new Error(message);
|
|
73
|
+
}
|
|
74
|
+
// Return the RAW token; the workos: prefix is applied at request time by
|
|
75
|
+
// formatClineAccessToken (idempotent), so it is never doubled.
|
|
76
|
+
return { accessToken, refreshToken, expiresAt: parseExpiresAt(payload.data?.expiresAt) };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** True when the stored access token is missing, expired, or inside the refresh buffer. */
|
|
80
|
+
export function isClineTokenStale(credential: KeyCredential, bufferMs = REFRESH_BUFFER_MS): boolean {
|
|
81
|
+
if (!credential.accessToken) return true;
|
|
82
|
+
if (credential.expiresAt === undefined) return false; // unknown expiry: use until a 401 says otherwise
|
|
83
|
+
return credential.expiresAt - Date.now() <= bufferMs;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Exchange a refresh token for a fresh access (+ rotated refresh) token. */
|
|
87
|
+
export async function refreshClineToken(refreshToken: string): Promise<ClineTokenUpdate> {
|
|
88
|
+
const response = await fetch(`${CLINE_API_BASE_URL}${CLINE_REFRESH_PATH}`, {
|
|
89
|
+
method: "POST",
|
|
90
|
+
headers: clineAuthHeaders(),
|
|
91
|
+
body: JSON.stringify({ refreshToken, grantType: "refresh_token" }),
|
|
92
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
93
|
+
});
|
|
94
|
+
if (!response.ok) {
|
|
95
|
+
const text = await response.text().catch(() => "");
|
|
96
|
+
throw new Error(`Cline token refresh failed: HTTP ${response.status}${text ? ` — ${text.slice(0, 200)}` : ""}`);
|
|
97
|
+
}
|
|
98
|
+
const payload = (await response.json().catch(() => ({}))) as ClineTokenResponse;
|
|
99
|
+
return requireClineTokens(payload, "Invalid Cline token refresh response");
|
|
100
|
+
}
|
|
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
|
+
|
|
115
|
+
// Single-flight per refresh token: concurrent subagents hitting the same
|
|
116
|
+
// account must share one in-flight refresh instead of racing each other.
|
|
117
|
+
const inFlight = new Map<string, Promise<ClineTokenUpdate>>();
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Return a usable access token for the credential, refreshing when stale.
|
|
121
|
+
* Pass `force` after a 401 to refresh even if the token looks valid.
|
|
122
|
+
*/
|
|
123
|
+
export async function ensureClineAccessToken(
|
|
124
|
+
credential: KeyCredential,
|
|
125
|
+
options?: { force?: boolean },
|
|
126
|
+
): Promise<ClineTokenUpdate & { refreshed: boolean }> {
|
|
127
|
+
const force = options?.force === true;
|
|
128
|
+
if (!force && !isClineTokenStale(credential) && credential.accessToken) {
|
|
129
|
+
return {
|
|
130
|
+
accessToken: formatClineAccessToken(credential.accessToken),
|
|
131
|
+
refreshToken: credential.refreshToken,
|
|
132
|
+
expiresAt: credential.expiresAt,
|
|
133
|
+
refreshed: false,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
const existing = inFlight.get(credential.refreshToken);
|
|
137
|
+
if (existing) return { ...(await existing), refreshed: true };
|
|
138
|
+
const task = refreshClineToken(credential.refreshToken).finally(() => {
|
|
139
|
+
inFlight.delete(credential.refreshToken);
|
|
140
|
+
});
|
|
141
|
+
inFlight.set(credential.refreshToken, task);
|
|
142
|
+
const update = await task;
|
|
143
|
+
return { ...update, accessToken: formatClineAccessToken(update.accessToken), refreshed: true };
|
|
144
|
+
}
|
|
145
|
+
// ── Device flow (initial sign-in) ───────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
interface WorkOSDeviceAuthorizationResponse {
|
|
148
|
+
device_code?: string;
|
|
149
|
+
user_code?: string;
|
|
150
|
+
verification_uri?: string;
|
|
151
|
+
verification_uri_complete?: string;
|
|
152
|
+
expires_in?: number;
|
|
153
|
+
interval?: number;
|
|
154
|
+
error?: string;
|
|
155
|
+
error_description?: string;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
interface WorkOSTokenResponse {
|
|
159
|
+
access_token?: string;
|
|
160
|
+
refresh_token?: string;
|
|
161
|
+
token_type?: string;
|
|
162
|
+
error?: string;
|
|
163
|
+
error_description?: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Interactive sign-in: request a device code, hand the verification URL to
|
|
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.
|
|
176
|
+
*/
|
|
177
|
+
export async function loginClineDeviceFlow(options: {
|
|
178
|
+
/** Show the verification URL/user code; resolve without waiting for dismissal. */
|
|
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;
|
|
182
|
+
/** Optional progress lines while polling (e.g. append to a progress panel). */
|
|
183
|
+
onProgress?: (message: string) => void;
|
|
184
|
+
}): Promise<ClineTokenUpdate> {
|
|
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
|
+
}
|
|
206
|
+
const device = (await authResponse.json().catch(() => ({}))) as WorkOSDeviceAuthorizationResponse;
|
|
207
|
+
if (!authResponse.ok || !device.device_code || !device.user_code || !device.verification_uri) {
|
|
208
|
+
const detail = device.error_description ?? (authResponse.ok ? "invalid WorkOS response" : `HTTP ${authResponse.status}`);
|
|
209
|
+
throw new Error(`Cline device authorization failed: ${detail}`);
|
|
210
|
+
}
|
|
211
|
+
|
|
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({
|
|
216
|
+
url: device.verification_uri_complete ?? device.verification_uri,
|
|
217
|
+
userCode: device.user_code,
|
|
218
|
+
}).catch(() => {});
|
|
219
|
+
|
|
220
|
+
const expiresInSeconds = device.expires_in ?? DEVICE_AUTH_EXPIRES_IN_SECONDS;
|
|
221
|
+
const deadline = Date.now() + expiresInSeconds * 1000;
|
|
222
|
+
let intervalSeconds = Math.max(1, device.interval ?? DEVICE_AUTH_INTERVAL_SECONDS);
|
|
223
|
+
let workosTokens: { access: string; refresh: string } | undefined;
|
|
224
|
+
let pollCount = 0;
|
|
225
|
+
|
|
226
|
+
while (Date.now() <= deadline) {
|
|
227
|
+
pollCount++;
|
|
228
|
+
const pollResponse = await fetch(`${WORKOS_API_BASE_URL}${WORKOS_AUTHENTICATE_PATH}`, {
|
|
229
|
+
method: "POST",
|
|
230
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
231
|
+
body: new URLSearchParams({
|
|
232
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
233
|
+
device_code: device.device_code,
|
|
234
|
+
client_id: WORKOS_CLIENT_ID,
|
|
235
|
+
}),
|
|
236
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
237
|
+
});
|
|
238
|
+
const payload = (await pollResponse.json().catch(() => ({}))) as WorkOSTokenResponse;
|
|
239
|
+
if (pollResponse.ok) {
|
|
240
|
+
if (!payload.access_token || !payload.refresh_token) {
|
|
241
|
+
throw new Error("Invalid WorkOS token response");
|
|
242
|
+
}
|
|
243
|
+
workosTokens = { access: payload.access_token, refresh: payload.refresh_token };
|
|
244
|
+
options.onAuthorized?.();
|
|
245
|
+
await panelDismissed;
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
switch (payload.error) {
|
|
249
|
+
case "authorization_pending":
|
|
250
|
+
options.onProgress?.("Waiting for browser authentication confirmation…");
|
|
251
|
+
await sleep(intervalSeconds * 1000);
|
|
252
|
+
break;
|
|
253
|
+
case "slow_down":
|
|
254
|
+
intervalSeconds += 1;
|
|
255
|
+
await sleep(intervalSeconds * 1000);
|
|
256
|
+
break;
|
|
257
|
+
case "access_denied":
|
|
258
|
+
throw new Error(`Cline authorization failed: ${payload.error_description ?? payload.error}`);
|
|
259
|
+
case "expired_token":
|
|
260
|
+
throw new Error(`Cline authorization timed out: ${payload.error_description ?? payload.error}`);
|
|
261
|
+
case "invalid_grant":
|
|
262
|
+
throw new Error(`Cline authorization failed: ${payload.error_description ?? payload.error}`);
|
|
263
|
+
default:
|
|
264
|
+
throw new Error(`WorkOS token polling failed: HTTP ${pollResponse.status}${payload.error_description ? ` — ${payload.error_description}` : ""}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (!workosTokens) throw new Error("WorkOS device authorization timed out");
|
|
268
|
+
|
|
269
|
+
// Exchange the WorkOS tokens for Cline account tokens.
|
|
270
|
+
const registerResponse = await fetch(`${CLINE_API_BASE_URL}${CLINE_REGISTER_PATH}`, {
|
|
271
|
+
method: "POST",
|
|
272
|
+
headers: clineAuthHeaders(),
|
|
273
|
+
body: JSON.stringify({ accessToken: workosTokens.access, refreshToken: workosTokens.refresh }),
|
|
274
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
275
|
+
});
|
|
276
|
+
if (!registerResponse.ok) {
|
|
277
|
+
const text = await registerResponse.text().catch(() => "");
|
|
278
|
+
throw new Error(`Cline token registration failed: HTTP ${registerResponse.status}${text ? ` — ${text.slice(0, 200)}` : ""}`);
|
|
279
|
+
}
|
|
280
|
+
const payload = (await registerResponse.json().catch(() => ({}))) as ClineTokenResponse;
|
|
281
|
+
return requireClineTokens(payload, "Invalid Cline token registration response");
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Human-readable expiry line for the TUI (token state only; quota lives server-side). */
|
|
285
|
+
export function describeClineCredential(credential: KeyCredential): string {
|
|
286
|
+
if (credential.expiresAt === undefined) return "token expiry unknown";
|
|
287
|
+
const remaining = credential.expiresAt - Date.now();
|
|
288
|
+
if (remaining <= 0) return `token expired ${Math.round(-remaining / 60_000)}m ago`;
|
|
289
|
+
if (remaining < 90_000) return `token expires in ${Math.round(remaining / 1000)}s`;
|
|
290
|
+
if (remaining < 90 * 60_000) return `token expires in ${Math.round(remaining / 60_000)}m`;
|
|
291
|
+
return `token expires in ${Math.round(remaining / 3_600_000)}h`;
|
|
292
|
+
}
|
|
293
|
+
|
package/config.ts
CHANGED
|
@@ -11,6 +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 { currentRequestId, currentSessionId, currentTaskId, turnKeyOf } from "./identity.ts";
|
|
14
15
|
|
|
15
16
|
/** Safe model defaults applied when a spec doesn't say otherwise (edit in multikey.json). */
|
|
16
17
|
export const DEFAULT_CONTEXT_WINDOW = 128_000;
|
|
@@ -37,6 +38,36 @@ export interface PoolKeyConfig {
|
|
|
37
38
|
label?: string;
|
|
38
39
|
/** Disabled keys are never selected. Default: true. */
|
|
39
40
|
enabled?: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Credential-backed key (Cline account OAuth). When present, `key` holds the
|
|
43
|
+
* current access token and is refreshed transparently from `credential`
|
|
44
|
+
* before requests and again on 401 — see cline-auth.ts.
|
|
45
|
+
*/
|
|
46
|
+
credential?: KeyCredential;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** OAuth-style credential for endpoints without static API keys (Cline free tier). */
|
|
50
|
+
export interface KeyCredential {
|
|
51
|
+
kind: "cline-oauth";
|
|
52
|
+
/** Long-lived refresh token; access tokens are minted from it. */
|
|
53
|
+
refreshToken: string;
|
|
54
|
+
/** Last known access token (mirrors `key`). */
|
|
55
|
+
accessToken?: string;
|
|
56
|
+
/** Access token expiry (epoch ms), when known. */
|
|
57
|
+
expiresAt?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Tracks which shipped preset a pool was created from / last aligned with.
|
|
62
|
+
* Lives in multikey.json as `_preset`. The fingerprint (see presetFingerprint
|
|
63
|
+
* in presets.ts) is persisted the moment an update prompt is shown — that is
|
|
64
|
+
* what guarantees each preset version is asked about at most once.
|
|
65
|
+
*/
|
|
66
|
+
export interface PresetMarker {
|
|
67
|
+
/** Preset id in presets.ts, e.g. "b-ai". */
|
|
68
|
+
id: string;
|
|
69
|
+
/** Fingerprint of the preset's model list at pool creation / last sync. */
|
|
70
|
+
fingerprint: string;
|
|
40
71
|
}
|
|
41
72
|
|
|
42
73
|
export interface PoolModelConfig {
|
|
@@ -70,6 +101,8 @@ export interface PoolConfig {
|
|
|
70
101
|
cooldownMs?: number;
|
|
71
102
|
/** Cooldown after a 401/403 (bad key). Default 600000ms. */
|
|
72
103
|
invalidKeyCooldownMs?: number;
|
|
104
|
+
/** Present when the pool was created from (or adopted) a shipped preset. */
|
|
105
|
+
_preset?: PresetMarker;
|
|
73
106
|
keys: PoolKeyConfig[];
|
|
74
107
|
models: PoolModelConfig[];
|
|
75
108
|
}
|
|
@@ -260,7 +293,8 @@ export function loadConfig(): { config: KeypoolConfig; created: boolean; migrate
|
|
|
260
293
|
return { config, created: true };
|
|
261
294
|
}
|
|
262
295
|
const raw = JSON.parse(readFileSync(path, "utf-8")) as KeypoolConfig;
|
|
263
|
-
|
|
296
|
+
const config = normalize(raw);
|
|
297
|
+
return { config, created: false };
|
|
264
298
|
}
|
|
265
299
|
|
|
266
300
|
export function saveConfig(config: KeypoolConfig): void {
|
|
@@ -280,6 +314,17 @@ export function normalize(config: KeypoolConfig): KeypoolConfig {
|
|
|
280
314
|
return { pools: normalized };
|
|
281
315
|
}
|
|
282
316
|
|
|
317
|
+
/** Structural check so a malformed credential in the JSON file can't break a pool. */
|
|
318
|
+
function isValidCredential(credential: unknown): credential is KeyCredential {
|
|
319
|
+
return (
|
|
320
|
+
!!credential &&
|
|
321
|
+
typeof credential === "object" &&
|
|
322
|
+
(credential as KeyCredential).kind === "cline-oauth" &&
|
|
323
|
+
typeof (credential as KeyCredential).refreshToken === "string" &&
|
|
324
|
+
(credential as KeyCredential).refreshToken.trim().length > 0
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
283
328
|
function normalizePool(pool: PoolConfig): PoolConfig {
|
|
284
329
|
const keys = (Array.isArray(pool.keys) ? pool.keys : [])
|
|
285
330
|
.filter((k) => k && typeof k.key === "string" && k.key.trim())
|
|
@@ -287,8 +332,14 @@ function normalizePool(pool: PoolConfig): PoolConfig {
|
|
|
287
332
|
key: k.key.trim(),
|
|
288
333
|
label: typeof k.label === "string" && k.label.trim() ? k.label.trim() : undefined,
|
|
289
334
|
enabled: k.enabled !== false,
|
|
335
|
+
// OAuth-backed keys keep their credential; only well-formed ones survive.
|
|
336
|
+
...(isValidCredential(k.credential) ? { credential: k.credential } : {}),
|
|
290
337
|
}));
|
|
291
338
|
const models = (Array.isArray(pool.models) ? pool.models : []).filter((m) => m && typeof m.id === "string" && m.id.trim());
|
|
339
|
+
const preset =
|
|
340
|
+
pool._preset && typeof pool._preset.id === "string" && typeof pool._preset.fingerprint === "string"
|
|
341
|
+
? { id: pool._preset.id, fingerprint: pool._preset.fingerprint }
|
|
342
|
+
: undefined;
|
|
292
343
|
return {
|
|
293
344
|
id: pool.id.trim(),
|
|
294
345
|
name: pool.name ?? pool.id,
|
|
@@ -302,6 +353,7 @@ function normalizePool(pool: PoolConfig): PoolConfig {
|
|
|
302
353
|
typeof pool.invalidKeyCooldownMs === "number" && pool.invalidKeyCooldownMs >= 0
|
|
303
354
|
? pool.invalidKeyCooldownMs
|
|
304
355
|
: DEFAULT_INVALID_KEY_COOLDOWN_MS,
|
|
356
|
+
_preset: preset,
|
|
305
357
|
keys,
|
|
306
358
|
models,
|
|
307
359
|
};
|
|
@@ -342,21 +394,78 @@ export function maskKey(key: string): string {
|
|
|
342
394
|
|
|
343
395
|
// ── Endpoint-required headers ────────────────────────────────────────────────
|
|
344
396
|
|
|
345
|
-
/** OpenCode Zen free tier endpoint that
|
|
397
|
+
/** OpenCode Zen free tier endpoint that mimics the official OpenCode client. */
|
|
346
398
|
const OPENCODE_ZEN_BASE_URL = "https://opencode.ai/zen/v1";
|
|
347
|
-
const OPENCODE_ZEN_USER_AGENT =
|
|
348
|
-
|
|
399
|
+
const OPENCODE_ZEN_USER_AGENT = "opencode/0.1.50 ai-sdk/openai-compatible/3.0.41";
|
|
400
|
+
const OPENCODE_ZEN_CLIENT = "tui";
|
|
401
|
+
|
|
402
|
+
/** True when a baseUrl points at OpenCode Zen (case-insensitive, trailing slash ok). */
|
|
403
|
+
export function isOpenCodeZenEndpoint(baseUrl: string | undefined): boolean {
|
|
404
|
+
if (!baseUrl) return false;
|
|
405
|
+
return baseUrl.replace(/\/+$/, "").toLowerCase() === OPENCODE_ZEN_BASE_URL;
|
|
406
|
+
}
|
|
349
407
|
|
|
350
408
|
/**
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
* Currently: OpenCode Zen's free tier endpoint requires this exact User-Agent;
|
|
354
|
-
* anything else returns an empty object.
|
|
409
|
+
* Constant headers a known endpoint expects on every request.
|
|
355
410
|
*
|
|
356
|
-
*
|
|
411
|
+
* OpenCode Zen's free tier wants the full OpenCode client header set; the
|
|
412
|
+
* per-conversation / per-device halves live in {@link endpointIdentityHeaders}.
|
|
413
|
+
* Cline's API gates on its official client's header set (versioned client
|
|
414
|
+
* identity + platform metadata), so we send the same shape the Cline CLI
|
|
415
|
+
* sends (mirroring sdk request-headers.ts). Anything else returns an empty object.
|
|
357
416
|
*/
|
|
358
417
|
export function endpointHeaders(baseUrl: string): Record<string, string> {
|
|
359
|
-
|
|
360
|
-
if (
|
|
361
|
-
return {};
|
|
418
|
+
if (isClineEndpoint(baseUrl)) return clineClientHeaders();
|
|
419
|
+
if (!isOpenCodeZenEndpoint(baseUrl)) return {};
|
|
420
|
+
return { "x-opencode-client": OPENCODE_ZEN_CLIENT, "User-Agent": OPENCODE_ZEN_USER_AGENT };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Per-request identity headers a known endpoint expects.
|
|
425
|
+
*
|
|
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.
|
|
431
|
+
* Cline reads `X-Task-ID` as a per-conversation correlation id.
|
|
432
|
+
*/
|
|
433
|
+
export function endpointIdentityHeaders(baseUrl: string | undefined, messages?: readonly unknown[]): Record<string, string> {
|
|
434
|
+
if (isClineEndpoint(baseUrl ?? "")) return { "X-Task-ID": currentTaskId() };
|
|
435
|
+
if (!isOpenCodeZenEndpoint(baseUrl ?? "")) return {};
|
|
436
|
+
return {
|
|
437
|
+
"x-opencode-session": currentSessionId(),
|
|
438
|
+
"x-opencode-request": currentRequestId(messages ? turnKeyOf(messages) : undefined),
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// ── Cline (api.cline.bot) ────────────────────────────────────────────────────
|
|
443
|
+
|
|
444
|
+
const CLINE_API_BASE_URL = "https://api.cline.bot";
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Header set the official Cline CLI sends to api.cline.bot (mirrors
|
|
448
|
+
* cline sdk request-headers.ts buildClineRequestHeaders with source "cli").
|
|
449
|
+
* The values track the cline repo versions: CLI 3.0.61, SDK core 0.0.82.
|
|
450
|
+
*/
|
|
451
|
+
const CLINE_CLIENT_HEADERS: Record<string, string> = {
|
|
452
|
+
"HTTP-Referer": "https://cline.bot",
|
|
453
|
+
"X-Title": "Cline",
|
|
454
|
+
"X-IS-MULTIROOT": "false",
|
|
455
|
+
"X-CLIENT-TYPE": "cline-cli",
|
|
456
|
+
"User-Agent": "Cline/3.0.61",
|
|
457
|
+
"X-CLIENT-VERSION": "3.0.61",
|
|
458
|
+
"X-PLATFORM": "cli",
|
|
459
|
+
"X-PLATFORM-VERSION": "3.0.61",
|
|
460
|
+
"X-CORE-VERSION": "0.0.82",
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
function clineClientHeaders(): Record<string, string> {
|
|
464
|
+
return { ...CLINE_CLIENT_HEADERS };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** True when a baseUrl points at Cline's API (any path depth, trailing slash ok). */
|
|
468
|
+
export function isClineEndpoint(baseUrl: string | undefined): boolean {
|
|
469
|
+
if (!baseUrl) return false;
|
|
470
|
+
return baseUrl.replace(/\/+$/, "").toLowerCase().startsWith(CLINE_API_BASE_URL);
|
|
362
471
|
}
|