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

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 +212 -71
  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
@@ -608,8 +609,13 @@ function isMessages(url, body) {
608
609
  // unsigned thinking is body itself, byte for byte.
609
610
  function withoutUnsigned(body) {
610
611
  const b = chatOf(body)
612
+ return unsignedOn(b) ? JSON.stringify(b) : body
613
+ }
614
+
615
+ // unsignedOn makes withoutUnsigned's change to a body's object; true when it made one
616
+ function unsignedOn(b) {
611
617
  const msgs = b?.messages
612
- if (!Array.isArray(msgs)) return body
618
+ if (!Array.isArray(msgs)) return false
613
619
  let changed = false
614
620
  for (const m of msgs) {
615
621
  if (m?.role !== "assistant" || !Array.isArray(m.content)) continue
@@ -618,7 +624,7 @@ function withoutUnsigned(body) {
618
624
  changed = true
619
625
  m.content = kept.length ? kept : [{ type: "text", text: "" }]
620
626
  }
621
- return changed ? JSON.stringify(b) : body
627
+ return changed
622
628
  }
623
629
 
624
630
  // withoutSignedThinking is a Messages body without any thinking block at
@@ -674,7 +680,10 @@ function sessionOf(headers) {
674
680
  // already there — the caller's own, or another plugin's — is kept, and the
675
681
  // message and request ids are the same one, as WorkBuddy's client has them.
676
682
  function attend(headers, body, session) {
677
- const chat = chatOf(body)
683
+ attendChat(headers, chatOf(body), session)
684
+ }
685
+
686
+ function attendChat(headers, chat, session) {
678
687
  const conv = conversationKey(chat, session)
679
688
  const msg = hex()
680
689
  headers.set("X-Conversation-Message-ID", msg)
@@ -792,29 +801,34 @@ function withSystem(body) {
792
801
  if (typeof body !== "string") return body
793
802
  try {
794
803
  const b = JSON.parse(body)
795
- if (!Array.isArray(b?.messages) || !b.messages.length) return body
796
- let changed = unflag(b.messages)
797
- if (b.max_completion_tokens !== undefined) {
798
- if (b.max_tokens === undefined) b.max_tokens = b.max_completion_tokens
799
- delete b.max_completion_tokens
800
- changed = true
801
- }
802
- for (const m of b.messages) {
803
- if (m?.role !== "developer") continue
804
- m.role = "system"
805
- changed = true
806
- }
807
- if (asReasoning(b.messages)) changed = true
808
- if (b.messages[0]?.role !== "system") {
809
- b.messages.unshift({ role: "system", content: "You are a helpful assistant." })
810
- changed = true
811
- }
812
- return changed ? JSON.stringify(b) : body
804
+ return systemOn(b) ? JSON.stringify(b) : body
813
805
  } catch {
814
806
  return body
815
807
  }
816
808
  }
817
809
 
810
+ // systemOn makes withSystem's changes to a chat's object; true when it made any
811
+ function systemOn(b) {
812
+ if (!Array.isArray(b?.messages) || !b.messages.length) return false
813
+ let changed = unflag(b.messages)
814
+ if (b.max_completion_tokens !== undefined) {
815
+ if (b.max_tokens === undefined) b.max_tokens = b.max_completion_tokens
816
+ delete b.max_completion_tokens
817
+ changed = true
818
+ }
819
+ for (const m of b.messages) {
820
+ if (m?.role !== "developer") continue
821
+ m.role = "system"
822
+ changed = true
823
+ }
824
+ if (asReasoning(b.messages)) changed = true
825
+ if (b.messages[0]?.role !== "system") {
826
+ b.messages.unshift({ role: "system", content: "You are a helpful assistant." })
827
+ changed = true
828
+ }
829
+ return changed
830
+ }
831
+
818
832
  // WorkBuddy's channel check reads the system and assistant messages only:
819
833
  // tried on 2026-10-21 with real accounts on both builds (glm-5.3,
820
834
  // deepseek-v4.1-flash, gpt-5.5), each refused line was served in the user's
@@ -841,7 +855,11 @@ function textOf(content) {
841
855
  // systemKey is the text of a chat's system messages, which a chat refused
842
856
  // once is known by: the same agent's next chat goes with systemAsUser at once
843
857
  function systemKey(body) {
844
- const msgs = chatOf(body)?.messages
858
+ return systemKeyOf(chatOf(body))
859
+ }
860
+
861
+ function systemKeyOf(chat) {
862
+ const msgs = chat?.messages
845
863
  if (!Array.isArray(msgs)) return ""
846
864
  const key = msgs.filter((m) => m?.role === "system").map((m) => textOf(m.content)).join("\n")
847
865
  return key.trim() === DEFAULT_SYSTEM ? "" : key
@@ -849,15 +867,21 @@ function systemKey(body) {
849
867
 
850
868
  function systemAsUser(body) {
851
869
  const b = chatOf(body)
870
+ return systemAsUserOn(b) ? JSON.stringify(b) : null
871
+ }
872
+
873
+ // systemAsUserOn makes systemAsUser's move on a chat's object; false when
874
+ // there's nothing to move
875
+ function systemAsUserOn(b) {
852
876
  const msgs = b?.messages
853
- if (!Array.isArray(msgs)) return null
877
+ if (!Array.isArray(msgs)) return false
854
878
  let i = 0
855
879
  const lead = []
856
880
  for (; i < msgs.length && msgs[i]?.role === "system"; i++) lead.push(textOf(msgs[i].content))
857
881
  const said = lead.join("\n\n").trim()
858
882
  const rest = msgs.slice(i).map((m) => (m?.role === "system" ? { ...m, role: "user" } : m))
859
883
  const moved = said && said !== DEFAULT_SYSTEM
860
- if (!moved && rest.every((m, k) => m === msgs[i + k])) return null
884
+ if (!moved && rest.every((m, k) => m === msgs[i + k])) return false
861
885
  if (moved) {
862
886
  const text = INSTRUCTIONS[0] + said + INSTRUCTIONS[1]
863
887
  const u = rest.findIndex((m) => m?.role === "user")
@@ -867,7 +891,7 @@ function systemAsUser(body) {
867
891
  else rest[u] = { ...rest[u], content: text }
868
892
  }
869
893
  b.messages = [{ role: "system", content: DEFAULT_SYSTEM }, ...rest]
870
- return JSON.stringify(b)
894
+ return true
871
895
  }
872
896
 
873
897
  // normalizeToolChoice converts the object form emitted by OpenAI clients to
@@ -877,42 +901,47 @@ function normalizeToolChoice(body) {
877
901
  if (typeof body !== "string") return body
878
902
  try {
879
903
  const b = JSON.parse(body)
880
- if (!b || typeof b !== "object" || Array.isArray(b) || !("tool_choice" in b)) return body
881
- const choice = b.tool_choice
882
- if (typeof choice === "string") {
883
- if (choice.trim().toLowerCase() === "none") {
884
- delete b.tool_choice
885
- delete b.tools
886
- delete b.functions
887
- }
888
- return JSON.stringify(b)
889
- }
890
- if (!choice || typeof choice !== "object" || Array.isArray(choice)) {
891
- delete b.tool_choice
892
- return JSON.stringify(b)
893
- }
894
- const type = typeof choice.type === "string" ? choice.type.trim().toLowerCase() : ""
895
- if (type === "none") {
896
- delete b.tool_choice
897
- delete b.tools
898
- delete b.functions
899
- } else if (type === "auto" || type === "required") {
900
- b.tool_choice = type
901
- } else if (type === "function") {
902
- const fn = choice.function && typeof choice.function === "object" && !Array.isArray(choice.function)
903
- ? choice.function
904
- : undefined
905
- const name = typeof fn?.name === "string" ? fn.name.trim() : ""
906
- b.tool_choice = name || "auto"
907
- } else {
908
- delete b.tool_choice
909
- }
910
- return JSON.stringify(b)
904
+ return toolChoiceOn(b) ? JSON.stringify(b) : body
911
905
  } catch {
912
906
  return body
913
907
  }
914
908
  }
915
909
 
910
+ // toolChoiceOn makes normalizeToolChoice's change to a chat's object; true
911
+ // when it made one (a string choice other than none is WorkBuddy's already)
912
+ function toolChoiceOn(b) {
913
+ if (!b || typeof b !== "object" || Array.isArray(b) || !("tool_choice" in b)) return false
914
+ const choice = b.tool_choice
915
+ if (typeof choice === "string") {
916
+ if (choice.trim().toLowerCase() !== "none") return false
917
+ delete b.tool_choice
918
+ delete b.tools
919
+ delete b.functions
920
+ return true
921
+ }
922
+ if (!choice || typeof choice !== "object" || Array.isArray(choice)) {
923
+ delete b.tool_choice
924
+ return true
925
+ }
926
+ const type = typeof choice.type === "string" ? choice.type.trim().toLowerCase() : ""
927
+ if (type === "none") {
928
+ delete b.tool_choice
929
+ delete b.tools
930
+ delete b.functions
931
+ } else if (type === "auto" || type === "required") {
932
+ b.tool_choice = type
933
+ } else if (type === "function") {
934
+ const fn = choice.function && typeof choice.function === "object" && !Array.isArray(choice.function)
935
+ ? choice.function
936
+ : undefined
937
+ const name = typeof fn?.name === "string" ? fn.name.trim() : ""
938
+ b.tool_choice = name || "auto"
939
+ } else {
940
+ delete b.tool_choice
941
+ }
942
+ return true
943
+ }
944
+
916
945
  // ---- tools WorkBuddy takes ----------------------------------------------------
917
946
 
918
947
  // WorkBuddy AI answers 400 code 11133 "Invalid request parameters" (the
@@ -1261,6 +1290,58 @@ function fitted(body) {
1261
1290
  }
1262
1291
  }
1263
1292
 
1293
+ // gzipped is a chat's body compressed, as WorkBuddy's own client sends its
1294
+ // model requests: its CLI's GzipRequestProcessor (WorkBuddy 5.9.3, CLI
1295
+ // 2.165.0; WorkBuddy AI 5.8.4 the same) gzips each POST's body and sets
1296
+ // Content-Encoding: gzip when the product's RequestBodyGzip feature is on,
1297
+ // which both builds' product.json turn on and /v3/config (merged over it)
1298
+ // doesn't turn off. Sent as plain JSON, a long agent conversation went up
1299
+ // whole on every step: runmiNeverstop on X saw 2.89 GB up to
1300
+ // copilot.tencent.com against 258 MB down. Measured 2026-10-28 on
1301
+ // www.workbuddy.ai: a gzipped chat answers as the plain one does. A
1302
+ // 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
1306
+ if (typeof b === "string") return gzipSync(Buffer.from(b))
1307
+ if (b instanceof ArrayBuffer || ArrayBuffer.isView(b)) return gzipSync(b)
1308
+ return null
1309
+ }
1310
+
1311
+ // prepared is a request's body read once for every change the loader makes
1312
+ // to it and everything it reads off it (plugins#461): a long chat, 45 MiB
1313
+ // of history, was parsed six times on its way, once by each step, and
1314
+ // written out again whole by normalizeToolChoice even when nothing changed.
1315
+ // chat is the parsed body, changed whether any step changed it; the caller
1316
+ // writes it out once, when it did. Each step is the one its own function
1317
+ // above makes, in the same order.
1318
+ function prepared(body, native) {
1319
+ if (!native && (body instanceof ArrayBuffer || ArrayBuffer.isView(body))) body = new TextDecoder().decode(body)
1320
+ const none = { body, chat: null, names: new Map(), changed: false }
1321
+ if (typeof body !== "string") return none
1322
+ let chat
1323
+ try {
1324
+ chat = JSON.parse(body)
1325
+ } catch {
1326
+ return none
1327
+ }
1328
+ if (chat === null || typeof chat !== "object") return none
1329
+ if (native) return { body, chat, names: new Map(), changed: unsignedOn(chat) }
1330
+ try {
1331
+ let changed = systemOn(chat)
1332
+ const fit = fitTools(chat)
1333
+ if (fit.changed) changed = true
1334
+ if (toolChoiceOn(chat)) changed = true
1335
+ return { body, chat, names: fit.names, changed }
1336
+ } catch {
1337
+ // a shape a step can't take: the steps one by one, each keeping the
1338
+ // body as it was when it can't, as they always have
1339
+ const fit = fitted(withSystem(body))
1340
+ const out = normalizeToolChoice(fit.body)
1341
+ return { body: out, chat: chatOf(out), names: fit.names, changed: false }
1342
+ }
1343
+ }
1344
+
1264
1345
  // namedBack gives the model's calls in a chat answer (one JSON body, or an
1265
1346
  // SSE stream's chunks) the names the client gave its tools.
1266
1347
  function namedBack(v, names) {
@@ -2057,6 +2138,37 @@ function effortsOf(d, site, id) {
2057
2138
  return EFFORT_ORDER.filter((e) => set.has(e))
2058
2139
  }
2059
2140
 
2141
+ // defaultEffortOf is the level WorkBuddy's own client asks a model for when
2142
+ // the user picked none: its CLI's configureThinkingSettings (WorkBuddy
2143
+ // 5.9.3, CLI 2.165.0) takes the session's effort, else the settings', else
2144
+ // the config's reasoning.defaultEffort, else reasoning.effort, and sends it
2145
+ // as reasoning_effort unless thinking was turned off. A request with no
2146
+ // reasoning_effort is not thinking on WorkBuddy's side: measured 2026-10-28
2147
+ // on www.workbuddy.ai, deepseek-v4.1-flash (defaultEffort "high") answered
2148
+ // with 0 reasoning tokens without one, and thought at low and high
2149
+ // (troilus1011 on X, OpenCode's default variant). A default the model's
2150
+ // levels don't hold is left out, as the CLI clamps it to one they do.
2151
+ function defaultEffortOf(d, efforts) {
2152
+ const own = d.reasoning?.defaultEffort ?? d.reasoning?.effort
2153
+ return typeof own === "string" && efforts.includes(own) ? own : undefined
2154
+ }
2155
+
2156
+ // withDefaultEffort gives a chat that names no effort the model's default
2157
+ // (defaultEffortOf), as WorkBuddy's client sends it; true when it did. An
2158
+ // effort the client named, "none" too, goes as it came.
2159
+ function withDefaultEffort(chat, list) {
2160
+ if (!chat || typeof chat !== "object" || namesEffort(chat)) return false
2161
+ const m = list?.find((x) => x.id === chat.model)
2162
+ if (!m?.effort) return false
2163
+ chat.reasoning_effort = m.effort
2164
+ return true
2165
+ }
2166
+
2167
+ // namesEffort is whether a chat names its effort itself, in either spelling
2168
+ function namesEffort(chat) {
2169
+ return chat.reasoning_effort !== undefined || (!!chat.reasoning && typeof chat.reasoning === "object" && chat.reasoning.effort !== undefined)
2170
+ }
2171
+
2060
2172
  async function liveModels(site, a) {
2061
2173
  const headers = new Headers()
2062
2174
  sign(site, a, headers)
@@ -2083,6 +2195,8 @@ async function liveModels(site, a) {
2083
2195
  if (es.length) {
2084
2196
  const canOff = d.reasoning?.canDisableThinking
2085
2197
  m.efforts = [...(!d.onlyReasoning && (canOff == null || canOff) ? ["none"] : []), ...es]
2198
+ const def = defaultEffortOf(d, es)
2199
+ if (def) m.effort = def
2086
2200
  }
2087
2201
  }
2088
2202
  out.push(m)
@@ -2176,6 +2290,24 @@ function makePlugin(site) {
2176
2290
  if (lists.size > 64) lists.delete(lists.keys().next().value)
2177
2291
  return list
2178
2292
  }
2293
+ // effortList is the list a chat's default effort is read from
2294
+ // (withDefaultEffort): listed's, or null when there is none to read — a
2295
+ // key WorkBuddy's CLI reads no config with (as models does), or a read
2296
+ // that failed within PRICE_TTL, so a config that can't be read costs a
2297
+ // chat one try a minute, not one each
2298
+ const unlisted = new Map()
2299
+ const effortList = async (a) => {
2300
+ if (a.source === "key" && !remoteConfig(a.key)) return null
2301
+ const k = listKey(a)
2302
+ if (Date.now() - (unlisted.get(k) ?? 0) < PRICE_TTL) return null
2303
+ try {
2304
+ return await listed(a)
2305
+ } catch {
2306
+ unlisted.set(k, Date.now())
2307
+ if (unlisted.size > 64) unlisted.delete(unlisted.keys().next().value)
2308
+ return null
2309
+ }
2310
+ }
2179
2311
  const freeOnly = () => magpie?.option?.(site.id, FREE_ONLY) === true
2180
2312
  // onlyFree is a list with the switch on: its free models, or a failure
2181
2313
  // when there are none to tell (magpie then keeps the list it had)
@@ -2302,18 +2434,25 @@ function makePlugin(site) {
2302
2434
  // /v1/messages, where a chat is refused 11128.
2303
2435
  const native = isMessages(req ? req.url : String(input), body)
2304
2436
  let names = {}
2305
- if (!native) {
2306
- body = withSystem(body)
2307
- const fit = fitted(body)
2308
- body = normalizeToolChoice(fit.body)
2309
- names = fit.names
2310
- } else {
2311
- body = withoutUnsigned(body)
2312
- }
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
2442
+ // a chat that names no effort is asked at the model's default,
2443
+ // as WorkBuddy's client asks it (withDefaultEffort); with no
2444
+ // list to read (effortList) the chat goes as it came
2445
+ if (!native && chat && !namesEffort(chat) && withDefaultEffort(chat, await effortList(a))) changed = true
2313
2446
  chatHeaders(site, headers)
2314
- attend(headers, body, session)
2315
- const post = (b) =>
2316
- fetch(req ? req.url : input, { ...init, method: init?.method ?? req?.method, headers, body: b }).catch((e) => unanswered(site, a, b, e, init?.signal))
2447
+ attendChat(headers, chat, session)
2448
+ const post = (b) => {
2449
+ // a chat goes gzipped, as WorkBuddy's client sends it
2450
+ // (gzipped); its size on the wire is what unanswered weighs
2451
+ const z = gzipped(native, b)
2452
+ if (z) headers.set("Content-Encoding", "gzip")
2453
+ else headers.delete("Content-Encoding")
2454
+ 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))
2455
+ }
2317
2456
  // a 401 is answered once, on whichever try meets it: by a
2318
2457
  // sign-in with another access token (reissued), the request
2319
2458
  // sent once more with it, never again after that; else, or
@@ -2358,11 +2497,13 @@ function makePlugin(site) {
2358
2497
  }
2359
2498
  // a model known to turn tool results then the user's message away
2360
2499
  // gets the empty reply between them up front (see endsAfterTools)
2361
- const model = chatOf(body)?.model
2500
+ const model = chat?.model
2362
2501
  // a system prompt WorkBuddy refused before goes as the user's
2363
2502
  // words at once (see systemAsUser)
2364
- const sysKey = native ? "" : systemKey(body)
2365
- if (sysKey && refusedSystems.has(sysKey)) body = systemAsUser(body) ?? body
2503
+ const sysKey = native ? "" : systemKeyOf(chat)
2504
+ if (sysKey && refusedSystems.has(sysKey) && systemAsUserOn(chat)) changed = true
2505
+ if (changed) body = JSON.stringify(chat)
2506
+ chat = null
2366
2507
  const shaped = (b) => (!native && endsAfterTools.has(model) ? replyAfterTools(b) ?? b : b)
2367
2508
  let res = await send(shaped(body))
2368
2509
  // a signature WorkBuddy's own signing didn't make — a session
@@ -2592,4 +2733,4 @@ export const WorkBuddyAuthPlugin = makePlugin(SITES.workbuddy)
2592
2733
  export const WorkBuddyAIAuthPlugin = makePlugin(SITES["workbuddy-ai"])
2593
2734
 
2594
2735
  // for tests
2595
- 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, answeredBefore, nativeOf, modelOf, configModels, API_MESSAGES: NPM_MESSAGES, API_CHAT: NPM }
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 }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-workbuddy-auth",
3
- "version": "0.1.41",
3
+ "version": "0.1.43",
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",