@magpie-community/opencode-zcode-auth 0.1.0 → 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 (2) hide show
  1. package/index.mjs +297 -1
  2. package/package.json +1 -1
package/index.mjs CHANGED
@@ -51,6 +51,12 @@ const uuid = () => crypto.randomUUID()
51
51
  const anyDevice = uuid() // X-Device-Mid for calls made signed out (the model list)
52
52
  const first = (...ss) => ss.map((s) => (typeof s === "string" ? s.trim() : "")).find((s) => s) ?? ""
53
53
 
54
+ // STATUS_TEXT is Go's http.StatusText, which magpie's built-in said a
55
+ // refusal with no message in.
56
+ const STATUS_TEXT = { 400: "Bad Request", 401: "Unauthorized", 403: "Forbidden", 404: "Not Found", 405: "Method Not Allowed",
57
+ 408: "Request Timeout", 429: "Too Many Requests", 500: "Internal Server Error", 502: "Bad Gateway", 503: "Service Unavailable",
58
+ 504: "Gateway Timeout" }
59
+
54
60
  class ZError extends Error {
55
61
  constructor(message, status) {
56
62
  super(message)
@@ -75,7 +81,7 @@ async function call(method, url, { auth, body, headers, device, signal } = {}) {
75
81
  const code = env?.code == null ? "" : String(env.code)
76
82
  if (!res.ok) {
77
83
  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)
84
+ throw new ZError(STATUS_TEXT[res.status] ?? `${res.status} ${res.statusText}`.trim(), res.status)
79
85
  }
80
86
  if (!env || typeof env !== "object") throw new ZError("not JSON", res.status)
81
87
  if (code && code !== "0" && code !== "200") throw new ZError(env.msg || `error ${code}`, res.status)
@@ -236,6 +242,270 @@ async function teamKey(s) {
236
242
  }
237
243
  }
238
244
 
245
+ // ---- allowance ---------------------------------------------------------------------
246
+ // As magpie's built-in told it (internal/provider zcode.go, zcode_start.go,
247
+ // zcode_team.go): the Coding Plan's windows and term, the Start Plan's
248
+ // buckets, or a team seat's windows and its plan's end.
249
+
250
+ // bizRoot is the business API of the site a plan is served from.
251
+ function bizRoot(base) {
252
+ let h = ""
253
+ try {
254
+ h = new URL(base).host.toLowerCase()
255
+ } catch {}
256
+ return h === new URL(BIGMODEL_BASE).host || h.endsWith("bigmodel.cn") ? BIGMODEL_API : rootOf(base)
257
+ }
258
+
259
+ // spanOf reads a limit's window, in seconds: unit 3 counts hours, 6 weeks
260
+ // (and 1, 4, 5 minutes, days and months by the same count). A team's five
261
+ // hours come with no count.
262
+ function spanOf(unit, n) {
263
+ n = Math.trunc(Number(n) || 0)
264
+ if (unit === 3 && n <= 0) n = 5
265
+ n = Math.max(n, 1)
266
+ return { 1: 60, 3: 3600, 4: 86400, 5: 30 * 86400, 6: 7 * 86400 }[unit] * n || 0
267
+ }
268
+
269
+ function windowName(span) {
270
+ if (span === 0) return "Credits"
271
+ if (span < 86400) return `${Math.trunc(span / 3600)} hours`
272
+ if (span === 7 * 86400) return "Weekly"
273
+ if (span >= 28 * 86400) return "Monthly"
274
+ return `${Math.trunc(span / 86400)} days`
275
+ }
276
+
277
+ // compact is a count as magpie says one: whole, else to two places.
278
+ const compact = (n) => (Number.isInteger(n) ? String(n) : n.toFixed(2).replace(/0+$/, "").replace(/\.$/, ""))
279
+ const isNum = (v) => typeof v === "number" && Number.isFinite(v)
280
+
281
+ // limitWindows are /api/monitor/usage/quota/limit's limits as windows: what
282
+ // is used of the whole when both are told (the whole less what remains, or
283
+ // the current value), else the percentage given.
284
+ function limitWindows(d) {
285
+ const out = []
286
+ for (const x of d?.limits ?? []) {
287
+ const span = spanOf(Math.trunc(Number(x?.unit) || 0), x?.number)
288
+ const w = { name: windowName(span), used: 0 }
289
+ if (isNum(x?.percentage)) w.used = x.percentage
290
+ if (isNum(x?.usage) && x.usage > 0) {
291
+ const total = x.usage
292
+ if (isNum(x.remaining)) {
293
+ const used = total - x.remaining
294
+ w.used = (100 * used) / total
295
+ w.display = `${compact(used)} / ${compact(total)}`
296
+ } else if (isNum(x.currentValue)) {
297
+ if (!isNum(x.percentage)) w.used = (100 * x.currentValue) / total
298
+ w.display = `${compact(x.currentValue)} / ${compact(total)}`
299
+ }
300
+ }
301
+ const reset = Math.trunc(Number(x?.nextResetTime) || 0)
302
+ if (reset > 0) w.resetsAt = new Date(reset).toISOString()
303
+ if (span) w.span = span
304
+ out.push(w)
305
+ }
306
+ return out
307
+ }
308
+
309
+ // beijing reads the plan's times, "2026-10-18 12:00:00" in Beijing: ms, or 0.
310
+ function beijing(s) {
311
+ s = String(s ?? "").trim().replace(" ", "T")
312
+ if (!/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2})?$/.test(s)) return 0
313
+ const t = Date.parse(s + (s.length === 10 ? "T00:00:00" : "") + "+08:00")
314
+ return Number.isFinite(t) ? t : 0
315
+ }
316
+
317
+ // termOf is how long a GLM Coding plan is paid for: the valid
318
+ // subscription's next renewal, a charge when it renews itself, else the
319
+ // end of its time, the last date its "valid" span names.
320
+ function termOf(subs) {
321
+ for (const x of Array.isArray(subs) ? subs : []) {
322
+ if (String(x?.status ?? "").toUpperCase() !== "VALID") continue
323
+ const auto = x.autoRenew === true || x.autoRenew === 1
324
+ const t = beijing(typeof x.nextRenewTime === "string" ? x.nextRenewTime : "")
325
+ if (t) return { until: new Date(t).toISOString(), renew: auto ? "auto" : "off" }
326
+ const ds = String(typeof x.valid === "string" ? x.valid : "").match(/\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}:\d{2})?/g) ?? []
327
+ if (ds.length && !auto) {
328
+ const end = beijing(ds[ds.length - 1])
329
+ if (end) return { until: new Date(end).toISOString(), renew: "off" }
330
+ }
331
+ return {}
332
+ }
333
+ return {}
334
+ }
335
+
336
+ // codingUsage is a Coding Plan key's allowance: credits per five hours and
337
+ // per week, the plan's level and its term.
338
+ async function codingUsage(s) {
339
+ const root = rootOf(s.base)
340
+ const d = (await call("GET", root + "/api/monitor/usage/quota/limit", { auth: s.key })) ?? {}
341
+ const out = {}
342
+ const level = typeof d.level === "string" ? d.level : ""
343
+ if (level) out.plan = "GLM Coding " + level[0].toUpperCase() + level.slice(1)
344
+ out.windows = limitWindows(d)
345
+ try {
346
+ Object.assign(out, termOf(await call("GET", root + "/api/biz/subscription/list", { auth: s.key })))
347
+ } catch {}
348
+ return out
349
+ }
350
+
351
+ // startNum reads a number the balance gives as a number or a string.
352
+ function startNum(v) {
353
+ if (isNum(v)) return v
354
+ if (typeof v === "string" && v.trim() !== "") {
355
+ const n = Number(v.trim())
356
+ if (!Number.isNaN(n)) return n
357
+ }
358
+ return undefined
359
+ }
360
+
361
+ // startUsage is the Start Plan's allowance: a window for each of its
362
+ // buckets, a model's tokens for the day or for the plan's time. A plan
363
+ // still "active" past its end is over, and its buckets are left out.
364
+ async function startUsage(s) {
365
+ if (!s.jwt) throw new Error("not signed in to ZCode")
366
+ if (jwtExpired(s.jwt)) throw new Error(EXPIRED)
367
+ const b = (await call("GET", `${ZCODE}/api/v1/zcode-plan/billing/balance?app_version=${APP_VERSION}`, { auth: "Bearer " + s.jwt, device: s.device })) ?? {}
368
+ const str = (v) => (typeof v === "string" ? v : "")
369
+ 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) }))
370
+ const now = startNum(b.server_time) > 0 ? startNum(b.server_time) : Math.trunc(Date.now() / 1000)
371
+ const over = new Set()
372
+ for (const p of plans) {
373
+ const end = startNum(p.ends_at)
374
+ if (end > 0 && end <= now && p.status.trim().toLowerCase() === "active") p.status = "expired"
375
+ if (p.status.trim().toLowerCase() === "expired") over.add(p.user_plan_id + "\0" + p.plan_id)
376
+ }
377
+ 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)
378
+ const balances = (Array.isArray(b.balances) ? b.balances : [])
379
+ .map((x) => ({ ...x, user_plan_id: str(x?.user_plan_id), plan_id: str(x?.plan_id) }))
380
+ .filter((x) => {
381
+ let keep = true
382
+ for (const p of plans) {
383
+ if (!same(x, p)) continue
384
+ keep = !over.has(p.user_plan_id + "\0" + p.plan_id)
385
+ if (keep) break
386
+ }
387
+ return keep
388
+ })
389
+
390
+ let active = null
391
+ for (const p of plans) {
392
+ if (p.status.trim().toLowerCase() !== "active") continue
393
+ const id = p.plan_id.trim().toLowerCase(), n = str(p.name).trim().toLowerCase()
394
+ const isStart = (x) => x.includes("start-plan") || x.includes("start plan")
395
+ if ((id || n) && !isStart(id) && !isStart(n)) continue
396
+ const end = startNum(p.ends_at)
397
+ active = { name: first(str(p.name), "Start Plan"), until: end > 0 ? new Date(Math.trunc(end) * 1000).toISOString() : "" }
398
+ break
399
+ }
400
+ if (!active) return { error: "this account has no GLM Coding Plan, and ZCode's Start Plan has ended or was never started" }
401
+ const out = { plan: active.name, windows: [] }
402
+ if (active.until) Object.assign(out, { until: active.until, renew: "off" })
403
+ for (const x of balances) {
404
+ const total = startNum(x.total_units), left = startNum(x.remaining_units)
405
+ let used = startNum(x.used_units)
406
+ if (total === undefined && used === undefined && left === undefined) continue
407
+ const models = (Array.isArray(x.capabilities) ? x.capabilities : [])
408
+ .map((c) => String(c ?? "").trim().replace(/^model:/, "").trim()).filter(Boolean)
409
+ const w = { name: first(str(x.show_name), models.join(", "), "Credits"), used: 0 }
410
+ if (used === undefined) used = total !== undefined && left !== undefined ? total - left : 0
411
+ if (total > 0) {
412
+ w.used = (100 * used) / total
413
+ w.display = `${compact(used)} / ${compact(total)}`
414
+ }
415
+ const exp = startNum(x.expires_at)
416
+ if (exp > 0) w.resetsAt = new Date(Math.trunc(exp) * 1000).toISOString()
417
+ let span = 0
418
+ for (const p of plans) {
419
+ if (!same(x, p)) continue
420
+ for (const e of Array.isArray(p.entitlements) ? p.entitlements : []) {
421
+ if (str(e?.entitlement_id) === str(x.entitlement_id)) span = periodOf(str(e?.period))
422
+ }
423
+ }
424
+ if (!span) {
425
+ const a = startNum(x.period_start), z = startNum(x.period_end)
426
+ if (a !== undefined && z !== undefined && z > a) span = Math.trunc(z - a)
427
+ }
428
+ if (span) w.span = span
429
+ if (models.length) w.models = models
430
+ out.windows.push(w)
431
+ }
432
+ return out
433
+ }
434
+
435
+ // periodOf reads an entitlement's period: "daily", "weekly", "monthly".
436
+ function periodOf(p) {
437
+ p = p.toLowerCase()
438
+ if (p.includes("day") || p.includes("daily")) return 86400
439
+ if (p.includes("week")) return 7 * 86400
440
+ if (p.includes("month")) return 30 * 86400
441
+ return 0
442
+ }
443
+
444
+ // when is a team plan's end: a time in Beijing, or a Unix time in seconds
445
+ // or milliseconds; ms, or 0.
446
+ function when(v) {
447
+ if (typeof v === "string") {
448
+ const t = beijing(v)
449
+ if (t) return t
450
+ }
451
+ const n = startNum(v)
452
+ if (n > 0) return n > 1e12 ? Math.trunc(n) : Math.trunc(n) * 1000
453
+ return 0
454
+ }
455
+
456
+ // teamUsage is a team seat's allowance: the team plan's five hours and
457
+ // week, and the plan's name and end.
458
+ async function teamUsage(s, key) {
459
+ const root = bizRoot(s.base)
460
+ const headers = teamHeaders(s.base, s.org, s.project)
461
+ const d = (await call("GET", root + "/api/monitor/usage/quota/limit?type=2", { auth: key, headers })) ?? {}
462
+ const out = { windows: limitWindows(d) }
463
+ if (!s.token) return out
464
+ const resets = teamResets(root, s.token, headers)
465
+ try {
466
+ const t = (await call("GET", root + "/api/biz/team/subscribe/product/querySubscribeDetail", { auth: s.token, headers })) ?? {}
467
+ const usable = (t.hasSubscription == null || t.hasSubscription === true) && String(t.status ?? "").toUpperCase() === "EFFECTIVE" &&
468
+ String(t.memberGrantStatus ?? "").toUpperCase() === "VALID"
469
+ if (usable) {
470
+ if (typeof t.productName === "string" && t.productName) out.plan = t.productName
471
+ const end = when(t.subscribeEndTime)
472
+ if (end) Object.assign(out, { until: new Date(end).toISOString(), renew: "off" })
473
+ }
474
+ } catch {}
475
+ const r = await resets
476
+ if (r) out.resets = r
477
+ return out
478
+ }
479
+
480
+ // teamResets is how many resets a team member may spend, of the five hours
481
+ // and of the week: those of customer-package-reset/list still available.
482
+ // null when it can't be told or there are none.
483
+ async function teamResets(root, auth, headers) {
484
+ let d
485
+ try {
486
+ d = (await call("GET", root + "/api/biz/customer-package-reset/list?targetType=TEAM", { auth, headers })) ?? {}
487
+ } catch {
488
+ return null
489
+ }
490
+ let until = 0
491
+ const count = (rs) => {
492
+ let n = 0
493
+ for (const x of Array.isArray(rs) ? rs : []) {
494
+ if (x?.available !== true) continue
495
+ n++
496
+ const t = when(x.expireTime)
497
+ if (t && (!until || t < until)) until = t
498
+ }
499
+ return n
500
+ }
501
+ const fiveHour = count(d.fiveHourResets)
502
+ const weekly = count(d.weekResets)
503
+ if (fiveHour + weekly === 0) return null
504
+ const r = { count: fiveHour + weekly, byWindow: true, fiveHour, weekly }
505
+ if (until) r.until = new Date(until).toISOString()
506
+ return r
507
+ }
508
+
239
509
  // ---- signing in ------------------------------------------------------------------
