@magpie-community/opencode-workbuddy-auth 0.1.42 → 0.1.44

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 +10 -1
  2. package/index.mjs +174 -44
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -235,7 +235,16 @@ is used.
235
235
  - Kimi-K3, Kimi-K2.6
236
236
  - MiniMax-M3
237
237
 
238
- A model's reasoning levels are its variants (`reasoningEffort`).
238
+ A model's reasoning levels are its variants (`reasoningEffort`). A chat
239
+ that names no level goes at the model's own default from the config
240
+ (`reasoning.defaultEffort`, "high" for DeepSeek-V4.1-Flash), as WorkBuddy's
241
+ client sends it: WorkBuddy doesn't think when a chat names none, so
242
+ OpenCode's default variant got answers without thinking. An effort the
243
+ client names, `none` too, goes as it came.
244
+
245
+ A chat goes gzipped (`Content-Encoding: gzip`), as WorkBuddy's own client
246
+ sends its model requests (`RequestBodyGzip` in both builds' product.json).
247
+ Claude's Messages route goes as it came.
239
248
 
240
249
  A model's price is its `credits` ("x0.29") and the config's
241
250
  `modelPromotions`. A discount for whole days (Step-5-Preview's 独家限免) is
package/index.mjs CHANGED
@@ -13,6 +13,7 @@ import { createHash, randomBytes } from "node:crypto"
13
13
  import { readFileSync } from "node:fs"
14
14
  import { homedir } from "node:os"
15
15
  import { join } from "node:path"
16
+ import { gzipSync } from "node:zlib"
16
17
 
17
18
  const APP_VERSION = "2.0.0" // the version WorkBuddy's sign-in page is opened with
18
19
  const POLL_MS = 1000
@@ -458,8 +459,28 @@ const SALT = randomBytes(16).toString("hex")
458
459
 
459
460
  // idFor is the 32 hex digits WorkBuddy's own ids are made of, made of a
460
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.
461
467
  function idFor(kind, key) {
462
- 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)
463
484
  }
464
485
 
465
486
  // signatureOf is a message content's signature: its text, and a digest of
@@ -468,14 +489,20 @@ function idFor(kind, key) {
468
489
  // clients of Anthropic's shape — is left out: it is not what the user said,
469
490
  // and it changes with every step of the send.
470
491
  function signatureOf(content) {
471
- if (typeof content === "string") return content
472
- if (!Array.isArray(content)) return ""
473
- 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 = []
474
500
  for (const part of content) {
475
501
  const type = part?.type ?? ""
476
- if (type === "" || type === "text") out += typeof part?.text === "string" ? part.text : ""
477
- else if (type !== "tool_result" && type !== "tool-result") {
478
- 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)}]`)
479
506
  }
480
507
  }
481
508
  return out
@@ -521,13 +548,18 @@ function hasBlock(m, type) {
521
548
  // has none at all: the caller then makes a fresh id for that request, as
522
549
  // before these were derived at all.
523
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) {
524
556
  const msgs = Array.isArray(chat?.messages) ? chat.messages : []
525
557
  for (let i = msgs.length - 1; i >= 0; i--) {
526
558
  if (msgs[i]?.role !== "user" || answeredBefore(msgs, i)) continue
527
- const sig = signatureOf(msgs[i].content)
528
- return sig ? `u${i}:${sig}` : ""
559
+ const sig = signatureParts(msgs[i].content)
560
+ return sig.length ? [`u${i}:`, ...sig] : []
529
561
  }
530
- return ""
562
+ return []
531
563
  }
532
564
 
533
565
  // conversationKey names the conversation a chat belongs to: the session
@@ -537,14 +569,19 @@ function turnKey(chat) {
537
569
  // conversation id is sent then: a made-up one would be a conversation of the
538
570
  // request's own, which is what fragments the usage detail in the first place.
539
571
  function conversationKey(chat, session) {
540
- 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}`]
541
578
  const msgs = Array.isArray(chat?.messages) ? chat.messages : []
542
579
  for (const m of msgs) {
543
580
  if (m?.role !== "user") continue
544
- const sig = signatureOf(m.content)
545
- if (sig) return `first:${sig}`
581
+ const sig = signatureParts(m.content)
582
+ if (sig.length) return ["first:", ...sig]
546
583
  }
547
- return ""
584
+ return []
548
585
  }
549
586
 
550
587
  // isMessages says whether a request is an Anthropic Messages one, which
@@ -683,17 +720,17 @@ function attend(headers, body, session) {
683
720
  }
