@magpie-community/opencode-zcode-auth 0.1.8 → 0.1.10

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 +31 -18
  2. package/index.mjs +151 -55
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -54,30 +54,32 @@ Before each request, the plugin checks which plan the account is on, and
54
54
  asks again every 10 minutes:
55
55
 
56
56
  - If the key's account has a GLM Coding Plan
57
- (`/api/biz/subscription/list`), its requests go to that plan —
58
- except for a model the account's gift plans also serve, which goes to
59
- the gift first (see below).
57
+ (`/api/biz/subscription/list`), its requests go to that plan. A model
58
+ the account's gift plans also serve is listed again as
59
+ `<model>-Trial`; asking that entry spends the gift (see below).
60
60
  - Otherwise they go to the gift plans.
61
61
 
62
62
  Every request carries the key as both `x-api-key` and
63
63
  `Authorization: Bearer`.
64
64
 
65
- **Spending the gift first.** An account with a GLM Coding Plan can also
66
- hold gift plans from ZCode activities, ones that expire on a fixed date.
67
- Their allowance would lapse unused if every request went to the coding
68
- plan, so the plugin reads the gift's balance (per the sign-in's session,
69
- kept for 10 minutes) and sends a request for a model the gift still has
70
- quota to spend to the gift first, dressed as ZCode's own. A bucket is
71
- judged spent only by its `remaining_units` — a missing one is unknown,
72
- not spent — and a model whose every bucket says none goes straight to
73
- the coding plan without asking. When the gift can't serve the request —
74
- any answer 400 or over (its 405, its 401, its 5xx), or its quota error
65
+ **The gift's trial entries.** An account with a GLM Coding Plan can
66
+ also hold gift plans from ZCode activities, ones that expire on a fixed
67
+ date. Their allowance would lapse unused if every request went to the
68
+ coding plan, so the plugin reads the gift's balance (per the sign-in's
69
+ session, kept for 10 minutes) and lists every model a live bucket
70
+ serves again as `<model>-Trial`, named in the catalog's spelling
71
+ (GLM-5.3-Flash-Trial). Asking a trial entry sends the model to the
72
+ gift, dressed as ZCode's own; asking the plain model spends the coding
73
+ plan — the choice is the user's. A bucket is judged spent only by its
74
+ `remaining_units` — a missing one is unknown, not spent. A trial entry
75
+ whose bucket is spent or blocked answers 429 without touching the
76
+ coding plan; when the gift answers a trial entry with a quota error
75
77
  (code 1113 "Insufficient balance or no resource package", code 1005
76
- "exceed quota limit", which it can answer inside an HTTP 200) — the
77
- plugin replays the request once to the coding plan, as the agent sent
78
- it. Only a quota answer keeps that model off the gift for a minute; a
79
- refusal that says nothing about quota is asked again next request. Every
80
- 429 counts as spent, Z.ai's 1302/1303 rate limits among them. A stream's
78
+ "exceed quota limit", which it can answer inside an HTTP 200) that too
79
+ becomes a 429 and keeps the entry off for a minute. Any other gift
80
+ answer (its 405, its 401, its 5xx) goes on as it came: the user picked
81
+ the gift, so the coding plan is not spent behind their back. Every 429
82
+ counts as spent, Z.ai's 1302/1303 rate limits among them. A stream's
81
83
  body is never read or buffered on the way.
82
84
 
83
85
  ## The usage card
@@ -105,3 +107,14 @@ minutes. When the config can't be read, the plugin falls back to this list:
105
107
  | GLM-5-Turbo | 200K | 64K | none, high |
106
108
 
107
109
  The Start Plan has all of these except GLM-5.3.
110
+
111
+ A gift plan's models appear again as `<model>-Trial` entries, so a
112
+ request can spend the gift's quota on purpose. A trial entry is listed
113
+ while any live bucket serves its model, spent buckets included (a spent
114
+ entry answers 429 rather than vanishing mid-session).
115
+
116
+ Two plans can give one model (ZCode Trust Build and the Start Plan both
117
+ give GLM-5.3-Flash). The server picks the bucket, so the usage card
118
+ counts the model on its live bucket, the least used, and sets a spent
119
+ sibling aside. A spent sibling never makes the model, or the account,
120
+ look used up while another bucket still has quota.
package/index.mjs CHANGED
@@ -130,6 +130,29 @@ const num = (v) => {
130
130
  return typeof n === "number" && Number.isFinite(n) ? n : undefined
131
131
  }
