@magpie-community/opencode-zcode-auth 0.1.0 → 0.1.2

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 +7 -1
  2. package/index.mjs +440 -3
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -20,6 +20,11 @@ over Anthropic's Messages API. Provider id: `zcode`.
20
20
  with ZCode's session token and ZCode's own headers.
21
21
  - The Start Plan's token can't be refreshed. When it runs out, sign in
22
22
  again.
23
+ - **ZCode app's sign-in**
24
+ - Uses the account the ZCode app on this computer is signed in to. For
25
+ every request it reads ZCode's own credentials (`~/.zcode/v2`), so it
26
+ follows ZCode as it signs in again, renews its session or switches to a
27
+ team's plan.
23
28
  - **GLM Coding Plan API key**
24
29
  - Paste a key (`<id>.<secret>`) and say which site it is from.
25
30
 
@@ -33,7 +38,8 @@ In OpenCode's `auth.json` (magpie: `plugin-auth.json`), under `zcode`:
33
38
  token, the team project, the plan and a device id of this plugin's own.
34
39
  - **A pasted key** is kept as an `api` entry, with the site in `metadata`.
35
40
 
36
- The plugin never reads or writes ZCode's own credential store.
41
+ The plugin never writes ZCode's own credential store. It reads it only for
42
+ the ZCode app's sign-in (`refresh` then holds `"source": "zcode"`).
37
43
 
38
44
  ## How requests are routed
39
45
 
package/index.mjs CHANGED
@@ -16,8 +16,9 @@
16
16
  // `refresh` the rest of it as JSON (site, base, key, jwt, team project,
17
17
  // plan, device id), `accountId` the account's email.
18
18
 
19
+ import { createDecipheriv, createHash } from "node:crypto"
19
20
  import { existsSync, readFileSync, readdirSync } from "node:fs"
20
- import { homedir } from "node:os"
21
+ import { homedir, userInfo } from "node:os"
21
22
  import { join } from "node:path"
22
23
 
23
24
  const PROVIDER = "zcode"
@@ -51,6 +52,12 @@ const uuid = () => crypto.randomUUID()
51
52
  const anyDevice = uuid() // X-Device-Mid for calls made signed out (the model list)
52
53
  const first = (...ss) => ss.map((s) => (typeof s === "string" ? s.trim() : "")).find((s) => s) ?? ""
53
54
 
