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 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 6 model definitions are wired up automatically. Models are available directly as `bai/<model-id>`, e.g. `bai/deepseek-v4-flash`.
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/deepseek-v4-flash
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
- - 401/403 → that key gets a long cooldown (default 10 minutes) and the request retries with the next key;
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、6 个模型的全部设定自动就位,模型通过
31
- `bai/<model-id>` 直接可用,例如 `bai/deepseek-v4-flash`。
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/deepseek-v4-flash
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
- return { config: normalize(raw), created: false };
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 requires a specific User-Agent header. */
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
- "opencode/1.15.0 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.13";
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
- * Headers that must be sent to known endpoints.
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
- * The comparison is case-insensitive and tolerates a trailing slash.
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
- const normalized = baseUrl.replace(/\/+$/, "").toLowerCase();
360
- if (normalized === OPENCODE_ZEN_BASE_URL) return { "User-Agent": OPENCODE_ZEN_USER_AGENT };
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
  }