132
132
 
133
+ // the alias is the catalog spelling of the model plus -trial, so the picker
134
+ // reads GLM-5.3-Trial while the loader strips it back to the raw name
135
+ const giftID = (m) => (MODELS.find((x) => x.id.toLowerCase() === String(m).toLowerCase())?.id ?? m) + "-Trial"
136
+ const uniqueModels = (names) => {
137
+ const byName = new Map()
138
+ for (const m of names) if (!byName.has(m.toLowerCase())) byName.set(m.toLowerCase(), m)
139
+ return [...byName.values()]
140
+ }
141
+ const giftNames = (g) => g?.allModels ?? g?.models ?? []
142
+ const giftModel = (g, id) => giftNames(g).find((m) => giftID(m).toLowerCase() === id.toLowerCase())
143
+ const bucketModels = (x) => (Array.isArray(x.capabilities) ? x.capabilities : [])
144
+ .map((c) => String(c ?? "").trim()).filter((c) => c.startsWith("model:") || !c.includes(":"))
145
+ .map((c) => c.replace(/^model:/, "").trim()).filter(Boolean)
146
+
147
+ // Both the card and the request route use the same test for a spent bucket.
148
+ // Older balances may omit remaining_units but still tell used and total.
149
+ const giftSpent = (x) => {
150
+ const left = num(x.remaining_units)
151
+ if (left !== undefined) return left <= 0
152
+ const total = num(x.total_units), used = num(x.used_units)
153
+ return total !== undefined && used !== undefined && used >= total
154
+ }
155
+
133
156
  // startPlan is the gift an account can spend now ({models}), as ZCode reads
134
157
  // its balance: several plans can be active at once, and a model routes to
135
158
  // the gift when any of them has a live bucket serving it — a bucket past
@@ -143,7 +166,8 @@ async function startPlan(jwt, device) {
143
166
  const same = (x, p) => (x.user_plan_id && p.user_plan_id ? x.user_plan_id === p.user_plan_id : String(x.plan_id ?? "") === String(p.plan_id ?? ""))
144
167
  const plans = Array.isArray(b.plans) ? b.plans : []
145
168
  const balances = Array.isArray(b.balances) ? b.balances : []
146
- const ms = new Set()
169
+ const ms = new Set(), all = new Map()
170
+ let ttl = 600_000
147
171
  let any = false
148
172
  let name = ""
149
173
  for (const p of plans) {
@@ -151,22 +175,21 @@ async function startPlan(jwt, device) {
151
175
  const end = num(p?.ends_at)
152
176
  if (st !== "active" || (end > 0 && end <= now)) continue
153
177
  any = true
178
+ if (end > 0) ttl = Math.min(ttl, (end - now) * 1000)
154
179
  if (!name) name = String(p?.name ?? "").trim()
155
180
  for (const x of balances) {
156
181
  if (!same(x, p)) continue
157
182
  const exp = num(x.expires_at)
158
183
  if (exp > 0 && exp <= now) continue
159
- // a bucket spends when it has quota: only remaining_units says so, and
160
- // a model whose every bucket says none drops out of models, so its
161
- // requests go to the coding plan without first asking the gift
162
- if ((num(x.remaining_units) ?? 1) <= 0) continue
163
- for (const c of Array.isArray(x.capabilities) ? x.capabilities : []) {
164
- const m = String(c ?? "").trim().replace(/^model:/, "").trim()
165
- if (m) ms.add(m)
184
+ if (exp > 0) ttl = Math.min(ttl, (exp - now) * 1000)
185
+ // Spent buckets still declare models; only live ones can spend now.
186
+ for (const m of bucketModels(x)) {
187
+ if (!all.has(m.toLowerCase())) all.set(m.toLowerCase(), m)
188
+ if (!giftSpent(x)) ms.add(m)
166
189
  }
167
190
  }
168
191
  }
169
- return any ? { name: name || "Start Plan", models: [...ms] } : null
192
+ return any ? { name: name || "Start Plan", models: [...ms], allModels: [...all.values()], ttl } : null
170
193
  }
171
194
 
172
195
  // plansOf says where an account's requests go and what more it can spend:
@@ -202,7 +225,7 @@ async function plansOf(s) {
202
225
  sure = false
203
226
  } catch {}
204
227
  }
205
- const out = { start, gift, at: Date.now(), ttl: sure ? 600_000 : 60_000 }
228
+ const out = { start, gift, at: Date.now(), ttl: Math.min(sure ? 600_000 : 60_000, gift?.ttl ?? 600_000) }
206
229
  routes.set(id, out)
207
230
  return out
208
231
  }
