@magpie-community/opencode-workbuddy-auth 0.1.35 → 0.1.37

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 +9 -0
  2. package/index.mjs +105 -9
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -26,6 +26,15 @@ The package exports `WorkBuddyAuthPlugin` (`workbuddy`) and
26
26
 
27
27
  WorkBuddy 5.6 and later keep the sign-in encrypted, which only the app
28
28
  opens, so this way fails there and says so: use the browser sign-in.
29
+ - **API key** (since 0.1.37). A key made on the build's own keys page
30
+ (`https://www.workbuddy.cn/profile/keys` for `workbuddy`,
31
+ `https://www.workbuddy.ai/profile/keys` for `workbuddy-ai`), sent as
32
+ WorkBuddy's own CLI sends one: `X-API-Key` and `Authorization: Bearer`,
33
+ both the key, and `X-User-Id: anonymous_<the key's last 8 characters>`,
34
+ to the same API as the other sign-ins. A key isn't renewed; a refused one
35
+ says to check it. A key's credits are unknown (WorkBuddy's own client reads
36
+ none with a key), and its models are `/v3/config`'s only for `ck_`/`pt_`
37
+ keys, as the CLI asks; any other key keeps the list below.
29
38
 
30
39
  A browser sign-in is kept as an OpenCode `oauth` auth: the access and
31
40
  refresh tokens, their expiry, the user id and the domain; desktop's as a
