@magpie-community/opencode-zcode-auth 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +64 -0
  2. package/index.mjs +711 -0
  3. package/package.json +16 -0
package/README.md ADDED
@@ -0,0 +1,64 @@
1
+ # @magpie-community/opencode-zcode-auth
2
+
3
+ Signs in to Z.ai's GLM Coding Plan, or BigModel's (智谱). It does this the
4
+ way [ZCode](https://zcode.z.ai) does, and serves the plan's GLM models
5
+ over Anthropic's Messages API. Provider id: `zcode`.
6
+
7
+ ## Signing in
8
+
9
+ - **ZCode: Z.ai GLM Coding Plan** and **ZCode: BigModel (智谱) GLM Coding Plan**
10
+ - zcode.z.ai opens a sign-in flow, and the browser signs in to Z.ai or to
11
+ BigModel. The plugin polls the flow until it is done, so there is
12
+ nothing to paste.
13
+ - With that sign-in it takes the key named `zcode-api-key` from the
14
+ account's default project, or makes it if it isn't there, as ZCode does.
15
+ Requests go to `api.z.ai/api/anthropic` or
16
+ `open.bigmodel.cn/api/anthropic`.
17
+ - If the account has no plan of its own, it looks for a seat on a team's
18
+ plan and uses that team project's `zcode-team-api-key`.
19
+ - Failing both, it uses ZCode's free **Start Plan**, served by zcode.z.ai
20
+ with ZCode's session token and ZCode's own headers.
21
+ - The Start Plan's token can't be refreshed. When it runs out, sign in
22
+ again.
23
+ - **GLM Coding Plan API key**
24
+ - Paste a key (`<id>.<secret>`) and say which site it is from.
25
+
26
+ ## Where the sign-in is kept
27
+
28
+ In OpenCode's `auth.json` (magpie: `plugin-auth.json`), under `zcode`:
29
+
30
+ - **The browser sign-ins** are kept as an `oauth` entry:
31
+ - `access` is the key.
32
+ - `refresh` is JSON holding the site, the endpoint, the key, ZCode's
33
+ token, the team project, the plan and a device id of this plugin's own.
34
+ - **A pasted key** is kept as an `api` entry, with the site in `metadata`.
35
+
36
+ The plugin never reads or writes ZCode's own credential store.
37
+
38
+ ## How requests are routed
39
+
40
+ Before each request, the plugin checks which plan the account is on, and
41
+ asks again every 10 minutes:
42
+
43
+ - If the key's account has a GLM Coding Plan
44
+ (`/api/biz/subscription/list`), requests go to that plan.
45
+ - Otherwise they go to the Start Plan.
46
+
47
+ Every request carries the key as both `x-api-key` and
48
+ `Authorization: Bearer`.
49
+
50
+ ## Models
51
+
52
+ The models are the ones ZCode offers for the account's plan. They come
53
+ from ZCode's provider config: the release zcode.z.ai names, or an
54
+ installed ZCode's copy if that one is newer. The list is kept for 10
55
+ minutes. When the config can't be read, the plugin falls back to this list:
56
+
57
+ | Model | Context | Output | Efforts |
58
+ |---|---|---|---|
59
+ | GLM-5.3 | 1M | 128K | low, high, max |
60
+ | GLM-5.3-Flash | 1M | 128K | low, high, max |
61
+ | GLM-5.2 | 1M | 128K | none, high, max |
62
+ | GLM-5-Turbo | 200K | 64K | none, high |
63
+
64
+ The Start Plan has all of these except GLM-5.3.
package/index.mjs ADDED
@@ -0,0 +1,711 @@
1
+ // ZCode's GLM Coding Plan, as an OpenCode provider plugin.
2
+ //
3
+ // ZCode (zcode.z.ai) signs in to a Z.ai account, or a BigModel (智谱,
4
+ // bigmodel.cn) one, through a flow zcode.z.ai opens and is polled for. With
5
+ // the account's sign-in it finds or makes the coding plan key it uses (the
6
+ // key named zcode-api-key in the account's default project) and sends the
7
+ // models' requests, Anthropic's Messages, to the plan's endpoint:
8
+ // api.z.ai/api/anthropic or open.bigmodel.cn/api/anthropic.
9
+ //
10
+ // An account with no plan of its own may have a seat on a team's plan (a
11
+ // project of type 2, and its zcode-team-api-key), or ZCode's free Start
12
+ // Plan, served by zcode.z.ai itself to ZCode's own session token (a JWT
13
+ // that can't be refreshed: when it runs out, sign in again).
14
+ //
15
+ // The sign-in is kept as OpenCode keeps an OAuth one: `access` is the key,
16
+ // `refresh` the rest of it as JSON (site, base, key, jwt, team project,
17
+ // plan, device id), `accountId` the account's email.
18
+
19
+ import { existsSync, readFileSync, readdirSync } from "node:fs"
20
+ import { homedir } from "node:os"
21
+ import { join } from "node:path"
22
+
23
+ const PROVIDER = "zcode"
24
+ const ZCODE = "https://zcode.z.ai"
25
+ const ZAI_API = "https://api.z.ai"
26
+ const BIGMODEL_API = "https://bigmodel.cn" // BigModel's business API, where ZCode asks it
27
+ const ZAI_BASE = "https://api.z.ai/api/anthropic"
28
+ const BIGMODEL_BASE = "https://open.bigmodel.cn/api/anthropic"
29
+ const START_BASE = ZCODE + "/api/v1/zcode-plan/anthropic"
30
+ const APP_VERSION = "3.14.3" // the ZCode whose sign-in this makes
31
+ const UA = "ZCode/" + APP_VERSION
32
+
33
+ // ZCode's models before its config is read; the Start Plan has all but GLM-5.3.
34
+ const MODELS = [
35
+ { id: "GLM-5.3", context: 1_000_000, output: 128_000, efforts: ["low", "high", "max"] },
36
+ { id: "GLM-5.3-Flash", context: 1_000_000, output: 128_000, efforts: ["low", "high", "max"] },
37
+ { id: "GLM-5.2", context: 1_000_000, output: 128_000, efforts: ["none", "high", "max"] },
38
+ { id: "GLM-5-Turbo", context: 200_000, output: 64_000, efforts: ["none", "high"] },
39
+ ]
40
+ const START_MODELS = MODELS.filter((m) => m.id !== "GLM-5.3")
41
+ const OUTPUT = 131_072 // when ZCode's config names none
42
+
43
+ const SITES = {
44
+ zai: { name: "Z.ai", api: ZAI_API, base: ZAI_BASE, subscribe: "z.ai/subscribe" },
45
+ bigmodel: { name: "BigModel", api: BIGMODEL_API, base: BIGMODEL_BASE, subscribe: "bigmodel.cn/glm-coding" },
46
+ }
47
+ const siteOf = (s) => (s === "bigmodel" ? "bigmodel" : "zai")
48
+
49
+ const platform = () => `${process.platform}-${process.arch}` // darwin-arm64, win32-x64, linux-x64, as ZCode names them
50
+ const uuid = () => crypto.randomUUID()
51
+ const anyDevice = uuid() // X-Device-Mid for calls made signed out (the model list)
52
+ const first = (...ss) => ss.map((s) => (typeof s === "string" ? s.trim() : "")).find((s) => s) ?? ""
53
+
54
+ class ZError extends Error {
55
+ constructor(message, status) {
56
+ super(message)
57
+ this.status = status
58
+ }
59
+ }
60
+
61
+ // call asks one of Z.ai's JSON endpoints, which wrap what they say in
62
+ // {code, msg, data}: a code of 0 or 200 is a success. zcode.z.ai is told
63
+ // the machine's id (X-Device-Mid), as ZCode tells it.
64
+ async function call(method, url, { auth, body, headers, device, signal } = {}) {
65
+ const h = { "Content-Type": "application/json", Accept: "application/json", "User-Agent": UA }
66
+ if (auth) h.Authorization = auth
67
+ if (url.startsWith(ZCODE + "/")) h["X-Device-Mid"] = device || anyDevice
68
+ Object.assign(h, headers ?? {})
69
+ const res = await fetch(url, { method, headers: h, body: body === undefined ? undefined : JSON.stringify(body), signal })
70
+ const text = await res.text()
71
+ let env
72
+ try {
73
+ env = JSON.parse(text)
74
+ } catch {}
75
+ const code = env?.code == null ? "" : String(env.code)
76
+ if (!res.ok) {
77
+ if (env?.msg) throw new ZError(code && code !== "0" ? `${env.msg} (${res.status}, code ${code})` : `${env.msg} (${res.status})`, res.status)
78
+ throw new ZError(`${res.status} ${res.statusText}`.trim(), res.status)
79
+ }
80
+ if (!env || typeof env !== "object") throw new ZError("not JSON", res.status)
81
+ if (code && code !== "0" && code !== "200") throw new ZError(env.msg || `error ${code}`, res.status)
82
+ return env.data ?? null
83
+ }
84
+
85
+ // ---- the account's plan ----------------------------------------------------------
86
+
87
+ const rootOf = (base) => {
88
+ try {
89
+ const u = new URL(base)
90
+ return `${u.protocol}//${u.host}`
91
+ } catch {
92
+ return ZAI_API
93
+ }
94
+ }
95
+ const isTeam = (s) => !!(s.org && s.project)
96
+
97
+ // plan names the coding plan a key has, "" for none.
98
+ async function plan(s) {
99
+ const subs = (await call("GET", rootOf(s.base) + "/api/biz/subscription/list", { auth: s.key })) ?? []
100
+ return (Array.isArray(subs) ? subs : []).find((x) => String(x.status).toUpperCase() === "VALID")?.productName ?? ""
101
+ }
102
+
103
+ function jwtExpiry(jwt) {
104
+ const p = String(jwt ?? "").split(".")
105
+ if (p.length !== 3) return 0
106
+ try {
107
+ const claims = JSON.parse(Buffer.from(p[1], "base64url").toString("utf8"))
108
+ return Number(claims.exp) > 0 ? Number(claims.exp) * 1000 : 0
109
+ } catch {
110
+ return 0
111
+ }
112
+ }
113
+ const jwtExpired = (jwt) => {
114
+ const t = jwtExpiry(jwt)
115
+ return t > 0 && Date.now() > t
116
+ }
117
+ const EXPIRED = "ZCode's sign-in has expired; sign in again"
118
+
119
+ const num = (v) => {
120
+ const n = typeof v === "string" ? parseFloat(v) : v
121
+ return typeof n === "number" && Number.isFinite(n) ? n : undefined
122
+ }
123
+
124
+ // startPlan is the Start Plan the account has now ({name, until}), as
125
+ // ZCode reads its balance: an "active" plan past its end is over.
126
+ async function startPlan(jwt, device) {
127
+ if (!jwt) throw new Error("not signed in to ZCode")
128
+ if (jwtExpired(jwt)) throw new Error(EXPIRED)
129
+ const b = (await call("GET", `${ZCODE}/api/v1/zcode-plan/billing/balance?app_version=${APP_VERSION}`, { auth: "Bearer " + jwt, device })) ?? {}
130
+ const now = num(b.server_time) > 0 ? num(b.server_time) : Date.now() / 1000
131
+ const isStart = (x) => x.includes("start-plan") || x.includes("start plan")
132
+ for (const p of b.plans ?? []) {
133
+ if (String(p.status ?? "").trim().toLowerCase() !== "active") continue
134
+ const end = num(p.ends_at)
135
+ if (end > 0 && end <= now) continue
136
+ const id = String(p.plan_id ?? "").trim().toLowerCase()
137
+ const n = String(p.name ?? "").trim().toLowerCase()
138
+ if ((id || n) && !isStart(id) && !isStart(n)) continue
139
+ return { name: first(p.name, "Start Plan"), until: end > 0 ? end * 1000 : 0 }
140
+ }
141
+ return null
142
+ }
143
+
144
+ // onStart says whether an account's requests go to the Start Plan: when it
145
+ // has ZCode's token and no coding plan key, or a key whose account has no
146
+ // coding plan. Asked again after 10 minutes (a minute when unsure).
147
+ const routes = new Map()
148
+ async function onStart(s) {
149
+ if (!s.jwt || isTeam(s)) return false // a team's seat is on the team's plan
150
+ if (!s.key) return true
151
+ const id = s.key + "\0" + s.jwt
152
+ const r = routes.get(id)
153
+ if (r && Date.now() - r.at < r.ttl) return r.start
154
+ let start = false
155
+ let sure = false
156
+ try {
157
+ start = !(await plan(s))
158
+ sure = true
159
+ } catch {
160
+ try {
161
+ if (await startPlan(s.jwt, s.device)) start = sure = true
162
+ } catch {}
163
+ }
164
+ routes.set(id, { start, at: Date.now(), ttl: sure ? 600_000 : 60_000 })
165
+ return start
166
+ }
167
+
168
+ // ---- keys ------------------------------------------------------------------------
169
+
170
+ function teamHeaders(base, org, project) {
171
+ return {
172
+ "Bigmodel-Organization": org,
173
+ "Bigmodel-Project": project,
174
+ "Set-Language": base.includes("bigmodel.cn") ? "zh" : "en",
175
+ "Accept-Language": "en-US,en",
176
+ }
177
+ }
178
+
179
+ const keysURL = (root, org, project) =>
180
+ `${root}/api/biz/v1/organization/${encodeURIComponent(org)}/projects/${encodeURIComponent(project)}/api_keys`
181
+
182
+ // projectKey finds the project's key named want.name (of want.keyType, when
183
+ // given), makes it when it isn't there and reads its secret: `<id>.<secret>`.
184
+ async function projectKey(site, keys, auth, headers, want) {
185
+ const name = SITES[site].name
186
+ let list
187
+ try {
188
+ list = (await call("GET", keys, { auth, headers })) ?? []
189
+ } catch (e) {
190
+ throw new Error(`${name} API keys: ${e.message}`)
191
+ }
192
+ const typed = "keyType" in want
193
+ let id = ""
194
+ for (const k of Array.isArray(list) ? list : []) {
195
+ if (k.name === want.name && (!typed || String(k.keyType) === String(want.keyType)) && first(k.apiKey)) id = first(k.apiKey)
196
+ }
197
+ if (!id) {
198
+ try {
199
+ id = first((await call("POST", keys, { auth, headers, body: want }))?.apiKey)
200
+ } catch (e) {
201
+ throw new Error(`${name} API key: ${e.message}`)
202
+ }
203
+ }
204
+ let secret = ""
205
+ if (id) {
206
+ try {
207
+ secret = first((await call("GET", `${keys}/copy/${encodeURIComponent(id)}`, { auth, headers }))?.secretKey)
208
+ } catch (e) {
209
+ throw new Error(`${name} API key: ${e.message}`)
210
+ }
211
+ }
212
+ if (id && !secret && typed) return id // a team's key with no secret goes as it is
213
+ if (!id || !secret) throw new Error(`${name} gave no API key`)
214
+ return `${id}.${secret}`
215
+ }
216
+
217
+ // teamKey is a team seat's key when the sign-in kept none.
218
+ const teamKeys = new Map()
219
+ async function teamKey(s) {
220
+ if (s.key) return s.key
221
+ const id = [s.token, s.org, s.project].join("\0")
222
+ const got = teamKeys.get(id)
223
+ if (got && (!got.err || Date.now() - got.at < 60_000)) {
224
+ if (got.err) throw got.err
225
+ return got.key
226
+ }
227
+ const site = s.base.includes("bigmodel.cn") ? "bigmodel" : "zai"
228
+ try {
229
+ const key = await projectKey(site, keysURL(SITES[site].api, s.org, s.project), s.token, teamHeaders(s.base, s.org, s.project), { name: "zcode-team-api-key", keyType: 2 })
230
+ teamKeys.set(id, { key, at: Date.now() })
231
+ return key
232
+ } catch (e) {
233
+ const err = new Error(`ZCode's team plan: ${e.message} — sign in again`)
234
+ teamKeys.set(id, { err, at: Date.now() })
235
+ throw err
236
+ }
237
+ }
238
+
239
+ // ---- signing in ------------------------------------------------------------------
240
+
241
+ // bizAuth is the Authorization a sign-in gives the business API: Z.ai's
242
+ // token exchanged for a business one, as a Bearer; BigModel's goes bare.
243
+ async function bizAuth(site, token) {
244
+ if (site === "bigmodel") return token
245
+ let biz
246
+ try {
247
+ biz = await call("POST", ZAI_API + "/api/auth/z/login", { body: { token } })
248
+ } catch (e) {
249
+ throw new Error(`Z.ai sign-in: ${e.message}`)
250
+ }
251
+ if (!biz?.access_token) throw new Error("Z.ai sign-in: no token")
252
+ return "Bearer " + biz.access_token
253
+ }
254
+
255
+ // mintKey is the account's own coding plan key, in its default project
256
+ // ("默认机构" / "默认项目", else the first), and the plan it has.
257
+ async function mintKey(site, root, auth, info) {
258
+ let org = ""
259
+ let proj = ""
260
+ for (const o of info?.organizations ?? []) {
261
+ const ps = (o.projects ?? []).filter((p) => p.projectId && String(p.projectType) !== "2")
262
+ if (!o.organizationId || !ps.length) continue
263
+ const def = (ps.find((p) => String(p.projectName ?? "").includes("默认项目")) ?? ps[0]).projectId
264
+ const named = String(o.organizationName ?? "").includes("默认机构")
265
+ if (!org || named) {
266
+ org = o.organizationId
267
+ proj = def
268
+ if (named) break
269
+ }
270
+ }
271
+ if (!org) throw new Error(`this ${SITES[site].name} account has no project for an API key`)
272
+ const key = await projectKey(site, keysURL(root, org, proj), auth, undefined, { name: "zcode-api-key" })
273
+ const s = { key, base: SITES[site].base }
274
+ try {
275
+ return { s, plan: await plan(s) }
276
+ } catch (e) {
277
+ throw new Error(`GLM Coding Plan: ${e.message}`)
278
+ }
279
+ }
280
+
281
+ // teamSignIn is the account's seat on a team's plan: the first team project
282
+ // whose plan is in force and gives it a seat, and that project's key.
283
+ async function teamSignIn(site, root, auth, info) {
284
+ const base = SITES[site].base
285
+ const ps = []
286
+ for (const o of info?.organizations ?? [])
287
+ for (const p of o.projects ?? []) if (o.organizationId && p.projectId && String(p.projectType) === "2") ps.push({ org: o.organizationId, project: p.projectId })
288
+ if (!ps.length) return { err: "no team plan", none: true }
289
+ let why = ""
290
+ let last = ""
291
+ for (const p of ps) {
292
+ let d
293
+ try {
294
+ d = (await call("GET", root + "/api/biz/team/subscribe/product/querySubscribeDetail", { auth, headers: teamHeaders(base, p.org, p.project) })) ?? {}
295
+ } catch (e) {
296
+ last = e.message
297
+ continue
298
+ }
299
+ const status = String(d.status ?? "").toUpperCase()
300
+ const grant = String(d.memberGrantStatus ?? "").toUpperCase()
301
+ if (!(d.hasSubscription !== false && status === "EFFECTIVE" && grant === "VALID")) {
302
+ if (!why && status === "EFFECTIVE" && grant === "UNASSIGNED")
303
+ why = `this ${SITES[site].name} account is in a team with a GLM Coding Plan but has no seat on it yet — ask the team's admin to give it one, then sign in again`
304
+ if (!why && status === "EXPIRED") why = `the ${SITES[site].name} team's GLM Coding Plan this account is in has expired`
305
+ continue
306
+ }
307
+ let key
308
+ try {
309
+ key = await projectKey(site, keysURL(root, p.org, p.project), auth, teamHeaders(base, p.org, p.project), { name: "zcode-team-api-key", keyType: 2 })
310
+ } catch (e) {
311
+ return { err: `the team's GLM Coding Plan: ${e.message}`, why: `the team's GLM Coding Plan: ${e.message}` }
312
+ }
313
+ return { s: { key, base, token: auth, org: p.org, project: p.project }, plan: first(d.productName, "GLM Coding Team") }
314
+ }
315
+ if (why) return { err: why, why }
316
+ const err = `the team's GLM Coding Plan: ${last || "no seat on it"}`
317
+ return { err, why: err }
318
+ }
319
+
320
+ // signedIn is what a sign-in gives: the account's coding plan key and plan;
321
+ // or, with none of its own, a seat on a team's plan; or else ZCode's token
322
+ // for its Start Plan while the account has one.
323
+ async function signedIn(site, token, jwt, device) {
324
+ const name = SITES[site].name
325
+ const root = SITES[site].api
326
+ let s = null
327
+ let err = null
328
+ let team = ""
329
+ try {
330
+ const auth = await bizAuth(site, token)
331
+ let info
332
+ try {
333
+ info = await call("GET", root + "/api/biz/customer/getCustomerInfo", { auth })
334
+ } catch (e) {
335
+ throw new Error(`${name} account: ${e.message}`)
336
+ }
337
+ const own = await mintKey(site, root, auth, info).catch((e) => ({ e }))
338
+ if (own.e) err = own.e
339
+ else if (own.plan) return { ...own.s, jwt, plan: own.plan }
340
+ else s = own.s
341
+ const t = await teamSignIn(site, root, auth, info)
342
+ if (t.s) return { ...t.s, jwt, plan: t.plan }
343
+ team = t.why ?? ""
344
+ } catch (e) {
345
+ err = e
346
+ }
347
+ let berr = null
348
+ if (jwt) {
349
+ try {
350
+ const sp = await startPlan(jwt, device)
351
+ if (sp) return { ...(err || !s?.key ? { base: SITES[site].base } : s), jwt, plan: sp.name }
352
+ } catch (e) {
353
+ berr = e
354
+ }
355
+ }
356
+ if (team && berr) throw new Error(`${team}; ZCode's Start Plan: ${berr.message}`)
357
+ if (team) throw new Error(team)
358
+ if (err && berr) throw new Error(`${err.message}; ZCode's Start Plan: ${berr.message}`)
359
+ if (err) throw err
360
+ if (berr) throw new Error(`this ${name} account has no GLM Coding Plan, of its own or a team's, and ZCode's Start Plan could not be read: ${berr.message}`)
361
+ throw new Error(
362
+ `this ${name} account has no GLM Coding Plan, of its own or a team's, and ZCode's Start Plan has ended or was never started — subscribe at ${SITES[site].subscribe}, then sign in again`,
363
+ )
364
+ }
365
+
366
+ const who = (u) => first(String(u?.email ?? "").replace(/@phone\.local$/, ""), u?.name, u?.user_id, "ZCode")
367
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
368
+
369
+ // authorize opens ZCode's sign-in flow on site; its callback polls it.
370
+ async function authorize(site, log) {
371
+ const poll = "Bearer " + Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("hex")
372
+ const device = uuid()
373
+ let flow
374
+ try {
375
+ flow = await call("POST", ZCODE + "/api/v1/oauth/cli/init", { auth: poll, body: { provider: site }, device })
376
+ } catch (e) {
377
+ throw new Error(`ZCode sign-in: ${e.message}`)
378
+ }
379
+ let u
380
+ try {
381
+ u = new URL(flow?.authorize_url)
382
+ } catch {}
383
+ if (!flow?.flow_id || u?.protocol !== "https:") throw new Error("ZCode gave no sign-in page")
384
+ // where Z.ai or BigModel sends the browser back, as ZCode sets it (BigModel's page names it redirect)
385
+ const back = `${ZCODE}/app/oauth/login?` + new URLSearchParams({ redirect: "zcode://oauth/callback", app_version: APP_VERSION })
386
+ u.searchParams.set(site === "bigmodel" ? "redirect" : "redirect_uri", back)
387
+ const interval = Math.max(Number(flow.poll_interval_sec) || 0, 1) * 1000
388
+ const deadline = Number(flow.expires_at) > 0 ? Number(flow.expires_at) * 1000 : Date.now() + 5 * 60_000
389
+ return {
390
+ url: u.toString(),
391
+ instructions: `Sign in to ${SITES[site].name} in the browser; this finishes on its own.`,
392
+ method: "auto",
393
+ async callback() {
394
+ const fail = (msg) => (log(msg), { type: "failed" })
395
+ for (;;) {
396
+ await sleep(interval)
397
+ if (Date.now() > deadline) return fail("the sign-in expired; start again")
398
+ let got
399
+ try {
400
+ got = (await call("GET", `${ZCODE}/api/v1/oauth/cli/poll/${encodeURIComponent(flow.flow_id)}`, { auth: poll, device })) ?? {}
401
+ } catch (e) {
402
+ if (e.status >= 400 && e.status < 500 && e.status !== 408 && e.status !== 429) return fail("ZCode sign-in: " + e.message)
403
+ continue // a hiccup: ask again
404
+ }
405
+ const token = site === "bigmodel" ? first(got.bigmodel?.access_token, got.bigmodel?.accessToken) : first(got.zai?.access_token)
406
+ if (got.status === "pending" || !got.status) continue
407
+ if (got.status === "failed") return fail(`the sign-in was declined on ${SITES[site].name}`)
408
+ if (got.status !== "ready" || !token) return fail("ZCode sign-in: unexpected answer " + got.status)
409
+ let s
410
+ try {
411
+ s = await signedIn(site, token, first(got.token), device)
412
+ } catch (e) {
413
+ return fail(e.message)
414
+ }
415
+ const state = { site, device, ...s }
416
+ return {
417
+ type: "success",
418
+ refresh: JSON.stringify(state),
419
+ access: s.key || s.jwt || "",
420
+ // the key doesn't run out; ZCode's token, when it is all there is, does
421
+ expires: s.key || isTeam(s) ? 0 : jwtExpiry(s.jwt),
422
+ accountId: who(got.user),
423
+ }
424
+ }
425
+ },
426
+ }
427
+ }
428
+
429
+ // stateOf is a stored sign-in as the requests need it.
430
+ function stateOf(auth) {
431
+ if (auth?.type === "oauth") {
432
+ let s = {}
433
+ try {
434
+ s = JSON.parse(auth.refresh)
435
+ } catch {}
436
+ const site = siteOf(s.site)
437
+ return { ...s, site, base: s.base || SITES[site].base, key: s.key ?? (s.jwt ? "" : auth.access), device: s.device || anyDevice }
438
+ }
439
+ if (auth?.type === "api") {
440
+ const site = siteOf(auth.metadata?.site)
441
+ return { site, base: SITES[site].base, key: String(auth.key ?? "").trim(), device: anyDevice }
442
+ }
443
+ return null
444
+ }
445
+
446
+ // ---- models ----------------------------------------------------------------------
447
+
448
+ // planID is ZCode's providerId for the plan served at base; a team's plan
449
+ // has the individual one's models.
450
+ const planID = (base) =>
451
+ base.includes("/zcode-plan/")
452
+ ? "account:zai-start-plan"
453
+ : base.includes("bigmodel.cn")
454
+ ? "account:bigmodel-individual-coding-plan"
455
+ : "account:zai-individual-coding-plan"
456
+
457
+ const efforts = (vs) => [...new Set(vs.map((v) => (v === "disabled" ? "none" : v === "enabled" ? "high" : v)))]
458
+
459
+ // modelsOf is a plan's models in ZCode's provider config.
460
+ function modelsOf(cfg, plan) {
461
+ const c = cfg?.config ?? {}
462
+ const ids = []
463
+ const on = new Set()
464
+ const add = (id) => {
465
+ if (!on.has(id.toLowerCase())) on.add(id.toLowerCase()), ids.push(id)
466
+ }
467
+ for (const r of c.providerConfigRules?.providerRules ?? []) if (r.providerId === plan) (r.builtinModelIds ?? []).forEach(add)
468
+ const off = new Set()
469
+ for (const r of c.modelConfigRules?.builtinProviderModelRules ?? []) {
470
+ if (r.providerId !== plan) continue
471
+ if (r.config?.enabled === false) off.add(String(r.modelId).toLowerCase())
472
+ else add(r.modelId)
473
+ }
474
+ const out = []
475
+ for (const id of ids) {
476
+ if (off.has(id.toLowerCase())) continue
477
+ const m = { id, context: 0, output: 0, efforts: [] }
478
+ for (const r of c.modelConfigRules?.modelRules ?? []) {
479
+ let re
480
+ try {
481
+ re = new RegExp(`^(?:${r.modelMatch})$`, "i")
482
+ } catch {
483
+ continue
484
+ }
485
+ if (!re.test(id)) continue
486
+ const x = r.config ?? {}
487
+ if (x.properties?.contextWindow > 0) m.context = x.properties.contextWindow
488
+ if (x.optionSpecs?.maxOutputTokens?.max > 0) m.output = x.optionSpecs.maxOutputTokens.max
489
+ if (typeof x.properties?.inputFormat?.supportsImage === "boolean") m.image = x.properties.inputFormat.supportsImage
490
+ if (x.optionSpecs?.reasoningLevel?.values?.length) m.efforts = efforts(x.optionSpecs.reasoningLevel.values)
491
+ }
492
+ out.push(m)
493
+ }
494
+ return out
495
+ }
496
+
497
+ // localConfig is the latest provider config an installed ZCode has.
498
+ function localConfig() {
499
+ const files = []
500
+ const rt = join(homedir(), ".zcode", "v2", "runtime", "provider")
501
+ const walk = (dir, depth) => {
502
+ let es = []
503
+ try {
504
+ es = readdirSync(dir, { withFileTypes: true })
505
+ } catch {
506
+ return
507
+ }
508
+ for (const e of es) {
509
+ if (depth < 3 && e.isDirectory()) walk(join(dir, e.name), depth + 1)
510
+ else if (depth === 3 && e.name === "zcode-builtin.json") files.push(join(dir, e.name))
511
+ }
512
+ }
513
+ walk(rt, 0)
514
+ if (process.platform === "darwin") files.push("/Applications/ZCode.app/Contents/Resources/config/provider/zcode-builtin.json")
515
+ let best = null
516
+ for (const f of files) {
517
+ try {
518
+ if (!existsSync(f)) continue
519
+ const b = JSON.parse(readFileSync(f, "utf8"))
520
+ if (!best || (b.revision ?? 0) > (best.revision ?? 0)) best = b
521
+ } catch {}
522
+ }
523
+ return best
524
+ }
525
+
526
+ // remoteConfig is the release of ZCode's provider config zcode.z.ai names now.
527
+ async function remoteConfig() {
528
+ const signal = AbortSignal.timeout(8000)
529
+ const c = await call("GET", `${ZCODE}/api/v1/client/configs?app_version=${APP_VERSION}&platform=${platform()}`, { signal })
530
+ const url = c?.configs?.builtin_provider_config_json
531
+ if (!String(url ?? "").startsWith("https://")) throw new Error("ZCode's configs name no provider config")
532
+ const res = await fetch(url, { headers: { "User-Agent": UA }, signal })
533
+ if (!res.ok) throw new Error(`ZCode's provider config: ${res.status}`)
534
+ return res.json()
535
+ }
536
+
537
+ // config is ZCode's provider config, the later of zcode.z.ai's and an
538
+ // installed ZCode's, kept for 10 minutes.
539
+ let cfgCache = null
540
+ async function zcodeConfig() {
541
+ if (cfgCache && Date.now() - cfgCache.at < 600_000) return cfgCache.cfg
542
+ let cfg = null
543
+ try {
544
+ cfg = await remoteConfig()
545
+ } catch {}
546
+ const local = localConfig()
547
+ if (local && (!cfg || (local.revision ?? 0) > (cfg.revision ?? 0))) cfg = local
548
+ cfgCache = { cfg, at: Date.now() }
549
+ return cfg
550
+ }
551
+
552
+ // variants are a model's reasoning efforts as @ai-sdk/anthropic options.
553
+ const BUDGET = { minimal: 1024, low: 4096, medium: 8192, high: 16_000, max: 31_999 }
554
+ function variants(es) {
555
+ const out = {}
556
+ for (const e of es) out[e] = e === "none" ? { thinking: { type: "disabled" } } : { thinking: { type: "enabled", budgetTokens: BUDGET[e] ?? 16_000 } }
557
+ return out
558
+ }
559
+
560
+ // entry is a model as a config hook declares it.
561
+ const entry = (m) => ({
562
+ id: m.id,
563
+ name: m.id,
564
+ reasoning: m.efforts.some((e) => e !== "none"),
565
+ tool_call: true,
566
+ attachment: !!m.image,
567
+ modalities: { input: m.image ? ["text", "image"] : ["text"], output: ["text"] },
568
+ limit: { context: m.context || 200_000, output: m.output || OUTPUT },
569
+ cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 },
570
+ variants: variants(m.efforts),
571
+ })
572
+
573
+ // model is a model as OpenCode's provider hands it to provider.models.
574
+ function model(m, url) {
575
+ const e = entry(m)
576
+ return {
577
+ id: m.id,
578
+ providerID: PROVIDER,
579
+ name: m.id,
580
+ api: { id: m.id, url, npm: "@ai-sdk/anthropic" },
581
+ status: "active",
582
+ cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
583
+ limit: e.limit,
584
+ options: {},
585
+ headers: {},
586
+ capabilities: {
587
+ temperature: true,
588
+ reasoning: e.reasoning,
589
+ attachment: e.attachment,
590
+ toolcall: true,
591
+ input: { text: true, image: !!m.image, audio: false, video: false, pdf: false },
592
+ output: { text: true, image: false, audio: false, video: false, pdf: false },
593
+ interleaved: false,
594
+ },
595
+ release_date: "",
596
+ variants: e.variants,
597
+ }
598
+ }
599
+
600
+ // ---- the plugin ------------------------------------------------------------------
601
+
602
+ export async function ZCodeAuthPlugin({ client }) {
603
+ const log = (message) => {
604
+ try {
605
+ client?.app?.log?.({ body: { service: "zcode-auth", level: "error", message } })
606
+ } catch {}
607
+ }
608
+ const oauth = (site) => ({
609
+ type: "oauth",
610
+ label: `ZCode: ${SITES[site].name}${site === "bigmodel" ? " (智谱)" : ""} GLM Coding Plan`,
611
+ authorize: () => authorize(site, log),
612
+ })
613
+ return {
614
+ config: async (cfg) => {
615
+ cfg.provider ??= {}
616
+ const was = cfg.provider[PROVIDER] ?? {}
617
+ cfg.provider[PROVIDER] = {
618
+ name: "ZCode",
619
+ npm: "@ai-sdk/anthropic",
620
+ api: ZAI_BASE + "/v1",
621
+ ...was,
622
+ models: { ...Object.fromEntries(MODELS.map((m) => [m.id, entry(m)])), ...(was.models ?? {}) },
623
+ }
624
+ },
625
+ provider: {
626
+ id: PROVIDER,
627
+ // the account's plan's models, as ZCode's config lists them now
628
+ async models(provider, { auth } = {}) {
629
+ const s = stateOf(auth)
630
+ if (!s) return provider.models
631
+ const start = await onStart(s).catch(() => false)
632
+ const base = start ? START_BASE : s.base
633
+ let ms = modelsOf(await zcodeConfig().catch(() => null), planID(base))
634
+ if (!ms.length) ms = start ? START_MODELS : MODELS
635
+ const url = s.base + "/v1"
636
+ return Object.fromEntries(ms.map((m) => [m.id, model(m, url)]))
637
+ },
638
+ },
639
+ auth: {
640
+ provider: PROVIDER,
641
+ async loader(getAuth, provider) {
642
+ const s0 = stateOf(await getAuth())
643
+ if (!s0) return {}
644
+ const id = provider?.id ?? PROVIDER
645
+ return {
646
+ baseURL: s0.base + "/v1",
647
+ apiKey: s0.key || "zcode", // the fetch below puts the real one on
648
+ async fetch(input, init) {
649
+ const auth = await getAuth()
650
+ const s = stateOf(auth)
651
+ if (!s) throw new Error("not signed in to ZCode")
652
+ let url = input instanceof Request ? input.url : String(input)
653
+ const opts = input instanceof Request ? { method: input.method, headers: input.headers, body: input.body, signal: input.signal, duplex: "half", ...init } : { ...init }
654
+ const start = await onStart(s)
655
+ // the request goes to the plan the account is on, wherever it was made for
656
+ for (const b of [START_BASE, ZAI_BASE, BIGMODEL_BASE]) {
657
+ if (url.startsWith(b)) {
658
+ url = (start ? START_BASE : s.base) + url.slice(b.length)
659
+ break
660
+ }
661
+ }
662
+ let key = s.key
663
+ if (isTeam(s) && !key) {
664
+ key = await teamKey(s)
665
+ if (auth?.type === "oauth") {
666
+ const next = { ...auth, refresh: JSON.stringify({ ...s, key }), access: key }
667
+ await client?.auth?.set?.({ path: { id }, body: next }).catch?.(() => {})
668
+ }
669
+ }
670
+ const h = new Headers(opts.headers)
671
+ if (start) {
672
+ if (jwtExpired(s.jwt)) throw new Error(EXPIRED)
673
+ key = s.jwt
674
+ // ZCode names itself on a Start Plan request
675
+ h.set("User-Agent", UA)
676
+ h.set("X-ZCode-App-Version", APP_VERSION)
677
+ h.set("X-Title", "Z Code@electron")
678
+ h.set("HTTP-Referer", ZCODE)
679
+ h.set("X-Platform", platform())
680
+ h.set("X-Device-Mid", s.device)
681
+ }
682
+ if (!key) throw new Error("ZCode's sign-in has no key; sign in again")
683
+ h.delete("authorization")
684
+ h.set("x-api-key", key)
685
+ h.set("Authorization", "Bearer " + key)
686
+ return fetch(url, { ...opts, headers: h })
687
+ },
688
+ }
689
+ },
690
+ methods: [
691
+ oauth("zai"),
692
+ oauth("bigmodel"),
693
+ {
694
+ type: "api",
695
+ label: "GLM Coding Plan API key",
696
+ prompts: [
697
+ {
698
+ type: "select",
699
+ key: "site",
700
+ message: "Where is the key from?",
701
+ options: [
702
+ { label: "Z.ai", value: "zai", hint: "api.z.ai" },
703
+ { label: "BigModel (智谱)", value: "bigmodel", hint: "open.bigmodel.cn" },
704
+ ],
705
+ },
706
+ ],
707
+ },
708
+ ],
709
+ },
710
+ }
711
+ }
package/package.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "@magpie-community/opencode-zcode-auth",
3
+ "version": "0.1.0",
4
+ "description": "OpenCode provider plugin: signs in to Z.ai's or BigModel's (智谱) GLM Coding Plan as ZCode does, including team seats and ZCode's Start Plan",
5
+ "type": "module",
6
+ "main": "./index.mjs",
7
+ "exports": "./index.mjs",
8
+ "files": ["index.mjs", "README.md"],
9
+ "keywords": ["opencode", "opencode-plugin", "magpie", "zcode", "z.ai", "bigmodel", "glm", "coding-plan"],
10
+ "license": "MIT",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/magpie-community/plugins.git",
14
+ "directory": "packages/zcode"
15
+ }
16
+ }