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

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 (4) hide show
  1. package/README.md +40 -3
  2. package/index.mjs +352 -68
  3. package/package.json +2 -2
  4. package/prompt.mjs +34 -0
package/README.md CHANGED
@@ -17,7 +17,14 @@ over Anthropic's Messages API. Provider id: `zcode`.
17
17
  - If the account has no plan of its own, it looks for a seat on a team's
18
18
  plan and uses that team project's `zcode-team-api-key`.
19
19
  - Failing both, it uses ZCode's free **Start Plan**, served by zcode.z.ai
20
- with ZCode's session token and ZCode's own headers.
20
+ with ZCode's session token.
21
+ - zcode.z.ai serves the Start Plan only to requests that look like the
22
+ ZCode client's own, and refuses others with HTTP 405 "request has been
23
+ blocked due to unusual activity". So a Start Plan request goes as
24
+ ZCode's does: ZCode's headers, ZCode's system prompt ahead of the
25
+ agent's, the day in a `<system-reminder>` turn before the agent's
26
+ turns, and the sign-in's device id in `metadata.user_id`. GLM Coding
27
+ Plan and team requests go as the agent sent them.
21
28
  - The Start Plan's token can't be refreshed. When it runs out, sign in
22
29
  again.
23
30
  - **ZCode app's sign-in**
@@ -47,12 +54,42 @@ Before each request, the plugin checks which plan the account is on, and
47
54
  asks again every 10 minutes:
48
55
 
49
56
  - If the key's account has a GLM Coding Plan
50
- (`/api/biz/subscription/list`), requests go to that plan.
51
- - Otherwise they go to the Start 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).
60
+ - Otherwise they go to the gift plans.
52
61
 
53
62
  Every request carries the key as both `x-api-key` and
54
63
  `Authorization: Bearer`.
55
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
75
+ (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
81
+ body is never read or buffered on the way.
82
+
83
+ ## The usage card
84
+
85
+ An account with both plans shows both allowances: the coding plan's
86
+ windows first, then the gift's buckets, each with its own name, models
87
+ and end. The card's plan and term stay the coding plan's. A gift read
88
+ that fails leaves just the coding card — magpie hides a card's windows
89
+ behind an error, so a gift hiccup must not black out a working Coding
90
+ Plan. A gift that goes out of date while requests use it simply stops
91
+ showing.
92
+
56
93
  ## Models
57
94
 
58
95
  The models are the ones ZCode offers for the account's plan. They come
package/index.mjs CHANGED
@@ -18,8 +18,9 @@
18
18
 
19
19
  import { createDecipheriv, createHash } from "node:crypto"
20
20
  import { existsSync, readFileSync, readdirSync } from "node:fs"
21
- import { homedir, userInfo } from "node:os"
22
- import { join } from "node:path"
21
+ import { arch, homedir, release, userInfo } from "node:os"
22
+ import { basename, join } from "node:path"
23
+ import PROMPT from "./prompt.mjs"
23
24
 
24
25
  const PROVIDER = "zcode"
25
26
  const ZCODE = "https://zcode.z.ai"
@@ -129,48 +130,103 @@ const num = (v) => {
129
130
  return typeof n === "number" && Number.isFinite(n) ? n : undefined
130
131
  }
131
132
 
132
- // startPlan is the Start Plan the account has now ({name, until}), as
133
- // ZCode reads its balance: an "active" plan past its end is over.
133
+ // startPlan is the gift an account can spend now ({models}), as ZCode reads
134
+ // its balance: several plans can be active at once, and a model routes to
135
+ // the gift when any of them has a live bucket serving it — a bucket past
136
+ // its plan's own end, or one whose remaining_units says none, is skipped
137
+ // rather than asked and refused. models are those live buckets' names.
134
138
  async function startPlan(jwt, device) {
135
139
  if (!jwt) throw new Error("not signed in to ZCode")
136
140
  if (jwtExpired(jwt)) throw new Error(EXPIRED)
137
141
  const b = (await call("GET", `${ZCODE}/api/v1/zcode-plan/billing/balance?app_version=${APP_VERSION}`, { auth: "Bearer " + jwt, device })) ?? {}
138
142
  const now = num(b.server_time) > 0 ? num(b.server_time) : Date.now() / 1000
139
- const isStart = (x) => x.includes("start-plan") || x.includes("start plan")
140
- for (const p of b.plans ?? []) {
141
- if (String(p.status ?? "").trim().toLowerCase() !== "active") continue
142
- const end = num(p.ends_at)
143
- if (end > 0 && end <= now) continue
144
- const id = String(p.plan_id ?? "").trim().toLowerCase()
145
- const n = String(p.name ?? "").trim().toLowerCase()
146
- if ((id || n) && !isStart(id) && !isStart(n)) continue
147
- return { name: first(p.name, "Start Plan"), until: end > 0 ? end * 1000 : 0 }
143
+ 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
+ const plans = Array.isArray(b.plans) ? b.plans : []
145
+ const balances = Array.isArray(b.balances) ? b.balances : []
146
+ const ms = new Set()
147
+ let any = false
148
+ let name = ""
149
+ for (const p of plans) {
150
+ const st = String(p?.status ?? "").trim().toLowerCase()
151
+ const end = num(p?.ends_at)
152
+ if (st !== "active" || (end > 0 && end <= now)) continue
153
+ any = true
154
+ if (!name) name = String(p?.name ?? "").trim()
155
+ for (const x of balances) {
156
+ if (!same(x, p)) continue
157
+ const exp = num(x.expires_at)
158
+ 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)
166
+ }
167
+ }
148
168
  }
149
- return null
169
+ return any ? { name: name || "Start Plan", models: [...ms] } : null
150
170
  }