package/index.mjs CHANGED
@@ -67,6 +67,8 @@ const SITES = {
67
67
  authID: "workbuddy-desktop",
68
68
  platform: "workbuddy",
69
69
  ai: false,
70
+ // where this build's API keys are made (see keyAccount)
71
+ keys: "https://www.workbuddy.cn/profile/keys",
70
72
  models: CN_MODELS,
71
73
  // it serves the Claude models on Anthropic's Messages API too (see
72
74
  // nativeOf)
@@ -79,6 +81,7 @@ const SITES = {
79
81
  authID: "workbuddy-desktop-ai",
80
82
  platform: "workbuddy-ai",
81
83
  ai: true,
84
+ keys: "https://www.workbuddy.ai/profile/keys",
82
85
  models: AI_MODELS,
83
86
  // models whose images WorkBuddy AI drops though its config says they
84
87
  // take them (see textOnly)
@@ -299,7 +302,14 @@ async function refreshed(site, auth) {
299
302
  function sign(site, a, headers) {
300
303
  headers.set("Authorization", "Bearer " + a.access)
301
304
  headers.set("X-User-Id", a.uid ?? "")
302
- headers.set("X-Domain", domainOf(site, a))
305
+ if (a.source === "key") {
306
+ // the key's own header, and no X-Domain: a key's session has none
307
+ headers.set("X-API-Key", a.key)
308
+ headers.delete("X-Domain")
309
+ } else {
310
+ headers.delete("X-API-Key")
311
+ headers.set("X-Domain", domainOf(site, a))
312
+ }
303
313
  headers.set("X-Product", "SaaS")
304
314
  headers.set("X-IDE-Type", "WorkBuddy")
305
315
  headers.set("User-Agent", "WorkBuddy/" + UA_VERSION)
@@ -313,6 +323,31 @@ function sign(site, a, headers) {
313
323
  headers.set("X-Agent-Type", "main")
314
324
  }
315
325
 
326
+ // keyAccount is the account an API key (OpenCode's {type: "api", key}) is
327
+ // sent as, the way WorkBuddy's own CLI sends one (@tencent-ai/codebuddy-code
328
+ // 2.177.0, CODEBUDDY_API_KEY): its API-key sign-in is a session whose access
329
+ // token is the key and whose uid is "anonymous_" and the key's last eight
330
+ // characters (ACC_USER_ID unset), personal, with no domain and no
331
+ // enterprise; and its model requests carry the key twice, X-API-Key and
332
+ // Authorization: Bearer (getCustomHeaders). Its requests go to the build's
333
+ // own host, the one its other sign-ins use: for the China build that is
334
+ // copilot.tencent.com, the CLI's own endpoint for that build
335
+ // (CODEBUDDY_INTERNET_ENVIRONMENT=internal, product.internal.json), and the
336
+ // same API as www.workbuddy.cn, where the keys are made (the CLI's
337
+ // CN_SAAS_CORE_API_FRONT_DOORS); for the international build,
338
+ // www.workbuddy.ai (the CLI's own default, www.codebuddy.ai, is CodeBuddy's,
339
+ // not WorkBuddy's). Nothing renews a key.
340
+ function keyAccount(auth) {
341
+ const key = typeof auth?.key === "string" ? auth.key.trim() : ""
342
+ if (!key) return null
343
+ return { key, access: key, uid: "anonymous_" + key.slice(-8), source: "key" }
344
+ }
345
+
346
+ // remoteConfig says whether WorkBuddy's own CLI asks /v3/config for the
347
+ // account's models with this key: only for keys that start ck_ or pt_
348
+ // (shouldLoadRemoteConfig); any other key keeps the built-in list.
349
+ const remoteConfig = (key) => /^(ck_|pt_)/.test(key)
350
+
316
351
  // enterpriseHeaders names the enterprise (team) an account signed in as, as
317
352
  // WorkBuddy's own client does on every request it makes for that account
318
353
  // (its auth interceptor: X-Enterprise-Id and X-Tenant-Id, both the
@@ -1419,6 +1454,8 @@ async function unanswered(site, a, body, err, signal) {
1419
1454
  if (signal?.aborted || err?.name === "AbortError" || err?.name === "TimeoutError") throw err
1420
1455
  const size = byteLength(body)
1421
1456
  if (size <= EDGE_READS) throw err
1457
+ // the billing API isn't asked with a key: what it answers one is unknown
1458
+ if (a.source === "key") throw err
1422
1459
  let status = 0
1423
1460
  let challenge = ""
1424
1461
  try {
@@ -1546,6 +1583,18 @@ function refusedText(site, a, why, note) {
1546
1583
  )
1547
1584
  }
1548
1585
 
1586
+ // keyRefused is the answer to a request WorkBuddy refused an API key for
1587
+ // (401): a key isn't renewed, so it says to check the key. Marked expired
1588
+ // only on WorkBuddy's word that the token itself is refused (invalidToken).
1589
+ function keyRefused(site, expired, note) {
1590
+ const message = `${site.name} refused this API key (401). ${note ? note + " " : ""}Check the key at ${site.keys}, then add it again in magpie: Providers → ${site.name}.`
1591
+ return new Response(JSON.stringify({ error: { message, type: "authentication_error", code: 401 } }), {
1592
+ status: 401,
1593
+ statusText: "Unauthorized",
1594
+ headers: { "content-type": "application/json", "X-Magpie-Sign-In": expired ? "expired" : "kept" },
1595
+ })
1596
+ }
1597
+
1549
1598
  // signedOut is the answer to a request WorkBuddy refused this account's
1550
1599
  // sign-in for, renewed or not: a 401 that says so and what to do, which
1551
1600
  // WorkBuddy's own (an HTML page, its {code, msg}) doesn't. expired marks
@@ -1728,14 +1777,33 @@ function runsOutOf(sum, packages, now = Date.now()) {
1728
1777
  // is over.
1729
1778
  function cycleOf(sum, packages, now = Date.now()) {
1730
1779
  const code = sum?.SubscriptionPackageCode
1731
- if (!code) return null
1732
1780
  const at = (v) => (typeof v === "string" && /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(v.trim()) ? Date.parse(v.trim().replace(" ", "T") + "+08:00") : 0)
1733
- for (const p of packages ?? []) {
1734
- if (p?.PackageCode !== code) continue
1781
+ const of = (p) => {
1735
1782
  const end = at(p?.CycleEndTime), start = at(p?.CycleStartTime)
1736
- if (!(end > now)) continue
1783
+ if (!(end > now)) return null
1737
1784
  return { resetsAt: new Date(end).toISOString(), ...(start > 0 && start < end ? { span: (end - start) / 1000 } : {}) }
1738
1785
  }
1786
+ // A summary that does name one goes by it alone: named and not listed, or
1787
+ // its cycle over, is unknown — a paid plan's cycle is its subscription's,
1788
+ // never the slice's (yetone, #415).
1789
+ if (code) {
1790
+ for (const p of packages ?? []) if (p?.PackageCode === code) return of(p)
1791
+ return null
1792
+ }
1793
+ // A free account's summary names no subscription at all: both sites answer
1794
+ // SubscriptionPackageCode "" (copilot.tencent.com and www.workbuddy.ai,
1795
+ // measured 2026-10-26 on eight CN accounts and one AI one), so this
1796
+ // returned null and the card of an account whose credits are being spent
1797
+ // said nothing of when they come back — the one thing cycleOf is for.
1798
+ //
1799
+ // Its plan's own credits are the slice (CapacityType 4): packagesOf's
1800
+ // free list returns a month's plan_base with it, 00:00:00 to 23:59:59
1801
+ // UTC+8, the same cycle on every account on both sites (500 credits on
1802
+ // the CN site, 100 on the international), and its CycleEndTime is when
1803
+ // the plan gives the next month's back. A slice tells no account apart,
1804
+ // which is why runsOutOf keeps it out: which account to spend first is a
1805
+ // different question from when the plan refills.
1806
+ for (const p of packages ?? []) if (String(p?.CapacityType) === SLICE) return of(p)
1739
1807
  return null
1740
1808
  }
1741
1809
 
@@ -1988,6 +2056,17 @@ function makePlugin(site) {
1988
2056
  // the account's own list, when it's signed in and WorkBuddy answers;
1989
2057
  // else the list above
1990
2058
  async models(provider, { auth }) {
2059
+ if (auth?.type === "api") {
2060
+ // asked as WorkBuddy's own CLI asks with a key (remoteConfig)
2061
+ const a = keyAccount(auth)
2062
+ if (!a || !remoteConfig(a.key)) return provider.models
2063
+ try {
2064
+ const ms = await liveModels(site, a)
2065
+ return ms.length ? Object.fromEntries(ms.map((m) => [m.id, modelOf(site, provider, m)])) : provider.models
2066
+ } catch {
2067
+ return provider.models
2068
+ }
2069
+ }
1991
2070
  if (auth?.type !== "oauth" || !(auth.access || auth.source)) return provider.models
1992
2071
  try {
1993
2072
  const a = await current(site, client, auth)
@@ -2010,7 +2089,7 @@ function makePlugin(site) {
2010
2089
  refresh: (auth) => refreshed(site, auth),
2011
2090
  async loader(getAuth) {
2012
2091
  const auth = await getAuth()
2013
- if (auth?.type !== "oauth") return {}
2092
+ if (auth?.type !== "oauth" && auth?.type !== "api") return {}
2014
2093
  return {
2015
2094
  // no baseURL: a model's own api.url says where it is served —
2016
2095
  // <endpoint>/v2 for a chat, <endpoint>/v1 for a Claude model on
@@ -2019,8 +2098,9 @@ function makePlugin(site) {
2019
2098
  apiKey: "",
2020
2099
  async fetch(input, init) {
2021
2100
  const stored = await getAuth()
2022
- if (stored?.type !== "oauth") throw new Error(`${site.name} isn't signed in`)
2023
- let a = await current(site, client, stored)
2101
+ const keyed = stored?.type === "api" ? keyAccount(stored) : null
2102
+ if (stored?.type !== "oauth" && !keyed) throw new Error(`${site.name} isn't signed in`)
2103
+ let a = keyed ?? (await current(site, client, stored))
2024
2104
  const req = input instanceof Request ? input : null
2025
2105
  const headers = new Headers(init?.headers ?? req?.headers)
2026
2106
  headers.delete("authorization")
@@ -2077,6 +2157,12 @@ function makePlugin(site) {
2077
2157
  const res = await post(b)
2078
2158
  if (res.status !== 401) return res
2079
2159
  if (renewed) return out("it refused the token renewed for it too", invalidToken(res), res)
2160
+ // a key isn't renewed: the 401 says the key was refused
2161
+ if (a.source === "key") {
2162
+ const r = keyRefused(site, invalidToken(res), edgeNotes.get(res))
2163
+ ours.add(r)
2164
+ return r
2165
+ }
2080
2166
  if (!resendable(b)) return res
2081
2167
  await res.body?.cancel().catch(() => {})
2082
2168
  const r = await reissued(site, client, getAuth, a, stored.access)
@@ -2141,6 +2227,9 @@ function makePlugin(site) {
2141
2227
  // an account lapsed nor cleared it, but for a 401 (gone, below)
2142
2228
  async usage(getAuth) {
2143
2229
  const auth = await getAuth()
2230
+ // WorkBuddy's own CLI reads no credits with a key, and what the
2231
+ // billing API answers one is unknown, so a key's card says so
2232
+ if (auth?.type === "api") return { error: `credits unknown with an API key (WorkBuddy's own client reads none with one); see ${site.keys.replace(/\/profile\/keys$/, "")}`, signIn: "kept" }
2144
2233
  if (auth?.type !== "oauth" || !(auth.access || auth.source)) return { error: "not signed in", signIn: "kept" }
2145
2234
  try {
2146
2235
  let a = await current(site, client, auth)
@@ -2206,6 +2295,13 @@ function makePlugin(site) {
2206
2295
  label: `${site.name} desktop's sign-in`,
2207
2296
  authorize: async () => desktopSignIn(site),
2208
2297
  },
2298
+ {
2299
+ // a key made at site.keys, sent as WorkBuddy's CLI sends one
2300
+ // (keyAccount)
2301
+ type: "api",
2302
+ label: `${site.name} API key`,
2303
+ placeholder: `from ${site.keys.replace(/^https:\/\//, "")}`,
2304
+ },
2209
2305
  ],
2210
2306
  },
2211
2307
  })
@@ -2313,4 +2409,4 @@ export const WorkBuddyAuthPlugin = makePlugin(SITES.workbuddy)
2313
2409
  export const WorkBuddyAIAuthPlugin = makePlugin(SITES["workbuddy-ai"])
2314
2410
 
2315
2411
  // for tests
2316
- export const _internal = { cycleOf, 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 }
2412
+ export const _internal = { 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, 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.35",
3
+ "version": "0.1.37",
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",