@magpie-community/opencode-workbuddy-auth 0.1.31 → 0.1.33

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 +36 -0
  2. package/index.mjs +262 -19
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -77,6 +77,42 @@ after a renewal takes a mark off (`renewed`).
77
77
  Chats are chat completions (`@ai-sdk/openai-compatible`) at `<API>/chat/completions`.
78
78
  They are streamed only; WorkBuddy refuses a chat that isn't.
79
79
 
80
+ **On WorkBuddy (China), the Claude models are served on Anthropic's Messages
81
+ API** instead: `@ai-sdk/anthropic` at `<endpoint>/v1/messages` (streamed only
82
+ as well). `claude-opus-5.5`, `claude-sonnet-5.5`, `claude-opus-5` and
83
+ `claude-opus-4.8-1m` answered there when each model of `/v3/config`'s `cli`
84
+ agent was asked on 2026-10-24; `glm-5.3` and `deepseek-v4.1-flash` answered
85
+ 500, `gpt-5.5` and `kimi-k3-1` 400, so every other model stays on chat.
86
+ WorkBuddy AI has no such route (`/v1/messages` is `404 Route Not Found` there
87
+ for every model), so all its models stay on chat, a Claude one too.
88
+
89
+ A Messages request goes as the agent sent it: no default system message, no
90
+ `max_completion_tokens`/`developer` rewrites, no tool reshaping, and nothing
91
+ moved for a refused system prompt — Claude Code's own prompt is taken as it
92
+ is, billing line and all, where a chat answers 11128 to it. One thing is
93
+ left out: a `thinking` block with no `signature`. A conversation that began
94
+ on chat (or on another provider) carries such blocks, and once a signed one
95
+ from this route sits beside them WorkBuddy refuses the whole request, 11159
96
+ "Invalid `signature` in `thinking` block … bound to a different
97
+ conversation", on every try. Signed blocks go back as they came. It carries
98
+ WorkBuddy's headers as a chat does, the same conversation and turn ids
99
+ included: a tool result in a `user` message of `tool_result` blocks answers
100
+ the step before it, as a `role: "tool"` message does in a chat.
101
+
102
+ The point of the route is effort. A chat carries `reasoning_effort` alone,
103
+ and WorkBuddy's chat endpoint answers at about the same length whatever it
104
+ says: asked the same question four times each (2026-10-24,
105
+ `claude-opus-5.5`), the median answer was 3884 output tokens at `low`, 5144
106
+ at `max` and 5715 at a value that isn't a level at all. Messages carries
107
+ `output_config.effort`, and `max` is honored there: a median of 30850
108
+ against 5748 at `low`, same model and question, four runs each. A Claude
109
+ model on Messages therefore lists `max` in its levels even where
110
+ `/v3/config` stops at `xhigh`.
111
+
112
+ `/v1/messages/count_tokens` **is not served** (404). magpie treats a 404 on
113
+ count_tokens as "no counter" (`unsupportedCount`) and answers with its own
114
+ estimate.
115
+
80
116
  Each chat carries WorkBuddy's headers:
81
117
  - `Authorization`, `X-User-Id`, `X-Domain`, `X-Product: SaaS`, `X-IDE-Type: WorkBuddy`;
82
118
  - `User-Agent: WorkBuddy/5.5.6`. Without it, WorkBuddy answers error 10085;
package/index.mjs CHANGED
@@ -68,6 +68,9 @@ const SITES = {
68
68
  platform: "workbuddy",
69
69
  ai: false,
70
70
  models: CN_MODELS,
71
+ // it serves the Claude models on Anthropic's Messages API too (see
72
+ // nativeOf)
73
+ messages: true,
71
74
  },
