@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.
- package/README.md +86 -0
- package/index.mjs +504 -0
- 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
|
+
}
|