151
171
 
152
- // onStart says whether an account's requests go to the Start Plan: when it
153
- // has ZCode's token and no coding plan key, or a key whose account has no
154
- // coding plan. Asked again after 10 minutes (a minute when unsure).
172
+ // plansOf says where an account's requests go and what more it can spend:
173
+ // {start, gift}. start is what onStart said: an account with no coding plan
174
+ // sends every request to the gift and reads no balance to do it; a team's
175
+ // seat, a sign-in with no ZCode token and a key whose plan-list read found
176
+ // no gift stay on the coding plan. gift is what a coding account also holds:
177
+ // the gift plans ({models}), and its requests spend the gift for a model it
178
+ // serves, until it says it is spent (see blocked). Asked again after 10
179
+ // minutes (a minute when unsure — a plan-list read that failed is retried
180
+ // sooner; a gift read that failed simply leaves gift null until the next
181
+ // ask).
155
182
  const routes = new Map()
156
- async function onStart(s) {
157
- if (!s.jwt || isTeam(s)) return false // a team's seat is on the team's plan
158
- if (!s.key) return true
183
+ async function plansOf(s) {
184
+ if (isTeam(s) || !s.jwt) return { start: false, gift: null }
185
+ if (!s.key) return { start: true, gift: null }
159
186
  const id = s.key + "\0" + s.jwt
160
187
  const r = routes.get(id)
161
- if (r && Date.now() - r.at < r.ttl) return r.start
188
+ if (r && Date.now() - r.at < r.ttl) return r
162
189
  let start = false
190
+ let gift = null
163
191
  let sure = false
164
192
  try {
165
- start = !(await plan(s))
193
+ if (!(await plan(s))) start = true
194
+ else gift = await startPlan(s.jwt, s.device).catch(() => null)
166
195
  sure = true
167
196
  } catch {
197
+ // the plan list could not be read: a key account takes the dual route
198
+ // (start stays false, the gift is kept, a minute's ttl), so a gift
199
+ // refusal still replays to the coding plan rather than blacking out
168
200
  try {
169
- if (await startPlan(s.jwt, s.device)) start = sure = true
201
+ gift = await startPlan(s.jwt, s.device)
202
+ sure = false
170
203
  } catch {}
171
204
  }
172
- routes.set(id, { start, at: Date.now(), ttl: sure ? 600_000 : 60_000 })
173
- return start
205
+ const out = { start, gift, at: Date.now(), ttl: sure ? 600_000 : 60_000 }
206
+ routes.set(id, out)
207
+ return out
208
+ }
209
+
210
+ // blocked remembers the models a gift said it has none left for: a key+jwt+
211
+ // model for a minute, so one turn that is refused does not ask again.
212
+ const blocked = new Map()
213
+
214
+ // giftServes: the gift's buckets name their models lowercased ("glm-5.3-
215
+ // flash"); what is asked for arrives as the plan lists it ("GLM-5.3-Flash").
216
+ const giftServes = (gift, m) => (gift.models ?? []).some((x) => x.toLowerCase() === m.toLowerCase())
217
+
218
+ // spentUp is the gift saying it has nothing left to spend: its own no-
219
+ // resource-package code 1113, its "exceed quota limit" 1005 — a spent gift
220
+ // can answer a 200 with a JSON body carrying one of those codes. Every 429
221
+ // counts as spent, Z.ai's 1302/1303 rate limits among them.
222
+ function spentUp(text, status) {
223
+ if (status === 429) return true
224
+ let b
225
+ try { b = JSON.parse(text) } catch { return false }
226
+ if (!b || typeof b !== "object") return false
227
+ const code = (v) => String(v ?? "")
228
+ const c1 = code(b.code), c2 = code(b.error?.code)
229
+ return c1 === "1113" || c1 === "1005" || c2 === "1113" || c2 === "1005"
174
230
  }
175
231
 
176
232
  // ---- keys ------------------------------------------------------------------------
@@ -365,10 +421,12 @@ function startNum(v) {
365
421
  return undefined
366
422
  }
367
423
 
368
- // startUsage is the Start Plan's allowance: a window for each of its
369
- // buckets, a model's tokens for the day or for the plan's time. A plan
370
- // still "active" past its end is over, and its buckets are left out.
371
- async function startUsage(s) {
424
+ // giftOf reads the balance and returns the gift's card fields: its plans and
425
+ // windows. Several plans can be active at once, so every live plan's buckets
426
+ // show, each keeping its own models and end; the plan and end put on the
427
+ // card (for a gift-only account) are the soonest-ending live plan's. null
428
+ // when there is no live plan to show.
429
+ async function giftOf(s) {
372
430
  if (!s.jwt) throw new Error("not signed in to ZCode")
373
431
  if (jwtExpired(s.jwt)) throw new Error(EXPIRED)
374
432
  const b = (await call("GET", `${ZCODE}/api/v1/zcode-plan/billing/balance?app_version=${APP_VERSION}`, { auth: "Bearer " + s.jwt, device: s.device })) ?? {}
@@ -381,7 +439,7 @@ async function startUsage(s) {
381
439
  if (end > 0 && end <= now && p.status.trim().toLowerCase() === "active") p.status = "expired"
382
440
  if (p.status.trim().toLowerCase() === "expired") over.add(p.user_plan_id + "\0" + p.plan_id)
383
441
  }
384
- 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)
442
+ 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 ?? ""))
385
443
  const balances = (Array.isArray(b.balances) ? b.balances : [])