@@ -214,6 +237,8 @@ const blocked = new Map()
214
237
  // giftServes: the gift's buckets name their models lowercased ("glm-5.3-
215
238
  // flash"); what is asked for arrives as the plan lists it ("GLM-5.3-Flash").
216
239
  const giftServes = (gift, m) => (gift.models ?? []).some((x) => x.toLowerCase() === m.toLowerCase())
240
+ const giftUsable = (s, gift, m) => !jwtExpired(s.jwt) && giftServes(gift, m) &&
241
+ Date.now() >= (blocked.get(s.key + "\0" + s.jwt + "\0" + m.toLowerCase()) ?? 0)
217
242
 
218
243
  // spentUp is the gift saying it has nothing left to spend: its own no-
219
244
  // resource-package code 1113, its "exceed quota limit" 1005 — a spent gift
@@ -478,15 +503,15 @@ async function giftOf(s) {
478
503
  const total = startNum(x.total_units), left = startNum(x.remaining_units)
479
504
  let used = startNum(x.used_units)
480
505
  if (total === undefined && used === undefined && left === undefined) continue
481
- const models = (Array.isArray(x.capabilities) ? x.capabilities : [])
482
- .map((c) => String(c ?? "").trim().replace(/^model:/, "").trim()).filter(Boolean)
506
+ const models = bucketModels(x)
483
507
  const base = first(str(x.show_name), models.join(", "), "Credits")
484
- const w = { name: base, used: 0, _plan: str(owner.name) }
508
+ const w = { name: base, used: 0, _plan: str(owner.name), _spent: giftSpent(x) }
485
509
  if (used === undefined) used = total !== undefined && left !== undefined ? total - left : 0
486
510
  if (total > 0) {
487
511
  w.used = (100 * used) / total
488
512
  w.display = `${compact(used)} / ${compact(total)}`
489
513
  }
514
+ if (w._spent) w.used = Math.max(100, w.used)
490
515
  if (exp > 0) w.resetsAt = new Date(Math.trunc(exp) * 1000).toISOString()
491
516
  let span = 0
492
517
  for (const p of plans) {
@@ -513,27 +538,85 @@ const NO_START = "this account has no GLM Coding Plan, and ZCode's Start Plan ha
513
538
  async function startUsage(s) {
514
539
  const g = await giftOf(s)
515
540
  if (!g) return { error: NO_START }
516
- for (const w of g.windows) delete w._plan
541
+ const best = bestBuckets(g.windows)
542
+ g.windows = g.windows.map(({ _plan, _spent, ...w }, i) => {
543
+ if (!w.models?.length) return w
544
+ // a model two plans give (Trust Build's and the Start Plan's Flash)
545
+ // counts on the bucket ZCode spends now; the other stays on the card
546
+ // without holding the model up (ganlerk, Discord)
547
+ const models = w.models.filter((m) => best.get(m.toLowerCase()) === i)
548
+ if (!models.length) {
549
+ delete w.models
550
+ return { ...w, aside: true }
551
+ }
552
+ return { ...w, models: [...models, ...models.map(giftID)] }
553
+ })
517
554
  return g
518
555
  }
519
556
 
520
- // dualUsage is an account with both a Coding Plan and a gift: its card shows
521
- // both allowances, the Coding windows first and the gift's buckets after,
522
- // each keeping its own models and end. The gift's windows carry their plan's
523
- // name as a prefix so they are not mistaken for the coding plan's own. A
524
- // gift read that fails leaves just the Coding card: magpie hides a card's
525
- // windows behind an error and leaves it out of the menu bar and auto-switch,
526
- // so erroring here would hide a working Coding Plan over a gift hiccup.
557
+ // bestBuckets is, for each model the gift's buckets serve, the index of
558
+ // the one a request spends: a live one before a spent one, the least
559
+ // used of those. The server picks among an account's buckets itself
560
+ // (ZCode sends no plan with a request), so one spent bucket doesn't stop
561
+ // a model another still has.
562
+ function bestBuckets(windows) {
563
+ const best = new Map()
564
+ windows.forEach((w, i) => {
565
+ for (const m of w.models ?? []) {
566
+ const key = m.toLowerCase(), old = best.get(key), prev = windows[old]
567
+ if (old === undefined || (prev._spent && !w._spent) || (prev._spent === w._spent && w.used < prev.used)) best.set(key, i)
568
+ }
569
+ })
570
+ return best
571
+ }
572
+
573
+ // dualUsage shows both pools but only the pool a request can use counts
574
+ // for its model. Live gift models don't spend the Coding windows; spent
575
+ // or blocked gift buckets stay visible without holding up the Coding Plan.
576
+ // Fresh usage also updates the request cache, so a replenished or spent
577
+ // gift takes effect before its ten-minute route cache expires. A failed
578
+ // gift read leaves the working Coding Plan's card and route alone.
527
579
  async function dualUsage(s) {
528
580
  const out = await codingUsage(s)
581
+ const cached = routes.get(s.key + "\0" + s.jwt)
529
582
  try {
530
583
  const g = await giftOf(s)
531
- if (g) out.windows = [...out.windows, ...g.windows.map((w) => {
532
- const p = w._plan
533
- delete w._plan
534
- return p && p !== w.name ? { ...w, name: p + " · " + w.name } : w
535
- })]
536
- } catch {}
584
+ const gift = g ? { name: g.plan, models: [...new Set(g.windows.filter((w) => !w._spent)
585
+ .flatMap((w) => w.models ?? []).map((m) => m.toLowerCase()))],
586
+ allModels: uniqueModels(g.windows.flatMap((w) => w.models ?? [])) } : null
587
+ if (cached) {
588
+ cached.gift = gift
589
+ const ends = g ? [g.until, ...g.windows.map((w) => w.resetsAt)].map((t) => Date.parse(t)).filter(Number.isFinite) : []
590
+ if (ends.length) cached.ttl = Math.min(cached.ttl, Math.max(0, Math.min(...ends) - cached.at))
591
+ }
592
+ if (g) {
593
+ // the gift's models are its trial entries alone: a plain model stays
594
+ // on the coding plan, and the user picks the gift by its entry
595
+ const notModels = gift.allModels.map((m) => giftID(m).toLowerCase())
596
+ if (notModels.length) out.windows = out.windows.map((w) => w.aside ? w : { ...w, notModels })
597
+ // Several buckets may serve one model: one spent sibling cannot block it.
598
+ const best = bestBuckets(g.windows)
599
+ out.windows.push(...g.windows.map(({ _plan: p, _spent, ...w }, i) => {
600
+ const selected = gift.allModels.filter((m) => best.get(m.toLowerCase()) === i)
601
+ const models = selected.map(giftID)
602
+ const scoped = models.length ? { ...w, models } : { ...w, aside: true }
603
+ // the card names a bucket's model as the picker does (GLM-5.3-Flash-Trial);
604
+ // no plan prefix — the card's section header already says the account
605
+ let name = w.name
606
+ for (const m of selected) {
607
+ const at = name.toLowerCase().indexOf(m.toLowerCase())
608
+ if (at >= 0) name = name.slice(0, at) + giftID(m) + name.slice(at + m.length)
609
+ }
610
+ return { ...scoped, name }
611
+ }))
612
+ }
613
+ } catch {
614
+ if (cached) {
615
+ cached.gift = null
616
+ cached.at = Date.now()
617
+ cached.ttl = 60_000 // an uncertain gift is read again sooner
618
+ }
619
+ }
537
620
  return out
538
621
  }
539
622
 
@@ -1285,15 +1368,24 @@ export async function ZCodeAuthPlugin({ client }) {
1285
1368
  async models(provider, { auth } = {}) {
1286
1369
  const s = stateOf(auth)
1287
1370
  if (!s) return provider.models
1288
- const start = await plansOf(s).then((r) => r.start).catch(() => false)
1371
+ const { start, gift: known } = await plansOf(s).catch(() => ({ start: false, gift: null }))
1372
+ const gift = start ? await startPlan(s.jwt, s.device).catch(() => null) : known
1289
1373
  const base = start ? START_BASE : s.base
1290
- let ms = modelsOf(await zcodeConfig().catch(() => null), planID(base))
1374
+ const cfg = await zcodeConfig().catch(() => null)
1375
+ let ms = modelsOf(cfg, planID(base))
1291
1376
  // ZCode's config can't be had: its table stands in, and magpie keeps
1292
1377
  // the list it was told last, as the built-in keeps the one it fetched last
1293
1378
  const fell = !ms.length
1294
1379
  if (fell) ms = start ? START_MODELS : MODELS
1295
1380
  const url = s.base + "/v1"
1296
1381
  const out = Object.fromEntries(ms.map((m) => [m.id, model(m, url)]))
1382
+ const details = [...ms, ...modelsOf(cfg, planID(START_BASE)), ...START_MODELS]
1383
+ for (const raw of giftNames(gift)) {
1384
+ const id = giftID(raw)
1385
+ if (out[id]) continue
1386
+ const m = details.find((m) => m.id.toLowerCase() === raw.toLowerCase()) ?? { efforts: [] }
1387
+ out[id] = model({ ...m, id }, url)
1388
+ }
1297
1389
  if (fell) out[Symbol.for("magpie.fellBack")] = true
1298
1390
  return out
1299
1391
  },
@@ -1313,7 +1405,8 @@ export async function ZCodeAuthPlugin({ client }) {
1313
1405
  if (!s) throw new Error("not signed in to ZCode")
1314
1406
  let url = input instanceof Request ? input.url : String(input)
1315
1407
  const opts = input instanceof Request ? { method: input.method, headers: input.headers, body: input.body, signal: input.signal, duplex: "half", ...init } : { ...init }
1316
- const { start: alone, gift } = await plansOf(s)
1408
+ const { start: alone, gift: known } = await plansOf(s)
1409
+ let gift = known
1317
1410
  let key = s.key
1318
1411
  if (isTeam(s) && !key) {
1319
1412
  key = await teamKey(s)
@@ -1325,14 +1418,24 @@ export async function ZCodeAuthPlugin({ client }) {
1325
1418
  // a dual account's body is read ahead of sending, so a gift that
1326
1419
  // says it is spent can spend nothing and replay what was asked for
1327
1420
  let start = alone
1328
- let orig
1329
- if (!alone && gift && opts.body != null) {
1421
+ let orig, forcedGift = false
1422
+ if (opts.body != null) {
1330
1423
  orig = opts.body = typeof opts.body === "string" ? opts.body : await new Response(opts.body).text()
1331
1424
  delete opts.duplex
1332
- const m = modelOf(orig)
1333
- // a ZCode sign-in past its end reaches no gift: the coding plan
1334
- // takes the request, as it did before the gift was known
1335
- start = !!m && !jwtExpired(s.jwt) && giftServes(gift, m) && Date.now() >= (blocked.get(s.key + "\0" + s.jwt + "\0" + m) ?? 0)
1425
+ let m = modelOf(orig)
1426
+ if (m.toLowerCase().endsWith("-trial")) {
1427
+ gift ??= await startPlan(s.jwt, s.device).catch(() => null)
1428
+ const raw = giftModel(gift, m)
1429
+ const available = raw && giftUsable(s, gift, raw)
1430
+ if (!available) {
1431
+ const error = { type: raw ? "rate_limit_error" : "invalid_request_error", message: raw ? `ZCode's limited quota for ${raw} is exhausted or temporarily unavailable` : `ZCode has no active limited quota for ${m}` }
1432
+ return kept(new Response(JSON.stringify({ type: "error", error }), { status: raw ? 429 : 400, headers: { "content-type": "application/json" } }))
1433
+ }
1434
+ orig = opts.body = JSON.stringify({ ...JSON.parse(orig), model: raw })
1435
+ m = raw
1436
+ forcedGift = true
1437
+ }
1438
+ start = alone || forcedGift
1336
1439
  }
1337
1440
  const swap = (base) => {
1338
1441
  for (const b of [START_BASE, ZAI_BASE, BIGMODEL_BASE]) {
@@ -1363,18 +1466,13 @@ export async function ZCodeAuthPlugin({ client }) {
1363
1466
  h.set("Authorization", "Bearer " + key)
1364
1467
  }
1365
1468
  let res = await fetch(url, { ...opts, headers: h })
1366
- // a dual account's gift answer that can't serve the request goes
1367
- // to its coding plan instead, as the agent sent it: any status
1368
- // 400 or over (its 405, its 401, its 5xx — a paid plan is waiting,
1369
- // and a gift that keeps refusing would otherwise black out the
1370
- // account), and its quota answers inside a 200. Only a quota
1371
- // answer keeps that model off the gift for a minute: a refusal
1372
- // that says nothing about quota is asked again next request.
1373
- // Every 429 counts as spent, Z.ai's 1302/1303 rate limits among
1374
- // them — replaying one to the coding plan is the good answer, and
1375
- // the minute's block keeps a rate limit from being knocked twice.
1376
- // A stream's body is never read here.
1377
- if (start && !alone) {
1469
+ // a trial entry's gift answer that can't serve the request is
1470
+ // the agent's answer, not the coding plan's: the user picked the
1471
+ // gift, so a spent bucket says 429 (its quota error inside a 200
1472
+ // among them) and anything else goes on as it came. A quota
1473
+ // answer keeps that model off the gift for a minute, so a rate
1474
+ // limit is not knocked twice. A stream's body is never read here.
1475
+ if (start && forcedGift) {
1378
1476
  const json = (res.headers.get("content-type") ?? "").includes("application/json")
1379
1477
  if (res.status >= 400 || json) {
1380
1478
  const text = await res.text()
@@ -1382,14 +1480,9 @@ export async function ZCodeAuthPlugin({ client }) {
1382
1480
  if (!spent && res.status < 400) return kept(res, text)
1383
1481
  if (spent) {
1384
1482
  for (const [k, until] of blocked) if (Date.now() >= until) blocked.delete(k)
1385
- blocked.set(s.key + "\0" + s.jwt + "\0" + modelOf(orig), Date.now() + 60_000)
1483
+ blocked.set(s.key + "\0" + s.jwt + "\0" + modelOf(orig).toLowerCase(), Date.now() + 60_000)
1386
1484
  }
1387
- swap(s.base)
1388
- h = new Headers(opts.headers)
1389
- h.delete("authorization")
1390
- h.set("x-api-key", key)
1391
- h.set("Authorization", "Bearer " + key)
1392
- res = await fetch(url, { ...opts, body: orig, headers: h })
1485
+ return spent && res.status < 400 ? kept(new Response(text, { status: 429, headers: res.headers })) : kept(res, text)
1393
1486
  }
1394
1487
  }
1395
1488
  // the plan's answer goes on as it came, the account kept: the
@@ -1411,6 +1504,9 @@ export async function ZCodeAuthPlugin({ client }) {
1411
1504
  const saved = (out) => {
1412
1505
  const plan = s.plan || auth?.plan
1413
1506
  if (plan && !out.plan) out.plan = plan
1507
+ // MCP reads as a footnote: its window goes last on the card
1508
+ if (Array.isArray(out.windows) && out.windows.some((w) => w.name === "MCP · Month"))
1509
+ out.windows = [...out.windows.filter((w) => w.name !== "MCP · Month"), ...out.windows.filter((w) => w.name === "MCP · Month")]
1414
1510
  out.signIn = "kept"
1415
1511
  return out
1416
1512
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-zcode-auth",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
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",