684
721
 
685
722
  function attendChat(headers, chat, session) {
686
- const conv = conversationKey(chat, session)
723
+ const conv = conversationParts(chat, session)
687
724
  const msg = hex()
688
725
  headers.set("X-Conversation-Message-ID", msg)
689
726
  headers.set("X-Request-ID", msg)
690
727
  if (!headers.has("X-Conversation-Request-ID")) {
691
- const turn = turnKey(chat)
728
+ const turn = turnParts(chat)
692
729
  // a turn is a conversation's own: two conversations whose user message
693
730
  // reads the same are still two sends, not one
694
- 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())
695
732
  }
696
- 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))
697
734
  }
698
735
 
699
736
  // FLAGGED are the words WorkBuddy refuses a chat for, "Illegal API
@@ -1289,6 +1326,24 @@ function fitted(body) {
1289
1326
  }
1290
1327
  }
1291
1328
 
1329
+ // gzipped is a chat's body compressed, as WorkBuddy's own client sends its
1330
+ // model requests: its CLI's GzipRequestProcessor (WorkBuddy 5.9.3, CLI
1331
+ // 2.165.0; WorkBuddy AI 5.8.4 the same) gzips each POST's body and sets
1332
+ // Content-Encoding: gzip when the product's RequestBodyGzip feature is on,
1333
+ // which both builds' product.json turn on and /v3/config (merged over it)
1334
+ // doesn't turn off. Sent as plain JSON, a long agent conversation went up
1335
+ // whole on every step: runmiNeverstop on X saw 2.89 GB up to
1336
+ // copilot.tencent.com against 258 MB down. Measured 2026-10-28 on
1337
+ // www.workbuddy.ai: a gzipped chat answers as the plain one does. A
1338
+ // Messages request (Claude on the China build) goes as it came: that route
1339
+ // wasn't tried gzipped. null when the body isn't sent gzipped.
1340
+ function gzipped(native, b) {
1341
+ if (native) return null
1342
+ if (typeof b === "string") return gzipSync(Buffer.from(b))
1343
+ if (b instanceof ArrayBuffer || ArrayBuffer.isView(b)) return gzipSync(b)
1344
+ return null
1345
+ }
1346
+
1292
1347
  // prepared is a request's body read once for every change the loader makes
1293
1348
  // to it and everything it reads off it (plugins#461): a long chat, 45 MiB
1294
1349
  // of history, was parsed six times on its way, once by each step, and
@@ -2119,6 +2174,37 @@ function effortsOf(d, site, id) {
2119
2174
  return EFFORT_ORDER.filter((e) => set.has(e))
2120
2175
  }
2121
2176
 
