@magpie-community/opencode-workbuddy-auth 0.1.30 → 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 +52 -0
  2. package/index.mjs +469 -40
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -56,11 +56,63 @@ Desktop's sign-in is refreshed only when the app hasn't refreshed it
56
56
  itself, and the new token is kept in memory, never written back, as magpie's
57
57
  built-in WorkBuddy does.
58
58
 
59
+ A chat or usage read WorkBuddy answers 401 (since 0.1.31) is renewed once,
60
+ whatever the token's time, and sent once more. A token another process
61
+ saved since is taken first instead of spending the refresh token again. A
62
+ desktop sign-in is never renewed for it: the app's file is read again, and
63
+ its token is used if the app signed in again. A 401 again, or no new
64
+ token, ends in a 401 that says to sign in again (Providers → WorkBuddy →
65
+ this account's Sign in again in magpie; WorkBuddy desktop for a desktop
66
+ sign-in). The plugin marks the account expired in magpie
67
+ (`X-Magpie-Sign-In: expired`, usage's `signIn`) only on WorkBuddy's word
68
+ that the sign-in is gone: the refresh token refused (401, `invalid_grant`),
69
+ or the access token refused (`WWW-Authenticate: … error="invalid_token"`)
70
+ with no refresh token left to renew it with or just after renewing. A
71
+ failed renewal for another reason, or a bare 401 without `invalid_token`,
72
+ leaves the account as it is; a desktop sign-in is never marked. A 401
73
+ after a renewal takes a mark off (`renewed`).
74
+
59
75
  ## Requests
60
76
 
61
77
  Chats are chat completions (`@ai-sdk/openai-compatible`) at `<API>/chat/completions`.
62
78
  They are streamed only; WorkBuddy refuses a chat that isn't.
63
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
+
64
116
  Each chat carries WorkBuddy's headers:
65
117
  - `Authorization`, `X-User-Id`, `X-Domain`, `X-Product: SaaS`, `X-IDE-Type: WorkBuddy`;
66
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)) {
@@ -1123,6 +1304,8 @@ function readDesktop(site) {
1123
1304
  uid: acct.uid,
1124
1305
  ...(typeof acct.enterpriseId === "string" && acct.enterpriseId.trim() ? { enterpriseId: acct.enterpriseId.trim() } : {}),
1125
1306
  name: acct.nickname || acct.phoneNumber || acct.uid,
1307
+ // what the errors below say to do goes by it: sign in to the app again
1308
+ source: "desktop",
1126
1309
  }
1127
1310
  } catch {
1128
1311
  return null
@@ -1237,23 +1420,143 @@ async function unanswered(site, a, body, err, signal) {
1237
1420
  const size = byteLength(body)
1238
1421
  if (size <= EDGE_READS) throw err
1239
1422
  let status = 0
1423
+ let challenge = ""
1240
1424
  try {
1241
1425
  const res = await meterAsked(site, a, "/billing/meter/get-user-resource-summary", {}, AbortSignal.timeout(20000))
1242
1426
  status = res.status
1427
+ challenge = res.headers.get("www-authenticate") ?? ""
1243
1428
  await res.body?.cancel()
1244
1429
  } catch {
1245
1430
  throw err
1246
1431
  }
1247
1432
  if (status !== 401) throw err
1248
1433
  const mb = (size / (1024 * 1024)).toFixed(1)
1434
+ const note = `It closes the connection on a refused request over 2 MiB (this one was ${mb} MiB) instead of answering, which is why it read as ECONNRESET.`
1249
1435
  const message =
1250
- `${site.name} refused this account's sign-in (401). It closes the connection on a refused request over 2 MiB ` +
1251
- `(this one was ${mb} MiB) instead of answering, which is why it read as ECONNRESET. ` +
1436
+ `${site.name} refused this account's sign-in (401). ${note} ` +
1252
1437
  (a.source === "desktop" ? `Sign in to ${site.name} desktop again.` : `Sign in to ${site.name} again in magpie.`)
1438
+ // the billing API's challenge goes with it: it says whether the token
1439
+ // itself was refused (invalidToken), as the chat's own 401 would have
1440
+ const headers = { "content-type": "application/json", ...(challenge ? { "www-authenticate": challenge } : {}) }
1441
+ const res = new Response(JSON.stringify({ error: { message, type: "authentication_error", code: 401 } }), {
1442
+ status: 401,
1443
+ statusText: "Unauthorized",
1444
+ headers,
1445
+ })
1446
+ edgeNotes.set(res, note)
1447
+ return res
1448
+ }
1449
+
1450
+ // edgeNotes is what unanswered's 401s say of the reset, for signedOut to
1451
+ // say again when the sign-in can't be renewed.
1452
+ const edgeNotes = new WeakMap()
1453
+
1454
+ // ---- a refused sign-in ----------------------------------------------------------
1455
+
1456
+ // invalidToken says res is WorkBuddy refusing the access token itself. Its
1457
+ // front door (APISIX, on both sites) answers a token it doesn't take with
1458
+ // 401 and WWW-Authenticate: Bearer realm="copilot", error="invalid_token",
1459
+ // on a chat (streamed or not) and on the billing API alike; measured on
1460
+ // www.workbuddy.ai on 2026-10-23 with a real account's token cut short,
1461
+ // and with "garbage". (Its /v3/config answers 200 to any token, so the
1462
+ // models list never meets a 401.) A 401 without it is not taken as the
1463
+ // sign-in's: WorkBuddy answers some paths with a bare 401 to an accepted
1464
+ // sign-in (magpie's check-in, モモコ on Discord).
1465
+ const invalidToken = (res) => res?.status === 401 && /invalid_token/i.test(res.headers.get("www-authenticate") ?? "")
1466
+
1467
+ // refreshRefused says e is WorkBuddy turning the refresh token itself
1468
+ // away: HTTP 401 with code 12153, "refresh token failed:400 Bad Request:
1469
+ // invalid_grant: Invalid refresh token" for a spent or unknown one, or
1470
+ // "10000:token format error" (measured 2026-10-23). Anything else (a 5xx,
1471
+ // a timeout) says nothing of the sign-in.
1472
+ const refreshRefused = (e) => e instanceof WBError && (e.status === 401 || /invalid_grant/i.test(e.message))
1473
+
1474
+ // reissued is the sign-in to send a request WorkBuddy refused a's access
1475
+ // token for (401) once more with, before that token's time: WorkBuddy can
1476
+ // refuse one long before it ends (a browser sign-in's lasts as long as
1477
+ // its refresh token, about 340 days), and renewing only by time left such
1478
+ // an account refused on every request for months. It is
1479
+ // { a }: a sign-in with another access token, to send the request with
1480
+ // once more;
1481
+ // { why, kind }: none to be had, why, and of what kind: "refresh
1482
+ // refused" (WorkBuddy turned the refresh token away too), "nothing to
1483
+ // renew with" (no refresh token, or one past its end), or "unknown"
1484
+ // (the refresh failed for another reason, the desktop's token is the
1485
+ // one refused).
1486
+ // began is the access token the request read at its start: a stored one
1487
+ // that isn't it was saved since by someone else (magpie's renewal,
1488
+ // another of its processes), and is taken rather than renewing again.
1489
+ //
1490
+ // A desktop sign-in is the app's: its file is read again, and what was
1491
+ // renewed here (desktopHeld) is let go, but its refresh token is never
1492
+ // spent for a refusal, which would sign the app itself out (see
1493
+ // desktopSignIn).
1494
+ async function reissued(site, client, getAuth, a, began) {
1495
+ if (a.source === "desktop") {
1496
+ desktopHeld.delete(site.id + "|" + a.uid)
1497
+ const d = readDesktop(site)
1498
+ if (!d) return { why: notDesktop(site), kind: "unknown" }
1499
+ if (d.access !== a.access) {
1500
+ desktopHeld.set(site.id + "|" + d.uid, d)
1501
+ return { a: d }
1502
+ }
1503
+ return { why: `${site.name} desktop's own sign-in is the one refused`, kind: "unknown" }
1504
+ }
1505
+ const newer = async () => {
1506
+ const now = await getAuth().catch(() => null)
1507
+ return now?.type === "oauth" && now.source !== "desktop" && now.access && now.access !== began && now.access !== a.access ? now : null
1508
+ }
1509
+ const saved = await newer()
1510
+ if (saved) return { a: saved }
1511
+ if (!refreshable(a)) return { why: a.refresh ? "its refresh token has ended" : "there is no refresh token to renew it with", kind: "nothing to renew with" }
1512
+ try {
1513
+ const next = await renew(site, a, async (n) => {
1514
+ try {
1515
+ await client?.auth?.set?.({ path: { id: site.id }, body: n })
1516
+ } catch {}
1517
+ })
1518
+ return next.access !== a.access ? { a: next } : { why: "renewing it gave the same token", kind: "unknown" }
1519
+ } catch (e) {
1520
+ if (refreshRefused(e)) {
1521
+ // spent by someone else, who saved what it gave
1522
+ const saved = await newer()
1523
+ if (saved) return { a: saved }
1524
+ return { why: `it refused the refresh token too (${e.said ?? e.message})`, kind: "refresh refused" }
1525
+ }
1526
+ return { why: `renewing it failed: ${e?.said ?? e?.message ?? e}`, kind: "unknown" }
1527
+ }
1528
+ }
1529
+
1530
+ // gone says whether a sign-in reissued (r) couldn't renew is gone, on
1531
+ // WorkBuddy's word: it turned the refresh token away, or refused the access
1532
+ // token itself (tokenRefused, invalidToken) with none to renew it with. A
1533
+ // desktop sign-in never is: the app's file is read on each request, and
1534
+ // signing in to the app again is all it takes.
1535
+ const gone = (a, r, tokenRefused) =>
1536
+ a.source !== "desktop" && (r.kind === "refresh refused" || (r.kind === "nothing to renew with" && !!tokenRefused))
1537
+
1538
+ // refusedText says WorkBuddy refused a's sign-in, why it wasn't renewed,
1539
+ // and what to do.
1540
+ function refusedText(site, a, why, note) {
1541
+ return (
1542
+ `${site.name} refused this account's sign-in (401)${why ? "; " + why : ""}. ${note ? note + " " : ""}` +
1543
+ (a.source === "desktop"
1544
+ ? `Sign in to ${site.name} desktop again; magpie reads its sign-in on the next request.`
1545
+ : `Sign in to it again in magpie: Providers → ${site.name} → this account's Sign in again.`)
1546
+ )
1547
+ }
1548
+
1549
+ // signedOut is the answer to a request WorkBuddy refused this account's
1550
+ // sign-in for, renewed or not: a 401 that says so and what to do, which
1551
+ // WorkBuddy's own (an HTML page, its {code, msg}) doesn't. expired marks
1552
+ // the account in magpie (X-Magpie-Sign-In), out of routing and "sign in
1553
+ // again" on its row, till it is signed in again; else it is kept.
1554
+ function signedOut(site, a, why, expired, note) {
1555
+ const message = refusedText(site, a, why, note)
1253
1556
  return new Response(JSON.stringify({ error: { message, type: "authentication_error", code: 401 } }), {
1254
1557
  status: 401,
1255
1558
  statusText: "Unauthorized",
1256
- headers: { "content-type": "application/json" },
1559
+ headers: { "content-type": "application/json", "X-Magpie-Sign-In": expired ? "expired" : "kept" },
1257
1560
  })
1258
1561
  }
1259
1562
 
@@ -1267,9 +1570,12 @@ async function meter(site, a, path, body = {}) {
1267
1570
  } catch {}
1268
1571
  const code = Number.parseInt(env.code, 10) || 0
1269
1572
  if (!res.ok) {
1270
- if (code) throw new Error(env.msg || `error ${code}`)
1271
- if (env.msg) throw new Error(env.msg)
1272
- throw new Error(statusText(res.status))
1573
+ // the status and whether the token itself was refused go with it, for
1574
+ // usage's one renewal on a 401 (reissued)
1575
+ const said = { status: res.status, invalidToken: invalidToken(res) }
1576
+ if (code) throw Object.assign(new Error(env.msg || `error ${code}`), said)
1577
+ if (env.msg) throw Object.assign(new Error(env.msg), said)
1578
+ throw Object.assign(new Error(statusText(res.status)), said)
1273
1579
  }
1274
1580
  if (code) throw new Error(env.msg || `error ${code}`)
1275
1581
  return env.data ?? null
@@ -1418,11 +1724,16 @@ function variants(efforts) {
1418
1724
  return Object.fromEntries((efforts ?? []).map((e) => [e, { reasoningEffort: e }]))
1419
1725
  }
1420
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.
1421
1729
  function configModels(site) {
1422
1730
  return Object.fromEntries(site.models.map(([id, name, context, efforts]) => [id, {
1423
1731
  name,
1424
1732
  limit: { context, output: 0 },
1425
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) } } : {}),
1426
1737
  ...(efforts ? { reasoning: true, variants: variants(efforts) } : {}),
1427
1738
  }]))