55
+ // STATUS_TEXT is Go's http.StatusText, which magpie's built-in said a
56
+ // refusal with no message in.
57
+ const STATUS_TEXT = { 400: "Bad Request", 401: "Unauthorized", 403: "Forbidden", 404: "Not Found", 405: "Method Not Allowed",
58
+ 408: "Request Timeout", 429: "Too Many Requests", 500: "Internal Server Error", 502: "Bad Gateway", 503: "Service Unavailable",
59
+ 504: "Gateway Timeout" }
60
+
54
61
  class ZError extends Error {
55
62
  constructor(message, status) {
56
63
  super(message)
@@ -75,7 +82,7 @@ async function call(method, url, { auth, body, headers, device, signal } = {}) {
75
82
  const code = env?.code == null ? "" : String(env.code)
76
83
  if (!res.ok) {
77
84
  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)
85
+ throw new ZError(STATUS_TEXT[res.status] ?? `${res.status} ${res.statusText}`.trim(), res.status)
79
86
  }
80
87
  if (!env || typeof env !== "object") throw new ZError("not JSON", res.status)
81
88
  if (code && code !== "0" && code !== "200") throw new ZError(env.msg || `error ${code}`, res.status)
@@ -236,6 +243,270 @@ async function teamKey(s) {
236
243
  }
237
244
  }
238
245
 
246
+ // ---- allowance ---------------------------------------------------------------------
247
+ // As magpie's built-in told it (internal/provider zcode.go, zcode_start.go,
248
+ // zcode_team.go): the Coding Plan's windows and term, the Start Plan's
249
+ // buckets, or a team seat's windows and its plan's end.
250
+
251
+ // bizRoot is the business API of the site a plan is served from.
252
+ function bizRoot(base) {
253
+ let h = ""
254
+ try {
255
+ h = new URL(base).host.toLowerCase()
256
+ } catch {}
257
+ return h === new URL(BIGMODEL_BASE).host || h.endsWith("bigmodel.cn") ? BIGMODEL_API : rootOf(base)
258
+ }
259
+
260
+ // spanOf reads a limit's window, in seconds: unit 3 counts hours, 6 weeks
261
+ // (and 1, 4, 5 minutes, days and months by the same count). A team's five
262
+ // hours come with no count.
263
+ function spanOf(unit, n) {
264
+ n = Math.trunc(Number(n) || 0)
265
+ if (unit === 3 && n <= 0) n = 5
266
+ n = Math.max(n, 1)
267
+ return { 1: 60, 3: 3600, 4: 86400, 5: 30 * 86400, 6: 7 * 86400 }[unit] * n || 0
268
+ }
269
+
270
+ function windowName(span) {
271
+ if (span === 0) return "Credits"
272
+ if (span < 86400) return `${Math.trunc(span / 3600)} hours`
273
+ if (span === 7 * 86400) return "Weekly"
274
+ if (span >= 28 * 86400) return "Monthly"
275
+ return `${Math.trunc(span / 86400)} days`
276
+ }
277
+
278
+ // compact is a count as magpie says one: whole, else to two places.
279
+ const compact = (n) => (Number.isInteger(n) ? String(n) : n.toFixed(2).replace(/0+$/, "").replace(/\.$/, ""))
280
+ const isNum = (v) => typeof v === "number" && Number.isFinite(v)
281
+
282
+ // limitWindows are /api/monitor/usage/quota/limit's limits as windows: what
283
+ // is used of the whole when both are told (the whole less what remains, or
284
+ // the current value), else the percentage given.
285
+ function limitWindows(d) {
286
+ const out = []
287
+ for (const x of d?.limits ?? []) {
288
+ const span = spanOf(Math.trunc(Number(x?.unit) || 0), x?.number)
289
+ const w = { name: windowName(span), used: 0 }
290
+ if (isNum(x?.percentage)) w.used = x.percentage
291
+ if (isNum(x?.usage) && x.usage > 0) {
292
+ const total = x.usage
293
+ if (isNum(x.remaining)) {
294
+ const used = total - x.remaining
295
+ w.used = (100 * used) / total
296
+ w.display = `${compact(used)} / ${compact(total)}`
297
+ } else if (isNum(x.currentValue)) {
298
+ if (!isNum(x.percentage)) w.used = (100 * x.currentValue) / total
299
+ w.display = `${compact(x.currentValue)} / ${compact(total)}`
300
+ }
301
+ }
302
+ const reset = Math.trunc(Number(x?.nextResetTime) || 0)
303
+ if (reset > 0) w.resetsAt = new Date(reset).toISOString()
304
+ if (span) w.span = span
305
+ out.push(w)
306
+ }
307
+ return out
308
+ }
309
+
310
+ // beijing reads the plan's times, "2026-10-18 12:00:00" in Beijing: ms, or 0.
311
+ function beijing(s) {
312
+ s = String(s ?? "").trim().replace(" ", "T")
313
+ if (!/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2})?$/.test(s)) return 0
314
+ const t = Date.parse(s + (s.length === 10 ? "T00:00:00" : "") + "+08:00")
315
+ return Number.isFinite(t) ? t : 0
316
+ }
317
+
318
+ // termOf is how long a GLM Coding plan is paid for: the valid
319
+ // subscription's next renewal, a charge when it renews itself, else the
320
+ // end of its time, the last date its "valid" span names.
321
+ function termOf(subs) {
322
+ for (const x of Array.isArray(subs) ? subs : []) {
323
+ if (String(x?.status ?? "").toUpperCase() !== "VALID") continue
324
+ const auto = x.autoRenew === true || x.autoRenew === 1
325
+ const t = beijing(typeof x.nextRenewTime === "string" ? x.nextRenewTime : "")
326
+ if (t) return { until: new Date(t).toISOString(), renew: auto ? "auto" : "off" }
327
+ const ds = String(typeof x.valid === "string" ? x.valid : "").match(/\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}:\d{2})?/g) ?? []
328
+ if (ds.length && !auto) {
329
+ const end = beijing(ds[ds.length - 1])
330
+ if (end) return { until: new Date(end).toISOString(), renew: "off" }
331
+ }
332
+ return {}
333
+ }
334
+ return {}
335
+ }
336
+
337
+ // codingUsage is a Coding Plan key's allowance: credits per five hours and
338
+ // per week, the plan's level and its term.
339
+ async function codingUsage(s) {
340
+ const root = rootOf(s.base)
341
+ const d = (await call("GET", root + "/api/monitor/usage/quota/limit", { auth: s.key })) ?? {}
342
+ const out = {}
343
+ const level = typeof d.level === "string" ? d.level : ""
344
+ if (level) out.plan = "GLM Coding " + level[0].toUpperCase() + level.slice(1)
345
+ out.windows = limitWindows(d)
346
+ try {
347
+ Object.assign(out, termOf(await call("GET", root + "/api/biz/subscription/list", { auth: s.key })))
348
+ } catch {}
349
+ return out
350
+ }
351
+
352
+ // startNum reads a number the balance gives as a number or a string.
353
+ function startNum(v) {
354
+ if (isNum(v)) return v
355
+ if (typeof v === "string" && v.trim() !== "") {
356
+ const n = Number(v.trim())
357
+ if (!Number.isNaN(n)) return n
358
+ }
359
+ return undefined
360
+ }
361
+
362
+ // startUsage is the Start Plan's allowance: a window for each of its
363
+ // buckets, a model's tokens for the day or for the plan's time. A plan
364
+ // still "active" past its end is over, and its buckets are left out.
365
+ async function startUsage(s) {
366
+ if (!s.jwt) throw new Error("not signed in to ZCode")
367
+ if (jwtExpired(s.jwt)) throw new Error(EXPIRED)
368
+ const b = (await call("GET", `${ZCODE}/api/v1/zcode-plan/billing/balance?app_version=${APP_VERSION}`, { auth: "Bearer " + s.jwt, device: s.device })) ?? {}
369
+ const str = (v) => (typeof v === "string" ? v : "")
370
+ const plans = (Array.isArray(b.plans) ? b.plans : []).map((p) => ({ ...p, status: str(p?.status), user_plan_id: str(p?.user_plan_id), plan_id: str(p?.plan_id) }))
371
+ const now = startNum(b.server_time) > 0 ? startNum(b.server_time) : Math.trunc(Date.now() / 1000)
372
+ const over = new Set()
373
+ for (const p of plans) {
374
+ const end = startNum(p.ends_at)
375
+ if (end > 0 && end <= now && p.status.trim().toLowerCase() === "active") p.status = "expired"
376
+ if (p.status.trim().toLowerCase() === "expired") over.add(p.user_plan_id + "\0" + p.plan_id)
377
+ }
378
+ const same = (x, p) => (x.user_plan_id && p.user_plan_id ? x.user_plan_id === p.user_plan_id : x.plan_id === p.plan_id)
379
+ const balances = (Array.isArray(b.balances) ? b.balances : [])
380
+ .map((x) => ({ ...x, user_plan_id: str(x?.user_plan_id), plan_id: str(x?.plan_id) }))
381
+ .filter((x) => {
382
+ let keep = true
383
+ for (const p of plans) {
384
+ if (!same(x, p)) continue
385
+ keep = !over.has(p.user_plan_id + "\0" + p.plan_id)
386
+ if (keep) break
387
+ }
388
+ return keep
389
+ })
390
+
391
+ let active = null
392
+ for (const p of plans) {
393
+ if (p.status.trim().toLowerCase() !== "active") continue
394
+ const id = p.plan_id.trim().toLowerCase(), n = str(p.name).trim().toLowerCase()
395
+ const isStart = (x) => x.includes("start-plan") || x.includes("start plan")
396
+ if ((id || n) && !isStart(id) && !isStart(n)) continue
397
+ const end = startNum(p.ends_at)
398
+ active = { name: first(str(p.name), "Start Plan"), until: end > 0 ? new Date(Math.trunc(end) * 1000).toISOString() : "" }
399
+ break
400
+ }
401
+ if (!active) return { error: "this account has no GLM Coding Plan, and ZCode's Start Plan has ended or was never started" }
402
+ const out = { plan: active.name, windows: [] }
403
+ if (active.until) Object.assign(out, { until: active.until, renew: "off" })
404
+ for (const x of balances) {
405
+ const total = startNum(x.total_units), left = startNum(x.remaining_units)
406
+ let used = startNum(x.used_units)
407
+ if (total === undefined && used === undefined && left === undefined) continue
408
+ const models = (Array.isArray(x.capabilities) ? x.capabilities : [])
409
+ .map((c) => String(c ?? "").trim().replace(/^model:/, "").trim()).filter(Boolean)
410
+ const w = { name: first(str(x.show_name), models.join(", "), "Credits"), used: 0 }
411
+ if (used === undefined) used = total !== undefined && left !== undefined ? total - left : 0
412
+ if (total > 0) {
413
+ w.used = (100 * used) / total
414
+ w.display = `${compact(used)} / ${compact(total)}`
415
+ }
416
+ const exp = startNum(x.expires_at)
417
+ if (exp > 0) w.resetsAt = new Date(Math.trunc(exp) * 1000).toISOString()
418
+ let span = 0
419
+ for (const p of plans) {
420
+ if (!same(x, p)) continue
421
+ for (const e of Array.isArray(p.entitlements) ? p.entitlements : []) {
422
+ if (str(e?.entitlement_id) === str(x.entitlement_id)) span = periodOf(str(e?.period))
423
+ }
424
+ }
425
+ if (!span) {
426
+ const a = startNum(x.period_start), z = startNum(x.period_end)
427
+ if (a !== undefined && z !== undefined && z > a) span = Math.trunc(z - a)
428
+ }
429
+ if (span) w.span = span
430
+ if (models.length) w.models = models
431
+ out.windows.push(w)
432
+ }
433
+ return out
434
+ }
435
+
436
+ // periodOf reads an entitlement's period: "daily", "weekly", "monthly".
437
+ function periodOf(p) {
438
+ p = p.toLowerCase()
439
+ if (p.includes("day") || p.includes("daily")) return 86400
440
+ if (p.includes("week")) return 7 * 86400
441
+ if (p.includes("month")) return 30 * 86400
442
+ return 0
443
+ }
444
+
445
+ // when is a team plan's end: a time in Beijing, or a Unix time in seconds
446
+ // or milliseconds; ms, or 0.
447
+ function when(v) {
448
+ if (typeof v === "string") {
449
+ const t = beijing(v)
450
+ if (t) return t
451
+ }
452
+ const n = startNum(v)
453
+ if (n > 0) return n > 1e12 ? Math.trunc(n) : Math.trunc(n) * 1000
454
+ return 0
455
+ }
456
+
457
+ // teamUsage is a team seat's allowance: the team plan's five hours and
458
+ // week, and the plan's name and end.
459
+ async function teamUsage(s, key) {
460
+ const root = bizRoot(s.base)
461
+ const headers = teamHeaders(s.base, s.org, s.project)
462
+ const d = (await call("GET", root + "/api/monitor/usage/quota/limit?type=2", { auth: key, headers })) ?? {}
463
+ const out = { windows: limitWindows(d) }
464
+ if (!s.token) return out
465
+ const resets = teamResets(root, s.token, headers)
466
+ try {
467
+ const t = (await call("GET", root + "/api/biz/team/subscribe/product/querySubscribeDetail", { auth: s.token, headers })) ?? {}
468
+ const usable = (t.hasSubscription == null || t.hasSubscription === true) && String(t.status ?? "").toUpperCase() === "EFFECTIVE" &&
469
+ String(t.memberGrantStatus ?? "").toUpperCase() === "VALID"
470
+ if (usable) {
471
+ if (typeof t.productName === "string" && t.productName) out.plan = t.productName
472
+ const end = when(t.subscribeEndTime)
473
+ if (end) Object.assign(out, { until: new Date(end).toISOString(), renew: "off" })
474
+ }
475
+ } catch {}
476
+ const r = await resets
477
+ if (r) out.resets = r
478
+ return out
479
+ }
480
+
481
+ // teamResets is how many resets a team member may spend, of the five hours
482
+ // and of the week: those of customer-package-reset/list still available.
483
+ // null when it can't be told or there are none.
484
+ async function teamResets(root, auth, headers) {
485
+ let d
486
+ try {
487
+ d = (await call("GET", root + "/api/biz/customer-package-reset/list?targetType=TEAM", { auth, headers })) ?? {}
488
+ } catch {
489
+ return null
490
+ }
491
+ let until = 0
492
+ const count = (rs) => {
493
+ let n = 0
494
+ for (const x of Array.isArray(rs) ? rs : []) {
495
+ if (x?.available !== true) continue
496
+ n++
497
+ const t = when(x.expireTime)
498
+ if (t && (!until || t < until)) until = t
499
+ }
500
+ return n
501
+ }
502
+ const fiveHour = count(d.fiveHourResets)
503
+ const weekly = count(d.weekResets)
504
+ if (fiveHour + weekly === 0) return null
505
+ const r = { count: fiveHour + weekly, byWindow: true, fiveHour, weekly }
506
+ if (until) r.until = new Date(until).toISOString()
507
+ return r
508
+ }
509
+
239
510
  // ---- signing in ------------------------------------------------------------------
