@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.
Files changed (3) hide show
  1. package/README.md +77 -2
  2. package/index.mjs +930 -0
  3. package/package.json +10 -4
package/README.md CHANGED
@@ -1,3 +1,78 @@
1
- # Temporary Holding Version
1
+ # @magpie-community/opencode-cline-auth
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }