@magpie-community/opencode-zcode-auth 0.1.23 → 0.1.25

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 +5 -0
  2. package/index.mjs +166 -4
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -53,6 +53,11 @@ over Anthropic's Messages API. Provider id: `zcode`.
53
53
  another's credentials: to use both, add the app's sign-in once for each.
54
54
  - **GLM Coding Plan API key**
55
55
  - Paste a key (`<id>.<secret>`) and say which site it is from.
56
+ - A key reaches the GLM Coding Plan alone. ZCode's gift plans (the
57
+ Start Plan, ZCode 1024 Dev Day) are served to ZCode's own session,
58
+ which a key doesn't carry: for an account with only a gift plan, the
59
+ card and each refused request (1113) say to sign in with ZCode
60
+ instead.
56
61
 
57
62
  ## Where the sign-in is kept
58
63
 
package/index.mjs CHANGED
@@ -437,14 +437,54 @@ async function codingUsage(s) {
437
437
  if (level) out.plan = "GLM Coding " + level[0].toUpperCase() + level.slice(1)
438
438
  out.windows = limitWindows(d)
439
439
  const resets = personalResets(root, s.key)
440
+ let subs = null
440
441
  try {
441
- Object.assign(out, termOf(await call("GET", root + "/api/biz/subscription/list", { auth: s.key })))
442
+ subs = await call("GET", root + "/api/biz/subscription/list", { auth: s.key })
443
+ Object.assign(out, termOf(subs))
442
444
  } catch {}
443
445
  const r = await resets
444
446
  if (r) out.resets = r
447
+ // a key whose account has no plan in force, read as such, says so
448
+ // rather than showing an empty card: what a key can spend is a GLM
449
+ // Coding Plan alone (a ZCode gift plan is never one, see NO_PLAN_KEY).
450
+ // A list that couldn't be read, a level, a counted window or a plan
451
+ // the sign-in saved keeps the card as it was.
452
+ const valid = (Array.isArray(subs) ? subs : []).some((x) => String(x?.status ?? "").toUpperCase() === "VALID")
453
+ if (subs !== null && !valid && !level && !s.plan && !out.windows.some((w) => !w.aside)) return { error: NO_PLAN_KEY }
445
454
  return out
446
455
  }
447
456
 
457
+ // NO_PLAN_KEY is a key with no GLM Coding Plan behind it. ZCode's gift
458
+ // plans (its Start Plan, ZCode 1024 Dev Day) are served by zcode.z.ai
459
+ // to ZCode's own session token, which a sign-in with ZCode (the app's,
460
+ // or Z.ai's or BigModel's in the browser) carries and a key never does:
461
+ // with a key, a gift-only account has nothing to spend, and Z.ai answers
462
+ // each request 1113 (rtbeilin on X, 10-24).
463
+ const NO_PLAN_KEY =
464
+ "this key's account has no GLM Coding Plan; ZCode's gift plans (the Start Plan, ZCode 1024 Dev Day) are spent only with ZCode's sign-in, never with a key — add the account with \"ZCode app's sign-in\" or a ZCode sign-in in the browser"
465
+
466
+ // noPlanKey is a coding plan's 1113 refusal (no plan, no resource
467
+ // package) to a sign-in with no ZCode session, its message followed by
468
+ // NO_PLAN_KEY; anything else goes on as it came. Read only once the
469
+ // answer has failed.
470
+ function noPlanKey(res, text) {
471
+ let v
472
+ try {
473
+ v = JSON.parse(text)
474
+ } catch {
475
+ return null
476
+ }
477
+ // Z.ai names the code in error.code ("1113", as magpie's
478
+ // zhipu_coding_test has it) or error.type (its 401's "1000"), BigModel's
479
+ // envelope in code
480
+ if (![v?.error?.code, v?.error?.type, v?.code].some((c) => String(c ?? "") === "1113")) return null
481
+ const said = first(v?.error?.message, v?.msg, v?.message)
482
+ const out = v?.error && typeof v.error === "object" ? { ...v, error: { ...v.error, message: (said ? said + " — " : "") + NO_PLAN_KEY } } : { ...v, msg: (said ? said + " — " : "") + NO_PLAN_KEY }
483
+ const h = new Headers(res.headers)
484
+ h.set("content-type", "application/json")
485
+ return new Response(JSON.stringify(out), { status: res.status, statusText: res.statusText, headers: h })
486
+ }
487
+
448
488
  // startNum reads a number the balance gives as a number or a string.