2177
+ // defaultEffortOf is the level WorkBuddy's own client asks a model for when
2178
+ // the user picked none: its CLI's configureThinkingSettings (WorkBuddy
2179
+ // 5.9.3, CLI 2.165.0) takes the session's effort, else the settings', else
2180
+ // the config's reasoning.defaultEffort, else reasoning.effort, and sends it
2181
+ // as reasoning_effort unless thinking was turned off. A request with no
2182
+ // reasoning_effort is not thinking on WorkBuddy's side: measured 2026-10-28
2183
+ // on www.workbuddy.ai, deepseek-v4.1-flash (defaultEffort "high") answered
2184
+ // with 0 reasoning tokens without one, and thought at low and high
2185
+ // (troilus1011 on X, OpenCode's default variant). A default the model's
2186
+ // levels don't hold is left out, as the CLI clamps it to one they do.
2187
+ function defaultEffortOf(d, efforts) {
2188
+ const own = d.reasoning?.defaultEffort ?? d.reasoning?.effort
2189
+ return typeof own === "string" && efforts.includes(own) ? own : undefined
2190
+ }
2191
+
2192
+ // withDefaultEffort gives a chat that names no effort the model's default
2193
+ // (defaultEffortOf), as WorkBuddy's client sends it; true when it did. An
2194
+ // effort the client named, "none" too, goes as it came.
2195
+ function withDefaultEffort(chat, list) {
2196
+ if (!chat || typeof chat !== "object" || namesEffort(chat)) return false
2197
+ const m = list?.find((x) => x.id === chat.model)
2198
+ if (!m?.effort) return false
2199
+ chat.reasoning_effort = m.effort
2200
+ return true
2201
+ }
2202
+
2203
+ // namesEffort is whether a chat names its effort itself, in either spelling
2204
+ function namesEffort(chat) {
2205
+ return chat.reasoning_effort !== undefined || (!!chat.reasoning && typeof chat.reasoning === "object" && chat.reasoning.effort !== undefined)
2206
+ }
2207
+
2122
2208
  async function liveModels(site, a) {
2123
2209
  const headers = new Headers()
2124
2210
  sign(site, a, headers)
@@ -2145,6 +2231,8 @@ async function liveModels(site, a) {
2145
2231
  if (es.length) {
2146
2232
  const canOff = d.reasoning?.canDisableThinking
2147
2233
  m.efforts = [...(!d.onlyReasoning && (canOff == null || canOff) ? ["none"] : []), ...es]
2234
+ const def = defaultEffortOf(d, es)
2235
+ if (def) m.effort = def
2148
2236
  }
2149
2237
  }
2150
2238
  out.push(m)
@@ -2185,13 +2273,19 @@ const AFTER_TOOLS_REFUSED = /must end with a user message|["']code["']\s*:\s*["'
2185
2273
  // ending with the assistant's message is a prefill, refused as it is.
2186
2274
  function replyAfterTools(body) {
2187
2275
  const b = chatOf(body)
2276
+ return replyAfterToolsOn(b) ? JSON.stringify(b) : null
2277
+ }
2278
+
2279
+ // replyAfterToolsOn makes replyAfterTools's change on a chat's object; false
2280
+ // for a chat it doesn't take
2281
+ function replyAfterToolsOn(b) {
2188
2282
  const msgs = b?.messages
2189
- if (!Array.isArray(msgs)) return null
2283
+ if (!Array.isArray(msgs)) return false
2190
2284
  let i = msgs.length - 1
2191
2285
  while (i >= 0 && msgs[i]?.role === "user") i--
2192
- if (i === msgs.length - 1 || i < 0 || msgs[i]?.role !== "tool") return null
2286
+ if (i === msgs.length - 1 || i < 0 || msgs[i]?.role !== "tool") return false
2193
2287
  msgs.splice(i + 1, 0, { role: "assistant", content: "" })
2194
- return JSON.stringify(b)
2288
+ return true
2195
2289
  }
2196
2290
 
2197
2291
  // FREE_ONLY is the switch that keeps an account off credits (#430): its
@@ -2238,6 +2332,24 @@ function makePlugin(site) {
2238
2332
  if (lists.size > 64) lists.delete(lists.keys().next().value)
2239
2333
  return list
2240
2334
  }
2335
+ // effortList is the list a chat's default effort is read from
2336
+ // (withDefaultEffort): listed's, or null when there is none to read — a
2337
+ // key WorkBuddy's CLI reads no config with (as models does), or a read
2338
+ // that failed within PRICE_TTL, so a config that can't be read costs a
2339
+ // chat one try a minute, not one each
2340
+ const unlisted = new Map()
2341
+ const effortList = async (a) => {
2342
+ if (a.source === "key" && !remoteConfig(a.key)) return null
2343
+ const k = listKey(a)
2344
+ if (Date.now() - (unlisted.get(k) ?? 0) < PRICE_TTL) return null
2345
+ try {
2346
+ return await listed(a)
2347
+ } catch {
2348
+ unlisted.set(k, Date.now())
2349
+ if (unlisted.size > 64) unlisted.delete(unlisted.keys().next().value)
2350
+ return null
2351
+ }
2352
+ }
2241
2353
  const freeOnly = () => magpie?.option?.(site.id, FREE_ONLY) === true
2242
2354
  // onlyFree is a list with the switch on: its free models, or a failure
2243
2355
  // when there are none to tell (magpie then keeps the list it had)
@@ -2341,18 +2453,6 @@ function makePlugin(site) {
2341
2453
  sign(site, a, headers)
2342
2454
  let body = init?.body
2343
2455
  if (body === undefined && req) body = await req.clone().text()
2344
- // the switch is checked before anything is sent: a model not
2345
- // free, or one whose price can't be read, never reaches WorkBuddy
2346
- if (freeOnly()) {
2347
- const model = chatOf(typeof body === "string" ? body : bodyString(body))?.model
2348
- let list
2349
- try {
2350
- list = await listed(a)
2351
- } catch (e) {
2352
- return notFree(site, model, e?.message ?? String(e))
2353
- }
2354
- if (!list.some((m) => m.id === model && m.free)) return notFree(site, model)
2355
- }
2356
2456
  // An Anthropic request goes as the agent sent it: WorkBuddy
2357
2457
  // serves these models on Messages itself, and the rewrites
2358
2458
  // below (a default system message, the chat endpoint's
@@ -2363,16 +2463,40 @@ function makePlugin(site) {
2363
2463
  // Codex's and DeepSeek Harness's openings all answer on
2364
2464
  // /v1/messages, where a chat is refused 11128.
2365
2465
  const native = isMessages(req ? req.url : String(input), body)
2366
- let names = {}
2367
- const p = prepared(body, native)
2368
- let chat = p.chat
2369
- let changed = p.changed
2370
- body = p.body
2371
- if (!native) names = p.names
2466
+ // read once (prepared): nothing below parses the body again on
2467
+ // its way out, and nothing keeps the parsed chat once it is
2468
+ // written (plugins#461)
2469
+ let chat, changed, names
2470
+ ;({ body, chat, changed, names } = prepared(body, native))
2471
+ if (native) names = {}
2472
+ // the switch is checked before anything is sent: a model not
2473
+ // free, or one whose price can't be read, never reaches
2474
+ // WorkBuddy. The model is read off the chat parsed above; a body
2475
+ // prepared leaves unread (bytes on Messages) is read for it.
2476
+ if (freeOnly()) {
2477
+ const model = (chat ?? chatOf(typeof body === "string" ? body : bodyString(body)))?.model
2478
+ let list
2479
+ try {
2480
+ list = await listed(a)
2481
+ } catch (e) {
2482
+ return notFree(site, model, e?.message ?? String(e))
2483
+ }
2484
+ if (!list.some((m) => m.id === model && m.free)) return notFree(site, model)
2485
+ }
2486
+ // a chat that names no effort is asked at the model's default,
2487
+ // as WorkBuddy's client asks it (withDefaultEffort); with no
2488
+ // list to read (effortList) the chat goes as it came
2489
+ if (!native && chat && !namesEffort(chat) && withDefaultEffort(chat, await effortList(a))) changed = true
2372
2490
  chatHeaders(site, headers)
2373
2491
  attendChat(headers, chat, session)
2374
- const post = (b) =>
2375
- fetch(req ? req.url : input, { ...init, method: init?.method ?? req?.method, headers, body: b }).catch((e) => unanswered(site, a, b, e, init?.signal))
2492
+ const post = (b) => {
2493
+ // a chat goes gzipped, as WorkBuddy's client sends it
2494
+ // (gzipped); its size on the wire is what unanswered weighs
2495
+ const z = gzipped(native, b)
2496
+ if (z) headers.set("Content-Encoding", "gzip")
2497
+ else headers.delete("Content-Encoding")
2498
+ 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))
2499
+ }
2376
2500
  // a 401 is answered once, on whichever try meets it: by a
2377
2501
  // sign-in with another access token (reissued), the request
2378
2502
  // sent once more with it, never again after that; else, or
@@ -2422,10 +2546,16 @@ function makePlugin(site) {
2422
2546
  // words at once (see systemAsUser)
2423
2547
  const sysKey = native ? "" : systemKeyOf(chat)
2424
2548
  if (sysKey && refusedSystems.has(sysKey) && systemAsUserOn(chat)) changed = true
2549
+ // shaped is a body as such a model takes it. The first try's
2550
+ // is made on the chat already parsed, after the move above,
2551
+ // as shaped would make it of the body written; a resend with
2552
+ // the system prompt moved (below) is shaped again, and a body
2553
+ // that has the reply already is left as it is.
2554
+ const shaped = (b) => (!native && endsAfterTools.has(model) ? replyAfterTools(b) ?? b : b)
2555
+ if (!native && endsAfterTools.has(model) && replyAfterToolsOn(chat)) changed = true
2425
2556
  if (changed) body = JSON.stringify(chat)
2426
2557
  chat = null
2427
- const shaped = (b) => (!native && endsAfterTools.has(model) ? replyAfterTools(b) ?? b : b)
2428
- let res = await send(shaped(body))
2558
+ let res = await send(body)
2429
2559
  // a signature WorkBuddy's own signing didn't make — a session
2430
2560
  // moved here from another provider keeps its thinking blocks,
2431
2561
  // and a truncated or altered one reads the same way — is 11159
@@ -2653,4 +2783,4 @@ export const WorkBuddyAuthPlugin = makePlugin(SITES.workbuddy)
2653
2783
  export const WorkBuddyAIAuthPlugin = makePlugin(SITES["workbuddy-ai"])
2654
2784
 
2655
2785
  // for tests
2656
- export const _internal = { 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 }
2786
+ 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.42",
3
+ "version": "0.1.44",
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",