240
510
 
241
511
  // bizAuth is the Authorization a sign-in gives the business API: Z.ai's
@@ -687,6 +957,29 @@ export async function ZCodeAuthPlugin({ client }) {
687
957
  },
688
958
  }
689
959
  },
960
+ // the plan's allowance, as magpie's built-in showed it
961
+ async usage(getAuth, provider) {
962
+ const auth = await getAuth()
963
+ const s = stateOf(auth)
964
+ if (!s) return { error: "not signed in" }
965
+ try {
966
+ if (isTeam(s)) {
967
+ let key = s.key
968
+ if (!key) {
969
+ key = await teamKey(s)
970
+ if (auth?.type === "oauth") {
971
+ const next = { ...auth, refresh: JSON.stringify({ ...s, key }), access: key }
972
+ await client?.auth?.set?.({ path: { id: provider?.id ?? PROVIDER }, body: next }).catch?.(() => {})
973
+ }
974
+ }
975
+ return await teamUsage(s, key)
976
+ }
977
+ if (await onStart(s)) return await startUsage(s)
978
+ return await codingUsage(s)
979
+ } catch (e) {
980
+ return { error: e?.message ?? String(e) }
981
+ }
982
+ },
690
983
  methods: [
691
984
  oauth("zai"),
692
985
  oauth("bigmodel"),
@@ -709,3 +1002,6 @@ export async function ZCodeAuthPlugin({ client }) {
709
1002
  },
710
1003
  }
711
1004
  }
1005
+
1006
+ // for tests
1007
+ export const _internal = { limitWindows, termOf, startUsage, routes, teamKeys }
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.1",
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",