449
489
  function startNum(v) {
450
490
  if (isNum(v)) return v
@@ -1459,6 +1499,110 @@ function startHeaders(h, jwt) {
1459
1499
  return out
1460
1500
  }
1461
1501
 
1502
+ // Claude Code's tool search, as ZCode's plans take it. Claude Code
1503
+ // (2.1.310) offers a tool named ToolSearch, names its deferred tools in a
1504
+ // system turn, and sends a deferred tool's schema with defer_loading only
1505
+ // once a search loaded it (a tool_reference in a ToolSearch result), with
1506
+ // a DeferredToolPlaceholder that keeps the search on. api.z.ai's
1507
+ // Anthropic endpoint, given a tool named exactly ToolSearch, shows the
1508
+ // model none of the other tools, Bash, Read and Write among them, but
1509
+ // those a tool_reference loaded: a subagent, which hasn't searched,
1510
+ // reports only the deferred tools, and the main turn has to search for
1511
+ // Bash before it can run it (小八 on Discord, 10-24; the beta header
1512
+ // changes nothing, the name alone does). ZCode itself sends no ToolSearch
1513
+ // and no defer_loading. So the search goes as SEARCH_ALIAS, in the tools
1514
+ // and in the turns that called it, the tools Claude Code sent with their
1515
+ // schemas go as ZCode's do, without defer_loading, and the placeholder is
1516
+ // left out; the model's calls to SEARCH_ALIAS come back as ToolSearch
1517
+ // (searchNamed).
1518
+ const SEARCH = "ToolSearch"
1519
+ const SEARCH_ALIAS = "ToolSearchTool"
1520
+ const PLACEHOLDER = "DeferredToolPlaceholder"
1521
+
1522
+ // searchless is a Messages request body without Claude Code's ToolSearch
1523
+ // name, and whether it had the tool; anything else goes as it is.
1524
+ function searchless(text) {
1525
+ if (typeof text !== "string" || !text.includes(`"${SEARCH}"`)) return { text, renamed: false }
1526
+ let b
1527
+ try {
1528
+ b = JSON.parse(text)
1529
+ } catch {
1530
+ return { text, renamed: false }
1531
+ }
1532
+ if (!b || !Array.isArray(b.tools) || !b.tools.some((t) => t?.name === SEARCH)) return { text, renamed: false }
1533
+ // a tool of the alias's own name keeps it; the search can't then be renamed
1534
+ if (b.tools.some((t) => t?.name === SEARCH_ALIAS)) return { text, renamed: false }
1535
+ b.tools = b.tools
1536
+ .filter((t) => t?.name !== PLACEHOLDER)
1537
+ .map((t) => {
1538
+ if (!t || typeof t !== "object") return t
1539
+ const { defer_loading, ...rest } = t
1540
+ return t.name === SEARCH ? { ...rest, name: SEARCH_ALIAS } : rest
1541
+ })
1542
+ if (Array.isArray(b.messages))
1543
+ for (const m of b.messages)
1544
+ if (m?.role === "assistant" && Array.isArray(m.content))
1545
+ for (const x of m.content) if (x?.type === "tool_use" && x.name === SEARCH) x.name = SEARCH_ALIAS
1546
+ if (b.tool_choice?.type === "tool" && b.tool_choice.name === SEARCH) b.tool_choice.name = SEARCH_ALIAS
1547
+ return { text: JSON.stringify(b), renamed: true }
1548
+ }
1549
+
1550
+ // searchNamed is an answer to a request searchless renamed, the model's
1551
+ // calls to SEARCH_ALIAS named ToolSearch again, as Claude Code runs them:
1552
+ // a stream's content_block_start events, or a whole message's content.
1553
+ function searchNamed(res) {
1554
+ if (!res.body) return res
1555
+ const type = res.headers.get("content-type") ?? ""
1556
+ const named = (block) => {
1557
+ if (block?.type === "tool_use" && block.name === SEARCH_ALIAS) {
1558
+ block.name = SEARCH
1559
+ return true
1560
+ }
1561
+ return false
1562
+ }
1563
+ const line = (l) => {
1564
+ if (!l.startsWith("data:") || !l.includes(`"${SEARCH_ALIAS}"`)) return l
1565
+ try {
1566
+ const d = JSON.parse(l.slice(5))
1567
+ return named(d?.content_block) ? "data: " + JSON.stringify(d) : l
1568
+ } catch {
1569
+ return l
1570
+ }
1571
+ }
1572
+ if (type.includes("text/event-stream")) {
1573
+ const dec = new TextDecoder()
1574
+ const enc = new TextEncoder()
1575
+ let held = ""
1576
+ const body = res.body.pipeThrough(
1577
+ new TransformStream({
1578
+ transform(chunk, ctl) {
1579
+ held += dec.decode(chunk, { stream: true })
1580
+ const i = held.lastIndexOf("\n")
1581
+ if (i < 0) return
1582
+ ctl.enqueue(enc.encode(held.slice(0, i + 1).split("\n").map(line).join("\n")))
1583
+ held = held.slice(i + 1)
1584
+ },
1585
+ flush(ctl) {
1586
+ held += dec.decode()
1587
+ if (held) ctl.enqueue(enc.encode(line(held)))
1588
+ },
1589
+ }),
1590
+ )
1591
+ return new Response(body, { status: res.status, statusText: res.statusText, headers: res.headers })
1592
+ }
1593
+ if (!type.includes("json")) return res
1594
+ return res.text().then((text) => {
1595
+ let out = text
1596
+ if (text.includes(`"${SEARCH_ALIAS}"`)) {
1597
+ try {
1598
+ const v = JSON.parse(text)
1599
+ if (Array.isArray(v?.content) && v.content.map(named).some(Boolean)) out = JSON.stringify(v)
1600
+ } catch {}
1601
+ }
1602
+ return new Response(out, { status: res.status, statusText: res.statusText, headers: res.headers })
1603
+ })
1604
+ }
1605
+
1462
1606
  // startURL is a Start Plan request's URL without the query the agent put
1463
1607
  // on it: Claude Code asks /v1/messages?beta=true, which magpie passes on,
1464
1608
  // and ZCode asks /v1/messages alone.
@@ -1670,9 +1814,18 @@ export async function ZCodeAuthPlugin({ client }) {
1670
1814
  // says it is spent can spend nothing and replay what was asked for
1671
1815
  let start = alone
1672
1816
  let orig, forcedGift = false
1817
+ let renamed = false
1818
+ const done = (res) => (renamed ? searchNamed(res) : res)
1673
1819
  if (opts.body != null) {
1674
1820
  orig = opts.body = typeof opts.body === "string" ? opts.body : await new Response(opts.body).text()
1675
1821
  delete opts.duplex
1822
+ // Claude Code's tool search goes under another name, or
1823
+ // ZCode's plans show the model no other tool (searchless)
1824
+ const plain = searchless(orig)
1825
+ if (plain.renamed) {
1826
+ orig = opts.body = plain.text
1827
+ renamed = true
1828
+ }
1676
1829
  let m = modelOf(orig)
1677
1830
  if (m.toLowerCase().endsWith("-trial")) {
1678
1831
  gift ??= await startPlan(s.jwt, s.device).catch(() => null)
@@ -1714,6 +1867,7 @@ export async function ZCodeAuthPlugin({ client }) {
1714
1867
  } else {
1715
1868
  if (!key) throw new Error("ZCode's sign-in has no key; sign in again")
1716
1869
  swap(s.base)
1870
+ if (renamed) h.delete("content-length")
1717
1871
  h.delete("authorization")
1718
1872
  h.set("x-api-key", key)
1719
1873
  h.set("Authorization", "Bearer " + key)
@@ -1726,6 +1880,14 @@ export async function ZCodeAuthPlugin({ client }) {
1726
1880
  if (startRefused(res.status, text)) return kept(startBlocked(res, text))
1727
1881
  res = new Response(text, { status: res.status, statusText: res.statusText, headers: res.headers })
1728
1882
  }
1883
+ // a key with no plan behind it: what Z.ai said, and why a
1884
+ // ZCode gift plan can't be had with a key (NO_PLAN_KEY)
1885
+ if (!start && !s.jwt && res.status >= 400) {
1886
+ const text = await res.text()
1887
+ const why = noPlanKey(res, text)
1888
+ if (why) return done(kept(why))
1889
+ res = new Response(text, { status: res.status, statusText: res.statusText, headers: res.headers })
1890
+ }
1729
1891
  // a trial entry's gift answer that can't serve the request is
1730
1892
  // the agent's answer, not the coding plan's: the user picked the
1731
1893
  // gift, so a spent bucket says 429 (its quota error inside a 200
@@ -1737,17 +1899,17 @@ export async function ZCodeAuthPlugin({ client }) {
1737
1899
  if (res.status >= 400 || json) {
1738
1900
  const text = await res.text()
1739
1901
  const spent = spentUp(text, res.status)
1740
- if (!spent && res.status < 400) return kept(res, text)
1902
+ if (!spent && res.status < 400) return done(kept(res, text))
1741
1903
  if (spent) {
1742
1904
  for (const [k, until] of blocked) if (Date.now() >= until) blocked.delete(k)
1743
1905
  blocked.set(s.key + "\0" + s.jwt + "\0" + modelOf(orig).toLowerCase(), Date.now() + 60_000)
1744
1906
  }
1745
- return spent && res.status < 400 ? kept(new Response(text, { status: 429, headers: res.headers })) : kept(res, text)
1907
+ return spent && res.status < 400 ? kept(new Response(text, { status: 429, headers: res.headers })) : done(kept(res, text))
1746
1908
  }
1747
1909
  }
1748
1910
  // the plan's answer goes on as it came, the account kept: the
1749
1911
  // built-in never marked a ZCode account lapsed nor cleared one
1750
- return kept(res)
1912
+ return done(kept(res))
1751
1913
  },
1752
1914
  }
1753
1915
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-zcode-auth",
3
- "version": "0.1.23",
3
+ "version": "0.1.25",
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",