240
511
 
241
512
  // bizAuth is the Authorization a sign-in gives the business API: Z.ai's
@@ -391,7 +662,7 @@ async function authorize(site, log) {
391
662
  instructions: `Sign in to ${SITES[site].name} in the browser; this finishes on its own.`,
392
663
  method: "auto",
393
664
  async callback() {
394
- const fail = (msg) => (log(msg), { type: "failed" })
665
+ const fail = (msg) => (log(msg), { type: "failed", error: msg })
395
666
  for (;;) {
396
667
  await sleep(interval)
397
668
  if (Date.now() > deadline) return fail("the sign-in expired; start again")
@@ -426,6 +697,136 @@ async function authorize(site, log) {
426
697
  }
427
698
  }
428
699
 
700
+ // ---- ZCode's own sign-in ------------------------------------------------------------
701
+ // ZCode keeps its sign-in in ~/.zcode/v2/credentials.json, each value
702
+ // encrypted (AES-256-GCM, "enc:v1:<iv>.<tag>.<data>") with a key made from
703
+ // the machine's user; ~/.zcode/v2/setting.json says whether it is on a
704
+ // team's plan. An account signed in "as ZCode" reads them for every
705
+ // request, so it follows ZCode's own sign-in, its renewed session and its
706
+ // switches between plans, as magpie's built-in did (zcode.go zcodeOwn).
707
+
708
+ const zcodeDir = () => join(homedir(), ".zcode", "v2")
709
+
710
+ // secret is the key ZCode encrypts its credentials with.
711
+ function secret() {
712
+ let seed = process.env.ZCODE_CREDENTIAL_SECRET
713
+ if (!seed) {
714
+ let name = ""
715
+ try {
716
+ name = userInfo().username
717
+ } catch {}
718
+ name = name.slice(name.lastIndexOf("\\") + 1) // DOMAIN\user
719
+ seed = `zcode-credential-fallback:${process.platform}:${homedir()}:${name}`
720
+ }
721
+ return createHash("sha256").update(seed).digest()
722
+ }
723
+
724
+ function decrypt(key, v) {
725
+ if (typeof v !== "string" || !v.startsWith("enc:v1:")) return null
726
+ const parts = v.slice(7).split(".")
727
+ if (parts.length !== 3) return null
728
+ try {
729
+ const [iv, tag, data] = parts.map((p) => Buffer.from(p.replace(/=+$/, ""), "base64url"))
730
+ const d = createDecipheriv("aes-256-gcm", key, iv)
731
+ d.setAuthTag(tag)
732
+ return Buffer.concat([d.update(data), d.final()]).toString("utf8")
733
+ } catch {
734
+ return null
735
+ }
736
+ }
737
+
738
+ function readJSON(path) {
739
+ try {
740
+ return JSON.parse(readFileSync(path, "utf8"))
741
+ } catch {
742
+ return null
743
+ }
744
+ }
745
+
746
+ // ownTeam is the team plan ZCode switched to, when it did.
747
+ function ownTeam(store, key) {
748
+ const set = readJSON(join(zcodeDir(), "setting.json"))
749
+ if (!set) return null
750
+ const families = set.providerFamilyDomain === "zai" || set.providerFamilyDomain === "bigmodel" ? [set.providerFamilyDomain] : ["zai", "bigmodel"]
751
+ for (const fam of families) {
752
+ let org = ""
753
+ let project = ""
754
+ const sel = set.providerFamilyConnectionSelections?.[fam]
755
+ if (sel) {
756
+ if (sel.kind === "team-coding-plan") [org, project] = [first(sel.organizationId), first(sel.projectId)]
757
+ } else {
758
+ // before providerFamilyConnectionSelections
759
+ const legacy = first(set.modelProviderFamilySelectedKeys?.[fam])
760
+ const pre = `team-plan:builtin:${fam}-coding-plan:`
761
+ if (legacy.startsWith(pre)) {
762
+ const ps = legacy.slice(pre.length).split(":")
763
+ if (ps.length === 3) {
764
+ try {
765
+ ;[org, project] = [decodeURIComponent(ps[1]), decodeURIComponent(ps[2])]
766
+ } catch {}
767
+ }
768
+ }
769
+ }
770
+ if (!org || !project) continue
771
+ const tok = first(decrypt(key, store[`oauth:${fam}:access_token`]))
772
+ if (!tok) continue
773
+ // a stale token ZCode won't use either
774
+ if (fam === "bigmodel" && first(decrypt(key, store.zcodejwttoken)) === tok) continue
775
+ return { site: fam, base: SITES[fam].base, key: "", token: tok, org, project }
776
+ }
777
+ return null
778
+ }
779
+
780
+ // ownSignIn is what ZCode is signed in to now: the account, its coding
781
+ // plan key, ZCode's session token (the Start Plan's key) and the team plan
782
+ // it is switched to; null when it has none of them.
783
+ function ownSignIn() {
784
+ const store = readJSON(join(zcodeDir(), "credentials.json"))
785
+ if (!store || typeof store !== "object") return null
786
+ const key = secret()
787
+ let s = { key: "", base: "" }
788
+ for (const [name, v] of Object.entries(store)) {
789
+ // account-provider:coding-plan:account:zai-individual-coding-plan:account:<uuid>:api-key
790
+ if (!name.includes(":coding-plan:") || !name.endsWith(":api-key")) continue
791
+ const k = decrypt(key, v)
792
+ if (!k || !k.includes(".")) continue
793
+ const base = name.includes(":bigmodel-") ? BIGMODEL_BASE : ZAI_BASE
794
+ // Z.ai's first, as ZCode lists it
795
+ if (!s.key || (base === ZAI_BASE && s.base !== ZAI_BASE)) s = { key: k, base }
796
+ }
797
+ const jwt = first(first(decrypt(key, store.zcodejwttoken)).replace(/^Bearer /, ""))
798
+ const team = ownTeam(store, key)
799
+ if (team) s = { ...team, jwt }
800
+ else s = { ...s, jwt, base: s.base || ZAI_BASE, site: s.base === BIGMODEL_BASE ? "bigmodel" : "zai" }
801
+ if (!s.key && !s.jwt && !isTeam(s)) return null
802
+ let info = {}
803
+ for (const name of ["oauth:zai:user_info", "oauth:bigmodel:user_info"]) {
804
+ const v = decrypt(key, store[name])
805
+ if (v && !first(info.email, info.name, info.user_id)) {
806
+ try {
807
+ info = JSON.parse(v)
808
+ } catch {}
809
+ }
810
+ }
811
+ return { ...s, user: who(info) }
812
+ }
813
+
814
+ // asZCode is the sign-in "as ZCode": what it reads is ZCode's own.
815
+ function asZCode() {
816
+ return {
817
+ url: "",
818
+ instructions: "Uses the account the ZCode app is signed in to, and follows it.",
819
+ method: "auto",
820
+ async callback() {
821
+ const own = ownSignIn()
822
+ if (!own) return { type: "failed", error: "ZCode isn't signed in on this computer; sign in to ZCode, or sign in to Z.ai or BigModel here" }
823
+ const { user, ...s } = own
824
+ const state = { source: "zcode", device: anyDevice, ...s }
825
+ return { type: "success", refresh: JSON.stringify(state), access: s.key || s.jwt || "", expires: 0, accountId: user }
826
+ },
827
+ }
828
+ }
829
+
429
830
  // stateOf is a stored sign-in as the requests need it.
430
831
  function stateOf(auth) {
431
832
  if (auth?.type === "oauth") {
@@ -433,6 +834,15 @@ function stateOf(auth) {
433
834
  try {
434
835
  s = JSON.parse(auth.refresh)
435
836
  } catch {}
837
+ if (s.source === "zcode") {
838
+ // ZCode's own, read now: what it was when this was signed in, if
839
+ // ZCode has since signed out
840
+ const own = ownSignIn()
841
+ if (own) {
842
+ const { user, ...now } = own
843
+ s = { source: "zcode", device: s.device, plan: s.plan, ...now }
844
+ }
845
+ }
436
846
  const site = siteOf(s.site)
437
847
  return { ...s, site, base: s.base || SITES[site].base, key: s.key ?? (s.jwt ? "" : auth.access), device: s.device || anyDevice }
438
848
  }
@@ -687,9 +1097,33 @@ export async function ZCodeAuthPlugin({ client }) {
687
1097
  },
688
1098
  }
689
1099
  },
1100
+ // the plan's allowance, as magpie's built-in showed it
1101
+ async usage(getAuth, provider) {
1102
+ const auth = await getAuth()
1103
+ const s = stateOf(auth)
1104
+ if (!s) return { error: "not signed in" }
1105
+ try {
1106
+ if (isTeam(s)) {
1107
+ let key = s.key
1108
+ if (!key) {
1109
+ key = await teamKey(s)
1110
+ if (auth?.type === "oauth") {
1111
+ const next = { ...auth, refresh: JSON.stringify({ ...s, key }), access: key }
1112
+ await client?.auth?.set?.({ path: { id: provider?.id ?? PROVIDER }, body: next }).catch?.(() => {})
1113
+ }
1114
+ }
1115
+ return await teamUsage(s, key)
1116
+ }
1117
+ if (await onStart(s)) return await startUsage(s)
1118
+ return await codingUsage(s)
1119
+ } catch (e) {
1120
+ return { error: e?.message ?? String(e) }
1121
+ }
1122
+ },
690
1123
  methods: [
691
1124
  oauth("zai"),
692
1125
  oauth("bigmodel"),
1126
+ { type: "oauth", label: "ZCode app's sign-in", authorize: async () => asZCode() },
693
1127
  {
694
1128
  type: "api",
695
1129
  label: "GLM Coding Plan API key",
@@ -709,3 +1143,6 @@ export async function ZCodeAuthPlugin({ client }) {
709
1143
  },
710
1144
  }
711
1145
  }
1146
+
1147
+ // for tests
1148
+ export const _internal = { limitWindows, termOf, startUsage, routes, teamKeys, ownSignIn, stateOf }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-zcode-auth",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
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
5
  "type": "module",
6
6
  "main": "./index.mjs",