pi-multikey 1.7.1 → 1.8.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 +68 -14
- package/README.zh.md +32 -15
- package/cline-auth.ts +242 -0
- package/config.ts +142 -13
- package/identity.ts +101 -0
- package/index.ts +34 -4
- package/manage.ts +289 -12
- package/package.json +3 -1
- package/pool.ts +49 -6
- package/presets.ts +114 -55
- package/probe.ts +8 -3
- package/stream.ts +98 -6
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 |
|
|
@@ -48,25 +46,77 @@ Built-in presets decouple "model settings" from "keys". The data comes from b.ai
|
|
|
48
46
|
|
|
49
47
|
### OpenCode Zen (free tier)
|
|
50
48
|
|
|
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** (
|
|
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.
|
|
52
50
|
|
|
53
|
-
Requests to this endpoint
|
|
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.
|
|
54
61
|
|
|
55
62
|
| Model | ctx / max-out | Modalities | Supported thinking levels |
|
|
56
63
|
|---|---|---|---|
|
|
57
64
|
| 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
65
|
| mimo-v2.5-free | 200K / 32K | text+image | always-on (no thinkingLevelMap) |
|
|
60
|
-
|
|
|
61
|
-
| ling-3.0-flash-fin-free | 256K / 32K | text | always-on (no thinkingLevelMap) |
|
|
66
|
+
| ling-3.0-flash-fin-free | 262K / 32K | text | always-on (no thinkingLevelMap) |
|
|
62
67
|
| nemotron-3-ultra-free | 1M / 128K | text | always-on (no thinkingLevelMap) |
|
|
63
|
-
| nemotron-3.5-lightning-free |
|
|
64
|
-
| muse-spark-1.
|
|
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:
|
|
65
83
|
|
|
66
|
-
|
|
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.
|
|
67
106
|
|
|
68
107
|
To add a preset: append one entry to the `PRESETS` array in `presets.ts`.
|
|
69
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
|
+
|
|
70
120
|
## Configuration
|
|
71
121
|
|
|
72
122
|
`~/.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 +125,7 @@ To add a preset: append one entry to the `PRESETS` array in `presets.ts`.
|
|
|
75
125
|
{
|
|
76
126
|
"pools": [
|
|
77
127
|
{
|
|
78
|
-
"id": "bai", // provider id in pi → bai/
|
|
128
|
+
"id": "bai", // provider id in pi → bai/hy3
|
|
79
129
|
"name": "B.AI (Key Pool)",
|
|
80
130
|
"baseUrl": "https://api.b.ai/v1",
|
|
81
131
|
"api": "openai-completions",
|
|
@@ -85,7 +135,9 @@ To add a preset: append one entry to the `PRESETS` array in `presets.ts`.
|
|
|
85
135
|
"invalidKeyCooldownMs": 600000, // 401/403 cooldown
|
|
86
136
|
"keys": [
|
|
87
137
|
{ "key": "sk-...", "label": "key-1", "enabled": true },
|
|
88
|
-
{ "key": "sk-...", "label": "key-2", "enabled": true }
|
|
138
|
+
{ "key": "sk-...", "label": "key-2", "enabled": true },
|
|
139
|
+
{ "key": "<access token>", "label": "cline-account", "enabled": true,
|
|
140
|
+
"credential": { "kind": "cline-oauth", "refreshToken": "...", "accessToken": "...", "expiresAt": 1735689600000 } }
|
|
89
141
|
],
|
|
90
142
|
"models": [ "…preset or hand-configured model definitions…" ]
|
|
91
143
|
}
|
|
@@ -147,8 +199,10 @@ Set `agentOverrides` in `settings.json` to the pool provider:
|
|
|
147
199
|
- The extension registers a provider via `pi.registerProvider()` with a custom `streamSimple`.
|
|
148
200
|
- Each request leases one key from the pool (`options.apiKey` overrides), and once the HTTP response headers arrive:
|
|
149
201
|
- 429 → that key is cooled down (default 20s, honoring `retry-after`) and the request immediately retries with the next key (no duplicated output);
|
|
150
|
-
-
|
|
202
|
+
- 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;
|
|
203
|
+
- 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
204
|
- other errors → handed back to pi's own retry mechanism.
|
|
205
|
+
- 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
206
|
- 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
207
|
|
|
154
208
|
## 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 |
|
|
@@ -52,27 +50,46 @@ DeepSeek / Tencent / 小米官方文档,并对每个 thinking 档位做过实
|
|
|
52
50
|
### OpenCode Zen(免费层)
|
|
53
51
|
|
|
54
52
|
端点 `https://opencode.ai/zen/v1`;密钥从 [opencode.ai/auth](https://opencode.ai/auth) → workspace Keys 获取。
|
|
55
|
-
上下文/最大输出为 **Zen 免费层限制**(
|
|
56
|
-
|
|
57
|
-
`muse-spark-1.2-contributor-free` 使用 OpenAI **Responses** API;其余七个使用 chat completions。
|
|
53
|
+
上下文/最大输出为 **Zen 免费层限制**(opencode.ai/docs/zen);原始模型更大——MiMo V2.5 = 1M ctx。
|
|
54
|
+
`muse-spark-1.3-contributor-free` 使用 OpenAI **Responses** API;其余五个使用 chat completions。
|
|
58
55
|
|
|
59
|
-
|
|
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` 后按位取反(降序),因此新会话排序在前;计数器仅在毫秒变化时重置。
|
|
60
66
|
|
|
61
67
|
| 模型 | ctx / max-out | 模态 | 生效 thinking 档位 |
|
|
62
68
|
|---|---|---|---|
|
|
63
69
|
| 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
70
|
| mimo-v2.5-free | 200K / 32K | text+image | 始终思考(无 thinkingLevelMap) |
|
|
66
|
-
|
|
|
67
|
-
| ling-3.0-flash-fin-free | 256K / 32K | text | 始终思考(无 thinkingLevelMap) |
|
|
71
|
+
| ling-3.0-flash-fin-free | 262K / 32K | text | 始终思考(无 thinkingLevelMap) |
|
|
68
72
|
| nemotron-3-ultra-free | 1M / 128K | text | 始终思考(无 thinkingLevelMap) |
|
|
69
|
-
| nemotron-3.5-lightning-free |
|
|
70
|
-
| muse-spark-1.
|
|
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) |
|
|
71
75
|
|
|
72
|
-
>
|
|
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 的前身遗留变体)。
|
|
73
79
|
|
|
74
80
|
新增 preset:在 `presets.ts` 的 `PRESETS` 数组里加一项即可。
|
|
75
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
|
+
|
|
76
93
|
## 配置
|
|
77
94
|
|
|
78
95
|
`~/.pi/agent/multikey.json`。首次运行时会从 `~/.pi/agent/models.json` 自动发现
|
|
@@ -83,7 +100,7 @@ DeepSeek / Tencent / 小米官方文档,并对每个 thinking 档位做过实
|
|
|
83
100
|
{
|
|
84
101
|
"pools": [
|
|
85
102
|
{
|
|
86
|
-
"id": "bai", // pi 里的 provider id → bai/
|
|
103
|
+
"id": "bai", // pi 里的 provider id → bai/hy3
|
|
87
104
|
"name": "B.AI (Key Pool)",
|
|
88
105
|
"baseUrl": "https://api.b.ai/v1",
|
|
89
106
|
"api": "openai-completions",
|
package/cline-auth.ts
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
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 { accessToken, refreshToken, expiresAt: parseExpiresAt(payload.data?.expiresAt) };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** True when the stored access token is missing, expired, or inside the refresh buffer. */
|
|
78
|
+
export function isClineTokenStale(credential: KeyCredential, bufferMs = REFRESH_BUFFER_MS): boolean {
|
|
79
|
+
if (!credential.accessToken) return true;
|
|
80
|
+
if (credential.expiresAt === undefined) return false; // unknown expiry: use until a 401 says otherwise
|
|
81
|
+
return credential.expiresAt - Date.now() <= bufferMs;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Exchange a refresh token for a fresh access (+ rotated refresh) token. */
|
|
85
|
+
export async function refreshClineToken(refreshToken: string): Promise<ClineTokenUpdate> {
|
|
86
|
+
const response = await fetch(`${CLINE_API_BASE_URL}${CLINE_REFRESH_PATH}`, {
|
|
87
|
+
method: "POST",
|
|
88
|
+
headers: clineAuthHeaders(),
|
|
89
|
+
body: JSON.stringify({ refreshToken, grantType: "refresh_token" }),
|
|
90
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
91
|
+
});
|
|
92
|
+
if (!response.ok) {
|
|
93
|
+
const text = await response.text().catch(() => "");
|
|
94
|
+
throw new Error(`Cline token refresh failed: HTTP ${response.status}${text ? ` — ${text.slice(0, 200)}` : ""}`);
|
|
95
|
+
}
|
|
96
|
+
const payload = (await response.json().catch(() => ({}))) as ClineTokenResponse;
|
|
97
|
+
return requireClineTokens(payload, "Invalid Cline token refresh response");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Single-flight per refresh token: concurrent subagents hitting the same
|
|
101
|
+
// account must share one in-flight refresh instead of racing each other.
|
|
102
|
+
const inFlight = new Map<string, Promise<ClineTokenUpdate>>();
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Return a usable access token for the credential, refreshing when stale.
|
|
106
|
+
* Pass `force` after a 401 to refresh even if the token looks valid.
|
|
107
|
+
*/
|
|
108
|
+
export async function ensureClineAccessToken(
|
|
109
|
+
credential: KeyCredential,
|
|
110
|
+
options?: { force?: boolean },
|
|
111
|
+
): Promise<ClineTokenUpdate & { refreshed: boolean }> {
|
|
112
|
+
const force = options?.force === true;
|
|
113
|
+
if (!force && !isClineTokenStale(credential) && credential.accessToken) {
|
|
114
|
+
return { accessToken: credential.accessToken, refreshToken: credential.refreshToken, expiresAt: credential.expiresAt, refreshed: false };
|
|
115
|
+
}
|
|
116
|
+
const existing = inFlight.get(credential.refreshToken);
|
|
117
|
+
if (existing) return { ...(await existing), refreshed: true };
|
|
118
|
+
const task = refreshClineToken(credential.refreshToken).finally(() => {
|
|
119
|
+
inFlight.delete(credential.refreshToken);
|
|
120
|
+
});
|
|
121
|
+
inFlight.set(credential.refreshToken, task);
|
|
122
|
+
return { ...(await task), refreshed: true };
|
|
123
|
+
}
|
|
124
|
+
// ── Device flow (initial sign-in) ───────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
interface WorkOSDeviceAuthorizationResponse {
|
|
127
|
+
device_code?: string;
|
|
128
|
+
user_code?: string;
|
|
129
|
+
verification_uri?: string;
|
|
130
|
+
verification_uri_complete?: string;
|
|
131
|
+
expires_in?: number;
|
|
132
|
+
interval?: number;
|
|
133
|
+
error?: string;
|
|
134
|
+
error_description?: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
interface WorkOSTokenResponse {
|
|
138
|
+
access_token?: string;
|
|
139
|
+
refresh_token?: string;
|
|
140
|
+
token_type?: string;
|
|
141
|
+
error?: string;
|
|
142
|
+
error_description?: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Interactive sign-in: request a device code, hand the verification URL to
|
|
149
|
+
* `onAuthInfo` (awaited, so the UI can show it before polling starts), poll
|
|
150
|
+
* WorkOS until the user approves, then exchange for Cline tokens.
|
|
151
|
+
*/
|
|
152
|
+
export async function loginClineDeviceFlow(options: {
|
|
153
|
+
/** Awaited once the verification URL/user code are known (e.g. show a dialog). */
|
|
154
|
+
onAuthInfo: (info: { url: string; userCode: string }) => Promise<void>;
|
|
155
|
+
/** Optional progress lines while polling (e.g. append to a progress panel). */
|
|
156
|
+
onProgress?: (message: string) => void;
|
|
157
|
+
}): Promise<ClineTokenUpdate> {
|
|
158
|
+
const authResponse = await fetch(`${WORKOS_API_BASE_URL}${WORKOS_DEVICE_AUTHORIZATION_PATH}`, {
|
|
159
|
+
method: "POST",
|
|
160
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
161
|
+
body: new URLSearchParams({ client_id: WORKOS_CLIENT_ID }),
|
|
162
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
163
|
+
});
|
|
164
|
+
const device = (await authResponse.json().catch(() => ({}))) as WorkOSDeviceAuthorizationResponse;
|
|
165
|
+
if (!authResponse.ok || !device.device_code || !device.user_code || !device.verification_uri) {
|
|
166
|
+
const detail = device.error_description ?? (authResponse.ok ? "invalid WorkOS response" : `HTTP ${authResponse.status}`);
|
|
167
|
+
throw new Error(`Cline device authorization failed: ${detail}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
await options.onAuthInfo({
|
|
171
|
+
url: device.verification_uri_complete ?? device.verification_uri,
|
|
172
|
+
userCode: device.user_code,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const expiresInSeconds = device.expires_in ?? DEVICE_AUTH_EXPIRES_IN_SECONDS;
|
|
176
|
+
const deadline = Date.now() + expiresInSeconds * 1000;
|
|
177
|
+
let intervalSeconds = Math.max(1, device.interval ?? DEVICE_AUTH_INTERVAL_SECONDS);
|
|
178
|
+
let workosTokens: { access: string; refresh: string } | undefined;
|
|
179
|
+
|
|
180
|
+
while (Date.now() <= deadline) {
|
|
181
|
+
const pollResponse = await fetch(`${WORKOS_API_BASE_URL}${WORKOS_AUTHENTICATE_PATH}`, {
|
|
182
|
+
method: "POST",
|
|
183
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
184
|
+
body: new URLSearchParams({
|
|
185
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
186
|
+
device_code: device.device_code,
|
|
187
|
+
client_id: WORKOS_CLIENT_ID,
|
|
188
|
+
}),
|
|
189
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
190
|
+
});
|
|
191
|
+
const payload = (await pollResponse.json().catch(() => ({}))) as WorkOSTokenResponse;
|
|
192
|
+
if (pollResponse.ok) {
|
|
193
|
+
if (!payload.access_token || !payload.refresh_token) {
|
|
194
|
+
throw new Error("Invalid WorkOS token response");
|
|
195
|
+
}
|
|
196
|
+
workosTokens = { access: payload.access_token, refresh: payload.refresh_token };
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
switch (payload.error) {
|
|
200
|
+
case "authorization_pending":
|
|
201
|
+
options.onProgress?.("Waiting for browser authentication confirmation…");
|
|
202
|
+
await sleep(intervalSeconds * 1000);
|
|
203
|
+
break;
|
|
204
|
+
case "slow_down":
|
|
205
|
+
intervalSeconds += 1;
|
|
206
|
+
await sleep(intervalSeconds * 1000);
|
|
207
|
+
break;
|
|
208
|
+
case "access_denied":
|
|
209
|
+
case "expired_token":
|
|
210
|
+
case "invalid_grant":
|
|
211
|
+
throw new Error(`Cline authorization failed: ${payload.error_description ?? payload.error}`);
|
|
212
|
+
default:
|
|
213
|
+
throw new Error(`WorkOS token polling failed: HTTP ${pollResponse.status}${payload.error_description ? ` — ${payload.error_description}` : ""}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (!workosTokens) throw new Error("WorkOS device authorization timed out");
|
|
217
|
+
|
|
218
|
+
// Exchange the WorkOS tokens for Cline account tokens.
|
|
219
|
+
const registerResponse = await fetch(`${CLINE_API_BASE_URL}${CLINE_REGISTER_PATH}`, {
|
|
220
|
+
method: "POST",
|
|
221
|
+
headers: clineAuthHeaders(),
|
|
222
|
+
body: JSON.stringify({ accessToken: workosTokens.access, refreshToken: workosTokens.refresh }),
|
|
223
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
224
|
+
});
|
|
225
|
+
if (!registerResponse.ok) {
|
|
226
|
+
const text = await registerResponse.text().catch(() => "");
|
|
227
|
+
throw new Error(`Cline token registration failed: HTTP ${registerResponse.status}${text ? ` — ${text.slice(0, 200)}` : ""}`);
|
|
228
|
+
}
|
|
229
|
+
const payload = (await registerResponse.json().catch(() => ({}))) as ClineTokenResponse;
|
|
230
|
+
return requireClineTokens(payload, "Invalid Cline token registration response");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Human-readable expiry line for the TUI (token state only; quota lives server-side). */
|
|
234
|
+
export function describeClineCredential(credential: KeyCredential): string {
|
|
235
|
+
if (credential.expiresAt === undefined) return "token expiry unknown";
|
|
236
|
+
const remaining = credential.expiresAt - Date.now();
|
|
237
|
+
if (remaining <= 0) return `token expired ${Math.round(-remaining / 60_000)}m ago`;
|
|
238
|
+
if (remaining < 90_000) return `token expires in ${Math.round(remaining / 1000)}s`;
|
|
239
|
+
if (remaining < 90 * 60_000) return `token expires in ${Math.round(remaining / 60_000)}m`;
|
|
240
|
+
return `token expires in ${Math.round(remaining / 3_600_000)}h`;
|
|
241
|
+
}
|
|
242
|
+
|