@magpie-community/opencode-zcode-auth 0.1.22 → 0.1.24

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 (2) hide show
  1. package/index.mjs +147 -10
  2. package/package.json +1 -1
package/index.mjs CHANGED
@@ -578,13 +578,16 @@ async function claimHint(s) {
578
578
 
579
579
  // startUsage is a gift-only account's card: its buckets, or the words
580
580
  // for having none to show. The card's header already names the plan, so
581
- // the windows keep their own names.
581
+ // the windows of one plan keep their own names; with several plans each
582
+ // bucket is named after its plan (see byPlan).
582
583
  const NO_START = "this account has no GLM Coding Plan, and ZCode's Start Plan has ended or was never started"
583
584
  async function startUsage(s) {
584
585
  const g = await giftOf(s)
585
586
  if (!g) return { error: NO_START }
586
587
  const best = bestBuckets(g.windows)
587
- g.windows = g.windows.map(({ _plan, _spent, ...w }, i) => {
588
+ const label = byPlan(g.windows)
589
+ g.windows = countedFirst(g.windows.map(({ _plan, _spent, ...w }, i) => {
590
+ w.name = label(w.name, _plan)
588
591
  if (!w.models?.length) return w
589
592
  // a model two plans give (Trust Build's and the Start Plan's Flash)
590
593
  // counts on the bucket ZCode spends now; the other stays on the card
@@ -595,10 +598,28 @@ async function startUsage(s) {
595
598
  return { ...w, aside: true }
596
599
  }
597
600
  return { ...w, models: [...models, ...models.map(giftID)] }
598
- })
601
+ }))
599
602
  return g
600
603
  }
601
604
 
605
+ // byPlan names a bucket after its plan ("ZCode 1024 Dev Day · GLM-5.3")
606
+ // when the gift's buckets come from more than one plan: two plans each
607
+ // give a GLM-5.3, and named alike a spent one and a live one can't be
608
+ // told apart (01huadalang, Discord: the new-user plan's spent GLM-5.3
609
+ // showed, the 1024 plan's own GLM-5.3 didn't). One plan's buckets keep
610
+ // their own names, as the card's header names the plan.
611
+ function byPlan(windows) {
612
+ const several = new Set(windows.map((w) => w._plan || "")).size > 1
613
+ return (name, plan) => (several && plan ? plan + " · " + name : name)
614
+ }
615
+
616
+ // countedFirst puts the buckets a request spends before those set aside
617
+ // (a spent bucket another plan's live one stands in for), each group in
618
+ // the balance's order: magpie's account row shows a card's first two
619
+ // windows and leaves the rest to its tooltip, so a spent plan listed first
620
+ // hid the live plan's allowance for the same model.
621
+ const countedFirst = (ws) => [...ws.filter((w) => !w.aside), ...ws.filter((w) => w.aside)]
622
+
602
623
  // bestBuckets is, for each model the gift's buckets serve, the index of
603
624
  // the one a request spends: a live one before a spent one, the least
604
625
  // used of those. The server picks among an account's buckets itself
@@ -641,19 +662,21 @@ async function dualUsage(s) {
641
662
  if (notModels.length) out.windows = out.windows.map((w) => w.aside ? w : { ...w, notModels })
642
663
  // Several buckets may serve one model: one spent sibling cannot block it.
643
664
  const best = bestBuckets(g.windows)
644
- out.windows.push(...g.windows.map(({ _plan: p, _spent, ...w }, i) => {
665
+ const label = byPlan(g.windows)
666
+ out.windows.push(...countedFirst(g.windows.map(({ _plan: p, _spent, ...w }, i) => {
645
667
  const selected = gift.allModels.filter((m) => best.get(m.toLowerCase()) === i)
646
668
  const models = selected.map(giftID)
647
669
  const scoped = models.length ? { ...w, models } : { ...w, aside: true }
648
670
  // the card names a bucket's model as the picker does (GLM-5.3-Flash-Trial);
649
- // no plan prefix — the card's section header already says the account
671
+ // no plan prefix while one plan gives them all — the card's section
672
+ // header already says the account; with several, byPlan names each
650
673
  let name = w.name
651
674
  for (const m of selected) {
652
675
  const at = name.toLowerCase().indexOf(m.toLowerCase())
653
676
  if (at >= 0) name = name.slice(0, at) + giftID(m) + name.slice(at + m.length)
654
677
  }
655
- return { ...scoped, name }
656
- }))
678
+ return { ...scoped, name: label(name, p) }
679
+ })))
657
680
  }
658
681
  } catch {
659
682
  if (cached) {
@@ -1436,6 +1459,110 @@ function startHeaders(h, jwt) {
1436
1459
  return out
1437
1460
  }
1438
1461
 
1462
+ // Claude Code's tool search, as ZCode's plans take it. Claude Code
1463
+ // (2.1.310) offers a tool named ToolSearch, names its deferred tools in a
1464
+ // system turn, and sends a deferred tool's schema with defer_loading only
1465
+ // once a search loaded it (a tool_reference in a ToolSearch result), with
1466
+ // a DeferredToolPlaceholder that keeps the search on. api.z.ai's
1467
+ // Anthropic endpoint, given a tool named exactly ToolSearch, shows the
1468
+ // model none of the other tools, Bash, Read and Write among them, but
1469
+ // those a tool_reference loaded: a subagent, which hasn't searched,
1470
+ // reports only the deferred tools, and the main turn has to search for
1471
+ // Bash before it can run it (小八 on Discord, 10-24; the beta header
1472
+ // changes nothing, the name alone does). ZCode itself sends no ToolSearch
1473
+ // and no defer_loading. So the search goes as SEARCH_ALIAS, in the tools
1474
+ // and in the turns that called it, the tools Claude Code sent with their
1475
+ // schemas go as ZCode's do, without defer_loading, and the placeholder is
1476
+ // left out; the model's calls to SEARCH_ALIAS come back as ToolSearch
1477
+ // (searchNamed).
1478
+ const SEARCH = "ToolSearch"
1479
+ const SEARCH_ALIAS = "ToolSearchTool"
1480
+ const PLACEHOLDER = "DeferredToolPlaceholder"
1481
+
1482
+ // searchless is a Messages request body without Claude Code's ToolSearch
1483
+ // name, and whether it had the tool; anything else goes as it is.
1484
+ function searchless(text) {
1485
+ if (typeof text !== "string" || !text.includes(`"${SEARCH}"`)) return { text, renamed: false }
1486
+ let b
1487
+ try {
1488
+ b = JSON.parse(text)
1489
+ } catch {
1490
+ return { text, renamed: false }
1491
+ }
1492
+ if (!b || !Array.isArray(b.tools) || !b.tools.some((t) => t?.name === SEARCH)) return { text, renamed: false }
1493
+ // a tool of the alias's own name keeps it; the search can't then be renamed
1494
+ if (b.tools.some((t) => t?.name === SEARCH_ALIAS)) return { text, renamed: false }
1495
+ b.tools = b.tools
1496
+ .filter((t) => t?.name !== PLACEHOLDER)
1497
+ .map((t) => {
1498
+ if (!t || typeof t !== "object") return t
1499
+ const { defer_loading, ...rest } = t
1500
+ return t.name === SEARCH ? { ...rest, name: SEARCH_ALIAS } : rest
1501
+ })
1502
+ if (Array.isArray(b.messages))
1503
+ for (const m of b.messages)
1504
+ if (m?.role === "assistant" && Array.isArray(m.content))
1505
+ for (const x of m.content) if (x?.type === "tool_use" && x.name === SEARCH) x.name = SEARCH_ALIAS
1506
+ if (b.tool_choice?.type === "tool" && b.tool_choice.name === SEARCH) b.tool_choice.name = SEARCH_ALIAS
1507
+ return { text: JSON.stringify(b), renamed: true }
1508
+ }
1509
+
1510
+ // searchNamed is an answer to a request searchless renamed, the model's
1511
+ // calls to SEARCH_ALIAS named ToolSearch again, as Claude Code runs them:
1512
+ // a stream's content_block_start events, or a whole message's content.
1513
+ function searchNamed(res) {
1514
+ if (!res.body) return res
1515
+ const type = res.headers.get("content-type") ?? ""
1516
+ const named = (block) => {
1517
+ if (block?.type === "tool_use" && block.name === SEARCH_ALIAS) {
1518
+ block.name = SEARCH
1519
+ return true
1520
+ }
1521
+ return false
1522
+ }
1523
+ const line = (l) => {
1524
+ if (!l.startsWith("data:") || !l.includes(`"${SEARCH_ALIAS}"`)) return l
1525
+ try {
1526
+ const d = JSON.parse(l.slice(5))
1527
+ return named(d?.content_block) ? "data: " + JSON.stringify(d) : l
1528
+ } catch {
1529
+ return l
1530
+ }
1531
+ }
1532
+ if (type.includes("text/event-stream")) {
1533
+ const dec = new TextDecoder()
1534
+ const enc = new TextEncoder()
1535
+ let held = ""
1536
+ const body = res.body.pipeThrough(
1537
+ new TransformStream({
1538
+ transform(chunk, ctl) {
1539
+ held += dec.decode(chunk, { stream: true })
1540
+ const i = held.lastIndexOf("\n")
1541
+ if (i < 0) return
1542
+ ctl.enqueue(enc.encode(held.slice(0, i + 1).split("\n").map(line).join("\n")))
1543
+ held = held.slice(i + 1)
1544
+ },
1545
+ flush(ctl) {
1546
+ held += dec.decode()
1547
+ if (held) ctl.enqueue(enc.encode(line(held)))
1548
+ },
1549
+ }),
1550
+ )
1551
+ return new Response(body, { status: res.status, statusText: res.statusText, headers: res.headers })
1552
+ }
1553
+ if (!type.includes("json")) return res
1554
+ return res.text().then((text) => {
1555
+ let out = text
1556
+ if (text.includes(`"${SEARCH_ALIAS}"`)) {
1557
+ try {
1558
+ const v = JSON.parse(text)
1559
+ if (Array.isArray(v?.content) && v.content.map(named).some(Boolean)) out = JSON.stringify(v)
1560
+ } catch {}
1561
+ }
1562
+ return new Response(out, { status: res.status, statusText: res.statusText, headers: res.headers })
1563
+ })
1564
+ }
1565
+
1439
1566
  // startURL is a Start Plan request's URL without the query the agent put
1440
1567
  // on it: Claude Code asks /v1/messages?beta=true, which magpie passes on,
1441
1568
  // and ZCode asks /v1/messages alone.
@@ -1647,9 +1774,18 @@ export async function ZCodeAuthPlugin({ client }) {
1647
1774
  // says it is spent can spend nothing and replay what was asked for
1648
1775
  let start = alone
1649
1776
  let orig, forcedGift = false
1777
+ let renamed = false
1778
+ const done = (res) => (renamed ? searchNamed(res) : res)
1650
1779
  if (opts.body != null) {
1651
1780
  orig = opts.body = typeof opts.body === "string" ? opts.body : await new Response(opts.body).text()
1652
1781
  delete opts.duplex
1782
+ // Claude Code's tool search goes under another name, or
1783
+ // ZCode's plans show the model no other tool (searchless)
1784
+ const plain = searchless(orig)
1785
+ if (plain.renamed) {
1786
+ orig = opts.body = plain.text
1787
+ renamed = true
1788
+ }
1653
1789
  let m = modelOf(orig)
1654
1790
  if (m.toLowerCase().endsWith("-trial")) {
1655
1791
  gift ??= await startPlan(s.jwt, s.device).catch(() => null)
@@ -1691,6 +1827,7 @@ export async function ZCodeAuthPlugin({ client }) {
1691
1827
  } else {
1692
1828
  if (!key) throw new Error("ZCode's sign-in has no key; sign in again")
1693
1829
  swap(s.base)
1830
+ if (renamed) h.delete("content-length")
1694
1831
  h.delete("authorization")
1695
1832
  h.set("x-api-key", key)
1696
1833
  h.set("Authorization", "Bearer " + key)
@@ -1714,17 +1851,17 @@ export async function ZCodeAuthPlugin({ client }) {
1714
1851
  if (res.status >= 400 || json) {
1715
1852
  const text = await res.text()
1716
1853
  const spent = spentUp(text, res.status)
1717
- if (!spent && res.status < 400) return kept(res, text)
1854
+ if (!spent && res.status < 400) return done(kept(res, text))
1718
1855
  if (spent) {
1719
1856
  for (const [k, until] of blocked) if (Date.now() >= until) blocked.delete(k)
1720
1857
  blocked.set(s.key + "\0" + s.jwt + "\0" + modelOf(orig).toLowerCase(), Date.now() + 60_000)
1721
1858
  }
1722
- return spent && res.status < 400 ? kept(new Response(text, { status: 429, headers: res.headers })) : kept(res, text)
1859
+ return spent && res.status < 400 ? kept(new Response(text, { status: 429, headers: res.headers })) : done(kept(res, text))
1723
1860
  }
1724
1861
  }
1725
1862
  // the plan's answer goes on as it came, the account kept: the
1726
1863
  // built-in never marked a ZCode account lapsed nor cleared one
1727
- return kept(res)
1864
+ return done(kept(res))
1728
1865
  },
1729
1866
  }
1730
1867
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-zcode-auth",
3
- "version": "0.1.22",
3
+ "version": "0.1.24",
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",