@magpie-community/opencode-zcode-auth 0.1.7 → 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 +39 -2
  2. package/index.mjs +295 -58
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -54,12 +54,44 @@ 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`), requests go to that plan.
58
- - Otherwise they go to the Start Plan.
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
+ - Otherwise they go to the gift plans.
59
61
 
60
62
  Every request carries the key as both `x-api-key` and
61
63
  `Authorization: Bearer`.
62
64
 
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
77
+ (code 1113 "Insufficient balance or no resource package", code 1005
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
83
+ body is never read or buffered on the way.
84
+
85
+ ## The usage card
86
+
87
+ An account with both plans shows both allowances: the coding plan's
88
+ windows first, then the gift's buckets, each with its own name, models
89
+ and end. The card's plan and term stay the coding plan's. A gift read
90
+ that fails leaves just the coding card — magpie hides a card's windows
91
+ behind an error, so a gift hiccup must not black out a working Coding
92
+ Plan. A gift that goes out of date while requests use it simply stops
93
+ showing.
94
+
63
95
  ## Models
64
96
 
65
97
  The models are the ones ZCode offers for the account's plan. They come
@@ -75,3 +107,8 @@ minutes. When the config can't be read, the plugin falls back to this list:
75
107
  | GLM-5-Turbo | 200K | 64K | none, high |
76
108
 
77
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,48 +130,128 @@ const num = (v) => {
130
130
  return typeof n === "number" && Number.isFinite(n) ? n : undefined
131
131
  }
132
132
 
133
- // startPlan is the Start Plan the account has now ({name, until}), as
134
- // ZCode reads its balance: an "active" plan past its end is over.
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
+
156
+ // startPlan is the gift an account can spend now ({models}), as ZCode reads
157
+ // its balance: several plans can be active at once, and a model routes to
158
+ // the gift when any of them has a live bucket serving it — a bucket past
159
+ // its plan's own end, or one whose remaining_units says none, is skipped
160
+ // rather than asked and refused. models are those live buckets' names.
135
161
  async function startPlan(jwt, device) {
136
162
  if (!jwt) throw new Error("not signed in to ZCode")
137
163
  if (jwtExpired(jwt)) throw new Error(EXPIRED)
138
164
  const b = (await call("GET", `${ZCODE}/api/v1/zcode-plan/billing/balance?app_version=${APP_VERSION}`, { auth: "Bearer " + jwt, device })) ?? {}
139
165
  const now = num(b.server_time) > 0 ? num(b.server_time) : Date.now() / 1000
140
- const isStart = (x) => x.includes("start-plan") || x.includes("start plan")
141
- for (const p of b.plans ?? []) {
142
- if (String(p.status ?? "").trim().toLowerCase() !== "active") continue
143
- const end = num(p.ends_at)
144
- if (end > 0 && end <= now) continue
145
- const id = String(p.plan_id ?? "").trim().toLowerCase()
146
- const n = String(p.name ?? "").trim().toLowerCase()
147
- if ((id || n) && !isStart(id) && !isStart(n)) continue
148
- return { name: first(p.name, "Start Plan"), until: end > 0 ? end * 1000 : 0 }
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 ?? ""))
167
+ const plans = Array.isArray(b.plans) ? b.plans : []
168
+ const balances = Array.isArray(b.balances) ? b.balances : []
169
+ const ms = new Set(), all = new Map()
170
+ let ttl = 600_000
171
+ let any = false
172
+ let name = ""
173
+ for (const p of plans) {
174
+ const st = String(p?.status ?? "").trim().toLowerCase()
175
+ const end = num(p?.ends_at)
176
+ if (st !== "active" || (end > 0 && end <= now)) continue
177
+ any = true
178
+ if (end > 0) ttl = Math.min(ttl, (end - now) * 1000)
179
+ if (!name) name = String(p?.name ?? "").trim()
180
+ for (const x of balances) {
181
+ if (!same(x, p)) continue
182
+ const exp = num(x.expires_at)
183
+ if (exp > 0 && exp <= now) continue
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)
189
+ }
190
+ }
149
191
  }
150
- return null
192
+ return any ? { name: name || "Start Plan", models: [...ms], allModels: [...all.values()], ttl } : null
151
193
  }
152
194
 
153
- // onStart says whether an account's requests go to the Start Plan: when it
154
- // has ZCode's token and no coding plan key, or a key whose account has no
155
- // coding plan. Asked again after 10 minutes (a minute when unsure).
195
+ // plansOf says where an account's requests go and what more it can spend:
196
+ // {start, gift}. start is what onStart said: an account with no coding plan
197
+ // sends every request to the gift and reads no balance to do it; a team's
198
+ // seat, a sign-in with no ZCode token and a key whose plan-list read found
199
+ // no gift stay on the coding plan. gift is what a coding account also holds:
200
+ // the gift plans ({models}), and its requests spend the gift for a model it
201
+ // serves, until it says it is spent (see blocked). Asked again after 10
202
+ // minutes (a minute when unsure — a plan-list read that failed is retried
203
+ // sooner; a gift read that failed simply leaves gift null until the next
204
+ // ask).
156
205
  const routes = new Map()
157
- async function onStart(s) {
158
- if (!s.jwt || isTeam(s)) return false // a team's seat is on the team's plan
159
- if (!s.key) return true
206
+ async function plansOf(s) {
207
+ if (isTeam(s) || !s.jwt) return { start: false, gift: null }
208
+ if (!s.key) return { start: true, gift: null }
160
209
  const id = s.key + "\0" + s.jwt
161
210
  const r = routes.get(id)
162
- if (r && Date.now() - r.at < r.ttl) return r.start
211
+ if (r && Date.now() - r.at < r.ttl) return r
163
212
  let start = false
213
+ let gift = null
164
214
  let sure = false
165
215
  try {
166
- start = !(await plan(s))
216
+ if (!(await plan(s))) start = true
217
+ else gift = await startPlan(s.jwt, s.device).catch(() => null)
167
218
  sure = true
168
219
  } catch {
220
+ // the plan list could not be read: a key account takes the dual route
221
+ // (start stays false, the gift is kept, a minute's ttl), so a gift
222
+ // refusal still replays to the coding plan rather than blacking out
169
223
  try {
170
- if (await startPlan(s.jwt, s.device)) start = sure = true
224
+ gift = await startPlan(s.jwt, s.device)
225
+ sure = false
171
226
  } catch {}
172
227
  }
173
- routes.set(id, { start, at: Date.now(), ttl: sure ? 600_000 : 60_000 })
174
- return start
228
+ const out = { start, gift, at: Date.now(), ttl: Math.min(sure ? 600_000 : 60_000, gift?.ttl ?? 600_000) }
229
+ routes.set(id, out)
230
+ return out
231
+ }
232
+
233
+ // blocked remembers the models a gift said it has none left for: a key+jwt+
234
+ // model for a minute, so one turn that is refused does not ask again.
235
+ const blocked = new Map()
236
+
237
+ // giftServes: the gift's buckets name their models lowercased ("glm-5.3-
238
+ // flash"); what is asked for arrives as the plan lists it ("GLM-5.3-Flash").
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)
242
+
243
+ // spentUp is the gift saying it has nothing left to spend: its own no-
244
+ // resource-package code 1113, its "exceed quota limit" 1005 — a spent gift
245
+ // can answer a 200 with a JSON body carrying one of those codes. Every 429
246
+ // counts as spent, Z.ai's 1302/1303 rate limits among them.
247
+ function spentUp(text, status) {
248
+ if (status === 429) return true
249
+ let b
250
+ try { b = JSON.parse(text) } catch { return false }
251
+ if (!b || typeof b !== "object") return false
252
+ const code = (v) => String(v ?? "")
253
+ const c1 = code(b.code), c2 = code(b.error?.code)
254
+ return c1 === "1113" || c1 === "1005" || c2 === "1113" || c2 === "1005"
175
255
  }
176
256
 
177
257
  // ---- keys ------------------------------------------------------------------------
@@ -366,10 +446,12 @@ function startNum(v) {
366
446
  return undefined
367
447
  }
368
448
 
369
- // startUsage is the Start Plan's allowance: a window for each of its
370
- // buckets, a model's tokens for the day or for the plan's time. A plan
371
- // still "active" past its end is over, and its buckets are left out.
372
- async function startUsage(s) {
449
+ // giftOf reads the balance and returns the gift's card fields: its plans and
450
+ // windows. Several plans can be active at once, so every live plan's buckets
451
+ // show, each keeping its own models and end; the plan and end put on the
452
+ // card (for a gift-only account) are the soonest-ending live plan's. null
453
+ // when there is no live plan to show.
454
+ async function giftOf(s) {
373
455
  if (!s.jwt) throw new Error("not signed in to ZCode")
374
456
  if (jwtExpired(s.jwt)) throw new Error(EXPIRED)
375
457
  const b = (await call("GET", `${ZCODE}/api/v1/zcode-plan/billing/balance?app_version=${APP_VERSION}`, { auth: "Bearer " + s.jwt, device: s.device })) ?? {}
@@ -382,7 +464,7 @@ async function startUsage(s) {
382
464
  if (end > 0 && end <= now && p.status.trim().toLowerCase() === "active") p.status = "expired"
383
465
  if (p.status.trim().toLowerCase() === "expired") over.add(p.user_plan_id + "\0" + p.plan_id)
384
466
  }
385
- 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)
467
+ 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 ?? ""))
386
468
  const balances = (Array.isArray(b.balances) ? b.balances : [])
387
469
  .map((x) => ({ ...x, user_plan_id: str(x?.user_plan_id), plan_id: str(x?.plan_id) }))
388
470
  .filter((x) => {
@@ -395,32 +477,41 @@ async function startUsage(s) {
395
477
  return keep
396
478
  })
397
479
 
480
+ // every live plan counts as the gift: no plan-name filter decides it,
481
+ // the balance says what the session can spend
398
482
  let active = null
399
483
  for (const p of plans) {
400
484
  if (p.status.trim().toLowerCase() !== "active") continue
401
- const id = p.plan_id.trim().toLowerCase(), n = str(p.name).trim().toLowerCase()
402
- const isStart = (x) => x.includes("start-plan") || x.includes("start plan")
403
- if ((id || n) && !isStart(id) && !isStart(n)) continue
404
485
  const end = startNum(p.ends_at)
405
- active = { name: first(str(p.name), "Start Plan"), until: end > 0 ? new Date(Math.trunc(end) * 1000).toISOString() : "" }
406
- break
486
+ if (end > 0 && end <= now) continue
487
+ if (active && !(end > 0 && (!startNum(active.end) || end < startNum(active.end)))) continue
488
+ active = { name: first(str(p.name), "Start Plan"), end, until: end > 0 ? new Date(Math.trunc(end) * 1000).toISOString() : "" }
407
489
  }
408
- if (!active) return { error: "this account has no GLM Coding Plan, and ZCode's Start Plan has ended or was never started" }
490
+ if (!active) return null
409
491
  const out = { plan: active.name, windows: [] }
410
492
  if (active.until) Object.assign(out, { until: active.until, renew: "off" })
493
+ const live = plans.filter((p) => {
494
+ if (p.status.trim().toLowerCase() !== "active") return false
495
+ const end = startNum(p.ends_at)
496
+ return !(end > 0 && end <= now)
497
+ })
411
498
  for (const x of balances) {
499
+ const owner = live.find((p) => same(x, p))
500
+ if (!owner) continue
501
+ const exp = startNum(x.expires_at)
502
+ if (exp > 0 && exp <= now) continue // a bucket past its own end is over
412
503
  const total = startNum(x.total_units), left = startNum(x.remaining_units)
413
504
  let used = startNum(x.used_units)
414
505
  if (total === undefined && used === undefined && left === undefined) continue
415
- const models = (Array.isArray(x.capabilities) ? x.capabilities : [])
416
- .map((c) => String(c ?? "").trim().replace(/^model:/, "").trim()).filter(Boolean)
417
- const w = { name: first(str(x.show_name), models.join(", "), "Credits"), used: 0 }
506
+ const models = bucketModels(x)
507
+ const base = first(str(x.show_name), models.join(", "), "Credits")
508
+ const w = { name: base, used: 0, _plan: str(owner.name), _spent: giftSpent(x) }
418
509
  if (used === undefined) used = total !== undefined && left !== undefined ? total - left : 0
419
510
  if (total > 0) {
420
511
  w.used = (100 * used) / total
421
512
  w.display = `${compact(used)} / ${compact(total)}`
422
513
  }
423
- const exp = startNum(x.expires_at)
514
+ if (w._spent) w.used = Math.max(100, w.used)
424
515
  if (exp > 0) w.resetsAt = new Date(Math.trunc(exp) * 1000).toISOString()
425
516
  let span = 0
426
517
  for (const p of plans) {
@@ -440,6 +531,77 @@ async function startUsage(s) {
440
531
  return out
441
532
  }
442
533
 
534
+ // startUsage is a gift-only account's card: its buckets, or the words
535
+ // for having none to show. The card's header already names the plan, so
536
+ // the windows keep their own names.
537
+ const NO_START = "this account has no GLM Coding Plan, and ZCode's Start Plan has ended or was never started"
538
+ async function startUsage(s) {
539
+ const g = await giftOf(s)
540
+ if (!g) return { error: NO_START }
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
+ }
546
+ return g
547
+ }
548
+
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.
555
+ async function dualUsage(s) {
556
+ const out = await codingUsage(s)
557
+ const cached = routes.get(s.key + "\0" + s.jwt)
558
+ try {
559
+ const g = await giftOf(s)
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
+ }
602
+ return out
603
+ }
604
+
443
605
  // periodOf reads an entitlement's period: "daily", "weekly", "monthly".
444
606
  function periodOf(p) {
445
607
  p = p.toLowerCase()
@@ -989,6 +1151,26 @@ const entry = (m) => ({
989
1151
  })
990
1152
 
991
1153
  // model is a model as OpenCode's provider hands it to provider.models.
1154
+ // modelOf reads the model a Messages body asks for.
1155
+ function modelOf(text) {
1156
+ try {
1157
+ const m = JSON.parse(text)?.model
1158
+ return typeof m === "string" ? m : ""
1159
+ } catch {
1160
+ return ""
1161
+ }
1162
+ }
1163
+
1164
+ // kept passes a response on as it came, the account kept: the built-in
1165
+ // never marked a ZCode account lapsed nor cleared one.
1166
+ function kept(res, text) {
1167
+ const h = new Headers(res.headers)
1168
+ h.delete("content-length")
1169
+ h.delete("content-encoding")
1170
+ h.set("X-Magpie-Sign-In", "kept")
1171
+ return new Response(text !== undefined ? text : res.body, { status: res.status, statusText: res.statusText, headers: h })
1172
+ }
1173
+
992
1174
  function model(m, url) {
993
1175
  const e = entry(m)
994
1176
  return {
@@ -1168,15 +1350,24 @@ export async function ZCodeAuthPlugin({ client }) {
1168
1350
  async models(provider, { auth } = {}) {
1169
1351
  const s = stateOf(auth)
1170
1352
  if (!s) return provider.models
1171
- const start = await onStart(s).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
1172
1355
  const base = start ? START_BASE : s.base
1173
- let ms = modelsOf(await zcodeConfig().catch(() => null), planID(base))
1356
+ const cfg = await zcodeConfig().catch(() => null)
1357
+ let ms = modelsOf(cfg, planID(base))
1174
1358
  // ZCode's config can't be had: its table stands in, and magpie keeps
1175
1359
  // the list it was told last, as the built-in keeps the one it fetched last
1176
1360
  const fell = !ms.length
1177
1361
  if (fell) ms = start ? START_MODELS : MODELS
1178
1362
  const url = s.base + "/v1"
1179
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
+ }
1180
1371
  if (fell) out[Symbol.for("magpie.fellBack")] = true
1181
1372
  return out
1182
1373
  },
@@ -1196,14 +1387,8 @@ export async function ZCodeAuthPlugin({ client }) {
1196
1387
  if (!s) throw new Error("not signed in to ZCode")
1197
1388
  let url = input instanceof Request ? input.url : String(input)
1198
1389
  const opts = input instanceof Request ? { method: input.method, headers: input.headers, body: input.body, signal: input.signal, duplex: "half", ...init } : { ...init }
1199
- const start = await onStart(s)
1200
- // the request goes to the plan the account is on, wherever it was made for
1201
- for (const b of [START_BASE, ZAI_BASE, BIGMODEL_BASE]) {
1202
- if (url.startsWith(b)) {
1203
- url = (start ? START_BASE : s.base) + url.slice(b.length)
1204
- break
1205
- }
1206
- }
1390
+ const { start: alone, gift: known } = await plansOf(s)
1391
+ let gift = known
1207
1392
  let key = s.key
1208
1393
  if (isTeam(s) && !key) {
1209
1394
  key = await teamKey(s)
@@ -1212,32 +1397,79 @@ export async function ZCodeAuthPlugin({ client }) {
1212
1397
  await client?.auth?.set?.({ path: { id }, body: next }).catch?.(() => {})
1213
1398
  }
1214
1399
  }
1215
- const h = new Headers(opts.headers)
1400
+ // a dual account's body is read ahead of sending, so a gift that
1401
+ // says it is spent can spend nothing and replay what was asked for
1402
+ let start = alone
1403
+ let orig, forcedGift = false
1404
+ if (opts.body != null) {
1405
+ orig = opts.body = typeof opts.body === "string" ? opts.body : await new Response(opts.body).text()
1406
+ delete opts.duplex
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
1421
+ }
1422
+ const swap = (base) => {
1423
+ for (const b of [START_BASE, ZAI_BASE, BIGMODEL_BASE]) {
1424
+ if (url.startsWith(b)) {
1425
+ url = base + url.slice(b.length)
1426
+ break
1427
+ }
1428
+ }
1429
+ }
1430
+ let h = new Headers(opts.headers)
1216
1431
  if (start) {
1217
1432
  if (jwtExpired(s.jwt)) throw new Error(EXPIRED)
1218
1433
  if (!s.jwt) throw new Error("ZCode's sign-in has no key; sign in again")
1434
+ swap(START_BASE)
1219
1435
  // the Start Plan is served only to what looks like ZCode's own request
1220
1436
  startHeaders(h, s.jwt)
1221
1437
  if (opts.body != null) {
1222
- const text = typeof opts.body === "string" ? opts.body : await new Response(opts.body).text()
1438
+ const text = orig ?? (typeof opts.body === "string" ? opts.body : await new Response(opts.body).text())
1223
1439
  opts.body = dress(text, s.site, s.device)
1224
1440
  delete opts.duplex
1225
1441
  h.delete("content-length")
1226
1442
  }
1227
1443
  } else {
1228
1444
  if (!key) throw new Error("ZCode's sign-in has no key; sign in again")
1445
+ swap(s.base)
1229
1446
  h.delete("authorization")
1230
1447
  h.set("x-api-key", key)
1231
1448
  h.set("Authorization", "Bearer " + key)
1232
1449
  }
1233
- const res = await fetch(url, { ...opts, headers: h })
1450
+ let res = await fetch(url, { ...opts, headers: h })
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) {
1458
+ const json = (res.headers.get("content-type") ?? "").includes("application/json")
1459
+ if (res.status >= 400 || json) {
1460
+ const text = await res.text()
1461
+ const spent = spentUp(text, res.status)
1462
+ if (!spent && res.status < 400) return kept(res, text)
1463
+ if (spent) {
1464
+ for (const [k, until] of blocked) if (Date.now() >= until) blocked.delete(k)
1465
+ blocked.set(s.key + "\0" + s.jwt + "\0" + modelOf(orig).toLowerCase(), Date.now() + 60_000)
1466
+ }
1467
+ return spent && res.status < 400 ? kept(new Response(text, { status: 429, headers: res.headers })) : kept(res, text)
1468
+ }
1469
+ }
1234
1470
  // the plan's answer goes on as it came, the account kept: the
1235
1471
  // built-in never marked a ZCode account lapsed nor cleared one
1236
- const kept = new Headers(res.headers)
1237
- kept.delete("content-length")
1238
- kept.delete("content-encoding")
1239
- kept.set("X-Magpie-Sign-In", "kept")
1240
- return new Response(res.body, { status: res.status, statusText: res.statusText, headers: kept })
1472
+ return kept(res)
1241
1473
  },
1242
1474
  }
1243
1475
  },
@@ -1254,6 +1486,9 @@ export async function ZCodeAuthPlugin({ client }) {
1254
1486
  const saved = (out) => {
1255
1487
  const plan = s.plan || auth?.plan
1256
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")]
1257
1492
  out.signIn = "kept"
1258
1493
  return out
1259
1494
  }
@@ -1269,7 +1504,9 @@ export async function ZCodeAuthPlugin({ client }) {
1269
1504
  }
1270
1505
  return saved(await teamUsage(s, key))
1271
1506
  }
1272
- if (await onStart(s)) return saved(await startUsage(s))
1507
+ const { start, gift } = await plansOf(s)
1508
+ if (start) return saved(await startUsage(s))
1509
+ if (gift) return saved(await dualUsage(s))
1273
1510
  return saved(await codingUsage(s))
1274
1511
  } catch (e) {
1275
1512
  return saved({ error: e?.message ?? String(e) })
@@ -1300,4 +1537,4 @@ export async function ZCodeAuthPlugin({ client }) {
1300
1537
  }
1301
1538
 
1302
1539
  // for tests
1303
- export const _internal = { entry, limitWindows, termOf, startUsage, routes, teamKeys, ownSignIn, stateOf, dress, PROMPT }
1540
+ export const _internal = { entry, limitWindows, termOf, startUsage, routes, blocked, plansOf, giftOf, dualUsage, spentUp, giftServes, modelOf, teamKeys, ownSignIn, stateOf, dress, PROMPT }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-zcode-auth",
3
- "version": "0.1.7",
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",