72
75
  "workbuddy-ai": {
73
76
  id: "workbuddy-ai",
@@ -103,6 +106,33 @@ const hex = () => randomBytes(16).toString("hex")
103
106
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
104
107
  const api = (site) => site.endpoint + "/v2"
105
108
 
109
+ // MESSAGES is the API SDK package for Anthropic's Messages shape, which
110
+ // WorkBuddy serves the Claude models on, beside its chat completions:
111
+ // <endpoint>/v1/messages. Those models are Anthropic's own, and their
112
+ // thinking is asked for the way Anthropic asks it (thinking.type adaptive
113
+ // with output_config.effort), which the chat endpoint has no field for: a
114
+ // chat carries reasoning_effort alone, and WorkBuddy's chat endpoint gives
115
+ // the same answer at every level, so an agent that picked max got no more
116
+ // than one that picked low. See nativeOf.
117
+ const NPM_MESSAGES = "@ai-sdk/anthropic"
118
+ const apiMessages = (site) => site.endpoint + "/v1"
119
+
120
+ // nativeOf is whether site serves model on Messages rather than chat: a
121
+ // Claude model, on a site that serves Messages at all (site.messages).
122
+ // Measured 2026-10-24 on WorkBuddy's China build, each model of
123
+ // /v3/config's cli agent asked on /v1/messages: claude-opus-5.5,
124
+ // claude-sonnet-5.5, claude-opus-5 and claude-opus-4.8-1m answered in
125
+ // Anthropic's stream; glm-5.3 and deepseek-v4.1-flash answered 500, gpt-5.5
126
+ // and kimi-k3-1 400. An account may still be refused a Claude model it has
127
+ // no entitlement for (11158), which is the account's, not the endpoint's.
128
+ // WorkBuddy AI (www.workbuddy.ai) has no such route: /v1/messages,
129
+ // /v2/messages and /v1/messages?beta=true are all "404 Route Not Found"
130
+ // there, whatever the model, so its models stay on chat, a Claude one too
131
+ // should it list one.
132
+ function nativeOf(site, model) {
133
+ return site?.messages === true && /^claude-/i.test(String(model ?? ""))
134
+ }
135
+
106
136
  // ---- WorkBuddy's API ------------------------------------------------------
107
137
 
108
138
  class WBError extends Error {
@@ -370,11 +400,32 @@ function signatureOf(content) {
370
400
  // words. A client that sends a tool result's images in a user message (pi's
371
401
  // "Attached image(s) from tool result:") puts that message right after the
372
402
  // result, and a turn keyed on it would be a turn of its own on every step.
403
+ //
404
+ // Anthropic's Messages shape says the same another way: a tool's result is a
405
+ // tool_result block in a user message, after an assistant message of
406
+ // tool_use blocks. Such a user message answers too, whatever text sits
407
+ // beside the results (Claude Code puts a reminder there, a user's words typed
408
+ // mid-task go there as well), and so does one right after it, as a chat's
409
+ // does after role "tool". Messages that are neither the user's nor the
410
+ // assistant's (Claude Code's role "system" ones, which carry a turn's
411
+ // effort, its environment and its token count between the steps) stand
412
+ // between them without changing which message answers.
373
413
  function answeredBefore(msgs, i) {
374
- const prev = msgs[i - 1]
414
+ if (hasBlock(msgs[i], "tool_result")) return true
415
+ let j = i - 1
416
+ while (j >= 0 && msgs[j]?.role === "system") j--
417
+ const prev = msgs[j]
375
418
  if (prev === null || typeof prev !== "object") return false
376
419
  if (prev.role === "tool") return true
377
- return prev.role === "assistant" && Array.isArray(prev.tool_calls) && prev.tool_calls.length > 0
420
+ if (prev.role === "user" && hasBlock(prev, "tool_result")) return true
421
+ if (prev.role !== "assistant") return false
422
+ if (Array.isArray(prev.tool_calls) && prev.tool_calls.length > 0) return true
423
+ return hasBlock(prev, "tool_use")
424
+ }
425
+
426
+ // hasBlock is whether message m's content holds a block of type.
427
+ function hasBlock(m, type) {
428
+ return Array.isArray(m?.content) && m.content.some((p) => p?.type === type)
378
429
  }
379
430
 
380
431
  // turnKey names the user send a chat answers: the body's last user message
@@ -410,6 +461,100 @@ function conversationKey(chat, session) {
410
461
  return ""
411
462
  }
412
463
 
464
+ // isMessages says whether a request is an Anthropic Messages one, which
465
+ // site serves the Claude models natively: its URL ends at /messages or at
466
+ // the counting endpoint beside it (/v1/messages/count_tokens, which the
467
+ // chat route's rewrites would otherwise turn into a chat), or (a body is
468
+ // all there is to go on) its system is Anthropic's blocks rather than a
469
+ // chat's string, and its tools carry input_schema where a chat's carry a
470
+ // function's parameters. A chat's own tools can look either way, so the URL
471
+ // decides when there is one.
472
+ function isMessages(url, body) {
473
+ const path = (() => {
474
+ try {
475
+ return new URL(String(url ?? "")).pathname
476
+ } catch {
477
+ return ""
478
+ }
479
+ })()
480
+ if (path) return /\/messages(\/count_tokens)?$/.test(path)
481
+ const b = chatOf(body)
482
+ if (!b) return false
483
+ if (Array.isArray(b.system)) return true
484
+ const tools = Array.isArray(b.tools) ? b.tools : []
485
+ return tools.some((t) => t && typeof t === "object" && "input_schema" in t && !t.function)
486
+ }
487
+
488
+ // withoutUnsigned is a Messages body without the thinking blocks that carry
489
+ // no signature, or whose signature WorkBuddy doesn't take.
490
+ //
491
+ // A conversation that began elsewhere — on WorkBuddy's chat route, whose
492
+ // thinking comes back unsigned, or on any other provider — and goes on here
493
+ // carries such blocks in its history, and WorkBuddy refuses the whole request
494
+ // for one of them when another assistant message holds a signed block:
495
+ // 11159 "Invalid `signature` in `thinking` block. The block is bound to a
496
+ // different conversation" (or the same wrapped in 11133). Seen 2026-10-24 in
497
+ // real Claude Code sessions moved onto this route mid-way: every request of
498
+ // two of them refused, each attempt alike, until the session ended. Replayed
499
+ // the same day, with a real account (20+ runs each):
500
+ //
501
+ // unsigned thinking alone, no signed block anywhere ....... 200
502
+ // one unsigned-only assistant message + a signed one ...... 11159
503
+ // that message with the unsigned block taken out .......... 200
504
+ // unsigned and signed blocks in the SAME message .......... 200
505
+ // thinking.block_binding.prefix_mismatch_behavior=drop_block ... 11159
506
+ //
507
+ // So what binds a signed block is the history it sits in, and a message that
508
+ // is thinking alone can't be part of it; whether that thinking is signed
509
+ // makes no difference (a signed block alone is accepted too). The signature
510
+ // itself isn't checked for where it came from: a block keeping a signature
511
+ // of an unrelated turn, of another model (sonnet-5.5's on opus-5.5), or
512
+ // another account's answers 200, while a truncated or altered one is 11159.
513
+ // What WorkBuddy checks is that it reads as a signature.
514
+ //
515
+ // A block with no signature can't be verified by the model anyway, so
516
+ // nothing it could use is lost. A message whose only block that was gets an
517
+ // empty text block: measured the same day, that history still answers 200
518
+ // with a signed turn after it (three runs), where the unsigned block itself
519
+ // answers 11159 (three runs), and Anthropic's API refuses an empty text
520
+ // block — 200 here means WorkBuddy takes it. The signed blocks, this
521
+ // route's own, go back as they came, as Anthropic asks. A body without
522
+ // unsigned thinking is body itself, byte for byte.
523
+ function withoutUnsigned(body) {
524
+ const b = chatOf(body)
525
+ const msgs = b?.messages
526
+ if (!Array.isArray(msgs)) return body
527
+ let changed = false
528
+ for (const m of msgs) {
529
+ if (m?.role !== "assistant" || !Array.isArray(m.content)) continue
530
+ const kept = m.content.filter((p) => !(p?.type === "thinking" && !p.signature))
531
+ if (kept.length === m.content.length) continue
532
+ changed = true
533
+ m.content = kept.length ? kept : [{ type: "text", text: "" }]
534
+ }
535
+ return changed ? JSON.stringify(b) : body
536
+ }
537
+
538
+ // withoutSignedThinking is a Messages body without any thinking block at
539
+ // all, for a refusal saying a signature is not one WorkBuddy made (see the
540
+ // retry in the loader). An assistant message left with nothing gets an
541
+ // empty text block, as withoutUnsigned leaves it. null for a body with no
542
+ // thinking block to drop: the retry would send the same request again.
543
+ function withoutSignedThinking(body) {
544
+ const b = chatOf(body)
545
+ const msgs = b?.messages
546
+ if (!Array.isArray(msgs)) return null
547
+ let dropped = 0
548
+ for (const m of msgs) {
549
+ if (m?.role !== "assistant" || !Array.isArray(m.content)) continue
550
+ const kept = m.content.filter((p) => p?.type !== "thinking")
551
+ if (kept.length === m.content.length) continue
552
+ dropped += m.content.length - kept.length
553
+ m.content = kept.length ? kept : [{ type: "text", text: "" }]
554
+ }
555
+ return dropped ? JSON.stringify(b) : null
556
+ }
557
+
413
558
  // chatOf is a chat's body as the object it is, null for one that isn't: a
414
559
  // body that isn't text (bytes), or isn't JSON at all.
415
560
  function chatOf(body) {
@@ -894,6 +1039,41 @@ function objectRoot(ps, root = ps, depth = 0) {
894
1039
  return true
895
1040
  }
896
1041
 
1042
+ // Kimi (kimi-k3.1-pro) answers 400 "Invalid request parameters" to a oneOf
1043
+ // that has no type, at the root of a $ref's target or deeper, even when every
1044
+ // branch is an object (congee949, #378: dsh's REA tools, on workbuddy and
1045
+ // workbuddy-ai alike; a "type": "object" beside it is taken). typeUnions gives
1046
+ // such a oneOf type "object" when each branch is typed object itself or
1047
+ // through a chain of local $refs, and changes nothing else: a union of other
1048
+ // or mixed types, an anyOf, or a $ref that is missing, external or circular
1049
+ // is left as it came. It reports whether it changed anything.
1050
+ function typeUnions(s, root = s, depth = 0) {
1051
+ if (!s || typeof s !== "object" || Array.isArray(s) || depth > 64) return false
1052
+ let changed = false
1053
+ if (!("type" in s) && Array.isArray(s.oneOf) && s.oneOf.length && s.oneOf.every((b) => objectBranch(root, b))) {
1054
+ s.type = "object"
1055
+ changed = true
1056
+ }
1057
+ for (const k of SUB_ONE) if (typeUnions(s[k], root, depth + 1)) changed = true
1058
+ for (const k of SUB_LIST) for (const v of Array.isArray(s[k]) ? s[k] : []) if (typeUnions(v, root, depth + 1)) changed = true
1059
+ for (const k of SUB_MAP) {
1060
+ const m = s[k]
1061
+ if (!m || typeof m !== "object" || Array.isArray(m)) continue
1062
+ for (const v of Object.values(m)) if (typeUnions(v, root, depth + 1)) changed = true
1063
+ }
1064
+ return changed
1065
+ }
1066
+
1067
+ // objectBranch says whether b is typed object, itself or where its local
1068
+ // $refs lead; a $ref seen before on the way is a cycle, and not.
1069
+ function objectBranch(root, b, seen = []) {
1070
+ if (!b || typeof b !== "object" || Array.isArray(b)) return false
1071
+ if ("type" in b) return b.type === "object"
1072
+ if (typeof b.$ref !== "string" || seen.includes(b.$ref) || seen.length > 16) return false
1073
+ const t = localRef(root, b)
1074
+ return t !== b && objectBranch(root, t, [...seen, b.$ref])
1075
+ }
1076
+
897
1077
  // fitTools mends b's function tools in place: their parameters (fitSchema,
898
1078
  // fitShape, objectRoot) and names. It reports whether it changed anything,
899
1079
  // and the names it gave, each to the name the client knows (ours), so that the
@@ -927,6 +1107,7 @@ function fitTools(b) {
927
1107
  }
928
1108
  if (fitSchema(fn.parameters)) changed = true
929
1109
  if (fitShape(fn.parameters)) changed = true
1110
+ if (typeUnions(fn.parameters)) changed = true
930
1111
  if (objectRoot(fn.parameters)) changed = true
931
1112
  }
932
1113
  if (theirs.size && Array.isArray(b.messages)) {
@@ -1543,11 +1724,16 @@ function variants(efforts) {
1543
1724
  return Object.fromEntries((efforts ?? []).map((e) => [e, { reasoningEffort: e }]))
1544
1725
  }
1545
1726
 
1727
+ // configModels is the site's own list as a provider's models, each on the
1728
+ // API it is served on: a Claude model on Messages, the rest on chat.
1546
1729
  function configModels(site) {
1547
1730
  return Object.fromEntries(site.models.map(([id, name, context, efforts]) => [id, {
1548
1731
  name,
1549
1732
  limit: { context, output: 0 },
1550
1733
  tool_call: true,
1734
+ // OpenCode (and magpie's host) read a config model's package and URL
1735
+ // from provider: { npm, api }, not api
1736
+ ...(nativeOf(site, id) ? { provider: { npm: NPM_MESSAGES, api: apiMessages(site) } } : {}),
1551
1737
  ...(efforts ? { reasoning: true, variants: variants(efforts) } : {}),
1552
1738
  }]))
1553
1739
  }
@@ -1557,7 +1743,7 @@ function modelOf(site, provider, m) {
1557
1743
  id: m.id,
1558
1744
  providerID: provider.id,
1559
1745
  name: m.name,
1560
- api: { id: m.id, url: api(site), npm: NPM },
1746
+ api: { id: m.id, url: nativeOf(site, m.id) ? apiMessages(site) : api(site), npm: nativeOf(site, m.id) ? NPM_MESSAGES : NPM },
1561
1747
  status: "active",
1562
1748
  headers: {},
1563
1749
  options: {},
@@ -1656,9 +1842,20 @@ function priceOf(d, promotions, now = Date.now()) {
1656
1842
  // and a default reasoning.effort. Those take low, high and max (each
1657
1843
  // reasoned under a real account, on four of them) and their own default.
1658
1844
  const EFFORT_ORDER = ["low", "medium", "high", "xhigh", "max"]
1659
- function effortsOf(d) {
1845
+ function effortsOf(d, site, id) {
1660
1846
  const said = d.reasoning?.supportedEfforts
1661
- if (Array.isArray(said) && said.length) return said
1847
+ if (Array.isArray(said) && said.length) {
1848
+ // A Claude model on Messages takes max though the config's list stops
1849
+ // at xhigh: measured 2026-10-24, output_config.effort "max" answered
1850
+ // 200 on each Claude model, and on claude-opus-5.5 with more thinking
1851
+ // than xhigh (11248 thinking tokens against 1999, one run each). How
1852
+ // much more a level gives varies by model and run; what matters here
1853
+ // is that a client picking max reaches the server as max, not xhigh.
1854
+ // On chat max means nothing more (see NPM_MESSAGES), so a model there
1855
+ // keeps what the config says.
1856
+ if (nativeOf(site, id) && !said.includes("max")) return [...said, "max"]
1857
+ return said
1858
+ }
1662
1859
  if (d.supportsReasoning !== true) return []
1663
1860
  const own = d.reasoning?.effort
1664
1861
  const set = new Set(["low", "high", "max", ...(typeof own === "string" && EFFORT_ORDER.includes(own) ? [own] : [])])
@@ -1687,7 +1884,7 @@ async function liveModels(site, a) {
1687
1884
  m.output = d.maxOutputTokens
1688
1885
  m.images = d.supportsImages === true && !textOnly(site, id)
1689
1886
  Object.assign(m, priceOf(d, cfg.modelPromotions))
1690
- const es = effortsOf(d)
1887
+ const es = effortsOf(d, site, id)
1691
1888
  if (es.length) {
1692
1889
  const canOff = d.reasoning?.canDisableThinking
1693
1890
  m.efforts = [...(!d.onlyReasoning && (canOff == null || canOff) ? ["none"] : []), ...es]
@@ -1793,7 +1990,10 @@ function makePlugin(site) {
1793
1990
  const auth = await getAuth()
1794
1991
  if (auth?.type !== "oauth") return {}
1795
1992
  return {
1796
- baseURL: api(site),
1993
+ // no baseURL: a model's own api.url says where it is served —
1994
+ // <endpoint>/v2 for a chat, <endpoint>/v1 for a Claude model on
1995
+ // Messages. A provider-wide base would put /messages under /v2.
1996
+ baseURL: "",
1797
1997
  apiKey: "",
1798
1998
  async fetch(input, init) {
1799
1999
  const stored = await getAuth()
@@ -1808,9 +2008,25 @@ function makePlugin(site) {
1808
2008
  sign(site, a, headers)
1809
2009
  let body = init?.body
1810
2010
  if (body === undefined && req) body = await req.clone().text()
1811
- body = withSystem(body)
1812
- const fit = fitted(body)
1813
- body = normalizeToolChoice(fit.body)
2011
+ // An Anthropic request goes as the agent sent it: WorkBuddy
2012
+ // serves these models on Messages itself, and the rewrites
2013
+ // below (a default system message, the chat endpoint's
2014
+ // max_tokens spelling, tool shapes, the move of a refused
2015
+ // system prompt) exist for the chat endpoint alone. Claude
2016
+ // Code's own system prompt is taken there as it is — measured
2017
+ // 2026-10-24: the billing line, the "official CLI" line,
2018
+ // Codex's and DeepSeek Harness's openings all answer on
2019
+ // /v1/messages, where a chat is refused 11128.
2020
+ const native = isMessages(req ? req.url : String(input), body)
2021
+ let names = {}
2022
+ if (!native) {
2023
+ body = withSystem(body)
2024
+ const fit = fitted(body)
2025
+ body = normalizeToolChoice(fit.body)
2026
+ names = fit.names
2027
+ } else {
2028
+ body = withoutUnsigned(body)
2029
+ }
1814
2030
  attend(headers, body, session)
1815
2031
  const post = (b) =>
1816
2032
  fetch(req ? req.url : input, { ...init, method: init?.method ?? req?.method, headers, body: b }).catch((e) => unanswered(site, a, b, e, init?.signal))
@@ -1855,12 +2071,27 @@ function makePlugin(site) {
1855
2071
  const model = chatOf(body)?.model
1856
2072
  // a system prompt WorkBuddy refused before goes as the user's
1857
2073
  // words at once (see systemAsUser)
1858
- const sysKey = systemKey(body)
2074
+ const sysKey = native ? "" : systemKey(body)
1859
2075
  if (sysKey && refusedSystems.has(sysKey)) body = systemAsUser(body) ?? body
1860
- const shaped = (b) => (endsAfterTools.has(model) ? replyAfterTools(b) ?? b : b)
2076
+ const shaped = (b) => (!native && endsAfterTools.has(model) ? replyAfterTools(b) ?? b : b)
1861
2077
  let res = await send(shaped(body))
1862
- if (res.status === 400) {
1863
- let text = await res.text()
2078
+ // a signature WorkBuddy's own signing didn't make — a session
2079
+ // moved here from another provider keeps its thinking blocks,
2080
+ // and a truncated or altered one reads the same way — is 11159
2081
+ // too, and none of them can be told apart from a good one
2082
+ // before sending. The request goes once more without them.
2083
+ // Measured 2026-10-24: another vendor's signature answers 11159
2084
+ // on its own, drop_block notwithstanding; without the block the
2085
+ // same history answers 200. Only when the history holds a
2086
+ // signature that isn't this route's own, so a session on this
2087
+ // route never sends twice.
2088
+ if (native && res.status >= 400) {
2089
+ const text = await res.text()
2090
+ const again = BAD_SIGNATURE.test(text) ? withoutSignedThinking(body) : null
2091
+ if (again) res = await send(again)
2092
+ else res = new Response(text, { status: res.status, statusText: res.statusText, headers: res.headers })
2093
+ }
2094
+ if (res.status === 400 && !native) { let text = await res.text()
1864
2095
  const moved = REFUSED.test(text) && sysKey && !refusedSystems.has(sysKey) ? systemAsUser(body) : null
1865
2096
  if (moved) {
1866
2097
  refusedSystems.add(sysKey)
@@ -1879,7 +2110,7 @@ function makePlugin(site) {
1879
2110
  }
1880
2111
  if (ours.has(res)) return res
1881
2112
  // renewed here: a mark the account had comes off
1882
- return res.status >= 400 ? explained(res) : kept(await renamed(res, fit.names), renewed ? "renewed" : "kept")
2113
+ return res.status >= 400 ? explained(res) : kept(native ? res : await renamed(res, names), renewed ? "renewed" : "kept")
1883
2114
  },
1884
2115
  }
1885
2116
  },
@@ -1969,6 +2200,13 @@ const REFUSED_HINT =
1969
2200
  "WorkBuddy refused words in this client's prompt (most often its system prompt); use it from Hermes, OpenCode or Pi, or add another provider to this group"
1970
2201
  const REFUSED = /unapproved channel|illegal api invocation/i
1971
2202
 
2203
+ // BAD_SIGNATURE is WorkBuddy's refusal of a thinking block whose signature it
2204
+ // didn't make: internal code 11159, "Invalid `signature` in `thinking` block.
2205
+ // The block is bound to a different conversation", else 11133 wrapping
2206
+ // "thinking_signature_invalid: replay the original thinking block and
2207
+ // signature unchanged".
2208
+ const BAD_SIGNATURE = /thinking_signature_invalid|invalid\s+`?signature`?\s+in\s+`?thinking|block is bound to a different conversation|"internal_code"\s*:\s*"?11159/i
2209
+
1972
2210
  // RESTRICTED_HINT is what the user can do about WorkBuddy's 403 code 11140
1973
2211
  // "request illegal", whose displayMsg says "Your account has been restricted
1974
2212
  // due to unusual activity" (magpie #1849): the account is out until WorkBuddy
@@ -2017,10 +2255,15 @@ async function explained(res) {
2017
2255
  v = JSON.parse(text)
2018
2256
  } catch {}
2019
2257
  const at = v?.error && typeof v.error === "object" ? v.error : v && typeof v === "object" ? v : null
2020
- const code = at?.data?.code ?? at?.code ?? v?.code ?? v?.data?.code
2258
+ // the Messages route words its code two more ways (seen 2026-10-24):
2259
+ // {"error": {"code": "400001", "internal_code": "11159", …}}, and
2260
+ // {"code": 11102, "error": "11158:…"}, the code that says what happened
2261
+ // first in a string error, where the outer one only says it failed
2262
+ const inner = at?.internal_code ?? (typeof v?.error === "string" ? v.error.match(/^(\d{5}):/)?.[1] : undefined)
2263
+ const codes = [inner, at?.data?.code, at?.code, v?.code, v?.data?.code].filter((c) => c != null).map(Number)
2021
2264
  const isRefused = REFUSED.test(text)
2022
- const isRestricted = !isRefused && (Number(code) === RESTRICTED_CODE || RESTRICTED_TEXT.test(text))
2023
- const isExhausted = !isRestricted && (code === 14018 || Number(code) === 14018 || EXHAUSTED_TEXT.test(text))
2265
+ const isRestricted = !isRefused && (codes.includes(RESTRICTED_CODE) || RESTRICTED_TEXT.test(text))
2266
+ const isExhausted = !isRestricted && (codes.includes(14018) || EXHAUSTED_TEXT.test(text))
2024
2267
  if (!isRefused && !isRestricted && !isExhausted) return again(text)
2025
2268
 
2026
2269
  let msg = text.trim()
@@ -2046,4 +2289,4 @@ export const WorkBuddyAuthPlugin = makePlugin(SITES.workbuddy)
2046
2289
  export const WorkBuddyAIAuthPlugin = makePlugin(SITES["workbuddy-ai"])
2047
2290
 
2048
2291
  // for tests
2049
- export const _internal = { systemAsUser, systemKey, DEFAULT_SYSTEM, EDGE_READS, fitShape, replyAfterTools, AFTER_TOOLS_REFUSED, enterpriseUsageOf, notDesktop, textOnly, runsOutOf, packagesOf, endOf, enterpriseHeaders, enterpriseOf, liveModels, browserSignIn, withSystem, normalizeToolChoice, fitTools, fitted, renamed, unflagged, usageOf, desktopHeld, explained, REFUSED_HINT, RESTRICTED_HINT, EXHAUSTED_HINT, freeCredits, creditsOf, priceOf, fresh, SITES, renewing, renewed, SESSION, attend, turnKey, conversationKey, signatureOf }
2292
+ export const _internal = { typeUnions, systemAsUser, systemKey, DEFAULT_SYSTEM, EDGE_READS, fitShape, replyAfterTools, AFTER_TOOLS_REFUSED, enterpriseUsageOf, notDesktop, textOnly, runsOutOf, packagesOf, endOf, enterpriseHeaders, enterpriseOf, liveModels, browserSignIn, withSystem, normalizeToolChoice, fitTools, fitted, renamed, unflagged, usageOf, desktopHeld, explained, REFUSED_HINT, RESTRICTED_HINT, EXHAUSTED_HINT, freeCredits, creditsOf, priceOf, fresh, SITES, renewing, renewed, SESSION, attend, turnKey, conversationKey, signatureOf, isMessages, withoutUnsigned, withoutSignedThinking, answeredBefore, nativeOf, modelOf, configModels, API_MESSAGES: NPM_MESSAGES, API_CHAT: NPM }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-workbuddy-auth",
3
- "version": "0.1.31",
3
+ "version": "0.1.33",
4
4
  "description": "OpenCode provider plugins for WorkBuddy's plans: WorkBuddy (CodeBuddy, copilot.tencent.com) and WorkBuddy AI (www.workbuddy.ai)",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",