@magpie-community/opencode-workbuddy-auth 0.1.1

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 +86 -0
  2. package/index.mjs +504 -0
  3. package/package.json +11 -0
package/README.md ADDED
@@ -0,0 +1,86 @@
1
+ # @magpie-community/opencode-workbuddy-auth
2
+
3
+ Signs in to the plans of WorkBuddy, Tencent's desktop agent from CodeBuddy.
4
+ It has one provider for each of WorkBuddy's two builds:
5
+
6
+ | Provider id | Build | API |
7
+ |---|---|---|
8
+ | `workbuddy` | WorkBuddy, the China build (CodeBuddy plan) | `https://copilot.tencent.com/v2` |
9
+ | `workbuddy-ai` | WorkBuddy AI, the international build | `https://www.workbuddy.ai/v2` |
10
+
11
+ The package exports `WorkBuddyAuthPlugin` (`workbuddy`) and
12
+ `WorkBuddyAIAuthPlugin` (`workbuddy-ai`).
13
+
14
+ ## Signing in
15
+
16
+ - **Account (browser).** Opens Tencent's sign-in page for the build, the
17
+ one WorkBuddy's desktop app opens. The plugin then asks WorkBuddy for the
18
+ token every second until you're signed in, for 5 minutes at most. Nothing
19
+ needs to be pasted.
20
+ - **Desktop's sign-in.** Uses the account WorkBuddy desktop is signed in
21
+ to. Nothing is copied: the app's file is read on each request, and never
22
+ changed:
23
+ - macOS: `~/Library/Application Support/CodeBuddyExtension/Data/Public/auth/workbuddy-desktop[-ai].info`
24
+ - Windows: `~/AppData/Local/CodeBuddyExtension/…`
25
+ - Linux: `~/.local/share/CodeBuddyExtension/…`
26
+
27
+ A sign-in the app keeps encrypted can't be read, and this way fails.
28
+
29
+ A browser sign-in is kept as an OpenCode `oauth` auth: the access and
30
+ refresh tokens, their expiry, the user id and the domain; desktop's as a
31
+ marker, `{"type": "oauth", "source": "desktop", "accountId", "uid"}`. OpenCode keeps it in
32
+ `auth.json`; magpie keeps it in `plugin-auth.json`. The access token is
33
+ refreshed a minute before it ends, and the new one is saved. If the refresh
34
+ fails, the old token is used while it lasts.
35
+
36
+ Desktop's sign-in is refreshed only when the app hasn't refreshed it
37
+ itself, and the new token is kept in memory, never written back, as magpie's
38
+ built-in WorkBuddy does.
39
+
40
+ ## Requests
41
+
42
+ Chats are chat completions (`@ai-sdk/openai-compatible`) at `<API>/chat/completions`.
43
+ They are streamed only; WorkBuddy refuses a chat that isn't.
44
+
45
+ Each chat carries WorkBuddy's headers:
46
+ - `Authorization`, `X-User-Id`, `X-Domain`, `X-Product: SaaS`, `X-IDE-Type: WorkBuddy`;
47
+ - `User-Agent: WorkBuddy/5.5.6`. Without it, WorkBuddy answers error 10085;
48
+ - WorkBuddy AI also takes its client and conversation headers.
49
+
50
+ A chat that doesn't open with a system message is given WorkBuddy's
51
+ default one, as the app does.
52
+
53
+ ## Models
54
+
55
+ When you are signed in, the models are the ones WorkBuddy's product config
56
+ gives its CLI agent (`GET /v3/config`), with their context, image input and
57
+ reasoning levels. Until then, or if WorkBuddy doesn't answer, the list below
58
+ is used.
59
+
60
+ `workbuddy`:
61
+ - Auto
62
+ - Hy4 preview, Hy3, Hy3-X
63
+ - Deepseek-V4.1-Flash, Deepseek-V4-Pro
64
+ - GLM-5.3, GLM-5.3-Flash, GLM-5.2, GLM-5.1, GLM-5v-Turbo
65
+ - MiniMax-M3
66
+ - Kimi-K3, Kimi-K2.7-Code, Kimi-K2.6
67
+
68
+ `workbuddy-ai`:
69
+ - Default, Fast, Balanced, Primary, Deep
70
+ - GPT-5.5, GPT-5.4, GPT-5.3-Codex
71
+ - Gemini-3.1-Pro, Gemini-3.5-Flash
72
+ - GLM-5.3, GLM-5.2
73
+ - Hy3
74
+ - Kimi-K3, Kimi-K2.6
75
+ - MiniMax-M3
76
+
77
+ A model's reasoning levels are its variants (`reasoningEffort`).
78
+
79
+ ## Not here
80
+
81
+ magpie's built-in WorkBuddy does more than this package:
82
+ - the daily check-in;
83
+ - the credits meter (`/billing/meter/get-user-resource-summary`);
84
+ - several accounts under one provider.
85
+
86
+ OpenCode's plugin API has no place for these.
package/index.mjs ADDED
@@ -0,0 +1,504 @@
1
+ // OpenCode provider plugins for WorkBuddy's plans, Tencent's two builds of
2
+ // WorkBuddy (CodeBuddy's desktop agent):
3
+ // - workbuddy: the China build, copilot.tencent.com
4
+ // - workbuddy-ai: the international build, www.workbuddy.ai
5
+ // A WorkBuddy account signs in in the browser (Tencent's page, polled for
6
+ // the token as WorkBuddy's desktop app does) or is taken from WorkBuddy
7
+ // desktop's own sign-in. Its chats are chat completions at <endpoint>/v2,
8
+ // streamed only, with WorkBuddy's headers: a request whose User-Agent isn't
9
+ // WorkBuddy/<version> is refused (error 10085).
10
+
11
+ import { randomBytes } from "node:crypto"
12
+ import { readFileSync } from "node:fs"
13
+ import { homedir } from "node:os"
14
+ import { join } from "node:path"
15
+
16
+ const APP_VERSION = "2.0.0" // the version WorkBuddy's sign-in page is opened with
17
+ const UA_VERSION = "5.5.6" // WorkBuddy/<this> is the User-Agent the API takes
18
+ const POLL_MS = 1000
19
+ const SIGN_IN_MS = 5 * 60 * 1000
20
+ const EARLY_MS = 60 * 1000 // a token this close to its end is refreshed
21
+
22
+ // [id, name, context, efforts]
23
+ const CN_MODELS = [
24
+ ["auto", "Auto", 168000],
25
+ ["hy4-preview-f", "Hy4 preview", 1000000, ["high"]],
26
+ ["hy3", "Hy3", 192000, ["low", "high"]],
27
+ ["hy3-x", "Hy3-X", 192000, ["low", "high"]],
28
+ ["deepseek-v4.1-flash", "Deepseek-V4.1-Flash", 1000000],
29
+ ["glm-5.3", "GLM-5.3", 1000000, ["low", "high", "max"]],
30
+ ["glm-5.3-flash", "GLM-5.3-Flash", 1000000, ["low", "high", "max"]],
31
+ ["glm-5.2", "GLM-5.2", 1000000, ["high", "xhigh"]],
32
+ ["glm-5.1", "GLM-5.1", 200000],
33
+ ["glm-5v-turbo", "GLM-5v-Turbo", 200000],
34
+ ["minimax-m3", "MiniMax-M3", 512000],
35
+ ["kimi-k3-1", "Kimi-K3", 1000000, ["low", "high", "xhigh"]],
36
+ ["kimi-k2.7", "Kimi-K2.7-Code", 256000],
37
+ ["kimi-k2.6", "Kimi-K2.6", 256000],
38
+ ["deepseek-v4-pro", "Deepseek-V4-Pro", 1000000, ["none", "high", "xhigh"]],
39
+ ]
40
+
41
+ const AI_MODELS = [
42
+ ["default-model", "Default", 176000],
43
+ ["fast-model", "Fast", 200000],
44
+ ["balanced-model", "Balanced", 256000],
45
+ ["primary-model", "Primary", 272000],
46
+ ["deep-model", "Deep", 176000],
47
+ ["gpt-5.5", "GPT-5.5", 1000000],
48
+ ["gpt-5.4", "GPT-5.4", 272000],
49
+ ["gpt-5.3-codex", "GPT-5.3-Codex", 272000],
50
+ ["gemini-3.1-pro", "Gemini-3.1-Pro", 400000],
51
+ ["gemini-3.5-flash", "Gemini-3.5-Flash", 1000000],
52
+ ["glm-5.3", "GLM-5.3", 1000000, ["low", "high", "max"]],
53
+ ["glm-5.2", "GLM-5.2", 1000000, ["high", "xhigh"]],
54
+ ["hy3", "Hy3", 192000, ["low", "high"]],
55
+ ["kimi-k3", "Kimi-K3", 1000000],
56
+ ["kimi-k2.6", "Kimi-K2.6", 256000],
57
+ ["minimax-m3", "MiniMax-M3", 512000],
58
+ ]
59
+
60
+ const SITES = {
61
+ workbuddy: {
62
+ id: "workbuddy",
63
+ name: "WorkBuddy",
64
+ endpoint: "https://copilot.tencent.com",
65
+ authID: "workbuddy-desktop",
66
+ platform: "workbuddy",
67
+ ai: false,
68
+ models: CN_MODELS,
69
+ },
70
+ "workbuddy-ai": {
71
+ id: "workbuddy-ai",
72
+ name: "WorkBuddy AI",
73
+ endpoint: "https://www.workbuddy.ai",
74
+ authID: "workbuddy-desktop-ai",
75
+ platform: "workbuddy-ai",
76
+ ai: true,
77
+ models: AI_MODELS,
78
+ },
79
+ }
80
+
81
+ const NPM = "@ai-sdk/openai-compatible"
82
+ const hex = () => randomBytes(16).toString("hex")
83
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
84
+ const api = (site) => site.endpoint + "/v2"
85
+
86
+ // ---- WorkBuddy's API ------------------------------------------------------
87
+
88
+ class WBError extends Error {
89
+ constructor(message, code, status) {
90
+ super(message)
91
+ this.code = code
92
+ this.status = status
93
+ }
94
+ }
95
+
96
+ // call makes a request of WorkBuddy's plugin API, which answers
97
+ // {code, msg, data}; a code but 0 is an error.
98
+ async function call(site, method, path, headers = {}, body) {
99
+ const res = await fetch(site.endpoint + path, {
100
+ method,
101
+ headers: {
102
+ "Content-Type": "application/json",
103
+ Accept: "application/json",
104
+ "User-Agent": "WorkBuddy/" + UA_VERSION,
105
+ ...headers,
106
+ },
107
+ body: body === undefined ? undefined : JSON.stringify(body),
108
+ signal: AbortSignal.timeout(30000),
109
+ })
110
+ const text = await res.text()
111
+ let env = {}
112
+ try {
113
+ env = JSON.parse(text)
114
+ } catch {}
115
+ if (!res.ok) {
116
+ const why = env.msg || env.message || env.error?.message || text.slice(0, 200) || res.statusText
117
+ throw new WBError(`${site.name}: ${why} (HTTP ${res.status}${env.code ? ", code " + env.code : ""})`, env.code, res.status)
118
+ }
119
+ if (env.code) throw new WBError(`${site.name}: ${env.msg || "error"} (${env.code})`, env.code, res.status)
120
+ return env.data
121
+ }
122
+
123
+ function domainOf(site, a) {
124
+ return a.domain || new URL(api(site)).host
125
+ }
126
+
127
+ // merge takes a token WorkBuddy gave (sign-in or refresh) into the
128
+ // stored sign-in, times given as at-ms or in-seconds.
129
+ function merge(a, got) {
130
+ const now = Date.now()
131
+ const out = { ...a, access: got.accessToken }
132
+ if (got.refreshToken) out.refresh = got.refreshToken
133
+ if (got.domain) out.domain = got.domain
134
+ if (got.tokenType) out.tokenType = got.tokenType
135
+ if (got.expiresAt > 0) out.expires = got.expiresAt
136
+ else if (got.expiresIn > 0) out.expires = now + got.expiresIn * 1000
137
+ if (got.refreshExpiresAt > 0) out.refreshExpiresAt = got.refreshExpiresAt
138
+ else if (got.refreshExpiresIn > 0) out.refreshExpiresAt = now + got.refreshExpiresIn * 1000
139
+ return out
140
+ }
141
+
142
+ function stale(a) {
143
+ return !!a.expires && Date.now() >= a.expires - EARLY_MS
144
+ }
145
+
146
+ // fresh is the sign-in with an access token that isn't about to end:
147
+ // refreshed (and saved) when it is. A refresh that fails, or a refresh
148
+ // token that has ended, leaves the access token there is.
149
+ async function fresh(site, client, a) {
150
+ if (!stale(a)) return a
151
+ const refreshable = a.refresh && !(a.refreshExpiresAt && Date.now() >= a.refreshExpiresAt)
152
+ if (!refreshable) {
153
+ if (a.access) return a
154
+ throw new Error(`this ${site.name} account is signed out; sign in again`)
155
+ }
156
+ try {
157
+ const got = await call(site, "POST", "/v2/plugin/auth/token/refresh", {
158
+ "X-Refresh-Token": a.refresh,
159
+ "X-Auth-Refresh-Source": "plugin",
160
+ "X-Domain": domainOf(site, a),
161
+ }, {})
162
+ if (!got?.accessToken) throw new Error(`${site.name} gave no refreshed token`)
163
+ const next = merge(a, got)
164
+ await client?.auth?.set?.({ path: { id: site.id }, body: next })
165
+ return next
166
+ } catch (e) {
167
+ if (a.access) return a
168
+ throw new Error(`${site.name} token refresh: ${e?.message ?? e}`)
169
+ }
170
+ }
171
+
172
+ // sign sets on headers what WorkBuddy's desktop app sends with a chat.
173
+ function sign(site, a, headers) {
174
+ headers.set("Authorization", "Bearer " + a.access)
175
+ headers.set("X-User-Id", a.uid ?? "")
176
+ headers.set("X-Domain", domainOf(site, a))
177
+ headers.set("X-Product", "SaaS")
178
+ headers.set("X-IDE-Type", "WorkBuddy")
179
+ headers.set("User-Agent", "WorkBuddy/" + UA_VERSION)
180
+ if (!site.ai) return
181
+ headers.set("X-Requested-With", "XMLHttpRequest")
182
+ headers.set("X-Agent-Intent", "craft")
183
+ headers.set("X-Agent-Type", "main")
184
+ headers.set("X-IDE-Name", "WorkBuddy")
185
+ headers.set("X-IDE-Version", UA_VERSION)
186
+ const conv = hex()
187
+ if (!headers.has("X-Conversation-ID")) headers.set("X-Conversation-ID", conv)
188
+ if (!headers.has("X-Conversation-Request-ID")) headers.set("X-Conversation-Request-ID", conv)
189
+ const msg = hex()
190
+ headers.set("X-Conversation-Message-ID", msg)
191
+ headers.set("X-Request-ID", msg)
192
+ }
193
+
194
+ // withSystem gives a chat that doesn't open with a system message
195
+ // WorkBuddy's default one, as its app does.
196
+ function withSystem(body) {
197
+ if (typeof body !== "string") return body
198
+ try {
199
+ const b = JSON.parse(body)
200
+ if (!Array.isArray(b?.messages) || !b.messages.length || b.messages[0]?.role === "system") return body
201
+ b.messages.unshift({ role: "system", content: "You are a helpful assistant." })
202
+ return JSON.stringify(b)
203
+ } catch {
204
+ return body
205
+ }
206
+ }
207
+
208
+ // ---- signing in -------------------------------------------------------------
209
+
210
+ // poll asks path until WorkBuddy answers with data, retrying the codes
211
+ // that mean "not yet".
212
+ async function poll(site, path, headers, retry, deadline) {
213
+ for (;;) {
214
+ if (Date.now() > deadline) throw new Error(`${site.name} sign-in timed out`)
215
+ try {
216
+ const data = await call(site, "GET", path, headers)
217
+ if (data && Object.keys(data).length) return data
218
+ } catch (e) {
219
+ if (!(retry.includes(e.code) || e.status === 408 || e.status === 429)) throw e
220
+ }
221
+ await sleep(POLL_MS)
222
+ }
223
+ }
224
+
225
+ async function browserSignIn(site) {
226
+ const state = await call(site, "POST", `/v2/plugin/auth/state?platform=${encodeURIComponent(site.platform)}`, {
227
+ "X-No-Authorization": "true",
228
+ "X-No-User-Id": "true",
229
+ "X-No-Enterprise-Id": "true",
230
+ "X-No-Department-Info": "true",
231
+ }, {})
232
+ if (!state?.state || !state?.authUrl) throw new Error(`${site.name} gave no sign-in page`)
233
+ const url = new URL(state.authUrl)
234
+ if (url.protocol !== "https:") throw new Error(`${site.name}'s sign-in page isn't https`)
235
+ url.searchParams.set("version", APP_VERSION)
236
+ url.searchParams.set("loginSessionId", hex())
237
+ return {
238
+ url: url.toString(),
239
+ instructions: `Sign in to ${site.name} in the browser; this finishes by itself.`,
240
+ method: "auto",
241
+ async callback() {
242
+ try {
243
+ const deadline = Date.now() + SIGN_IN_MS
244
+ const q = encodeURIComponent(state.state)
245
+ const token = await poll(site, `/v2/plugin/auth/token?state=${q}`, {}, [11217], deadline)
246
+ if (!token.accessToken) return { type: "failed" }
247
+ let a = merge({}, token)
248
+ const who = await poll(site, `/v2/plugin/login/account?state=${q}`, {
249
+ Authorization: "Bearer " + a.access,
250
+ "X-Domain": domainOf(site, a),
251
+ "X-No-User-Id": "true",
252
+ "X-No-Enterprise-Id": "true",
253
+ }, [12151], deadline)
254
+ a.uid = who.uid ?? ""
255
+ return success(site, a, who.nickname || who.phoneNumber || who.uid)
256
+ } catch {
257
+ return { type: "failed" }
258
+ }
259
+ },
260
+ }
261
+ }
262
+
263
+ function success(site, a, name) {
264
+ return {
265
+ type: "success",
266
+ refresh: a.refresh ?? "",
267
+ access: a.access,
268
+ expires: a.expires ?? 0,
269
+ accountId: name || site.name,
270
+ uid: a.uid ?? "",
271
+ domain: a.domain ?? "",
272
+ ...(a.refreshExpiresAt ? { refreshExpiresAt: a.refreshExpiresAt } : {}),
273
+ ...(a.tokenType ? { tokenType: a.tokenType } : {}),
274
+ }
275
+ }
276
+
277
+ // desktopFile is where WorkBuddy's desktop app keeps its sign-in.
278
+ function desktopFile(site) {
279
+ const home = homedir()
280
+ const base =
281
+ process.platform === "darwin" ? join(home, "Library", "Application Support", "CodeBuddyExtension")
282
+ : process.platform === "win32" ? join(home, "AppData", "Local", "CodeBuddyExtension")
283
+ : join(home, ".local", "share", "CodeBuddyExtension")
284
+ return join(base, "Data", "Public", "auth", site.authID + ".info")
285
+ }
286
+
287
+ // readDesktop is WorkBuddy desktop's sign-in as the app keeps it now, null
288
+ // for none (or tokens the app keeps encrypted, which can't be read).
289
+ function readDesktop(site) {
290
+ try {
291
+ const f = JSON.parse(readFileSync(desktopFile(site), "utf8"))
292
+ const t = f?.auth ?? {}
293
+ if (typeof t.accessToken !== "string" || !t.accessToken || !f?.account?.uid) return null
294
+ const now = Date.now()
295
+ const acct = f.account
296
+ return {
297
+ access: t.accessToken,
298
+ refresh: typeof t.refreshToken === "string" ? t.refreshToken : "",
299
+ expires: t.expiresAt > 0 ? t.expiresAt : t.expiresIn > 0 ? now + t.expiresIn * 1000 : 0,
300
+ refreshExpiresAt: t.refreshExpiresAt > 0 ? t.refreshExpiresAt : t.refreshExpiresIn > 0 ? now + t.refreshExpiresIn * 1000 : 0,
301
+ domain: typeof t.domain === "string" ? t.domain : "",
302
+ tokenType: typeof t.tokenType === "string" ? t.tokenType : "",
303
+ uid: acct.uid,
304
+ name: acct.nickname || acct.phoneNumber || acct.uid,
305
+ }
306
+ } catch {
307
+ return null
308
+ }
309
+ }
310
+
311
+ // desktopSignIn uses the account WorkBuddy desktop is signed in to: nothing
312
+ // is copied (a copied refresh token would be the app's and the plugin's
313
+ // both, and the first to use it would sign the other out); its file is
314
+ // read each time, as the app keeps it.
315
+ function desktopSignIn(site) {
316
+ return {
317
+ url: "",
318
+ instructions: `Uses the account ${site.name} desktop is signed in to.`,
319
+ method: "auto",
320
+ async callback() {
321
+ const d = readDesktop(site)
322
+ if (!d) return { type: "failed" }
323
+ return { type: "success", refresh: "", access: "", expires: 0, source: "desktop", accountId: d.name || site.name, uid: d.uid }
324
+ },
325
+ }
326
+ }
327
+
328
+ // desktopHeld is what a desktop sign-in was renewed to here, by site and
329
+ // account: kept in memory only, never written where the app keeps it.
330
+ const desktopHeld = new Map()
331
+
332
+ // current is the sign-in a stored auth names, fresh: WorkBuddy desktop's,
333
+ // read where it keeps it, or one kept here.
334
+ async function current(site, client, auth) {
335
+ if (auth?.source !== "desktop") return fresh(site, client, auth)
336
+ const d = readDesktop(site)
337
+ if (!d) throw new Error(`${site.name} desktop isn't signed in`)
338
+ const k = site.id + "|" + d.uid
339
+ const h = desktopHeld.get(k)
340
+ const a = await fresh(site, null, h && h.expires >= d.expires ? h : d)
341
+ desktopHeld.set(k, a)
342
+ return a
343
+ }
344
+
345
+ // ---- models -----------------------------------------------------------------
346
+
347
+ // variants are the reasoning levels as OpenCode gives them to the AI SDK.
348
+ function variants(efforts) {
349
+ return Object.fromEntries((efforts ?? []).map((e) => [e, { reasoningEffort: e }]))
350
+ }
351
+
352
+ function configModels(site) {
353
+ return Object.fromEntries(site.models.map(([id, name, context, efforts]) => [id, {
354
+ name,
355
+ limit: { context, output: 0 },
356
+ tool_call: true,
357
+ ...(efforts ? { reasoning: true, variants: variants(efforts) } : {}),
358
+ }]))
359
+ }
360
+
361
+ function modelOf(site, provider, m) {
362
+ return {
363
+ id: m.id,
364
+ providerID: provider.id,
365
+ name: m.name,
366
+ api: { id: m.id, url: api(site), npm: NPM },
367
+ status: "active",
368
+ headers: {},
369
+ options: {},
370
+ cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
371
+ limit: { context: m.context ?? 0, output: m.output ?? 0 },
372
+ capabilities: {
373
+ temperature: true,
374
+ reasoning: !!m.efforts?.length,
375
+ attachment: !!m.images,
376
+ toolcall: true,
377
+ input: { text: true, image: !!m.images, audio: false, video: false, pdf: false },
378
+ output: { text: true, image: false, audio: false, video: false, pdf: false },
379
+ interleaved: false,
380
+ },
381
+ release_date: "",
382
+ variants: variants(m.efforts),
383
+ }
384
+ }
385
+
386
+ // liveModels asks WorkBuddy's product config for the plan's models: the
387
+ // "cli" agent's list, each with its details. The CLI User-Agent picks
388
+ // WorkBuddy's config (a bare WorkBuddy/<v> gets CodeBuddy IDE's, no cli).
389
+ async function liveModels(site, a) {
390
+ const headers = new Headers()
391
+ sign(site, a, headers)
392
+ headers.set("User-Agent", `CLI/${APP_VERSION} WorkBuddy/${UA_VERSION}`)
393
+ headers.set("X-Requested-With", "XMLHttpRequest")
394
+ headers.set("Accept", "application/json")
395
+ const res = await fetch(site.endpoint + "/v3/config", { headers, signal: AbortSignal.timeout(15000) })
396
+ const env = await res.json().catch(() => ({}))
397
+ if (!res.ok) throw new Error(`${site.name}'s config: HTTP ${res.status}`)
398
+ if (env.code) throw new Error(`${site.name}'s config: ${env.msg} (${env.code})`)
399
+ const cfg = env.data ?? {}
400
+ const out = []
401
+ for (const agent of cfg.agents ?? []) {
402
+ if (agent.name !== "cli") continue
403
+ for (const id of agent.models ?? []) {
404
+ const d = (cfg.models ?? []).find((x) => x.id === id)
405
+ const m = { id, name: d?.name || id }
406
+ if (d) {
407
+ m.context = d.maxInputTokens
408
+ m.output = d.maxOutputTokens
409
+ m.images = d.supportsImages === true
410
+ const es = d.reasoning?.supportedEfforts ?? []
411
+ if (es.length) {
412
+ const canOff = d.reasoning?.canDisableThinking
413
+ m.efforts = [...(!d.onlyReasoning && (canOff == null || canOff) ? ["none"] : []), ...es]
414
+ }
415
+ }
416
+ out.push(m)
417
+ }
418
+ }
419
+ // tell apart models the config names alike: deepseek-v4.1-flash and
420
+ // deepseek-v4.1-flash-sg are both "Deepseek-V4.1-Flash"
421
+ const first = new Map()
422
+ for (const m of out) {
423
+ const id = first.get(m.name)
424
+ if (id === undefined) {
425
+ first.set(m.name, m.id)
426
+ continue
427
+ }
428
+ const rest = m.id.startsWith(id + "-") ? m.id.slice(id.length + 1) : ""
429
+ m.name = `${m.name} (${rest ? rest.toUpperCase() : m.id})`
430
+ }
431
+ return out
432
+ }
433
+
434
+ // ---- the plugins --------------------------------------------------------------
435
+
436
+ function makePlugin(site) {
437
+ return async ({ client }) => ({
438
+ config: async (config) => {
439
+ config.provider ??= {}
440
+ config.provider[site.id] ??= {}
441
+ const p = config.provider[site.id]
442
+ p.name ??= site.name
443
+ p.npm ??= NPM
444
+ p.api ??= api(site)
445
+ p.models = { ...configModels(site), ...(p.models ?? {}) }
446
+ },
447
+ provider: {
448
+ id: site.id,
449
+ // the account's own list, when it's signed in and WorkBuddy answers;
450
+ // else the list above
451
+ async models(provider, { auth }) {
452
+ if (auth?.type !== "oauth" || !(auth.access || auth.source)) return provider.models
453
+ try {
454
+ const a = await current(site, client, auth)
455
+ const ms = await liveModels(site, a)
456
+ if (!ms.length) return provider.models
457
+ return Object.fromEntries(ms.map((m) => [m.id, modelOf(site, provider, m)]))
458
+ } catch {
459
+ return provider.models
460
+ }
461
+ },
462
+ },
463
+ auth: {
464
+ provider: site.id,
465
+ async loader(getAuth) {
466
+ const auth = await getAuth()
467
+ if (auth?.type !== "oauth") return {}
468
+ return {
469
+ baseURL: api(site),
470
+ apiKey: "",
471
+ async fetch(input, init) {
472
+ let a = await getAuth()
473
+ if (a?.type !== "oauth") throw new Error(`${site.name} isn't signed in`)
474
+ a = await current(site, client, a)
475
+ const req = input instanceof Request ? input : null
476
+ const headers = new Headers(init?.headers ?? req?.headers)
477
+ headers.delete("authorization")
478
+ headers.delete("x-api-key")
479
+ headers.delete("content-length")
480
+ sign(site, a, headers)
481
+ let body = init?.body
482
+ if (body === undefined && req) body = await req.clone().text()
483
+ return fetch(req ? req.url : input, { ...init, method: init?.method ?? req?.method, headers, body: withSystem(body) })
484
+ },
485
+ }
486
+ },
487
+ methods: [
488
+ {
489
+ type: "oauth",
490
+ label: `${site.name} account (browser)`,
491
+ authorize: () => browserSignIn(site),
492
+ },
493
+ {
494
+ type: "oauth",
495
+ label: `${site.name} desktop's sign-in`,
496
+ authorize: async () => desktopSignIn(site),
497
+ },
498
+ ],
499
+ },
500
+ })
501
+ }
502
+
503
+ export const WorkBuddyAuthPlugin = makePlugin(SITES.workbuddy)
504
+ export const WorkBuddyAIAuthPlugin = makePlugin(SITES["workbuddy-ai"])
package/package.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "@magpie-community/opencode-workbuddy-auth",
3
+ "version": "0.1.1",
4
+ "description": "OpenCode provider plugins for WorkBuddy's plans: WorkBuddy (CodeBuddy, copilot.tencent.com) and WorkBuddy AI (www.workbuddy.ai)",
5
+ "type": "module",
6
+ "main": "./index.mjs",
7
+ "exports": "./index.mjs",
8
+ "files": ["index.mjs", "README.md"],
9
+ "keywords": ["opencode", "opencode-plugin", "workbuddy", "codebuddy"],
10
+ "license": "MIT"
11
+ }