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

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 +25 -18
  2. package/index.mjs +133 -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,8 @@ 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).
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,67 @@ 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
+ for (const w of g.windows) {
542
+ if (w.models?.length) w.models = [...w.models, ...w.models.map(giftID)]
543
+ delete w._plan
544
+ delete w._spent
545
+ }
517
546
  return g
518
547
  }
519
548
 
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.
549
+ // dualUsage shows both pools but only the pool a request can use counts
550
+ // for its model. Live gift models don't spend the Coding windows; spent
551
+ // or blocked gift buckets stay visible without holding up the Coding Plan.
552
+ // Fresh usage also updates the request cache, so a replenished or spent
553
+ // gift takes effect before its ten-minute route cache expires. A failed
554
+ // gift read leaves the working Coding Plan's card and route alone.
527
555
  async function dualUsage(s) {
528
556
  const out = await codingUsage(s)
557
+ const cached = routes.get(s.key + "\0" + s.jwt)
529
558
  try {
530
559
  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 {}
560
+ const gift = g ? { name: g.plan, models: [...new Set(g.windows.filter((w) => !w._spent)
561
+ .flatMap((w) => w.models ?? []).map((m) => m.toLowerCase()))],
562
+ allModels: uniqueModels(g.windows.flatMap((w) => w.models ?? [])) } : null
563
+ if (cached) {
564
+ cached.gift = gift
565
+ const ends = g ? [g.until, ...g.windows.map((w) => w.resetsAt)].map((t) => Date.parse(t)).filter(Number.isFinite) : []
566
+ if (ends.length) cached.ttl = Math.min(cached.ttl, Math.max(0, Math.min(...ends) - cached.at))
567
+ }
568
+ if (g) {
569
+ // the gift's models are its trial entries alone: a plain model stays
570
+ // on the coding plan, and the user picks the gift by its entry
571
+ const notModels = gift.allModels.map((m) => giftID(m).toLowerCase())
572
+ if (notModels.length) out.windows = out.windows.map((w) => w.aside ? w : { ...w, notModels })
573
+ // Several buckets may serve one model: one spent sibling cannot block it.
574
+ const best = new Map()
575
+ g.windows.forEach((w, i) => {
576
+ for (const m of w.models ?? []) {
577
+ const key = m.toLowerCase(), old = best.get(key), prev = g.windows[old]
578
+ if (old === undefined || (prev._spent && !w._spent) || (prev._spent === w._spent && w.used < prev.used)) best.set(key, i)
579
+ }
580
+ })
581
+ out.windows.push(...g.windows.map(({ _plan: p, _spent, ...w }, i) => {
582
+ const selected = gift.allModels.filter((m) => best.get(m.toLowerCase()) === i)
583
+ const models = selected.map(giftID)
584
+ const scoped = models.length ? { ...w, models } : { ...w, aside: true }
585
+ // the card names a bucket's model as the picker does (GLM-5.3-Flash-Trial);
586
+ // no plan prefix — the card's section header already says the account
587
+ let name = w.name
588
+ for (const m of selected) {
589
+ const at = name.toLowerCase().indexOf(m.toLowerCase())
590
+ if (at >= 0) name = name.slice(0, at) + giftID(m) + name.slice(at + m.length)
591
+ }
592
+ return { ...scoped, name }
593
+ }))
594
+ }
595
+ } catch {
596
+ if (cached) {
597
+ cached.gift = null
598
+ cached.at = Date.now()
599
+ cached.ttl = 60_000 // an uncertain gift is read again sooner
600
+ }
601
+ }
537
602
  return out
538
603
  }
539
604
 