1428
1739
  }
@@ -1432,7 +1743,7 @@ function modelOf(site, provider, m) {
1432
1743
  id: m.id,
1433
1744
  providerID: provider.id,
1434
1745
  name: m.name,
1435
- 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 },
1436
1747
  status: "active",
1437
1748
  headers: {},
1438
1749
  options: {},
@@ -1531,9 +1842,20 @@ function priceOf(d, promotions, now = Date.now()) {
1531
1842
  // and a default reasoning.effort. Those take low, high and max (each
1532
1843
  // reasoned under a real account, on four of them) and their own default.
1533
1844
  const EFFORT_ORDER = ["low", "medium", "high", "xhigh", "max"]
1534
- function effortsOf(d) {
1845
+ function effortsOf(d, site, id) {
1535
1846
  const said = d.reasoning?.supportedEfforts
1536
- 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
+ }
1537
1859
  if (d.supportsReasoning !== true) return []
1538
1860
  const own = d.reasoning?.effort
1539
1861
  const set = new Set(["low", "high", "max", ...(typeof own === "string" && EFFORT_ORDER.includes(own) ? [own] : [])])
@@ -1562,7 +1884,7 @@ async function liveModels(site, a) {
1562
1884
  m.output = d.maxOutputTokens
1563
1885
  m.images = d.supportsImages === true && !textOnly(site, id)
1564
1886
  Object.assign(m, priceOf(d, cfg.modelPromotions))
1565
- const es = effortsOf(d)
1887
+ const es = effortsOf(d, site, id)
1566
1888
  if (es.length) {
1567
1889
  const canOff = d.reasoning?.canDisableThinking
1568
1890
  m.efforts = [...(!d.onlyReasoning && (canOff == null || canOff) ? ["none"] : []), ...es]
@@ -1668,12 +1990,15 @@ function makePlugin(site) {
1668
1990
  const auth = await getAuth()
1669
1991
  if (auth?.type !== "oauth") return {}
1670
1992
  return {
1671
- 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: "",
1672
1997
  apiKey: "",
1673
1998
  async fetch(input, init) {
1674
- let a = await getAuth()
1675
- if (a?.type !== "oauth") throw new Error(`${site.name} isn't signed in`)
1676
- a = await current(site, client, a)
1999
+ const stored = await getAuth()
2000
+ if (stored?.type !== "oauth") throw new Error(`${site.name} isn't signed in`)
2001
+ let a = await current(site, client, stored)
1677
2002
  const req = input instanceof Request ? input : null
1678
2003
  const headers = new Headers(init?.headers ?? req?.headers)
1679
2004
  headers.delete("authorization")
@@ -1683,23 +2008,90 @@ function makePlugin(site) {
1683
2008
  sign(site, a, headers)
1684
2009
  let body = init?.body
1685
2010
  if (body === undefined && req) body = await req.clone().text()
1686
- body = withSystem(body)
1687
- const fit = fitted(body)
1688
- 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
+ }
1689
2030
  attend(headers, body, session)
1690
- const send = (b) =>
2031
+ const post = (b) =>
1691
2032
  fetch(req ? req.url : input, { ...init, method: init?.method ?? req?.method, headers, body: b }).catch((e) => unanswered(site, a, b, e, init?.signal))
2033
+ // a 401 is answered once, on whichever try meets it: by a
2034
+ // sign-in with another access token (reissued), the request
2035
+ // sent once more with it, never again after that; else, or
2036
+ // when that is refused too, by signedOut. The body is a
2037
+ // string here (or bytes), which goes again as it is; one that
2038
+ // can't be read twice isn't sent again.
2039
+ //
2040
+ // The account is marked (expired) only on WorkBuddy's word
2041
+ // that the sign-in is gone: it turned the refresh token away,
2042
+ // or refused the access token itself (invalidToken) with none
2043
+ // to renew it with, or refused even the token just renewed. A
2044
+ // desktop sign-in is never marked: the app's file is read on
2045
+ // each request, so signing in to the app again is enough.
2046
+ let renewed = false
2047
+ const ours = new WeakSet() // the answers signedOut gave, passed on as they are
2048
+ const out = (why, expired, res) => {
2049
+ const r = signedOut(site, a, why, expired && a.source !== "desktop", edgeNotes.get(res))
2050
+ ours.add(r)
2051
+ return r
2052
+ }
2053
+ const resendable = (b) => b == null || typeof b === "string" || b instanceof ArrayBuffer || ArrayBuffer.isView(b)
2054
+ const send = async (b) => {
2055
+ const res = await post(b)
2056
+ if (res.status !== 401) return res
2057
+ if (renewed) return out("it refused the token renewed for it too", invalidToken(res), res)
2058
+ if (!resendable(b)) return res
2059
+ await res.body?.cancel().catch(() => {})
2060
+ const r = await reissued(site, client, getAuth, a, stored.access)
2061
+ if (!r.a) return out(r.why, gone(a, r, invalidToken(res)), res)
2062
+ renewed = true
2063
+ a = r.a
2064
+ sign(site, a, headers)
2065
+ const again = await post(b)
2066
+ if (again.status !== 401) return again
2067
+ return out("it refused the token renewed for it too", invalidToken(again), again)
2068
+ }
1692
2069
  // a model known to turn tool results then the user's message away
1693
2070
  // gets the empty reply between them up front (see endsAfterTools)
1694
2071
  const model = chatOf(body)?.model
1695
2072
  // a system prompt WorkBuddy refused before goes as the user's
1696
2073
  // words at once (see systemAsUser)
1697
- const sysKey = systemKey(body)
2074
+ const sysKey = native ? "" : systemKey(body)
1698
2075
  if (sysKey && refusedSystems.has(sysKey)) body = systemAsUser(body) ?? body
1699
- const shaped = (b) => (endsAfterTools.has(model) ? replyAfterTools(b) ?? b : b)
2076
+ const shaped = (b) => (!native && endsAfterTools.has(model) ? replyAfterTools(b) ?? b : b)
1700
2077
  let res = await send(shaped(body))
1701
- if (res.status === 400) {
1702
- 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()
1703
2095
  const moved = REFUSED.test(text) && sysKey && !refusedSystems.has(sysKey) ? systemAsUser(body) : null
1704
2096
  if (moved) {
1705
2097
  refusedSystems.add(sysKey)
@@ -1716,20 +2108,43 @@ function makePlugin(site) {
1716
2108
  res = new Response(text, { status: res.status, statusText: res.statusText, headers: res.headers })
1717
2109
  }
1718
2110
  }
1719
- return res.status >= 400 ? explained(res) : kept(await renamed(res, fit.names))
2111
+ if (ours.has(res)) return res
2112
+ // renewed here: a mark the account had comes off
2113
+ return res.status >= 400 ? explained(res) : kept(native ? res : await renamed(res, names), renewed ? "renewed" : "kept")
1720
2114
  },
1721
2115
  }
1722
2116
  },
1723
2117
  // the account's credits, as magpie's built-in showed them; each
1724
2118
  // read keeps the sign-in (signIn), as the built-in's neither marked
1725
- // an account lapsed nor cleared it
2119
+ // an account lapsed nor cleared it, but for a 401 (gone, below)
1726
2120
  async usage(getAuth) {
1727
2121
  const auth = await getAuth()
1728
2122
  if (auth?.type !== "oauth" || !(auth.access || auth.source)) return { error: "not signed in", signIn: "kept" }
1729
2123
  try {
1730
- const a = await current(site, client, auth)
2124
+ let a = await current(site, client, auth)
1731
2125
  // the meter has no /v2 prefix, on either site
1732
- const sum = await meter(site, a, "/billing/meter/get-user-resource-summary")
2126
+ const SUMMARY = "/billing/meter/get-user-resource-summary"
2127
+ // a 401 is answered as a chat's is: once, by a sign-in with
2128
+ // another access token, else the card says WorkBuddy refused
2129
+ // the sign-in and what to do; renewed, a mark comes off
2130
+ let said = "kept"
2131
+ let sum
2132
+ try {
2133
+ sum = await meter(site, a, SUMMARY)
2134
+ } catch (e) {
2135
+ if (e?.status !== 401) throw e
2136
+ const r = await reissued(site, client, getAuth, a, auth.access)
2137
+ if (!r.a) return { error: refusedText(site, a, r.why), signIn: gone(a, r, e.invalidToken) ? "expired" : "kept" }
2138
+ a = r.a
2139
+ try {
2140
+ sum = await meter(site, a, SUMMARY)
2141
+ } catch (e) {
2142
+ if (e?.status !== 401) throw e
2143
+ const why = "it refused the token renewed for it too"
2144
+ return { error: refusedText(site, a, why), signIn: a.source !== "desktop" && e.invalidToken ? "expired" : "kept" }
2145
+ }
2146
+ said = "renewed"
2147
+ }
1733
2148
  const out = usageOf(sum, auth.plan)
1734
2149
  // an enterprise account's summary has no packages: its credits
1735
2150
  // are the enterprise meter's (#1904). A personal account's
@@ -1740,7 +2155,7 @@ function makePlugin(site) {
1740
2155
  try {
1741
2156
  ent = enterpriseUsageOf(await meter(site, a, "/billing/meter/get-enterprise-user-usage"))
1742
2157
  } catch {}
1743
- if (ent) return { plan: auth.plan || "Enterprise", windows: await spareFree(site, a, [ent]), signIn: "kept" }
2158
+ if (ent) return { plan: auth.plan || "Enterprise", windows: await spareFree(site, a, [ent]), signIn: said }
1744
2159
  }
1745
2160
  // when the credits left run out, for routing to spend them first;
1746
2161
  // a list that can't be read leaves it unknown, the card stands
@@ -1751,7 +2166,7 @@ function makePlugin(site) {
1751
2166
  } catch {}
1752
2167
  }
1753
2168
  await spareFree(site, a, out.windows)
1754
- return { ...out, signIn: "kept" }
2169
+ return { ...out, signIn: said }
1755
2170
  } catch (e) {
1756
2171
  return { error: e?.message ?? String(e), signIn: "kept" }
1757
2172
  }
@@ -1785,6 +2200,13 @@ const REFUSED_HINT =
1785
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"
1786
2201
  const REFUSED = /unapproved channel|illegal api invocation/i
1787
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
+
1788
2210
  // RESTRICTED_HINT is what the user can do about WorkBuddy's 403 code 11140
1789
2211
  // "request illegal", whose displayMsg says "Your account has been restricted
1790
2212
  // due to unusual activity" (magpie #1849): the account is out until WorkBuddy
@@ -1806,17 +2228,19 @@ const EXHAUSTED_HINT =
1806
2228
  // another error, and insufficient_quota would rest the account as out of credit.
1807
2229
  const EXHAUSTED_TEXT = /credits exhausted|额度已用尽|["']code["']\s*:\s*["']?14018(?![\d.])/i
1808
2230
 
1809
- // kept is an answer that went through, saying the sign-in is kept: the
1810
- // built-in never cleared a WorkBuddy account's mark, as it never set one.
1811
- function kept(res) {
2231
+ // kept is an answer that went through, saying the sign-in is kept (the
2232
+ // built-in never cleared a WorkBuddy account's mark, as it never set one),
2233
+ // or "renewed" when it went through on a sign-in renewed for a 401, which
2234
+ // takes a mark off (see signedOut).
2235
+ function kept(res, said = "kept") {
1812
2236
  const headers = new Headers(res.headers)
1813
- headers.set("X-Magpie-Sign-In", "kept")
2237
+ headers.set("X-Magpie-Sign-In", said)
1814
2238
  return new Response(res.body, { status: res.status, statusText: res.statusText, headers })
1815
2239
  }
1816
2240
 
1817
2241
  // explained is res with REFUSED_HINT, RESTRICTED_HINT or EXHAUSTED_HINT added to that refusal's message.
1818
- // Each refusal says the sign-in is kept: the built-in passed WorkBuddy's
1819
- // 401 on and never marked a WorkBuddy account lapsed.
2242
+ // Each refusal says the sign-in is kept: a 401 never comes here, it is
2243
+ // signedOut's.
1820
2244
  async function explained(res) {
1821
2245
  const text = await res.text()
1822
2246
  const again = (b) => {
@@ -1831,10 +2255,15 @@ async function explained(res) {
1831
2255
  v = JSON.parse(text)
1832
2256
  } catch {}
1833
2257
  const at = v?.error && typeof v.error === "object" ? v.error : v && typeof v === "object" ? v : null
1834
- 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)
1835
2264
  const isRefused = REFUSED.test(text)
1836
- const isRestricted = !isRefused && (Number(code) === RESTRICTED_CODE || RESTRICTED_TEXT.test(text))
1837
- 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))
1838
2267
  if (!isRefused && !isRestricted && !isExhausted) return again(text)
1839
2268
 
1840
2269
  let msg = text.trim()
@@ -1860,4 +2289,4 @@ export const WorkBuddyAuthPlugin = makePlugin(SITES.workbuddy)
1860
2289
  export const WorkBuddyAIAuthPlugin = makePlugin(SITES["workbuddy-ai"])
1861
2290
 
1862
2291
  // for tests
1863
- 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.30",
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",