@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.
- package/README.md +7 -1
- package/index.mjs +440 -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
|
|
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.
|
|
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",
|