@magpie-community/opencode-workbuddy-auth 0.1.36 → 0.1.38

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 +25 -0
  2. package/index.mjs +171 -11
  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
@@ -219,6 +228,22 @@ some hours of the day (Hy4 preview, free 23:00–8:00) is given as its full
219
228
  price, never as free: magpie keeps the list until it asks again, so a list
220
229
  asked for at night would say "free" all day (#121).
221
230
 
231
+ ### Free models only
232
+
233
+ Each site (WorkBuddy, WorkBuddy AI) has a "Free models only" switch in its
234
+ provider settings in magpie, off by default (#430). When it is on:
235
+
236
+ - the list holds only the models the site serves at no cost now. A model free
237
+ only at night, or one with no price, counts as paid;
238
+ - a request for any other model is answered by the plugin with a 404
239
+ `model_not_found` and never sent, so no credits are spent. magpie rests
240
+ only that model and tries the next provider;
241
+ - if the price list can't be read, nothing is sent (503), since whether the
242
+ model is free is unknown. An API key WorkBuddy lists no prices for lists
243
+ no models.
244
+
245
+ The price list is read again at most once a minute for requests.
246
+
222
247
  ## Credits in magpie
223
248
 
224
249
  magpie's usage card shows the account's credits from the resource summary.
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
@@ -557,6 +592,12 @@ function withoutSignedThinking(body) {
557
592
 
558
593
  // chatOf is a chat's body as the object it is, null for one that isn't: a
559
594
  // body that isn't text (bytes), or isn't JSON at all.
595
+ // bodyString is a body given as bytes, as text; anything else is null.
596
+ function bodyString(body) {
597
+ if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) return new TextDecoder().decode(body)
598
+ return null
599
+ }
600
+
560
601
  function chatOf(body) {
561
602
  if (typeof body !== "string") return null
562
603
  try {
@@ -1419,6 +1460,8 @@ async function unanswered(site, a, body, err, signal) {
1419
1460
  if (signal?.aborted || err?.name === "AbortError" || err?.name === "TimeoutError") throw err
1420
1461
  const size = byteLength(body)
1421
1462
  if (size <= EDGE_READS) throw err
1463
+ // the billing API isn't asked with a key: what it answers one is unknown
1464
+ if (a.source === "key") throw err
1422
1465
  let status = 0
1423
1466
  let challenge = ""
1424
1467
  try {
@@ -1546,6 +1589,18 @@ function refusedText(site, a, why, note) {
1546
1589
  )
1547
1590
  }
1548
1591
 
1592
+ // keyRefused is the answer to a request WorkBuddy refused an API key for
1593
+ // (401): a key isn't renewed, so it says to check the key. Marked expired
1594
+ // only on WorkBuddy's word that the token itself is refused (invalidToken).
1595
+ function keyRefused(site, expired, note) {
1596
+ 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}.`
1597
+ return new Response(JSON.stringify({ error: { message, type: "authentication_error", code: 401 } }), {
1598
+ status: 401,
1599
+ statusText: "Unauthorized",
1600
+ headers: { "content-type": "application/json", "X-Magpie-Sign-In": expired ? "expired" : "kept" },
1601
+ })
1602
+ }
1603
+
1549
1604
  // signedOut is the answer to a request WorkBuddy refused this account's
1550
1605
  // sign-in for, renewed or not: a 401 that says so and what to do, which
1551
1606
  // WorkBuddy's own (an HTML page, its {code, msg}) doesn't. expired marks
@@ -1978,6 +2033,29 @@ function replyAfterTools(body) {
1978
2033
  return JSON.stringify(b)
1979
2034
  }
1980
2035
 
2036
+ // FREE_ONLY is the switch that keeps an account off credits (#430): its
2037
+ // list holds only the models WorkBuddy serves at no cost (priceOf's free),
2038
+ // and a request for any other is answered here, never sent. A price that
2039
+ // can't be read is not free, and a list that can't be read sends nothing.
2040
+ const FREE_ONLY = "free_only"
2041
+ // PRICE_TTL is how long a list read for that check is used before it is
2042
+ // read again: a promotion that ends stops counting as free within it.
2043
+ const PRICE_TTL = 60_000
2044
+
2045
+ // notFree is the answer to a request the switch keeps from WorkBuddy. Its
2046
+ // words say the model, not the account, is what is turned away, so magpie
2047
+ // rests that model alone and asks another provider of it; they name no
2048
+ // quota or credit, which would rest the whole account as out of credit.
2049
+ function notFree(site, model, why) {
2050
+ const msg = why
2051
+ ? `${site.name}'s model list couldn't be read (${why}), so whether model ${model || "(none)"} is free is unknown; with "Free models only" on it isn't sent`
2052
+ : `model ${model || "(none)"} is not available while "Free models only" is on: ${site.name} charges for it. Turn the switch off in ${site.name}'s provider settings to use it`
2053
+ return new Response(JSON.stringify({ error: { message: msg, type: "invalid_request_error", code: why ? "free_only_unknown" : "model_not_found" } }), {
2054
+ status: why ? 503 : 404,
2055
+ headers: { "content-type": "application/json", "X-Magpie-Sign-In": "kept" },
2056
+ })
2057
+ }
2058
+
1981
2059
  function makePlugin(site) {
1982
2060
  // the models of this site that refused tool results then the user's
1983
2061
  // message, for this run: their next chats go with replyAfterTools at once
@@ -1985,7 +2063,30 @@ function makePlugin(site) {
1985
2063
  // the system prompts this run WorkBuddy refused as an unapproved channel:
1986
2064
  // their next chats go with systemAsUser at once, not refused first
1987
2065
  const refusedSystems = new Set()
1988
- return async ({ client }) => ({
2066
+ return async ({ client, magpie }) => {
2067
+ // each account's list as last read, for FREE_ONLY: {at, list}
2068
+ const lists = new Map()
2069
+ const listKey = (a) => `${a.uid ?? ""}|${a.enterpriseId ?? ""}|${a.source === "key" ? a.key : ""}`
2070
+ // listed is the account's live list, read again past PRICE_TTL
2071
+ const listed = async (a, fresh = false) => {
2072
+ const k = listKey(a)
2073
+ const was = lists.get(k)
2074
+ if (!fresh && was && Date.now() - was.at < PRICE_TTL) return was.list
2075
+ const list = await liveModels(site, a)
2076
+ lists.set(k, { at: Date.now(), list })
2077
+ if (lists.size > 64) lists.delete(lists.keys().next().value)
2078
+ return list
2079
+ }
2080
+ const freeOnly = () => magpie?.option?.(site.id, FREE_ONLY) === true
2081
+ // onlyFree is a list with the switch on: its free models, or a failure
2082
+ // when there are none to tell (magpie then keeps the list it had)
2083
+ const onlyFree = (ms) => {
2084
+ if (!freeOnly()) return ms
2085
+ const free = ms.filter((m) => m.free)
2086
+ if (!free.length) throw new Error(`${site.name} lists no free model, and "Free models only" is on`)
2087
+ return free
2088
+ }
2089
+ return {
1989
2090
  config: async (config) => {
1990
2091
  config.provider ??= {}
1991
2092
  config.provider[site.id] ??= {}
@@ -2007,15 +2108,37 @@ function makePlugin(site) {
2007
2108
  // the account's own list, when it's signed in and WorkBuddy answers;
2008
2109
  // else the list above
2009
2110
  async models(provider, { auth }) {
2111
+ if (auth?.type === "api") {
2112
+ // asked as WorkBuddy's own CLI asks with a key (remoteConfig)
2113
+ const a = keyAccount(auth)
2114
+ if (!a || !remoteConfig(a.key)) {
2115
+ if (freeOnly()) throw new Error(`${site.name} lists no prices for this key, and "Free models only" is on`)
2116
+ return provider.models
2117
+ }
2118
+ let ms
2119
+ try {
2120
+ ms = await listed(a, true)
2121
+ } catch (e) {
2122
+ if (freeOnly()) throw e
2123
+ return provider.models
2124
+ }
2125
+ if (!ms.length && !freeOnly()) return provider.models
2126
+ return Object.fromEntries(onlyFree(ms).map((m) => [m.id, modelOf(site, provider, m)]))
2127
+ }
2010
2128
  if (auth?.type !== "oauth" || !(auth.access || auth.source)) return provider.models
2129
+ let ms
2011
2130
  try {
2012
2131
  const a = await current(site, client, auth)
2013
- const ms = await liveModels(site, a)
2014
- if (!ms.length) return provider.models
2015
- return Object.fromEntries(ms.map((m) => [m.id, modelOf(site, provider, m)]))
2016
- } catch {
2132
+ ms = await listed(a, true)
2133
+ } catch (e) {
2134
+ // with the switch on, a list without prices would offer models
2135
+ // that cost credits: the failure goes up, and magpie keeps the
2136
+ // list it had
2137
+ if (freeOnly()) throw e
2017
2138
  return provider.models
2018
2139
  }
2140
+ if (!ms.length && !freeOnly()) return provider.models
2141
+ return Object.fromEntries(onlyFree(ms).map((m) => [m.id, modelOf(site, provider, m)]))
2019
2142
  },
2020
2143
  },
2021
2144
  auth: {
@@ -2027,9 +2150,16 @@ function makePlugin(site) {
2027
2150
  // and is renewed as before.
2028
2151
  refreshLead: LEAD_MS,
2029
2152
  refresh: (auth) => refreshed(site, auth),
2153
+ options: [
2154
+ {
2155
+ key: FREE_ONLY,
2156
+ label: "Free models only",
2157
+ description: `Lists only the models ${site.name} serves at no cost, and turns away a request for any other here instead of sending it, so no credits are spent. A model free only at night counts as paid.`,
2158
+ },
2159
+ ],
2030
2160
  async loader(getAuth) {
2031
2161
  const auth = await getAuth()
2032
- if (auth?.type !== "oauth") return {}
2162
+ if (auth?.type !== "oauth" && auth?.type !== "api") return {}
2033
2163
  return {
2034
2164
  // no baseURL: a model's own api.url says where it is served —
2035
2165
  // <endpoint>/v2 for a chat, <endpoint>/v1 for a Claude model on
@@ -2038,8 +2168,9 @@ function makePlugin(site) {
2038
2168
  apiKey: "",
2039
2169
  async fetch(input, init) {
2040
2170
  const stored = await getAuth()
2041
- if (stored?.type !== "oauth") throw new Error(`${site.name} isn't signed in`)
2042
- let a = await current(site, client, stored)
2171
+ const keyed = stored?.type === "api" ? keyAccount(stored) : null
2172
+ if (stored?.type !== "oauth" && !keyed) throw new Error(`${site.name} isn't signed in`)
2173
+ let a = keyed ?? (await current(site, client, stored))
2043
2174
  const req = input instanceof Request ? input : null
2044
2175
  const headers = new Headers(init?.headers ?? req?.headers)
2045
2176
  headers.delete("authorization")
@@ -2049,6 +2180,18 @@ function makePlugin(site) {
2049
2180
  sign(site, a, headers)
2050
2181
  let body = init?.body
2051
2182
  if (body === undefined && req) body = await req.clone().text()
2183
+ // the switch is checked before anything is sent: a model not
2184
+ // free, or one whose price can't be read, never reaches WorkBuddy
2185
+ if (freeOnly()) {
2186
+ const model = chatOf(typeof body === "string" ? body : bodyString(body))?.model
2187
+ let list
2188
+ try {
2189
+ list = await listed(a)
2190
+ } catch (e) {
2191
+ return notFree(site, model, e?.message ?? String(e))
2192
+ }
2193
+ if (!list.some((m) => m.id === model && m.free)) return notFree(site, model)
2194
+ }
2052
2195
  // An Anthropic request goes as the agent sent it: WorkBuddy
2053
2196
  // serves these models on Messages itself, and the rewrites
2054
2197
  // below (a default system message, the chat endpoint's
@@ -2096,6 +2239,12 @@ function makePlugin(site) {
2096
2239
  const res = await post(b)
2097
2240
  if (res.status !== 401) return res
2098
2241
  if (renewed) return out("it refused the token renewed for it too", invalidToken(res), res)
2242
+ // a key isn't renewed: the 401 says the key was refused
2243
+ if (a.source === "key") {
2244
+ const r = keyRefused(site, invalidToken(res), edgeNotes.get(res))
2245
+ ours.add(r)
2246
+ return r
2247
+ }
2099
2248
  if (!resendable(b)) return res
2100
2249
  await res.body?.cancel().catch(() => {})
2101
2250
  const r = await reissued(site, client, getAuth, a, stored.access)
@@ -2160,6 +2309,9 @@ function makePlugin(site) {
2160
2309
  // an account lapsed nor cleared it, but for a 401 (gone, below)
2161
2310
  async usage(getAuth) {
2162
2311
  const auth = await getAuth()
2312
+ // WorkBuddy's own CLI reads no credits with a key, and what the
2313
+ // billing API answers one is unknown, so a key's card says so
2314
+ 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" }
2163
2315
  if (auth?.type !== "oauth" || !(auth.access || auth.source)) return { error: "not signed in", signIn: "kept" }
2164
2316
  try {
2165
2317
  let a = await current(site, client, auth)
@@ -2225,9 +2377,17 @@ function makePlugin(site) {
2225
2377
  label: `${site.name} desktop's sign-in`,
2226
2378
  authorize: async () => desktopSignIn(site),
2227
2379
  },
2380
+ {
2381
+ // a key made at site.keys, sent as WorkBuddy's CLI sends one
2382
+ // (keyAccount)
2383
+ type: "api",
2384
+ label: `${site.name} API key`,
2385
+ placeholder: `from ${site.keys.replace(/^https:\/\//, "")}`,
2386
+ },
2228
2387
  ],
2229
2388
  },
2230
- })
2389
+ }
2390
+ }
2231
2391
  }
2232
2392
 
2233
2393
  // REFUSED_HINT is what the user can do about WorkBuddy's "Illegal API
@@ -2332,4 +2492,4 @@ export const WorkBuddyAuthPlugin = makePlugin(SITES.workbuddy)
2332
2492
  export const WorkBuddyAIAuthPlugin = makePlugin(SITES["workbuddy-ai"])
2333
2493
 
2334
2494
  // for tests
2335
- 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 }
2495
+ 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, 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.36",
3
+ "version": "0.1.38",
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",