386
444
  .map((x) => ({ ...x, user_plan_id: str(x?.user_plan_id), plan_id: str(x?.plan_id) }))
387
445
  .filter((x) => {
@@ -394,32 +452,41 @@ async function startUsage(s) {
394
452
  return keep
395
453
  })
396
454
 
455
+ // every live plan counts as the gift: no plan-name filter decides it,
456
+ // the balance says what the session can spend
397
457
  let active = null
398
458
  for (const p of plans) {
399
459
  if (p.status.trim().toLowerCase() !== "active") continue
400
- const id = p.plan_id.trim().toLowerCase(), n = str(p.name).trim().toLowerCase()
401
- const isStart = (x) => x.includes("start-plan") || x.includes("start plan")
402
- if ((id || n) && !isStart(id) && !isStart(n)) continue
403
460
  const end = startNum(p.ends_at)
404
- active = { name: first(str(p.name), "Start Plan"), until: end > 0 ? new Date(Math.trunc(end) * 1000).toISOString() : "" }
405
- break
461
+ if (end > 0 && end <= now) continue
462
+ if (active && !(end > 0 && (!startNum(active.end) || end < startNum(active.end)))) continue
463
+ active = { name: first(str(p.name), "Start Plan"), end, until: end > 0 ? new Date(Math.trunc(end) * 1000).toISOString() : "" }
406
464
  }
407
- if (!active) return { error: "this account has no GLM Coding Plan, and ZCode's Start Plan has ended or was never started" }
465
+ if (!active) return null
408
466
  const out = { plan: active.name, windows: [] }
409
467
  if (active.until) Object.assign(out, { until: active.until, renew: "off" })
468
+ const live = plans.filter((p) => {
469
+ if (p.status.trim().toLowerCase() !== "active") return false
470
+ const end = startNum(p.ends_at)
471
+ return !(end > 0 && end <= now)
472
+ })
410
473
  for (const x of balances) {
474
+ const owner = live.find((p) => same(x, p))
475
+ if (!owner) continue
476
+ const exp = startNum(x.expires_at)
477
+ if (exp > 0 && exp <= now) continue // a bucket past its own end is over
411
478
  const total = startNum(x.total_units), left = startNum(x.remaining_units)
412
479
  let used = startNum(x.used_units)
413
480
  if (total === undefined && used === undefined && left === undefined) continue
414
481
  const models = (Array.isArray(x.capabilities) ? x.capabilities : [])
415
482
  .map((c) => String(c ?? "").trim().replace(/^model:/, "").trim()).filter(Boolean)
416
- const w = { name: first(str(x.show_name), models.join(", "), "Credits"), used: 0 }
483
+ const base = first(str(x.show_name), models.join(", "), "Credits")
484
+ const w = { name: base, used: 0, _plan: str(owner.name) }
417
485
  if (used === undefined) used = total !== undefined && left !== undefined ? total - left : 0
418
486
  if (total > 0) {
419
487
  w.used = (100 * used) / total
420
488
  w.display = `${compact(used)} / ${compact(total)}`
421
489
  }
422
- const exp = startNum(x.expires_at)
423
490
  if (exp > 0) w.resetsAt = new Date(Math.trunc(exp) * 1000).toISOString()
424
491
  let span = 0
425
492
  for (const p of plans) {
@@ -439,6 +506,37 @@ async function startUsage(s) {
439
506
  return out
440
507
  }
441
508
 
509
+ // startUsage is a gift-only account's card: its buckets, or the words
510
+ // for having none to show. The card's header already names the plan, so
511
+ // the windows keep their own names.
512
+ const NO_START = "this account has no GLM Coding Plan, and ZCode's Start Plan has ended or was never started"
513
+ async function startUsage(s) {
514
+ const g = await giftOf(s)
515
+ if (!g) return { error: NO_START }
516
+ for (const w of g.windows) delete w._plan
517
+ return g
518
+ }
519
+
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.
527
+ async function dualUsage(s) {
528
+ const out = await codingUsage(s)
529
+ try {
530
+ 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 {}
537
+ return out
538
+ }
539
+
442
540
  // periodOf reads an entitlement's period: "daily", "weekly", "monthly".
443
541
  function periodOf(p) {
444
542
  p = p.toLowerCase()
@@ -988,6 +1086,26 @@ const entry = (m) => ({
988
1086
  })
989
1087
 
990
1088
  // model is a model as OpenCode's provider hands it to provider.models.
1089
+ // modelOf reads the model a Messages body asks for.
1090
+ function modelOf(text) {
1091
+ try {
1092
+ const m = JSON.parse(text)?.model
1093
+ return typeof m === "string" ? m : ""
1094
+ } catch {
1095
+ return ""
1096
+ }
1097
+ }
1098
+
1099
+ // kept passes a response on as it came, the account kept: the built-in
1100
+ // never marked a ZCode account lapsed nor cleared one.
1101
+ function kept(res, text) {
1102
+ const h = new Headers(res.headers)
1103
+ h.delete("content-length")
1104
+ h.delete("content-encoding")
1105
+ h.set("X-Magpie-Sign-In", "kept")
1106
+ return new Response(text !== undefined ? text : res.body, { status: res.status, statusText: res.statusText, headers: h })
1107
+ }
1108
+
991
1109
  function model(m, url) {
992
1110
  const e = entry(m)
993
1111
  return {
@@ -1014,6 +1132,128 @@ function model(m, url) {
1014
1132
  }
1015
1133
  }
1016
1134
 
1135
+ // ---- the Start Plan's requests ----------------------------------------------------
1136
+ // zcode.z.ai serves the Start Plan only to what looks like ZCode's own
1137
+ // request, and turns others away with 405 "request has been blocked due to
1138
+ // unusual activity" (code 3012). A Start Plan request goes as the plugin
1139
+ // the plan serves (ARNO's "Freeflow", provider zcode-start) sends it: its
1140
+ // headers (startHeaders) and its body (dress). The texts are prompt.mjs.
1141
+
1142
+ const osVersion = () => `${process.platform} ${release()} ${arch()}`
1143
+ const osCategory = () => (process.platform === "darwin" ? "macos" : process.platform === "win32" ? "windows" : "linux")
1144
+
1145
+ function intl() {
1146
+ try {
1147
+ return Intl.DateTimeFormat().resolvedOptions()
1148
+ } catch {
1149
+ return {}
1150
+ }
1151
+ }
1152
+
1153
+ // startHeaders puts ZCode's headers on a Start Plan request, jwt its key.
1154
+ function startHeaders(h, jwt) {
1155
+ h.delete("x-api-key")
1156
+ h.delete("authorization")
1157
+ h.delete("X-Device-Mid")
1158
+ h.set("Authorization", "Bearer " + jwt)
1159
+ h.set("anthropic-version", "2023-06-01")
1160
+ h.set("HTTP-Referer", ZCODE)
1161
+ h.set("User-Agent", UA + " ai-sdk/anthropic/3.0.81")
1162
+ h.set("X-ZCode-App-Version", APP_VERSION)
1163
+ h.set("X-Title", "Z Code@cli")
1164
+ h.set("X-Release-Channel", "production")
1165
+ const o = intl()
1166
+ h.set("X-Client-Language", o.locale || "unknown")
1167
+ if (o.timeZone) h.set("X-Client-Timezone", o.timeZone)
1168
+ h.set("X-ZCode-Agent", "glm")
1169
+ h.set("X-Platform", platform())
1170
+ h.set("X-Os-Category", osCategory())
1171
+ h.set("X-Os-Version", osVersion())
1172
+ h.set("x-request-id", uuid())
1173
+ h.set("x-zcode-session-type", "main")
1174
+ h.set("x-zcode-trace-id", uuid())
1175
+ }
1176
+
1177
+ // environment is the prompt's environment section; provider and model
1178
+ // name the powered-by line, left out with no model.
1179
+ function environment(provider, model) {
1180
+ const e = PROMPT.environment
1181
+ const sh = process.env.SHELL || process.env.ComSpec
1182
+ const lines = [
1183
+ e.heading,
1184
+ e.invokedLine,
1185
+ `- ${e.cwdLabel}: ${process.cwd()}`,
1186
+ `- ${e.gitLabel}: ${e.gitNo}`,
1187
+ `- ${e.platformLabel}: ${process.platform}`,
1188
+ `- ${e.shellLabel}: ${sh ? basename(sh) : "unknown"}`,
1189
+ `- ${e.osVersionLabel}: ${osVersion()}`,
1190
+ ]
1191
+ if (model) lines.push(e.poweredByLine.replace("{provider}", provider).replace("{model}", model))
1192
+ return lines.join("\n")
1193
+ }
1194
+
1195
+ // today is the local date, YYYY-MM-DD.
1196
+ function today(now = new Date()) {
1197
+ const p = (n) => String(n).padStart(2, "0")
1198
+ return `${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())}`
1199
+ }
1200
+
1201
+ // contextPrefix is the user turn ZCode puts before the agent's: the day in
1202
+ // a <system-reminder>, the tags hugging the text.
1203
+ function contextPrefix() {
1204
+ const c = PROMPT.context
1205
+ const body = [c.intro, c.currentDateHeading + "\n" + c.currentDateLine.replace("{date}", today()), "", c.outro].join("\n")
1206
+ return { role: "user", content: [{ type: "text", text: "<system-reminder>" + body + "</system-reminder>" }] }
1207
+ }
1208
+
1209
+ // dress is a Messages request body as ZCode sends it: ZCode's three cached
1210
+ // system blocks, then the agent's own system text uncached; the context
1211
+ // prefix as the first turn; no cache mark in the turns but on the last
1212
+ // block of the last; none on the tools; metadata.user_id naming the
1213
+ // sign-in's device. site names the powered-by line's provider. A body that
1214
+ // isn't a Messages request goes as it is.
1215
+ function dress(text, site, device) {
1216
+ if (typeof text !== "string" || !text.includes("messages")) return text
1217
+ let b
1218
+ try {
1219
+ b = JSON.parse(text)
1220
+ } catch {
1221
+ return text
1222
+ }
1223
+ if (!b || typeof b !== "object" || !Array.isArray(b.messages)) return text
1224
+ const cached = (t) => ({ type: "text", text: t, cache_control: { type: "ephemeral" } })
1225
+ const provider = site === "bigmodel" ? "bigmodel-api" : "zai-api"
1226
+ const model = typeof b.model === "string" ? b.model : ""
1227
+ const own = []
1228
+ if (typeof b.system === "string") {
1229
+ if (b.system.trim()) own.push({ type: "text", text: b.system })
1230
+ } else if (Array.isArray(b.system)) {
1231
+ for (const x of b.system) if (x?.type === "text" && typeof x.text === "string" && x.text) own.push({ type: "text", text: x.text })
1232
+ }
1233
+ b.system = [
1234
+ cached(PROMPT.cliPrefix),
1235
+ cached(PROMPT.stableSections.join("\n\n")),
1236
+ cached("\n\n" + [PROMPT.beforeEnvironment, environment(provider, model), PROMPT.afterEnvironment].join("\n\n")),
1237
+ ...own,
1238
+ ]
1239
+ b.messages = [contextPrefix(), ...b.messages]
1240
+ let last = null
1241
+ for (const m of b.messages) {
1242
+ if (!m || m.role === "system") continue
1243
+ last = m
1244
+ if (Array.isArray(m.content)) for (const x of m.content) if (x && typeof x === "object") delete x.cache_control
1245
+ }
1246
+ if (last && typeof last.content === "string") last.content = [cached(last.content)]
1247
+ else if (last && Array.isArray(last.content) && last.content.length) {
1248
+ const x = last.content[last.content.length - 1]
1249
+ if (x && typeof x === "object" && !x.cache_control) x.cache_control = { type: "ephemeral" }
1250
+ }
1251
+ if (Array.isArray(b.tools)) for (const t of b.tools) if (t && typeof t === "object") delete t.cache_control
1252
+ const meta = b.metadata && typeof b.metadata === "object" ? b.metadata : {}
1253
+ b.metadata = { ...meta, user_id: JSON.stringify({ device_id: device, account_uuid: "", session_id: "" }) }
1254
+ return JSON.stringify(b)
1255
+ }
1256
+
1017
1257
  // ---- the plugin ------------------------------------------------------------------
1018
1258
 
1019
1259
  export async function ZCodeAuthPlugin({ client }) {
@@ -1045,7 +1285,7 @@ export async function ZCodeAuthPlugin({ client }) {
1045
1285
  async models(provider, { auth } = {}) {
1046
1286
  const s = stateOf(auth)
1047
1287
  if (!s) return provider.models
1048
- const start = await onStart(s).catch(() => false)
1288
+ const start = await plansOf(s).then((r) => r.start).catch(() => false)
1049
1289
  const base = start ? START_BASE : s.base
1050
1290
  let ms = modelsOf(await zcodeConfig().catch(() => null), planID(base))
1051
1291
  // ZCode's config can't be had: its table stands in, and magpie keeps
@@ -1073,14 +1313,7 @@ export async function ZCodeAuthPlugin({ client }) {
1073
1313
  if (!s) throw new Error("not signed in to ZCode")
1074
1314
  let url = input instanceof Request ? input.url : String(input)
1075
1315
  const opts = input instanceof Request ? { method: input.method, headers: input.headers, body: input.body, signal: input.signal, duplex: "half", ...init } : { ...init }
1076
- const start = await onStart(s)
1077
- // the request goes to the plan the account is on, wherever it was made for
1078
- for (const b of [START_BASE, ZAI_BASE, BIGMODEL_BASE]) {
1079
- if (url.startsWith(b)) {
1080
- url = (start ? START_BASE : s.base) + url.slice(b.length)
1081
- break
1082
- }
1083
- }
1316
+ const { start: alone, gift } = await plansOf(s)
1084
1317
  let key = s.key
1085
1318
  if (isTeam(s) && !key) {
1086
1319
  key = await teamKey(s)
@@ -1089,30 +1322,79 @@ export async function ZCodeAuthPlugin({ client }) {
1089
1322
  await client?.auth?.set?.({ path: { id }, body: next }).catch?.(() => {})
1090
1323
  }
1091
1324
  }
1092
- const h = new Headers(opts.headers)
1325
+ // a dual account's body is read ahead of sending, so a gift that
1326
+ // says it is spent can spend nothing and replay what was asked for
1327
+ let start = alone
1328
+ let orig
1329
+ if (!alone && gift && opts.body != null) {
1330
+ orig = opts.body = typeof opts.body === "string" ? opts.body : await new Response(opts.body).text()
1331
+ 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)
1336
+ }
1337
+ const swap = (base) => {
1338
+ for (const b of [START_BASE, ZAI_BASE, BIGMODEL_BASE]) {
1339
+ if (url.startsWith(b)) {
1340
+ url = base + url.slice(b.length)
1341
+ break
1342
+ }
1343
+ }
1344
+ }
1345
+ let h = new Headers(opts.headers)
1093
1346
  if (start) {
1094
1347
  if (jwtExpired(s.jwt)) throw new Error(EXPIRED)
1095
- key = s.jwt
1096
- // ZCode names itself on a Start Plan request
1097
- h.set("User-Agent", UA)
1098
- h.set("X-ZCode-App-Version", APP_VERSION)
1099
- h.set("X-Title", "Z Code@electron")
1100
- h.set("HTTP-Referer", ZCODE)
1101
- h.set("X-Platform", platform())
1102
- h.set("X-Device-Mid", s.device)
1348
+ if (!s.jwt) throw new Error("ZCode's sign-in has no key; sign in again")
1349
+ swap(START_BASE)
1350
+ // the Start Plan is served only to what looks like ZCode's own request
1351
+ startHeaders(h, s.jwt)
1352
+ if (opts.body != null) {
1353
+ const text = orig ?? (typeof opts.body === "string" ? opts.body : await new Response(opts.body).text())
1354
+ opts.body = dress(text, s.site, s.device)
1355
+ delete opts.duplex
1356
+ h.delete("content-length")
1357
+ }
1358
+ } else {
1359
+ if (!key) throw new Error("ZCode's sign-in has no key; sign in again")
1360
+ swap(s.base)
1361
+ h.delete("authorization")
1362
+ h.set("x-api-key", key)
1363
+ h.set("Authorization", "Bearer " + key)
1364
+ }
1365
+ 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) {
1378
+ const json = (res.headers.get("content-type") ?? "").includes("application/json")
1379
+ if (res.status >= 400 || json) {
1380
+ const text = await res.text()
1381
+ const spent = spentUp(text, res.status)
1382
+ if (!spent && res.status < 400) return kept(res, text)
1383
+ if (spent) {
1384
+ 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)
1386
+ }
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 })
1393
+ }
1103
1394
  }
1104
- if (!key) throw new Error("ZCode's sign-in has no key; sign in again")
1105
- h.delete("authorization")
1106
- h.set("x-api-key", key)
1107
- h.set("Authorization", "Bearer " + key)
1108
- const res = await fetch(url, { ...opts, headers: h })
1109
1395
  // the plan's answer goes on as it came, the account kept: the
1110
1396
  // built-in never marked a ZCode account lapsed nor cleared one
1111
- const kept = new Headers(res.headers)
1112
- kept.delete("content-length")
1113
- kept.delete("content-encoding")
1114
- kept.set("X-Magpie-Sign-In", "kept")
1115
- return new Response(res.body, { status: res.status, statusText: res.statusText, headers: kept })
1397
+ return kept(res)
1116
1398
  },
1117
1399
  }
1118
1400
  },
@@ -1144,7 +1426,9 @@ export async function ZCodeAuthPlugin({ client }) {
1144
1426
  }
1145
1427
  return saved(await teamUsage(s, key))
1146
1428
  }
1147
- if (await onStart(s)) return saved(await startUsage(s))
1429
+ const { start, gift } = await plansOf(s)
1430
+ if (start) return saved(await startUsage(s))
1431
+ if (gift) return saved(await dualUsage(s))
1148
1432
  return saved(await codingUsage(s))
1149
1433
  } catch (e) {
1150
1434
  return saved({ error: e?.message ?? String(e) })
@@ -1175,4 +1459,4 @@ export async function ZCodeAuthPlugin({ client }) {
1175
1459
  }
1176
1460
 
1177
1461
  // for tests
1178
- export const _internal = { entry, limitWindows, termOf, startUsage, routes, teamKeys, ownSignIn, stateOf }
1462
+ 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,11 +1,11 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-zcode-auth",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
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",
7
7
  "exports": "./index.mjs",
8
- "files": ["index.mjs", "README.md"],
8
+ "files": ["index.mjs", "prompt.mjs", "README.md"],
9
9
  "keywords": ["opencode", "opencode-plugin", "magpie", "zcode", "z.ai", "bigmodel", "glm", "coding-plan"],
10
10
  "license": "MIT",
11
11
  "repository": {
package/prompt.mjs ADDED
@@ -0,0 +1,34 @@
1
+ // ZCode's own prompt texts, which zcode.z.ai looks for on a Start Plan
2
+ // request, byte for byte as the plugin the Start Plan serves (ARNO's
3
+ // "Freeflow", provider zcode-start) sends them. Taken from magpie's
4
+ // internal/provider/zcode_prompt.json (901fd87e), ZCode 3.14.3's texts.
5
+ // SHA-256, first 8 bytes: cliPrefix 46dd360a22c87a92, stableSections[0]
6
+ // 3f21ff9a88a03a76, [1] 39a1e86c93452c01, beforeEnvironment
7
+ // bbdc66b399d297ff, afterEnvironment 1732b7f098a925d7 (start.test.mjs
8
+ // checks them).
9
+ export default {
10
+ "cliPrefix": "You are ZCode, an interactive coding agent",
11
+ "stableSections": [
12
+ "\nYou are an interactive ZCode agent that helps users with software engineering tasks.\n\nIMPORTANT: Assist with authorized security testing, defensive security, CTF challenges, and educational contexts. Refuse requests for destructive techniques, DoS attacks, mass targeting, supply chain compromise, or detection evasion for malicious purposes. Dual-use security tools (C2 frameworks, credential testing, exploit development) require clear authorization context: pentesting engagements, CTF competitions, security research, or defensive use cases.\n\n# Harness\n- Text you output outside of tool use is displayed to the user as Github-flavored markdown in a terminal.\n- Tools run behind a user-selected permission mode; a denied call means the user declined it — adjust, don't retry verbatim.\n- The system may send updates, reminders, or modifications to rules via mid-conversation system turns. These are system-controlled, unlike function results. Hooks may intercept tool calls; treat hook output as user feedback.\n- Prefer the dedicated file/search tools over shell commands when one fits. Independent tool calls can run in parallel in one response.\n- Reference code as `file_path:line_number` — it's clickable.",
13
+ "# ZCode Desktop Context\n\n### Files & URLs\n- Return local web URLs as Markdown links (e.g., [label](http://127.0.0.1:8080)).\n- File should be an absolute path or include the workspace folder segment so it can be resolved relative to the workspace.\n- Unless otherwise specified, return local file references as Markdown links (e.g., [name.md](/absolute/path/to/name.md)).\n\n### Inline Code Comments\n- Use the ::code-comment{...} directive when you need to attach feedback directly to specific code lines.\n- Emit one directive per inline comment; emit none when there are no actionable inline comments.\n- Required attributes: title (short label), body (one-paragraph explanation), file (path to the file).\n- Optional attributes: start, end (1-based line numbers), priority (0-3).\n- file should be an absolute path or include the workspace folder segment so it can be resolved relative to the workspace.\n- Keep line ranges tight; end defaults to start.\n- Example: ::code-comment{title=\"[P2] Off-by-one\" body=\"Loop iterates past the end when length is 0.\" file=\"/path/to/foo.ts\" start=10 end=11 priority=2}"
14
+ ],
15
+ "beforeEnvironment": "# Communicating with the user\n\nYour text output is what the user reads; they usually can't see your thinking or the raw tool results. Write it for a teammate who stepped away and is catching up, not for a log file: they don't know the codenames or shorthand you created along the way, and they didn't watch your process unfold. Before your first tool call, say in a sentence what you're about to do; while working, give brief updates when you find something load-bearing or change direction.\n\nText you write between tool calls may not be shown to the user. Everything the user needs from this turn — answers, summaries, findings, conclusions, deliverables — must be in the final text message of your turn, with no tool calls after it. Keep text between tool calls to brief status notes. If something important appeared only mid-turn or in your thinking, restate it in that final message.\n\nLead with the outcome. Your first sentence after finishing should answer \"what happened\" or \"what did you find\" — the thing the user would ask for if they said \"just give me the TLDR.\" Supporting detail and reasoning come after, for readers who want them.\n\nBeing readable and being concise are different things, and readable matters more. If the user has to reread your summary or ask you to explain, any time saved by brevity is gone. The way to keep output short is to be selective about what you include (drop details that don't change what the reader would do next), not to compress the writing into fragments, abbreviations, arrow chains like `A → B → fails`, or jargon. What you do include, write in complete sentences with the technical terms spelled out. Don't make the reader cross-reference labels or numbering you invented earlier; say what you mean in place.\n\nMatch the response to the question: a simple question gets a direct answer in prose, not headers and sections. Use tables only for short enumerable facts, with explanations in the surrounding prose rather than the cells. Calibrate to the user — a bit tighter for an expert, more explanatory for someone newer.\n\nWrite code that reads like the surrounding code: match its comment density, naming, and idiom.\nOnly write a code comment to state a constraint the code itself can't show — never to say where it came from, what the next line does, or why your change is correct; that's you talking to the reviewer, not the next reader, and it's noise the moment the PR merges.\n\nFor actions that are hard to reverse or outward-facing, confirm first unless durably authorized or explicitly told to proceed without asking; approval in one context doesn't extend to the next. Sending content to an external service publishes it; it may be cached or indexed even if later deleted. Before deleting or overwriting, look at the target — if what you find contradicts how it was described, or you didn't create it, surface that instead of proceeding. Report outcomes faithfully: if tests fail, say so with the output; if a step was skipped, say that; when something is done and verified, state it plainly without hedging.",
16
+ "afterEnvironment": "# Context management\nWhen the conversation grows long, some or all of the current context is summarized; the summary, along with any remaining unsummarized context, is provided in the next context window so work can continue — you don't need to wrap up early or hand off mid-task.\n\nWhen you have enough information to act, act. Do not re-derive facts already established in the conversation, re-litigate a decision the user has already made, or narrate options you will not pursue. If you are weighing a choice, give a recommendation, not an exhaustive survey\n\nYou are operating autonomously. The user is not watching in real time and cannot answer questions mid-task, so asking 'Want me to…?' or 'Shall I…?' will block the work. For reversible actions that follow from the original request, proceed without asking. Stop only for destructive actions or genuine scope changes the user must decide. Offering follow-ups after the task is done is fine; asking permission before doing the work is not.\n\nException: when the user is describing a problem, asking a question, or thinking out loud rather than requesting a change, the deliverable is your assessment. Report your findings and stop. Don't apply a fix until they ask for one.\n\nBefore ending your turn, check your last paragraph. If it is a plan, an analysis, a question, a list of next steps, or a promise about work you have not done ('I'll…', 'let me know when…'), do that work now with tool calls. That includes retrying after errors and gathering missing information yourself. Do not stop because the context or session is long. End your turn only when the task is complete or you are blocked on input only the user can provide.\n\nBefore running a command that changes system state — restarts, deletes, config edits — check that the evidence actually supports that specific action. A signal that pattern-matches to a known failure may have a different cause.",
17
+ "environment": {
18
+ "heading": "# Environment",
19
+ "invokedLine": "You have been invoked in the following environment:",
20
+ "cwdLabel": "Primary working directory",
21
+ "gitLabel": "Is a git repository",
22
+ "gitNo": "no",
23
+ "platformLabel": "Platform",
24
+ "shellLabel": "Shell",
25
+ "osVersionLabel": "OS Version",
26
+ "poweredByLine": "- You are powered by the model named {provider}/{model}."
27
+ },
28
+ "context": {
29
+ "intro": "As you answer the user's questions, you can use the following context:",
30
+ "outro": " IMPORTANT: this context may or may not be relevant to your tasks. You should not respond to this context unless it is highly relevant to your task.",
31
+ "currentDateHeading": "# currentDate",
32
+ "currentDateLine": "Today's date is {date}."
33
+ }
34
+ }