@magpie-community/opencode-workbuddy-auth 0.1.43 → 0.1.45

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 +3 -1
  2. package/index.mjs +115 -47
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -244,7 +244,9 @@ client names, `none` too, goes as it came.
244
244
 
245
245
  A chat goes gzipped (`Content-Encoding: gzip`), as WorkBuddy's own client
246
246
  sends its model requests (`RequestBodyGzip` in both builds' product.json).
247
- Claude's Messages route goes as it came.
247
+ Claude's Messages route goes as it came, and so does anything that isn't a
248
+ chat completion: magpie's drawings (`/v2/images/generations`,
249
+ `/v2/images/edits`) go as plain JSON, which is all the images API reads.
248
250
 
249
251
  A model's price is its `credits` ("x0.29") and the config's
250
252
  `modelPromotions`. A discount for whole days (Step-5-Preview's 独家限免) is
package/index.mjs CHANGED
@@ -459,8 +459,28 @@ const SALT = randomBytes(16).toString("hex")
459
459
 
460
460
  // idFor is the 32 hex digits WorkBuddy's own ids are made of, made of a
461
461
  // name of the thing the id is for (a turn, a conversation) and what names it.
462
+ // key is a string, or the pieces it is made of: the digest is the same
463
+ // either way, and a key made of a 45 MiB message is never joined into one
464
+ // string to be hashed (plugins#461). Each piece goes to the hash whole:
465
+ // Bun reads a one-byte string's bytes in place, where a piece cut into
466
+ // slices was measured costing a copy of each slice.
462
467
  function idFor(kind, key) {
463
- return createHash("sha256").update(`${SALT}|${kind}|${key}`).digest("hex").slice(0, 32)
468
+ const h = createHash("sha256").update(`${SALT}|${kind}|`)
469
+ // a surrogate pair's first half ending one piece waits for the next, as
470
+ // the joined key would hold the pair together
471
+ let held = ""
472
+ for (let piece of typeof key === "string" ? [key] : key) {
473
+ if (held) piece = held + piece
474
+ held = ""
475
+ const c = piece.charCodeAt(piece.length - 1)
476
+ if (c >= 0xd800 && c <= 0xdbff) {
477
+ held = piece.slice(-1)
478
+ piece = piece.slice(0, -1)
479
+ }
480
+ h.update(piece)
481
+ }
482
+ if (held) h.update(held)
483
+ return h.digest("hex").slice(0, 32)
464
484
  }
465
485
 
466
486
  // signatureOf is a message content's signature: its text, and a digest of
@@ -469,14 +489,20 @@ function idFor(kind, key) {
469
489
  // clients of Anthropic's shape — is left out: it is not what the user said,
470
490
  // and it changes with every step of the send.
471
491
  function signatureOf(content) {
472
- if (typeof content === "string") return content
473
- if (!Array.isArray(content)) return ""
474
- let out = ""
492
+ return signatureParts(content).join("")
493
+ }
494
+
495
+ // signatureParts is signatureOf in the pieces it is joined from, none empty
496
+ function signatureParts(content) {
497
+ if (typeof content === "string") return content ? [content] : []
498
+ if (!Array.isArray(content)) return []
499
+ const out = []
475
500
  for (const part of content) {
476
501
  const type = part?.type ?? ""
477
- if (type === "" || type === "text") out += typeof part?.text === "string" ? part.text : ""
478
- else if (type !== "tool_result" && type !== "tool-result") {
479
- out += `[${type}:${createHash("sha256").update(JSON.stringify(part)).digest("hex").slice(0, 8)}]`
502
+ if (type === "" || type === "text") {
503
+ if (typeof part?.text === "string" && part.text) out.push(part.text)
504
+ } else if (type !== "tool_result" && type !== "tool-result") {
505
+ out.push(`[${type}:${createHash("sha256").update(JSON.stringify(part)).digest("hex").slice(0, 8)}]`)
480
506
  }
481
507
  }
482
508
  return out
@@ -522,13 +548,18 @@ function hasBlock(m, type) {
522
548
  // has none at all: the caller then makes a fresh id for that request, as
523
549
  // before these were derived at all.
524
550
  function turnKey(chat) {
551
+ return turnParts(chat).join("")
552
+ }
553
+
554
+ // turnParts is turnKey in pieces (idFor takes them): [] for ""
555
+ function turnParts(chat) {
525
556
  const msgs = Array.isArray(chat?.messages) ? chat.messages : []
526
557
  for (let i = msgs.length - 1; i >= 0; i--) {
527
558
  if (msgs[i]?.role !== "user" || answeredBefore(msgs, i)) continue
528
- const sig = signatureOf(msgs[i].content)
529
- return sig ? `u${i}:${sig}` : ""
559
+ const sig = signatureParts(msgs[i].content)
560
+ return sig.length ? [`u${i}:`, ...sig] : []
530
561
  }
531
- return ""
562
+ return []
532
563
  }
533
564
 
534
565
  // conversationKey names the conversation a chat belongs to: the session
@@ -538,14 +569,19 @@ function turnKey(chat) {
538
569
  // conversation id is sent then: a made-up one would be a conversation of the
539
570
  // request's own, which is what fragments the usage detail in the first place.
540
571
  function conversationKey(chat, session) {
541
- if (session) return `session:${session}`
572
+ return conversationParts(chat, session).join("")
573
+ }
574
+
575
+ // conversationParts is conversationKey in pieces (idFor takes them): [] for ""
576
+ function conversationParts(chat, session) {
577
+ if (session) return [`session:${session}`]
542
578
  const msgs = Array.isArray(chat?.messages) ? chat.messages : []
543
579
  for (const m of msgs) {
544
580
  if (m?.role !== "user") continue
545
- const sig = signatureOf(m.content)
546
- if (sig) return `first:${sig}`
581
+ const sig = signatureParts(m.content)
582
+ if (sig.length) return ["first:", ...sig]
547
583
  }
548
- return ""
584
+ return []
549
585
  }
550
586
 
551
587
  // isMessages says whether a request is an Anthropic Messages one, which
@@ -684,17 +720,17 @@ function attend(headers, body, session) {
684
720
  }
685
721
 
686
722
  function attendChat(headers, chat, session) {
687
- const conv = conversationKey(chat, session)
723
+ const conv = conversationParts(chat, session)
688
724
  const msg = hex()
689
725
  headers.set("X-Conversation-Message-ID", msg)
690
726
  headers.set("X-Request-ID", msg)
691
727
  if (!headers.has("X-Conversation-Request-ID")) {
692
- const turn = turnKey(chat)
728
+ const turn = turnParts(chat)
693
729
  // a turn is a conversation's own: two conversations whose user message
694
730
  // reads the same are still two sends, not one
695
- headers.set("X-Conversation-Request-ID", turn ? idFor("turn", `${conv}|${turn}`) : hex())
731
+ headers.set("X-Conversation-Request-ID", turn.length ? idFor("turn", [...conv, "|", ...turn]) : hex())
696
732
  }
697
- if (conv && !headers.has("X-Conversation-ID")) headers.set("X-Conversation-ID", idFor("conversation", conv))
733
+ if (conv.length && !headers.has("X-Conversation-ID")) headers.set("X-Conversation-ID", idFor("conversation", conv))
698
734
  }
699
735
 
700
736
  // FLAGGED are the words WorkBuddy refuses a chat for, "Illegal API
@@ -1300,9 +1336,13 @@ function fitted(body) {
1300
1336
  // copilot.tencent.com against 258 MB down. Measured 2026-10-28 on
1301
1337
  // www.workbuddy.ai: a gzipped chat answers as the plain one does. A
1302
1338
  // Messages request (Claude on the China build) goes as it came: that route
1303
- // wasn't tried gzipped. null when the body isn't sent gzipped.
1304
- function gzipped(native, b) {
1305
- if (native) return null
1339
+ // wasn't tried gzipped. Only a chat completion goes gzipped (isCompletion):
1340
+ // WorkBuddy's images API reads its body as plain JSON, and magpie's drawing
1341
+ // (/v2/images/generations, /v2/images/edits) is sent through this same
1342
+ // fetch; gzipped it answered 400 11101 "invalid character '\x1f'" (iwinux on
1343
+ // Discord, 0.1.43). null when the body isn't sent gzipped.
1344
+ function gzipped(completion, b) {
1345
+ if (!completion) return null
1306
1346
  if (typeof b === "string") return gzipSync(Buffer.from(b))
1307
1347
  if (b instanceof ArrayBuffer || ArrayBuffer.isView(b)) return gzipSync(b)
1308
1348
  return null
@@ -2138,6 +2178,19 @@ function effortsOf(d, site, id) {
2138
2178
  return EFFORT_ORDER.filter((e) => set.has(e))
2139
2179
  }
2140
2180
 
2181
+ // isCompletion is whether a request is a chat completion: one to
2182
+ // .../chat/completions, or, with no URL to read, a body holding a chat's
2183
+ // messages. Nothing else the loader's fetch sends (magpie's drawings to
2184
+ // /v2/images/*) is a chat: it goes neither gzipped nor given an effort.
2185
+ function isCompletion(url, chat) {
2186
+ let path = ""
2187
+ try {
2188
+ path = new URL(String(url ?? "")).pathname
2189
+ } catch {}
2190
+ if (path) return /\/chat\/completions$/.test(path)
2191
+ return Array.isArray(chat?.messages)
2192
+ }
2193
+
2141
2194
  // defaultEffortOf is the level WorkBuddy's own client asks a model for when
2142
2195
  // the user picked none: its CLI's configureThinkingSettings (WorkBuddy
2143
2196
  // 5.9.3, CLI 2.165.0) takes the session's effort, else the settings', else
@@ -2237,13 +2290,19 @@ const AFTER_TOOLS_REFUSED = /must end with a user message|["']code["']\s*:\s*["'
2237
2290
  // ending with the assistant's message is a prefill, refused as it is.
2238
2291
  function replyAfterTools(body) {
2239
2292
  const b = chatOf(body)
2293
+ return replyAfterToolsOn(b) ? JSON.stringify(b) : null
2294
+ }
2295
+
2296
+ // replyAfterToolsOn makes replyAfterTools's change on a chat's object; false
2297
+ // for a chat it doesn't take
2298
+ function replyAfterToolsOn(b) {
2240
2299
  const msgs = b?.messages
2241
- if (!Array.isArray(msgs)) return null
2300
+ if (!Array.isArray(msgs)) return false
2242
2301
  let i = msgs.length - 1
2243
2302
  while (i >= 0 && msgs[i]?.role === "user") i--
2244
- if (i === msgs.length - 1 || i < 0 || msgs[i]?.role !== "tool") return null
2303
+ if (i === msgs.length - 1 || i < 0 || msgs[i]?.role !== "tool") return false
2245
2304
  msgs.splice(i + 1, 0, { role: "assistant", content: "" })
2246
- return JSON.stringify(b)
2305
+ return true
2247
2306
  }
2248
2307
 
2249
2308
  // FREE_ONLY is the switch that keeps an account off credits (#430): its
@@ -2411,18 +2470,6 @@ function makePlugin(site) {
2411
2470
  sign(site, a, headers)
2412
2471
  let body = init?.body
2413
2472
  if (body === undefined && req) body = await req.clone().text()
2414
- // the switch is checked before anything is sent: a model not
2415
- // free, or one whose price can't be read, never reaches WorkBuddy
2416
- if (freeOnly()) {
2417
- const model = chatOf(typeof body === "string" ? body : bodyString(body))?.model
2418
- let list
2419
- try {
2420
- list = await listed(a)
2421
- } catch (e) {
2422
- return notFree(site, model, e?.message ?? String(e))
2423
- }
2424
- if (!list.some((m) => m.id === model && m.free)) return notFree(site, model)
2425
- }
2426
2473
  // An Anthropic request goes as the agent sent it: WorkBuddy
2427
2474
  // serves these models on Messages itself, and the rewrites
2428
2475
  // below (a default system message, the chat endpoint's
@@ -2433,22 +2480,37 @@ function makePlugin(site) {
2433
2480
  // Codex's and DeepSeek Harness's openings all answer on
2434
2481
  // /v1/messages, where a chat is refused 11128.
2435
2482
  const native = isMessages(req ? req.url : String(input), body)
2436
- let names = {}
2437
- const p = prepared(body, native)
2438
- let chat = p.chat
2439
- let changed = p.changed
2440
- body = p.body
2441
- if (!native) names = p.names
2483
+ // read once (prepared): nothing below parses the body again on
2484
+ // its way out, and nothing keeps the parsed chat once it is
2485
+ // written (plugins#461)
2486
+ let chat, changed, names
2487
+ ;({ body, chat, changed, names } = prepared(body, native))
2488
+ if (native) names = {}
2489
+ // the switch is checked before anything is sent: a model not
2490
+ // free, or one whose price can't be read, never reaches
2491
+ // WorkBuddy. The model is read off the chat parsed above; a body
2492
+ // prepared leaves unread (bytes on Messages) is read for it.
2493
+ if (freeOnly()) {
2494
+ const model = (chat ?? chatOf(typeof body === "string" ? body : bodyString(body)))?.model
2495
+ let list
2496
+ try {
2497
+ list = await listed(a)
2498
+ } catch (e) {
2499
+ return notFree(site, model, e?.message ?? String(e))
2500
+ }
2501
+ if (!list.some((m) => m.id === model && m.free)) return notFree(site, model)
2502
+ }
2442
2503
  // a chat that names no effort is asked at the model's default,
2443
2504
  // as WorkBuddy's client asks it (withDefaultEffort); with no
2444
2505
  // list to read (effortList) the chat goes as it came
2445
- if (!native && chat && !namesEffort(chat) && withDefaultEffort(chat, await effortList(a))) changed = true
2506
+ const completion = !native && isCompletion(req ? req.url : input, chat)
2507
+ if (completion && chat && !namesEffort(chat) && withDefaultEffort(chat, await effortList(a))) changed = true
2446
2508
  chatHeaders(site, headers)
2447
2509
  attendChat(headers, chat, session)
2448
2510
  const post = (b) => {
2449
2511
  // a chat goes gzipped, as WorkBuddy's client sends it
2450
2512
  // (gzipped); its size on the wire is what unanswered weighs
2451
- const z = gzipped(native, b)
2513
+ const z = gzipped(completion, b)
2452
2514
  if (z) headers.set("Content-Encoding", "gzip")
2453
2515
  else headers.delete("Content-Encoding")
2454
2516
  return fetch(req ? req.url : input, { ...init, method: init?.method ?? req?.method, headers, body: z ?? b }).catch((e) => unanswered(site, a, z ?? b, e, init?.signal))
@@ -2502,10 +2564,16 @@ function makePlugin(site) {
2502
2564
  // words at once (see systemAsUser)
2503
2565
  const sysKey = native ? "" : systemKeyOf(chat)
2504
2566
  if (sysKey && refusedSystems.has(sysKey) && systemAsUserOn(chat)) changed = true
2567
+ // shaped is a body as such a model takes it. The first try's
2568
+ // is made on the chat already parsed, after the move above,
2569
+ // as shaped would make it of the body written; a resend with
2570
+ // the system prompt moved (below) is shaped again, and a body
2571
+ // that has the reply already is left as it is.
2572
+ const shaped = (b) => (!native && endsAfterTools.has(model) ? replyAfterTools(b) ?? b : b)
2573
+ if (!native && endsAfterTools.has(model) && replyAfterToolsOn(chat)) changed = true
2505
2574
  if (changed) body = JSON.stringify(chat)
2506
2575
  chat = null
2507
- const shaped = (b) => (!native && endsAfterTools.has(model) ? replyAfterTools(b) ?? b : b)
2508
- let res = await send(shaped(body))
2576
+ let res = await send(body)
2509
2577
  // a signature WorkBuddy's own signing didn't make — a session
2510
2578
  // moved here from another provider keeps its thinking blocks,
2511
2579
  // and a truncated or altered one reads the same way — is 11159
@@ -2733,4 +2801,4 @@ export const WorkBuddyAuthPlugin = makePlugin(SITES.workbuddy)
2733
2801
  export const WorkBuddyAIAuthPlugin = makePlugin(SITES["workbuddy-ai"])
2734
2802
 
2735
2803
  // for tests
2736
- export const _internal = { withDefaultEffort, defaultEffortOf, gzipped, FREE_ONLY, PRICE_TTL, keyAccount, remoteConfig, cycleOf, typeUnions, systemAsUser, systemKey, DEFAULT_SYSTEM, EDGE_READS, fitShape, replyAfterTools, AFTER_TOOLS_REFUSED, enterpriseUsageOf, notDesktop, textOnly, runsOutOf, packagesOf, endOf, enterpriseHeaders, enterpriseOf, liveModels, browserSignIn, withSystem, asReasoning, 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, prepared, answeredBefore, nativeOf, modelOf, configModels, API_MESSAGES: NPM_MESSAGES, API_CHAT: NPM }
2804
+ export const _internal = { idFor, replyAfterToolsOn, withDefaultEffort, defaultEffortOf, gzipped, FREE_ONLY, PRICE_TTL, keyAccount, remoteConfig, cycleOf, typeUnions, systemAsUser, systemKey, DEFAULT_SYSTEM, EDGE_READS, fitShape, replyAfterTools, AFTER_TOOLS_REFUSED, enterpriseUsageOf, notDesktop, textOnly, runsOutOf, packagesOf, endOf, enterpriseHeaders, enterpriseOf, liveModels, browserSignIn, withSystem, asReasoning, 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, prepared, 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.43",
3
+ "version": "0.1.45",
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",