@magpie-community/opencode-cline-auth 0.0.0-stage → 0.1.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 +77 -2
- package/index.mjs +930 -0
- package/package.json +10 -4
package/README.md
CHANGED
|
@@ -1,3 +1,78 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @magpie-community/opencode-cline-auth
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Cline (cline.bot) on your own Cline account: the models Cline's gateway
|
|
4
|
+
serves — usage-billed models, ClinePass's and Cline's free models alike.
|
|
5
|
+
Provider id: `cline`.
|
|
6
|
+
|
|
7
|
+
## Signing in
|
|
8
|
+
|
|
9
|
+
- **Cline device sign-in** — the flow Cline's own client runs: the plugin asks
|
|
10
|
+
WorkOS for a device code and opens the approval page, polls until you
|
|
11
|
+
approve, then trades the WorkOS tokens at Cline's `/api/v1/auth/register`
|
|
12
|
+
for the account's own token pair.
|
|
13
|
+
- **Cline API key** — create one at [app.cline.bot → Settings → API Keys](https://app.cline.bot/dashboard/account)
|
|
14
|
+
and paste it. Sent as a plain Bearer key.
|
|
15
|
+
|
|
16
|
+
The sign-in is kept where OpenCode keeps auth (`auth.json` under the data
|
|
17
|
+
directory); magpie keeps plugin sign-ins in its own store
|
|
18
|
+
(`~/.config/magpie/plugin-auth.json`).
|
|
19
|
+
|
|
20
|
+
Refreshing:
|
|
21
|
+
- Access tokens are renewed through `/api/v1/auth/refresh`. Cline rotates
|
|
22
|
+
the refresh token and refuses one already spent, so the rotated pair is
|
|
23
|
+
kept in memory beside the store the moment it arrives: a save that fails
|
|
24
|
+
can't leave the next request spending the token that was already spent.
|
|
25
|
+
- magpie renews the token ten minutes before it ends, through the plugin's
|
|
26
|
+
`auth.refresh`, once for the account and before its requests, models and
|
|
27
|
+
usage need it; magpie saves the new pair. OpenCode doesn't call that hook:
|
|
28
|
+
there the token is refreshed five minutes before it ends, before a
|
|
29
|
+
request, and the new pair is saved.
|
|
30
|
+
- Refreshes are serialized, magpie's renewal included, so two at once can't
|
|
31
|
+
spend one token and lose the other; one that finds the token already
|
|
32
|
+
renewed uses that pair.
|
|
33
|
+
- A refresh that fails for a while keeps the token that is still good, as
|
|
34
|
+
Cline's own client does (magpie tries its renewal again shortly); only a
|
|
35
|
+
refresh Cline refuses, or a token that has actually expired, is treated as
|
|
36
|
+
the sign-in gone.
|
|
37
|
+
|
|
38
|
+
## Requests
|
|
39
|
+
|
|
40
|
+
Chat completions go to `https://api.cline.bot/api/v1/chat/completions` in
|
|
41
|
+
OpenAI's format (streaming included), with `Authorization: Bearer
|
|
42
|
+
workos:<access token>` for a signed-in account or the raw API key, and the
|
|
43
|
+
full client-identity header set Cline's own clients send (`HTTP-Referer`,
|
|
44
|
+
`X-Title`, `User-Agent: Cline/<version>`, `X-CLIENT-TYPE: cline-cli`,
|
|
45
|
+
`X-CLIENT-VERSION`, `X-PLATFORM`, `X-PLATFORM-VERSION`, `X-CORE-VERSION`,
|
|
46
|
+
`X-IS-MULTIROOT`, and a per-task `X-Task-ID`) — pinned to Cline's current CLI
|
|
47
|
+
release, which is also what the free models' gate reads; when Cline ships a
|
|
48
|
+
new release, move the pinned numbers in `index.mjs` along. Auth and account
|
|
49
|
+
requests carry the same identity. A finished non-streaming answer rides in a
|
|
50
|
+
`{"data": …}` envelope the official clients take apart; the plugin takes it
|
|
51
|
+
apart too. The model list is read from
|
|
52
|
+
`/api/v1/ai/cline/recommended-models` and `/api/v1/ai/cline/models` (neither
|
|
53
|
+
needs a sign-in); usage reads `/api/v1/users/me` and the account's `/balance`
|
|
54
|
+
(its balance is counted in millionths of a dollar).
|
|
55
|
+
|
|
56
|
+
## Models
|
|
57
|
+
|
|
58
|
+
The list is Cline's own, merged from the two feeds its clients read: the
|
|
59
|
+
recommended-models feed (its recommended picks, its free models and
|
|
60
|
+
ClinePass's) and the whole cloud catalog (`/ai/cline/models`, where the
|
|
61
|
+
usage-billed models the recommended feed doesn't name live). Refreshed from
|
|
62
|
+
the live feeds whenever they answer; until then a bundled snapshot of the
|
|
63
|
+
recommended list, Cline's own default (`anthropic/claude-sonnet-5`) among it.
|
|
64
|
+
Cline's `clineCloud` group is left out, as Cline's own clients leave it out
|
|
65
|
+
unless they opt in.
|
|
66
|
+
|
|
67
|
+
- The **free** group (`cline-free/…`, `stealth/…`) costs nothing — the names
|
|
68
|
+
say "(free)". They are gated on the client surface a request claims; the
|
|
69
|
+
plugin claims what it is, and the gate takes it. If Cline ever narrows that
|
|
70
|
+
gate to its own apps, those models go away.
|
|
71
|
+
- The **ClinePass** group (`cline-pass/…`) needs a ClinePass subscription —
|
|
72
|
+
the names say so, and asking without one answers 403.
|
|
73
|
+
- The rest are usage-billed against the account's Credits balance.
|
|
74
|
+
|
|
75
|
+
## Not included
|
|
76
|
+
|
|
77
|
+
- Cline's MCP and web-search endpoints — a chat provider only.
|
|
78
|
+
- Team/organization account switching.
|
package/index.mjs
ADDED
|
@@ -0,0 +1,930 @@
|
|
|
1
|
+
// Cline (cline.bot) as a magpie / OpenCode provider plugin.
|
|
2
|
+
//
|
|
3
|
+
// The same sign-in Cline's own client runs — a WorkOS device code the browser
|
|
4
|
+
// approves, traded at Cline's register endpoint for the account's token pair —
|
|
5
|
+
// or a plain API key from app.cline.bot. Chat completions go to Cline's
|
|
6
|
+
// gateway in OpenAI's format; the model list comes from its recommended-models
|
|
7
|
+
// and cloud-models feeds; usage reads the account's credit balance.
|
|
8
|
+
import { STATUS_CODES } from "node:http"
|
|
9
|
+
import { randomUUID } from "node:crypto"
|
|
10
|
+
|
|
11
|
+
const PROVIDER = "cline"
|
|
12
|
+
const API = "https://api.cline.bot/api/v1"
|
|
13
|
+
|
|
14
|
+
// the WorkOS client Cline's own apps sign in with (their source, production)
|
|
15
|
+
const WORKOS_CLIENT = "client_01K3A541FN8TA3EPPHTD2325AR"
|
|
16
|
+
const WORKOS_DEVICE = "https://api.workos.com/user_management/authorize/device"
|
|
17
|
+
const WORKOS_TOKEN = "https://api.workos.com/user_management/authenticate"
|
|
18
|
+
const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"
|
|
19
|
+
|
|
20
|
+
// the gateway wants the account's token told apart from a raw API key: a
|
|
21
|
+
// signed-in one rides as "workos:<jwt>" (Cline's own clients send it so)
|
|
22
|
+
const WORKOS_PREFIX = "workos:"
|
|
23
|
+
|
|
24
|
+
const REFRESH_LEAD = 5 * 60 * 1000 // a token this close to its end is refreshed before a request
|
|
25
|
+
const RENEW_LEAD = 10 * 60 * 1000 // and this close, magpie renews it ahead of time (auth.refresh)
|
|
26
|
+
// a refresh that failed transiently keeps a token with this much left, as
|
|
27
|
+
// Cline's getValidClineCredentials does (DEFAULT_RETRYABLE_TOKEN_GRACE_MS);
|
|
28
|
+
// only once it has actually expired is the sign-in the one to blame
|
|
29
|
+
const RETRYABLE_GRACE = 30 * 1000
|
|
30
|
+
const DEVICE_CAP = 600 // seconds the browser may take, however long the code lives
|
|
31
|
+
|
|
32
|
+
// CLIENT is who every request says it comes from: the same client surface
|
|
33
|
+
// Cline's own CLI runs as, headers down (apps/cli registers exactly this
|
|
34
|
+
// identity, and the free models' gate reads these). Pinned to Cline's current
|
|
35
|
+
// release — when they ship a new one, move these along (the numbers are the
|
|
36
|
+
// only thing that has to match).
|
|
37
|
+
const CLIENT = { type: "cline-cli", version: "3.0.68", platform: "cli", core: "0.0.90" }
|
|
38
|
+
|
|
39
|
+
// clientHeaders is the header set resolveProviderRequestHeaders builds for a
|
|
40
|
+
// client with that identity; a task id rides per chat request, as the
|
|
41
|
+
// official clients ride one per task
|
|
42
|
+
function clientHeaders(taskId) {
|
|
43
|
+
return {
|
|
44
|
+
"HTTP-Referer": "https://cline.bot",
|
|
45
|
+
"X-Title": "Cline",
|
|
46
|
+
"User-Agent": `Cline/${CLIENT.version}`,
|
|
47
|
+
"X-IS-MULTIROOT": "false",
|
|
48
|
+
"X-CLIENT-TYPE": CLIENT.type,
|
|
49
|
+
"X-CLIENT-VERSION": CLIENT.version,
|
|
50
|
+
"X-PLATFORM": CLIENT.platform,
|
|
51
|
+
"X-PLATFORM-VERSION": CLIENT.version,
|
|
52
|
+
"X-CORE-VERSION": CLIENT.core,
|
|
53
|
+
...(taskId ? { "X-Task-ID": taskId } : {}),
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---- small helpers ------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
const firstOf = (...vs) => vs.find((v) => typeof v === "string" && v.trim())?.trim() ?? ""
|
|
60
|
+
|
|
61
|
+
// compactNumber is a whole number bare, else two decimals with the trailing
|
|
62
|
+
// zeroes off (magpie's subscription_usage.go)
|
|
63
|
+
function compactNumber(n) {
|
|
64
|
+
if (!Number.isFinite(n)) return "0"
|
|
65
|
+
if (n === Math.trunc(n)) return String(Math.trunc(n))
|
|
66
|
+
return String(Number(n.toFixed(2)))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// toMs is an expiry Cline sends as an ISO datetime; one it didn't send (or one
|
|
70
|
+
// that won't parse) is taken as an hour out rather than as "never"
|
|
71
|
+
function toMs(v) {
|
|
72
|
+
const t = typeof v === "string" ? Date.parse(v) : Number(v)
|
|
73
|
+
if (Number.isFinite(t) && t > Date.now()) return t
|
|
74
|
+
return Date.now() + 60 * 60 * 1000
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// timeOf reads a reset time: epoch seconds, epoch ms, or a date string
|
|
78
|
+
function timeOf(m, keys) {
|
|
79
|
+
for (const k of keys) {
|
|
80
|
+
const v = m?.[k]
|
|
81
|
+
if (typeof v === "number" && v > 1e12) return new Date(v).toISOString()
|
|
82
|
+
if (typeof v === "number" && v > 1e9) return new Date(v * 1000).toISOString()
|
|
83
|
+
if (typeof v === "string") {
|
|
84
|
+
const t = Date.parse(v)
|
|
85
|
+
if (!Number.isNaN(t)) return new Date(t).toISOString()
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return ""
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
92
|
+
|
|
93
|
+
// ---- the sign-in as it is kept --------------------------------------------------
|
|
94
|
+
|
|
95
|
+
// authOf reads a sign-in as magpie keeps it: OpenCode's type field, and what
|
|
96
|
+
// this plugin needs beside it to renew it later
|
|
97
|
+
const authOf = (a) => ({
|
|
98
|
+
type: a?.type === "api" ? "api" : "oauth",
|
|
99
|
+
access: typeof a?.access === "string" ? a.access : "",
|
|
100
|
+
refresh: typeof a?.refresh === "string" ? a.refresh : "",
|
|
101
|
+
expires: Number(a?.expires) || 0,
|
|
102
|
+
key: typeof a?.key === "string" ? a.key : "",
|
|
103
|
+
uid: typeof a?.uid === "string" ? a.uid : "",
|
|
104
|
+
email: typeof a?.email === "string" ? a.email : "",
|
|
105
|
+
name: typeof a?.name === "string" ? a.name : "",
|
|
106
|
+
accountId: typeof a?.accountId === "string" ? a.accountId : "",
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
// bearerOf is what an Authorization header carries: a signed-in account's
|
|
110
|
+
// access token with Cline's routing prefix, or the raw API key
|
|
111
|
+
function bearerOf(a) {
|
|
112
|
+
if (a?.type === "api") return a.key
|
|
113
|
+
const access = typeof a?.access === "string" ? a.access : ""
|
|
114
|
+
if (!access) return ""
|
|
115
|
+
// the prefix is matched the way Cline's own client matches it, whatever case
|
|
116
|
+
return access.toLowerCase().startsWith(WORKOS_PREFIX) ? access : WORKOS_PREFIX + access
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// parseAuth reads Cline's token answer ({accessToken, refreshToken, expiresAt,
|
|
120
|
+
// userInfo}) into what this plugin keeps, with a refresh to fall back on when
|
|
121
|
+
// the answer didn't rotate it
|
|
122
|
+
function parseAuth(data, fallbackRefresh = "") {
|
|
123
|
+
const access = typeof data?.accessToken === "string" ? data.accessToken : ""
|
|
124
|
+
if (!access) return { error: "Cline sent back no access token" }
|
|
125
|
+
const refresh = firstOf(data?.refreshToken, fallbackRefresh)
|
|
126
|
+
if (!refresh) return { error: "Cline sent back no refresh token" }
|
|
127
|
+
const ui = data?.userInfo && typeof data.userInfo === "object" ? data.userInfo : {}
|
|
128
|
+
const uid = firstOf(ui.clineUserId, ui.subject)
|
|
129
|
+
const email = firstOf(ui.email)
|
|
130
|
+
return {
|
|
131
|
+
access,
|
|
132
|
+
refresh,
|
|
133
|
+
expires: toMs(data?.expiresAt),
|
|
134
|
+
uid,
|
|
135
|
+
email,
|
|
136
|
+
name: firstOf(ui.name),
|
|
137
|
+
accountId: firstOf(email, uid),
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---- talking to Cline ------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
// Lapsed is the sign-in itself gone: a refused refresh, or an API that turned
|
|
144
|
+
// the account's token away. magpie marks the account from e.expired.
|
|
145
|
+
class Lapsed extends Error {
|
|
146
|
+
constructor(message) {
|
|
147
|
+
super(message)
|
|
148
|
+
this.expired = true
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// clineApi is one call to Cline's own API, and the {success, data} envelope it
|
|
153
|
+
// answers in. lapsed says which answers mean the sign-in itself is gone (401
|
|
154
|
+
// and 403, unless the caller knows this endpoint refuses otherwise); the rest
|
|
155
|
+
// is a request that failed.
|
|
156
|
+
async function clineApi(url, { method = "GET", body, headers = {}, signal, lapsed } = {}) {
|
|
157
|
+
const gone = lapsed ?? ((status) => status === 401 || status === 403)
|
|
158
|
+
let res
|
|
159
|
+
try {
|
|
160
|
+
res = await fetch(url, {
|
|
161
|
+
method,
|
|
162
|
+
headers: {
|
|
163
|
+
Accept: "application/json",
|
|
164
|
+
...clientHeaders(),
|
|
165
|
+
...headers,
|
|
166
|
+
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
|
|
167
|
+
},
|
|
168
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
169
|
+
signal: signal ?? AbortSignal.timeout(15_000),
|
|
170
|
+
})
|
|
171
|
+
} catch (e) {
|
|
172
|
+
throw new Error(`Cline: ${e?.message ?? e}`)
|
|
173
|
+
}
|
|
174
|
+
const text = (await res.text().catch(() => "")).trim()
|
|
175
|
+
if (gone(res.status, text))
|
|
176
|
+
throw new Lapsed(`the sign-in lapsed — Cline refused it (HTTP ${res.status}${text ? `: ${text.slice(0, 200)}` : ""}); sign in to Cline again`)
|
|
177
|
+
if (!res.ok) throw new Error(`Cline request failed (HTTP ${res.status}${text ? `: ${text.slice(0, 200)}` : ""})`)
|
|
178
|
+
const env = safeJson(text)
|
|
179
|
+
if (env && typeof env === "object" && "success" in env) {
|
|
180
|
+
if (!env.success) throw new Error(`Cline request failed${env.error ? `: ${env.error}` : ""}`)
|
|
181
|
+
return env.data
|
|
182
|
+
}
|
|
183
|
+
return env
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// safeJson is text as JSON, or null
|
|
187
|
+
const safeJson = (text) => {
|
|
188
|
+
try {
|
|
189
|
+
return JSON.parse(text)
|
|
190
|
+
} catch {
|
|
191
|
+
return null
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// refresh trades the refresh token for a new pair; Cline rotates it, so the
|
|
196
|
+
// caller saves what comes back. A dead token is refused as 400 invalid_grant
|
|
197
|
+
// (WorkOS's wording rides in the body), not 401 — that refusal is the sign-in
|
|
198
|
+
// gone, anything else is worth a retry later.
|
|
199
|
+
async function refresh(a) {
|
|
200
|
+
if (!a.refresh) throw new Lapsed("Cline's refresh token is gone; sign in to Cline again")
|
|
201
|
+
const data = await clineApi(`${API}/auth/refresh`, {
|
|
202
|
+
method: "POST",
|
|
203
|
+
body: { refreshToken: a.refresh, grantType: "refresh_token" },
|
|
204
|
+
lapsed: (status, text) => [400, 401, 403].includes(status) && /invalid|expired|revoked|unauthorized/i.test(text),
|
|
205
|
+
})
|
|
206
|
+
const p = parseAuth(data, a.refresh)
|
|
207
|
+
if (p.error) throw new Error(`Cline token refresh: ${p.error}`)
|
|
208
|
+
return p
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---- the device sign-in ----------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
// deviceAuthorize starts the flow: a code to approve in the browser
|
|
214
|
+
async function deviceAuthorize() {
|
|
215
|
+
let res
|
|
216
|
+
try {
|
|
217
|
+
res = await fetch(WORKOS_DEVICE, {
|
|
218
|
+
method: "POST",
|
|
219
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
|
220
|
+
body: new URLSearchParams({ client_id: WORKOS_CLIENT }).toString(),
|
|
221
|
+
signal: AbortSignal.timeout(15_000),
|
|
222
|
+
})
|
|
223
|
+
} catch (e) {
|
|
224
|
+
throw new Error(`Cline sign-in: ${e?.message ?? e}`)
|
|
225
|
+
}
|
|
226
|
+
if (!res.ok) throw new Error(`Cline sign-in: the device code request failed (HTTP ${res.status})`)
|
|
227
|
+
const d = await res.json().catch(() => null)
|
|
228
|
+
if (!d?.device_code || !d?.verification_uri) throw new Error("Cline sign-in: the device code answer was incomplete")
|
|
229
|
+
return d
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// pollDevice waits the device code out, as Cline's pollWorkOSTokens does: the
|
|
233
|
+
// interval only grows (slow_down raises it for every later poll, RFC 8628), it
|
|
234
|
+
// starts at a second, and a 5xx, a non-JSON reply or a network error is a poll
|
|
235
|
+
// to try again, not the sign-in failing.
|
|
236
|
+
async function pollDevice(d, { fetchImpl = fetch, sleep: sleeper = sleep, now = Date.now } = {}) {
|
|
237
|
+
const deadline = now() + Math.min(Number(d?.expires_in) > 0 ? Number(d.expires_in) : 300, DEVICE_CAP) * 1000
|
|
238
|
+
let interval = Math.max(1, Number(d?.interval) > 0 ? Math.floor(Number(d.interval)) : 5)
|
|
239
|
+
while (now() < deadline) {
|
|
240
|
+
await sleeper(interval * 1000)
|
|
241
|
+
let res = null
|
|
242
|
+
let j = null
|
|
243
|
+
try {
|
|
244
|
+
res = await fetchImpl(WORKOS_TOKEN, {
|
|
245
|
+
method: "POST",
|
|
246
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
|
247
|
+
body: new URLSearchParams({ grant_type: DEVICE_GRANT, device_code: d.device_code, client_id: WORKOS_CLIENT }).toString(),
|
|
248
|
+
signal: AbortSignal.timeout(15_000),
|
|
249
|
+
})
|
|
250
|
+
j = await res.json().catch(() => null)
|
|
251
|
+
} catch {}
|
|
252
|
+
// WorkOS having a moment, or the network: not the sign-in
|
|
253
|
+
if (!res || res.status >= 500) continue
|
|
254
|
+
// a reply that isn't JSON and isn't a 5xx is the poll turned away: an
|
|
255
|
+
// HTML 400 would otherwise keep polling to the deadline (ten minutes)
|
|
256
|
+
// and fail there anyway
|
|
257
|
+
if (j === null) {
|
|
258
|
+
if (res.status >= 400) throw new Error(`Cline sign-in: the token poll was refused (HTTP ${res.status})`)
|
|
259
|
+
continue
|
|
260
|
+
}
|
|
261
|
+
if (j.error === "authorization_pending") continue
|
|
262
|
+
if (j.error === "slow_down") {
|
|
263
|
+
interval += 1
|
|
264
|
+
continue
|
|
265
|
+
}
|
|
266
|
+
if (j.error) throw new Error(`Cline sign-in: ${j.error}${j.error_description ? ` (${j.error_description})` : ""}`)
|
|
267
|
+
if (typeof j.access_token !== "string" || !j.access_token) throw new Error("Cline sign-in: the token answer had no access token")
|
|
268
|
+
return j
|
|
269
|
+
}
|
|
270
|
+
throw new Error("the sign-in wasn't finished in time")
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// deviceSignIn is the browser method: the device code page opened, WorkOS
|
|
274
|
+
// polled until the code is approved, and the WorkOS tokens traded at Cline's
|
|
275
|
+
// register endpoint for the account's own pair
|
|
276
|
+
async function deviceSignIn() {
|
|
277
|
+
const d = await deviceAuthorize()
|
|
278
|
+
return {
|
|
279
|
+
url: firstOf(d.verification_uri_complete, d.verification_uri, "https://app.cline.bot"),
|
|
280
|
+
instructions: `Approve the sign-in in the browser${d.user_code ? ` — the code is ${d.user_code}` : ""}.`,
|
|
281
|
+
method: "auto",
|
|
282
|
+
async callback() {
|
|
283
|
+
let j
|
|
284
|
+
try {
|
|
285
|
+
j = await pollDevice(d)
|
|
286
|
+
} catch (e) {
|
|
287
|
+
return { type: "failed", error: e?.message ?? String(e) }
|
|
288
|
+
}
|
|
289
|
+
try {
|
|
290
|
+
const data = await clineApi(`${API}/auth/register`, { method: "POST", body: { accessToken: j.access_token, refreshToken: j.refresh_token ?? "" }, signal: AbortSignal.timeout(30_000) })
|
|
291
|
+
const p = parseAuth(data, j.refresh_token ?? "")
|
|
292
|
+
if (p.error) throw new Error(`Cline sign-in: ${p.error}`)
|
|
293
|
+
return { type: "success", ...p }
|
|
294
|
+
} catch (e) {
|
|
295
|
+
return { type: "failed", error: e?.message ?? String(e) }
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ---- models ----------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
// CAPS is the shorthand a model id spells in capitals, MIXED the ones with a
|
|
304
|
+
// capital of their own
|
|
305
|
+
const CAPS = new Set(["gpt", "glm", "ai", "api", "llm", "k3"])
|
|
306
|
+
const MIXED = { mimo: "MiMo" }
|
|
307
|
+
|
|
308
|
+
// prettify is a model id as a name: anthropic/claude-sonnet-5.5 becomes
|
|
309
|
+
// "Claude Sonnet 5.5"
|
|
310
|
+
function prettify(id) {
|
|
311
|
+
const seg = String(id).split("/").pop() ?? ""
|
|
312
|
+
return seg
|
|
313
|
+
.split(/[-_]/)
|
|
314
|
+
.filter(Boolean)
|
|
315
|
+
.map((w) => MIXED[w.toLowerCase()] ?? (CAPS.has(w.toLowerCase()) ? w.toUpperCase() : w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()))
|
|
316
|
+
.join(" ")
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// DEFAULT_MODELS is the list before Cline's feed answered: the bundled
|
|
320
|
+
// snapshot of Cline's own recommended-models feed — its recommended models,
|
|
321
|
+
// its free ones and ClinePass's (cline-recommended.generated.ts). The first is
|
|
322
|
+
// also Cline's own default.
|
|
323
|
+
const DEFAULT_MODELS = [
|
|
324
|
+
{ id: "anthropic/claude-sonnet-5.5" },
|
|
325
|
+
{ id: "anthropic/claude-opus-5.5" },
|
|
326
|
+
{ id: "anthropic/claude-sonnet-5" },
|
|
327
|
+
{ id: "openai/gpt-6-astra" },
|
|
328
|
+
{ id: "openai/gpt-6.1-sol" },
|
|
329
|
+
{ id: "spacexai/grok-4.7" },
|
|
330
|
+
{ id: "moonshotai/kimi-k3" },
|
|
331
|
+
{ id: "cline-free/deepseek-v4.1-flash", free: true },
|
|
332
|
+
{ id: "stealth/space-bunny-alpha", free: true },
|
|
333
|
+
{ id: "cline-free/mimo-v2.6-flash", free: true },
|
|
334
|
+
{ id: "cline-free/muse-spark-1.3-contributor", free: true },
|
|
335
|
+
{ id: "cline-pass/deepseek-v4.1-flash" },
|
|
336
|
+
{ id: "cline-pass/mimo-v2.6-flash" },
|
|
337
|
+
{ id: "cline-pass/mimo-v2.6-pro" },
|
|
338
|
+
{ id: "cline-pass/glm-5.3" },
|
|
339
|
+
{ id: "cline-pass/deepseek-v4-pro" },
|
|
340
|
+
{ id: "cline-pass/qwen3.8-max" },
|
|
341
|
+
{ id: "cline-pass/muse-spark-1.3-contributor" },
|
|
342
|
+
{ id: "cline-pass/kimi-k3" },
|
|
343
|
+
{ id: "cline-pass/glm-5.3-flash" },
|
|
344
|
+
{ id: "cline-pass/qwen3.7-max" },
|
|
345
|
+
{ id: "cline-pass/qwen3.7-plus" },
|
|
346
|
+
{ id: "cline-pass/minimax-m3" },
|
|
347
|
+
{ id: "cline-pass/mimo-v2.5-pro" },
|
|
348
|
+
{ id: "cline-pass/mimo-v2.5" },
|
|
349
|
+
].map((m) => ({ name: prettify(m.id) + (m.free ? " (free)" : m.id.startsWith("cline-pass/") ? " (ClinePass)" : ""), ...m }))
|
|
350
|
+
|
|
351
|
+
const MODEL_ID = /^[a-z0-9][a-z0-9_.-]*\/[a-z0-9][a-z0-9_.-]*$/i
|
|
352
|
+
|
|
353
|
+
// FEED_GROUPS is the recommended-models answer's shape, as Cline's own client
|
|
354
|
+
// reads it (cline-recommended.generated.ts): four arrays, and nothing else.
|
|
355
|
+
// clineCloud rides only under the client's includeClineCloudModels, so it is
|
|
356
|
+
// left out here the way the client leaves it out by default.
|
|
357
|
+
const FEED_GROUPS = ["recommended", "free", "clinePass"]
|
|
358
|
+
|
|
359
|
+
// parseFeed reads the recommended-models answer: the four arrays by name, not
|
|
360
|
+
// every value in the object (a description or tag is not a model). A model the
|
|
361
|
+
// feed lists twice keeps the stronger gate, so one in both recommended and
|
|
362
|
+
// free keeps its "(free)" mark.
|
|
363
|
+
function parseFeed(v) {
|
|
364
|
+
const payload = v && typeof v === "object" && !Array.isArray(v) ? v : {}
|
|
365
|
+
const by = new Map()
|
|
366
|
+
// entry is one model: an id bare or in an object, named for the plan gate a
|
|
367
|
+
// picker should show. A feed name that is just the id (or its last part) is
|
|
368
|
+
// the raw id, not a name, so it is prettified like the bundled list's.
|
|
369
|
+
const entry = (e, group) => {
|
|
370
|
+
const o = e && typeof e === "object" && !Array.isArray(e) ? e : {}
|
|
371
|
+
const raw = typeof e === "string" ? e.trim() : firstOf(o.id, o.model, o.modelId, o.model_id, o.key, o.slug)
|
|
372
|
+
if (!MODEL_ID.test(raw)) return null
|
|
373
|
+
const free = group === "free" || o.free === true || o.free === "true"
|
|
374
|
+
const pass = group === "clinePass" || raw.startsWith("cline-pass/")
|
|
375
|
+
const given = firstOf(o.display_name, o.displayName, o.name, o.label)
|
|
376
|
+
const last = raw.split("/").pop()
|
|
377
|
+
return { id: raw, name: given && given !== raw && given !== last ? given : prettify(raw), free, pass }
|
|
378
|
+
}
|
|
379
|
+
const add = (e, group) => {
|
|
380
|
+
const m = entry(e, group)
|
|
381
|
+
if (!m) return
|
|
382
|
+
const was = by.get(m.id)
|
|
383
|
+
if (!was) {
|
|
384
|
+
by.set(m.id, m)
|
|
385
|
+
return
|
|
386
|
+
}
|
|
387
|
+
// a model in both recommended and free keeps the free mark (and name)
|
|
388
|
+
if (m.free && !was.free) {
|
|
389
|
+
was.free = true
|
|
390
|
+
was.name = m.name
|
|
391
|
+
}
|
|
392
|
+
if (m.pass) was.pass = true
|
|
393
|
+
}
|
|
394
|
+
for (const group of FEED_GROUPS) for (const e of Array.isArray(payload[group]) ? payload[group] : []) add(e, group)
|
|
395
|
+
return [...by.values()].map((m) => ({ ...m, name: m.name + (m.free ? " (free)" : m.pass ? " (ClinePass)" : "") }))
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// fetchRecommended is Cline's recommended-models feed, which it serves without
|
|
399
|
+
// a sign-in
|
|
400
|
+
async function fetchRecommended() {
|
|
401
|
+
let res
|
|
402
|
+
try {
|
|
403
|
+
res = await fetch(`${API}/ai/cline/recommended-models`, {
|
|
404
|
+
headers: { Accept: "application/json", ...clientHeaders() },
|
|
405
|
+
signal: AbortSignal.timeout(10_000),
|
|
406
|
+
})
|
|
407
|
+
} catch (e) {
|
|
408
|
+
throw new Error(`Cline model list: ${e?.message ?? e}`)
|
|
409
|
+
}
|
|
410
|
+
if (!res.ok) throw new Error(`Cline model list: HTTP ${res.status}`)
|
|
411
|
+
const env = await res.json().catch(() => null)
|
|
412
|
+
return parseFeed(env && typeof env === "object" && "data" in env ? env.data : env)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// fetchCloudModels is Cline's whole cloud catalog, which its clients load
|
|
416
|
+
// beside the recommended feed (loadCloudModels reads /ai/cline/models): the
|
|
417
|
+
// usage-billed models the recommended feed doesn't name. Entries carry an id
|
|
418
|
+
// and a display name.
|
|
419
|
+
async function fetchCloudModels() {
|
|
420
|
+
let res
|
|
421
|
+
try {
|
|
422
|
+
res = await fetch(`${API}/ai/cline/models`, {
|
|
423
|
+
headers: { Accept: "application/json", ...clientHeaders() },
|
|
424
|
+
signal: AbortSignal.timeout(10_000),
|
|
425
|
+
})
|
|
426
|
+
} catch (e) {
|
|
427
|
+
throw new Error(`Cline model catalog: ${e?.message ?? e}`)
|
|
428
|
+
}
|
|
429
|
+
if (!res.ok) throw new Error(`Cline model catalog: HTTP ${res.status}`)
|
|
430
|
+
const j = await res.json().catch(() => null)
|
|
431
|
+
const list = Array.isArray(j) ? j : Array.isArray(j?.data) ? j.data : []
|
|
432
|
+
return list.flatMap((e) => {
|
|
433
|
+
// the catalog's ids are bare (no provider prefix), unlike the
|
|
434
|
+
// recommended feed's: any non-empty id is one
|
|
435
|
+
const id = typeof e?.id === "string" ? e.id.trim() : ""
|
|
436
|
+
if (!id) return []
|
|
437
|
+
const given = firstOf(e.display_name, e.displayName, e.name)
|
|
438
|
+
const last = id.split("/").pop()
|
|
439
|
+
return [{ id, name: given && given !== id && given !== last ? given : prettify(id), free: false, pass: id.startsWith("cline-pass/") }]
|
|
440
|
+
})
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// fetchFeed is both of Cline's model lists merged, recommended first: the
|
|
444
|
+
// recommended feed is required, the cloud catalog is what it adds.
|
|
445
|
+
async function fetchFeed() {
|
|
446
|
+
const [rec, cloud] = await Promise.all([fetchRecommended(), fetchCloudModels().catch(() => [])])
|
|
447
|
+
const ms = [...rec]
|
|
448
|
+
const seen = new Set(ms.map((m) => m.id))
|
|
449
|
+
for (const m of cloud) {
|
|
450
|
+
if (seen.has(m.id)) continue
|
|
451
|
+
seen.add(m.id)
|
|
452
|
+
ms.push(m)
|
|
453
|
+
}
|
|
454
|
+
if (!ms.length) throw new Error("Cline listed no models")
|
|
455
|
+
return ms
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const configModel = (m) => ({
|
|
459
|
+
name: m.name ?? m.id,
|
|
460
|
+
limit: { context: m.context ?? 0, output: 0 },
|
|
461
|
+
tool_call: true,
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
const runtimeModel = (m) => ({
|
|
465
|
+
id: m.id,
|
|
466
|
+
providerID: PROVIDER,
|
|
467
|
+
name: m.name || m.id,
|
|
468
|
+
api: { id: m.id, url: API, npm: "@ai-sdk/openai-compatible" },
|
|
469
|
+
status: "active",
|
|
470
|
+
headers: {},
|
|
471
|
+
options: {},
|
|
472
|
+
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
|
473
|
+
limit: { context: m.context ?? 0, output: 0 },
|
|
474
|
+
capabilities: {
|
|
475
|
+
temperature: true,
|
|
476
|
+
reasoning: false,
|
|
477
|
+
attachment: false,
|
|
478
|
+
toolcall: true,
|
|
479
|
+
input: { text: true, image: false, audio: false, video: false, pdf: false },
|
|
480
|
+
output: { text: true, image: false, audio: false, video: false, pdf: false },
|
|
481
|
+
interleaved: false,
|
|
482
|
+
},
|
|
483
|
+
release_date: "",
|
|
484
|
+
variants: {},
|
|
485
|
+
free: !!m.free,
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
// ---- the reply as errors -----------------------------------------------------------
|
|
489
|
+
|
|
490
|
+
// the refusals Cline's own client keys on, as it spells them (errors.ts and
|
|
491
|
+
// ClineError.ts): whole phrases and codes, never a loose substring — a 403
|
|
492
|
+
// that happens to mention "tokens" is not a lapsed sign-in
|
|
493
|
+
const NOT_SUBSCRIBED = "the user is not subscribed to required model plan"
|
|
494
|
+
const NOT_SUBSCRIBED_FORMATTED = "no access to clinepass subscription models yet. subscribe to clinepass"
|
|
495
|
+
|
|
496
|
+
// failure is an upstream chat refusal as OpenAI's API gives it. A refused
|
|
497
|
+
// chat is the sign-in at work, not the sign-in gone: only a refused refresh
|
|
498
|
+
// marks the account (errorResponse keeps it). The gates Cline puts in front
|
|
499
|
+
// of a model each say what they want: a plan subscription, or money.
|
|
500
|
+
function failure(status, text) {
|
|
501
|
+
if (status < 400 || status > 599) status = 502
|
|
502
|
+
let msg = String(text ?? "").trim()
|
|
503
|
+
let j = null
|
|
504
|
+
try {
|
|
505
|
+
j = JSON.parse(text)
|
|
506
|
+
} catch {}
|
|
507
|
+
const code = firstOf(j?.error?.code, j?.code, j?.error?.error)
|
|
508
|
+
if (j) {
|
|
509
|
+
if (typeof j?.error?.message === "string") msg = j.error.message
|
|
510
|
+
else if (typeof j?.message === "string") msg = j.message
|
|
511
|
+
}
|
|
512
|
+
msg ||= STATUS_CODES[status] ?? `HTTP ${status}`
|
|
513
|
+
const low = msg.toLowerCase()
|
|
514
|
+
// a model the account isn't entitled to: the sign-in is fine, the plan
|
|
515
|
+
// isn't there (the cline-pass group, mostly)
|
|
516
|
+
if (code === "ENTITLEMENT_ERROR" || low.includes(NOT_SUBSCRIBED) || low.includes(NOT_SUBSCRIBED_FORMATTED))
|
|
517
|
+
return {
|
|
518
|
+
status: 403,
|
|
519
|
+
message: "this model needs a subscription the account hasn't got (ClinePass) — pick a usage-billed model, or subscribe at app.cline.bot (" + msg.replace(/^Error\s*\d*[::]?\s*/, "") + ")",
|
|
520
|
+
}
|
|
521
|
+
// an empty account: say what the gateway said and where to fill it. The
|
|
522
|
+
// code is the one Cline's client keys on; "Insufficient balance" is the
|
|
523
|
+
// gateway's older wording for the same answer
|
|
524
|
+
if (status === 402 || code === "insufficient_credits" || low.includes("insufficient_credits") || (low.includes("insufficient balance") && low.includes("cline credits balance"))) {
|
|
525
|
+
const bal = Number(j?.error?.current_balance ?? j?.current_balance)
|
|
526
|
+
const at = firstOf(j?.error?.buy_credits_url, j?.buy_credits_url) || "https://app.cline.bot/credits"
|
|
527
|
+
return { status: 429, message: `out of credits ($${Number.isFinite(bal) ? bal.toFixed(2) : "0.00"}) — top up at ${at}` }
|
|
528
|
+
}
|
|
529
|
+
// the account's token was refused: 401 always, and a 403 only when it
|
|
530
|
+
// says so in so many words — not when it merely mentions "token"
|
|
531
|
+
if (status === 401 || (status === 403 && /^(unauthorized|invalid[ _]token|sign[ -]?in|not signed in|token (?:expired|invalid|revoked)|(?:access |refresh )?token expired)$/i.test(msg.trim())))
|
|
532
|
+
return { status: 401, message: "the sign-in lapsed — sign in again" }
|
|
533
|
+
// a spend cap or a rate limit, as Cline's client names them
|
|
534
|
+
if (status === 429 || code === "SPEND_LIMIT_EXCEEDED" || /\b(?:rate limit|too many requests|quota exceeded|spend limit)\b/i.test(msg)) return { status: 429, message: "usage limit reached: " + msg }
|
|
535
|
+
return { status, message: msg.slice(0, 2000) }
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const errorResponse = ({ status, message, signIn }) =>
|
|
539
|
+
new Response(JSON.stringify({ error: { message, type: "cline_error", code: status } }), {
|
|
540
|
+
status,
|
|
541
|
+
headers: { "Content-Type": "application/json", ...(signIn ? { "X-Magpie-Sign-In": signIn } : {}) },
|
|
542
|
+
})
|
|
543
|
+
|
|
544
|
+
// signed is res saying what it means for the sign-in: a renewed token cleared
|
|
545
|
+
// the lapse mark whatever came of the request
|
|
546
|
+
function signed(res, renewed) {
|
|
547
|
+
const said = renewed ? "renewed" : res.ok ? "kept" : null
|
|
548
|
+
if (!said) return res
|
|
549
|
+
const headers = new Headers(res.headers)
|
|
550
|
+
headers.delete("content-length")
|
|
551
|
+
headers.delete("content-encoding")
|
|
552
|
+
headers.set("X-Magpie-Sign-In", said)
|
|
553
|
+
return new Response(res.body, { status: res.status, statusText: res.statusText, headers })
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
async function bodyText(input, init) {
|
|
557
|
+
const b = init?.body ?? (input instanceof Request ? await input.clone().text() : undefined)
|
|
558
|
+
if (b === undefined || b === null) return ""
|
|
559
|
+
if (typeof b === "string") return b
|
|
560
|
+
if (b instanceof URLSearchParams) return b.toString()
|
|
561
|
+
if (b instanceof ArrayBuffer || ArrayBuffer.isView(b)) return new TextDecoder().decode(b)
|
|
562
|
+
try {
|
|
563
|
+
return await new Response(b).text()
|
|
564
|
+
} catch {
|
|
565
|
+
return ""
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// unwrapped is Cline's non-streaming answer with its envelope taken off: a
|
|
570
|
+
// finished chat completion rides as {"data": …}, the shape the official
|
|
571
|
+
// clients take apart. Streaming answers are plain SSE and pass untouched.
|
|
572
|
+
async function unwrapped(res) {
|
|
573
|
+
if (!(res.headers.get("content-type") ?? "").includes("application/json")) return res
|
|
574
|
+
const text = await res.text()
|
|
575
|
+
let j
|
|
576
|
+
try {
|
|
577
|
+
j = JSON.parse(text)
|
|
578
|
+
} catch {
|
|
579
|
+
return new Response(text, { status: res.status, statusText: res.statusText, headers: res.headers })
|
|
580
|
+
}
|
|
581
|
+
if (j && typeof j === "object" && Array.isArray(j.data?.choices))
|
|
582
|
+
return new Response(JSON.stringify(j.data), { status: res.status, statusText: res.statusText, headers: { "Content-Type": "application/json" } })
|
|
583
|
+
return new Response(text, { status: res.status, statusText: res.statusText, headers: res.headers })
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// ---- usage -----------------------------------------------------------------------
|
|
587
|
+
|
|
588
|
+
// usd is a dollar amount as it's usually written: cents above a cent, four
|
|
589
|
+
// decimals under one (an account with half a cent isn't "$0.00" to its owner)
|
|
590
|
+
const usd = (n) => (n === 0 || n >= 0.01 ? `$${n.toFixed(2)}` : `$${Number(n.toFixed(4))}`)
|
|
591
|
+
|
|
592
|
+
// balanceWindow is the one balance shape Cline serves today: {balance} in
|
|
593
|
+
// millionths of a dollar — the chat gateway's current_balance is the same
|
|
594
|
+
// number with the decimal point moved six places
|
|
595
|
+
function balanceWindow(v) {
|
|
596
|
+
const n = typeof v?.balance === "number" ? v.balance : typeof v?.balance === "string" && Number.isFinite(Number(v.balance)) ? Number(v.balance) : NaN
|
|
597
|
+
if (!Number.isFinite(n) || n < 0) return null
|
|
598
|
+
return { name: "Credits", used: 0, display: `${usd(n / 1e6)} left` }
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// balanceOf is the account's credit balance, a shape Cline hasn't fixed: a
|
|
602
|
+
// total with what is used or left of it, or just what is left
|
|
603
|
+
function balanceOf(v) {
|
|
604
|
+
if (!v || typeof v !== "object") return null
|
|
605
|
+
const flat = {}
|
|
606
|
+
const collect = (n, depth) => {
|
|
607
|
+
if (!n || typeof n !== "object" || depth > 2) return
|
|
608
|
+
for (const [k, x] of Object.entries(n)) {
|
|
609
|
+
if (typeof x === "number" && Number.isFinite(x)) flat[k.toLowerCase()] = x
|
|
610
|
+
else if (typeof x === "string" && x.trim() !== "" && Number.isFinite(Number(x.trim()))) flat[k.toLowerCase()] = Number(x.trim())
|
|
611
|
+
else collect(x, depth + 1)
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
collect(v, 0)
|
|
615
|
+
const pick = (keys) => {
|
|
616
|
+
for (const k of keys) if (typeof flat[k] === "number") return flat[k]
|
|
617
|
+
return NaN
|
|
618
|
+
}
|
|
619
|
+
const total = pick(["total", "totalcredits", "totalbalance", "creditstotal", "limit", "amount", "quota"])
|
|
620
|
+
const used = pick(["used", "usedcredits", "usedamount", "spent", "consumed", "usagetotal"])
|
|
621
|
+
const remaining = pick(["remaining", "remainingcredits", "remainingbalance", "left", "available", "balance", "credits", "current"])
|
|
622
|
+
const w = { name: "Credits", used: 0 }
|
|
623
|
+
if (Number.isFinite(total) && total > 0) {
|
|
624
|
+
if (Number.isFinite(used)) {
|
|
625
|
+
w.used = (100 * used) / total
|
|
626
|
+
w.display = `${compactNumber(used)} / ${compactNumber(total)}`
|
|
627
|
+
} else if (Number.isFinite(remaining) && remaining <= total) {
|
|
628
|
+
w.used = (100 * (total - remaining)) / total
|
|
629
|
+
w.display = `${compactNumber(total - remaining)} / ${compactNumber(total)}`
|
|
630
|
+
} else return null
|
|
631
|
+
} else if (Number.isFinite(remaining) && remaining > 0) {
|
|
632
|
+
w.display = `${compactNumber(remaining)} credits left`
|
|
633
|
+
} else if (Number.isFinite(used) && used > 0) {
|
|
634
|
+
w.used = used
|
|
635
|
+
w.display = `${compactNumber(used)} credits used`
|
|
636
|
+
} else return null
|
|
637
|
+
const at = timeOf(v, ["resetAt", "resetTime", "nextResetAt", "renewsAt", "expiresAt"])
|
|
638
|
+
if (at) w.resetsAt = at
|
|
639
|
+
return w
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function usageOf(me, balance) {
|
|
643
|
+
const out = { windows: [] }
|
|
644
|
+
const plan = firstOf(me?.plan, me?.planType, me?.planName, me?.membership, me?.tier)
|
|
645
|
+
if (plan) out.plan = plan
|
|
646
|
+
const w = balanceWindow(balance) ?? balanceOf(balance)
|
|
647
|
+
if (w) out.windows.push(w)
|
|
648
|
+
return out
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// ---- the plugin --------------------------------------------------------------------
|
|
652
|
+
|
|
653
|
+
export const ClinePlugin = async ({ client } = {}) => {
|
|
654
|
+
// the accounts fresh renewed this run: the sign-in mark comes off on the
|
|
655
|
+
// renewed token, whatever the request then met
|
|
656
|
+
const renewals = new WeakSet()
|
|
657
|
+
const renewed = (cred) => renewals.has(cred)
|
|
658
|
+
// the rotated pair, kept in memory beside what magpie saved: a save through
|
|
659
|
+
// client.auth.set may fail, and the next request must not spend a refresh
|
|
660
|
+
// token that was already spent (kiro's held). Every token this run has spent
|
|
661
|
+
// is kept, not only the last one — a store that failed once usually goes on
|
|
662
|
+
// failing, so getAuth keeps handing back the first of them — and each token
|
|
663
|
+
// is kept against the pair its own rotation chain reached, since one plugin
|
|
664
|
+
// instance serves every account of the provider at once: one newest pair
|
|
665
|
+
// shared by all of them would send an account on another account's token
|
|
666
|
+
const held = new Map() // a spent refresh token → its chain's newest pair
|
|
667
|
+
|
|
668
|
+
// remember saves a refreshed pair where magpie keeps it
|
|
669
|
+
const remember = async (next) => {
|
|
670
|
+
try {
|
|
671
|
+
await client?.auth?.set?.({
|
|
672
|
+
path: { id: PROVIDER },
|
|
673
|
+
body: {
|
|
674
|
+
type: "oauth",
|
|
675
|
+
access: next.access,
|
|
676
|
+
refresh: next.refresh,
|
|
677
|
+
expires: next.expires,
|
|
678
|
+
uid: next.uid ?? "",
|
|
679
|
+
email: next.email ?? "",
|
|
680
|
+
name: next.name ?? "",
|
|
681
|
+
accountId: next.accountId ?? "",
|
|
682
|
+
},
|
|
683
|
+
})
|
|
684
|
+
} catch {}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// refreshes are serialized: the refresh token is single-use, and two
|
|
688
|
+
// requests renewing at once spend one and lose the other
|
|
689
|
+
let lock = Promise.resolve()
|
|
690
|
+
const locked = (fn) => {
|
|
691
|
+
const run = lock.then(fn, fn)
|
|
692
|
+
lock = run.catch(() => {})
|
|
693
|
+
return run
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// spend trades r's refresh token for a new pair and holds it, whatever a
|
|
697
|
+
// save then does, against the token it spent and the one getAuth handed
|
|
698
|
+
// back (a's): the next request offering either of them finds this chain's
|
|
699
|
+
// newest. Called under the lock only.
|
|
700
|
+
const spend = async (a, r) => {
|
|
701
|
+
const p = await refresh(r)
|
|
702
|
+
const next = { ...r, access: p.access, refresh: p.refresh, expires: p.expires, uid: p.uid || r.uid, email: p.email || r.email, name: p.name || r.name, accountId: r.accountId || p.accountId }
|
|
703
|
+
const chain = held.get(a.refresh) ?? {}
|
|
704
|
+
Object.assign(chain, { access: next.access, refresh: next.refresh, expires: next.expires })
|
|
705
|
+
held.set(a.refresh, chain)
|
|
706
|
+
held.set(r.refresh, chain)
|
|
707
|
+
return next
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// fresh is the account with a token still good: one near its end is
|
|
711
|
+
// renewed, and the rotated pair saved back. A transient refresh failure
|
|
712
|
+
// keeps the token that is still good, as Cline's own client does; only a
|
|
713
|
+
// token that has actually expired is the sign-in gone.
|
|
714
|
+
const fresh = (getAuth) =>
|
|
715
|
+
locked(async () => {
|
|
716
|
+
const a = authOf(await getAuth())
|
|
717
|
+
if (a.type === "api") {
|
|
718
|
+
if (!a.key) throw new Lapsed("Cline: no API key; add one to the account")
|
|
719
|
+
return { bearer: a.key, renewed: false, accountId: a.accountId }
|
|
720
|
+
}
|
|
721
|
+
if (!a.access && !a.refresh) throw new Lapsed("Cline: not signed in")
|
|
722
|
+
if (a.access && a.expires - Date.now() > REFRESH_LEAD) return { ...a, bearer: bearerOf(a), renewed: false }
|
|
723
|
+
// a pair this run already rotated is newer than the one getAuth
|
|
724
|
+
// still hands back: use it rather than re-spending a spent token
|
|
725
|
+
const h = held.get(a.refresh)
|
|
726
|
+
const r = h ? { ...a, access: h.access, refresh: h.refresh, expires: h.expires } : a
|
|
727
|
+
if (r.access && r.expires - Date.now() > REFRESH_LEAD) return { ...r, bearer: bearerOf(r), renewed: false }
|
|
728
|
+
if (!r.refresh) throw new Lapsed("Cline's access token has expired and there is no refresh token; sign in to Cline again")
|
|
729
|
+
let next
|
|
730
|
+
try {
|
|
731
|
+
next = await spend(a, r)
|
|
732
|
+
} catch (e) {
|
|
733
|
+
// a token that is still good rides on through a refresh that
|
|
734
|
+
// failed for a while; an expired one is the sign-in gone
|
|
735
|
+
if (!e?.expired && r.access && r.expires - Date.now() > RETRYABLE_GRACE) return { ...r, bearer: bearerOf(r), renewed: false }
|
|
736
|
+
throw e
|
|
737
|
+
}
|
|
738
|
+
await remember(next)
|
|
739
|
+
const cred = { ...next, bearer: bearerOf(next), renewed: true }
|
|
740
|
+
renewals.add(cred)
|
|
741
|
+
return cred
|
|
742
|
+
})
|
|
743
|
+
|
|
744
|
+
const signedInError = (e) =>
|
|
745
|
+
errorResponse({
|
|
746
|
+
status: e?.expired ? 401 : 502,
|
|
747
|
+
message: String(e?.message ?? e).replace(/^Cline: /, ""),
|
|
748
|
+
signIn: e?.expired ? "expired" : undefined,
|
|
749
|
+
})
|
|
750
|
+
|
|
751
|
+
// usage is the account's credit balance, magpie's own hook. A read the
|
|
752
|
+
// account's token couldn't make is the sign-in, not the read.
|
|
753
|
+
const usage = async (getAuth) => {
|
|
754
|
+
try {
|
|
755
|
+
const cred = await fresh(getAuth)
|
|
756
|
+
const authz = { Authorization: `Bearer ${cred.bearer}` }
|
|
757
|
+
const me = await clineApi(`${API}/users/me`, { headers: authz })
|
|
758
|
+
const ids = [me?.clineUserId, me?.subject, me?.id, cred.uid].filter((v) => typeof v === "string" && v.trim())
|
|
759
|
+
let balance = null
|
|
760
|
+
let lastErr = null
|
|
761
|
+
for (const id of ids) {
|
|
762
|
+
try {
|
|
763
|
+
balance = await clineApi(`${API}/users/${encodeURIComponent(id)}/balance`, { headers: authz })
|
|
764
|
+
break
|
|
765
|
+
} catch (e) {
|
|
766
|
+
lastErr = e
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
if (!balance) throw lastErr ?? new Error("Cline usage: no user id")
|
|
770
|
+
return { ...usageOf(me, balance), user: firstOf(cred.accountId, cred.email, cred.uid, ids[0]), signIn: renewed(cred) ? "renewed" : "kept" }
|
|
771
|
+
} catch (e) {
|
|
772
|
+
return { windows: [], error: e?.message ?? String(e), signIn: e?.expired ? "expired" : "kept" }
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
return {
|
|
777
|
+
config: async (cfg) => {
|
|
778
|
+
cfg.provider ??= {}
|
|
779
|
+
const was = cfg.provider[PROVIDER] ?? {}
|
|
780
|
+
cfg.provider[PROVIDER] = {
|
|
781
|
+
name: "Cline",
|
|
782
|
+
npm: "@ai-sdk/openai-compatible",
|
|
783
|
+
api: API,
|
|
784
|
+
...was,
|
|
785
|
+
models: { ...Object.fromEntries(DEFAULT_MODELS.map((m) => [m.id, configModel(m)])), ...(was.models ?? {}) },
|
|
786
|
+
}
|
|
787
|
+
},
|
|
788
|
+
|
|
789
|
+
auth: {
|
|
790
|
+
provider: PROVIDER,
|
|
791
|
+
|
|
792
|
+
// magpie renews the sign-in RENEW_LEAD before its end, once for the
|
|
793
|
+
// account, before its requests, models and usage ask for it; the
|
|
794
|
+
// check in fresh before each request stays for OpenCode, which
|
|
795
|
+
// doesn't call this. It takes the same lock and the same held pairs
|
|
796
|
+
// as fresh, so the two never spend one refresh token twice. magpie
|
|
797
|
+
// saves what this gives (it is merged over the sign-in): only what
|
|
798
|
+
// changed comes back, and remember isn't called here.
|
|
799
|
+
refreshLead: RENEW_LEAD,
|
|
800
|
+
refresh: (auth) =>
|
|
801
|
+
locked(async () => {
|
|
802
|
+
const a = authOf(auth)
|
|
803
|
+
// an API key, or a sign-in with nothing to renew with
|
|
804
|
+
if (auth?.type !== "oauth" || !a.refresh) return undefined
|
|
805
|
+
// renewed here already (a request's refresh), the store not
|
|
806
|
+
// yet saying so: that pair, not another spend
|
|
807
|
+
const h = held.get(a.refresh)
|
|
808
|
+
const r = h ? { ...a, access: h.access, refresh: h.refresh, expires: h.expires } : a
|
|
809
|
+
if (h && r.access && r.expires - Date.now() > RENEW_LEAD) return { access: r.access, refresh: r.refresh, expires: r.expires }
|
|
810
|
+
let next
|
|
811
|
+
try {
|
|
812
|
+
next = await spend(a, r)
|
|
813
|
+
} catch (e) {
|
|
814
|
+
// a refused refresh is the sign-in gone; anything else is
|
|
815
|
+
// retried by magpie, the token in hand riding on meanwhile
|
|
816
|
+
if (e?.expired) throw Object.assign(new Error(String(e.message ?? e).replace(/^Cline: /, "")), { signIn: "expired" })
|
|
817
|
+
throw e
|
|
818
|
+
}
|
|
819
|
+
const out = { access: next.access, refresh: next.refresh, expires: next.expires }
|
|
820
|
+
for (const k of ["uid", "email", "name", "accountId"]) if (next[k] && next[k] !== a[k]) out[k] = next[k]
|
|
821
|
+
return out
|
|
822
|
+
}),
|
|
823
|
+
|
|
824
|
+
async loader(getAuth) {
|
|
825
|
+
const a = await getAuth()
|
|
826
|
+
if (a?.type !== "oauth" && a?.type !== "api") return {}
|
|
827
|
+
return {
|
|
828
|
+
baseURL: API,
|
|
829
|
+
apiKey: "cline",
|
|
830
|
+
// every chat completion as Cline's gateway takes it: OpenAI's
|
|
831
|
+
// format, the account's token, Cline's own headers
|
|
832
|
+
async fetch(input, init = {}) {
|
|
833
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
|
|
834
|
+
if (!/\/chat\/completions$/.test(new URL(url).pathname))
|
|
835
|
+
return errorResponse({ status: 404, message: "only chat completions are served" })
|
|
836
|
+
let body
|
|
837
|
+
try {
|
|
838
|
+
body = await bodyText(input, init)
|
|
839
|
+
const chat = JSON.parse(body)
|
|
840
|
+
if (!chat || typeof chat !== "object" || Array.isArray(chat)) throw new Error("not a chat completion")
|
|
841
|
+
} catch {
|
|
842
|
+
return errorResponse({ status: 400, message: "a request that isn't a chat completion" })
|
|
843
|
+
}
|
|
844
|
+
let cred
|
|
845
|
+
try {
|
|
846
|
+
cred = await fresh(getAuth)
|
|
847
|
+
} catch (e) {
|
|
848
|
+
return signedInError(e)
|
|
849
|
+
}
|
|
850
|
+
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined))
|
|
851
|
+
headers.set("Authorization", `Bearer ${cred.bearer}`)
|
|
852
|
+
// the request says who it comes from, headers down, exactly as
|
|
853
|
+
// Cline's own client does — and rides its own task id
|
|
854
|
+
for (const [k, v] of Object.entries(clientHeaders(randomUUID()))) headers.set(k, v)
|
|
855
|
+
headers.delete("content-length")
|
|
856
|
+
headers.delete("host")
|
|
857
|
+
let res
|
|
858
|
+
try {
|
|
859
|
+
res = await fetch(url, { ...init, method: init?.method ?? "POST", headers, body, signal: init?.signal ?? (input instanceof Request ? input.signal : undefined) })
|
|
860
|
+
} catch (e) {
|
|
861
|
+
return signed(errorResponse({ status: 502, message: String(e?.message ?? e) }), renewed(cred))
|
|
862
|
+
}
|
|
863
|
+
// a renewed token cleared the lapse mark whatever the
|
|
864
|
+
// request then met, a refused one included (qoder's
|
|
865
|
+
// renewed()): the failure is reported, the mark stays off
|
|
866
|
+
if (!res.ok) return signed(errorResponse(failure(res.status, (await res.text()).slice(0, 1 << 20))), renewed(cred))
|
|
867
|
+
return signed(await unwrapped(res), renewed(cred))
|
|
868
|
+
},
|
|
869
|
+
}
|
|
870
|
+
},
|
|
871
|
+
|
|
872
|
+
usage,
|
|
873
|
+
|
|
874
|
+
methods: [
|
|
875
|
+
{ type: "oauth", label: "Sign in to Cline (browser)", authorize: deviceSignIn },
|
|
876
|
+
{ type: "api", label: "Cline API key (app.cline.bot → Settings → API Keys)" },
|
|
877
|
+
],
|
|
878
|
+
},
|
|
879
|
+
|
|
880
|
+
provider: {
|
|
881
|
+
id: PROVIDER,
|
|
882
|
+
async models(provider) {
|
|
883
|
+
const have = provider?.models ?? {}
|
|
884
|
+
try {
|
|
885
|
+
const ms = await fetchFeed()
|
|
886
|
+
return Object.fromEntries(ms.map((m) => [m.id, runtimeModel(m)]))
|
|
887
|
+
} catch {
|
|
888
|
+
// a list Cline couldn't give is a failure, not the few
|
|
889
|
+
// configured: magpie keeps the list it had
|
|
890
|
+
return have
|
|
891
|
+
}
|
|
892
|
+
},
|
|
893
|
+
},
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// for tests
|
|
898
|
+
export const _internal = {
|
|
899
|
+
firstOf,
|
|
900
|
+
compactNumber,
|
|
901
|
+
toMs,
|
|
902
|
+
timeOf,
|
|
903
|
+
authOf,
|
|
904
|
+
bearerOf,
|
|
905
|
+
parseAuth,
|
|
906
|
+
parseFeed,
|
|
907
|
+
prettify,
|
|
908
|
+
fetchFeed,
|
|
909
|
+
fetchRecommended,
|
|
910
|
+
fetchCloudModels,
|
|
911
|
+
balanceOf,
|
|
912
|
+
balanceWindow,
|
|
913
|
+
usd,
|
|
914
|
+
usageOf,
|
|
915
|
+
failure,
|
|
916
|
+
errorResponse,
|
|
917
|
+
bodyText,
|
|
918
|
+
unwrapped,
|
|
919
|
+
clientHeaders,
|
|
920
|
+
deviceAuthorize,
|
|
921
|
+
pollDevice,
|
|
922
|
+
refresh,
|
|
923
|
+
constants: { PROVIDER, API, WORKOS_CLIENT, WORKOS_PREFIX, CLIENT, DEFAULT_MODELS },
|
|
924
|
+
errors: { Lapsed },
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// The shape the plugin host looks for: default {id, server}. The host takes
|
|
928
|
+
// this over the named exports, so also exporting ClinePlugin by name (as the
|
|
929
|
+
// tests import it) changes nothing the host does.
|
|
930
|
+
export default { id: PROVIDER, server: ClinePlugin }
|
package/package.json
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@magpie-community/opencode-cline-auth",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Cline (cline.bot) on your own account — usage billing, ClinePass and the free models — in OpenCode and magpie",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./index.mjs",
|
|
7
|
+
"exports": "./index.mjs",
|
|
8
|
+
"files": ["index.mjs", "README.md"],
|
|
9
|
+
"keywords": ["opencode", "opencode-plugin", "magpie", "cline", "clinepass"],
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"repository": { "type": "git", "url": "git+https://github.com/magpie-community/plugins.git", "directory": "packages/cline" }
|
|
12
|
+
}
|