@@ -1285,15 +1350,24 @@ export async function ZCodeAuthPlugin({ client }) {
1285
1350
  async models(provider, { auth } = {}) {
1286
1351
  const s = stateOf(auth)
1287
1352
  if (!s) return provider.models
1288
- const start = await plansOf(s).then((r) => r.start).catch(() => false)
1353
+ const { start, gift: known } = await plansOf(s).catch(() => ({ start: false, gift: null }))
1354
+ const gift = start ? await startPlan(s.jwt, s.device).catch(() => null) : known
1289
1355
  const base = start ? START_BASE : s.base
1290
- let ms = modelsOf(await zcodeConfig().catch(() => null), planID(base))
1356
+ const cfg = await zcodeConfig().catch(() => null)
1357
+ let ms = modelsOf(cfg, planID(base))
1291
1358
  // ZCode's config can't be had: its table stands in, and magpie keeps
1292
1359
  // the list it was told last, as the built-in keeps the one it fetched last
1293
1360
  const fell = !ms.length
1294
1361
  if (fell) ms = start ? START_MODELS : MODELS
1295
1362
  const url = s.base + "/v1"
1296
1363
  const out = Object.fromEntries(ms.map((m) => [m.id, model(m, url)]))
1364
+ const details = [...ms, ...modelsOf(cfg, planID(START_BASE)), ...START_MODELS]
1365
+ for (const raw of giftNames(gift)) {
1366
+ const id = giftID(raw)
1367
+ if (out[id]) continue
1368
+ const m = details.find((m) => m.id.toLowerCase() === raw.toLowerCase()) ?? { efforts: [] }
1369
+ out[id] = model({ ...m, id }, url)
1370
+ }
1297
1371
  if (fell) out[Symbol.for("magpie.fellBack")] = true
1298
1372
  return out
1299
1373
  },
@@ -1313,7 +1387,8 @@ export async function ZCodeAuthPlugin({ client }) {
1313
1387
  if (!s) throw new Error("not signed in to ZCode")
1314
1388
  let url = input instanceof Request ? input.url : String(input)
1315
1389
  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)
1390
+ const { start: alone, gift: known } = await plansOf(s)
1391
+ let gift = known
1317
1392
  let key = s.key
1318
1393
  if (isTeam(s) && !key) {
1319
1394
  key = await teamKey(s)
@@ -1325,14 +1400,24 @@ export async function ZCodeAuthPlugin({ client }) {
1325
1400
  // a dual account's body is read ahead of sending, so a gift that
1326
1401
  // says it is spent can spend nothing and replay what was asked for
1327
1402
  let start = alone
1328
- let orig
1329
- if (!alone && gift && opts.body != null) {
1403
+ let orig, forcedGift = false
1404
+ if (opts.body != null) {
1330
1405
  orig = opts.body = typeof opts.body === "string" ? opts.body : await new Response(opts.body).text()
1331
1406
  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)
1407
+ let m = modelOf(orig)
1408
+ if (m.toLowerCase().endsWith("-trial")) {
1409
+ gift ??= await startPlan(s.jwt, s.device).catch(() => null)
1410
+ const raw = giftModel(gift, m)
1411
+ const available = raw && giftUsable(s, gift, raw)
1412
+ if (!available) {
1413
+ 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}` }
1414
+ return kept(new Response(JSON.stringify({ type: "error", error }), { status: raw ? 429 : 400, headers: { "content-type": "application/json" } }))
1415
+ }
1416
+ orig = opts.body = JSON.stringify({ ...JSON.parse(orig), model: raw })
1417
+ m = raw
1418
+ forcedGift = true
1419
+ }
1420
+ start = alone || forcedGift
1336
1421
  }
1337
1422
  const swap = (base) => {
1338
1423
  for (const b of [START_BASE, ZAI_BASE, BIGMODEL_BASE]) {
@@ -1363,18 +1448,13 @@ export async function ZCodeAuthPlugin({ client }) {
1363
1448
  h.set("Authorization", "Bearer " + key)
1364
1449
  }
1365
1450
  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) {
1451
+ // a trial entry's gift answer that can't serve the request is
1452
+ // the agent's answer, not the coding plan's: the user picked the
1453
+ // gift, so a spent bucket says 429 (its quota error inside a 200
1454
+ // among them) and anything else goes on as it came. A quota
1455
+ // answer keeps that model off the gift for a minute, so a rate
1456
+ // limit is not knocked twice. A stream's body is never read here.
1457
+ if (start && forcedGift) {
1378
1458
  const json = (res.headers.get("content-type") ?? "").includes("application/json")
1379
1459
  if (res.status >= 400 || json) {
1380
1460
  const text = await res.text()
@@ -1382,14 +1462,9 @@ export async function ZCodeAuthPlugin({ client }) {
1382
1462
  if (!spent && res.status < 400) return kept(res, text)
1383
1463
  if (spent) {
1384
1464
  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)
1465
+ blocked.set(s.key + "\0" + s.jwt + "\0" + modelOf(orig).toLowerCase(), Date.now() + 60_000)
1386
1466
  }
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 })
1467
+ return spent && res.status < 400 ? kept(new Response(text, { status: 429, headers: res.headers })) : kept(res, text)
1393
1468
  }
1394
1469
  }
1395
1470
  // the plan's answer goes on as it came, the account kept: the
@@ -1411,6 +1486,9 @@ export async function ZCodeAuthPlugin({ client }) {
1411
1486
  const saved = (out) => {
1412
1487
  const plan = s.plan || auth?.plan
1413
1488
  if (plan && !out.plan) out.plan = plan
1489
+ // MCP reads as a footnote: its window goes last on the card
1490
+ if (Array.isArray(out.windows) && out.windows.some((w) => w.name === "MCP · Month"))
1491
+ out.windows = [...out.windows.filter((w) => w.name !== "MCP · Month"), ...out.windows.filter((w) => w.name === "MCP · Month")]
1414
1492
  out.signIn = "kept"
1415
1493
  return out
1416
1494
  }
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.9",
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",