@magpie-community/opencode-qoder-auth 0.1.0

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.
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-2005.9 Luis Pater
4
+ Copyright (c) 2025.9-present Router-For.ME
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,96 @@
1
+ # @magpie-community/opencode-qoder-auth
2
+
3
+ Your [Qoder](https://qoder.com) subscription in OpenCode and
4
+ [magpie](https://usemagpie.ai), with provider id `qoder`.
5
+
6
+ ## Sign-in
7
+
8
+ **Sign in with Qoder** is the sign-in Qoder's desktop client uses:
9
+
10
+ 1. A PKCE device flow opens qoder.com's account page.
11
+ 2. openapi.qoder.sh is polled every 2 s until you authorize, for at most
12
+ 15 minutes.
13
+ 3. The device token is traded for a job token, and the account's email and
14
+ name are read.
15
+
16
+ What is kept: the job token, its refresh token and expiry, the uid, the
17
+ device token and a machine id made for this sign-in. OpenCode keeps them in
18
+ `auth.json`; magpie keeps them in `plugin-auth.json`.
19
+
20
+ The job token is refreshed 5 minutes before it runs out. Qoder spends a
21
+ refresh token once, so the new pair is saved straight away, and refreshes
22
+ never run at the same time. When Qoder refuses a refresh (401 or 403), the
23
+ request says to sign in again.
24
+
25
+ ## Requests
26
+
27
+ Qoder serves its models on the API its client talks to:
28
+ `api3.qoder.sh/algo/api/v2/service/pro/sse/agent_chat_generation`. The
29
+ plugin's `fetch` takes the chat completion OpenCode sends and does the rest
30
+ itself.
31
+
32
+ **Writing the request**
33
+
34
+ - It writes the chat completion as Qoder's request:
35
+ - Qoder's own system line comes before yours.
36
+ - Messages become text and image blocks.
37
+ - Tool calls and results become OpenAI tool turns. Images a tool returned
38
+ follow in a user turn.
39
+ - Your tools become Qoder's native function tools.
40
+ - The model's own configuration from Qoder's list goes with it.
41
+ - It picks the reasoning effort from the model's own levels:
42
+ - The nearest level to the one asked, the higher one on a tie.
43
+ - The model's default when none is asked.
44
+ - The lowest level for "none", when the model can't turn thinking off.
45
+ - It encodes the body with the client's codec and signs the call with the
46
+ client's COSY envelope. The envelope carries:
47
+ - the account, AES-encrypted, with the key wrapped by Qoder's RSA key;
48
+ - an MD5 signature;
49
+ - the machine id and the client's headers.
50
+
51
+ **Reading the reply**
52
+
53
+ - Qoder's SSE comes back as a chat completion, streamed or not, with
54
+ reasoning and usage.
55
+ - Tool calls Qoder writes as XML or JSON in its text become tool calls.
56
+ Tool calls it sends the OpenAI way keep Qoder's id.
57
+
58
+ **Errors**
59
+
60
+ | What Qoder says | What the request returns |
61
+ |---|---|
62
+ | A refused sign-in | 401 |
63
+ | A quota | 429 |
64
+ | A failure before the answer starts | Qoder's status |
65
+ | A failure after the answer has started | The stream ends with an error |
66
+
67
+ ## Models
68
+
69
+ The `provider.models` hook reads the account's own list, as Qoder's client
70
+ asks for it (`/algo/api/v2/model/list`). It keeps the enabled chat models
71
+ and leaves out "auto" and "default", which route inside Qoder. It takes
72
+ each model's reasoning levels from its `thinking_config`.
73
+
74
+ The `config` hook declares the list as it was on 2026-09-30:
75
+
76
+ - Ultimate, Performance, Efficient
77
+ - Sonus, Cantus
78
+ - Qwen3.8-Max, Qwen3.8-Flash, Qwen3.7-Max, Qwen3.7-Plus
79
+ - Kimi-K3, Kimi-K2.8-Preview
80
+ - GLM-5.3, GLM-5.3-Flash
81
+ - DeepSeek-V4-Pro, DeepSeek-Flash
82
+ - MiniMax-M3
83
+
84
+ Every model speaks chat completions (`@ai-sdk/openai-compatible`).
85
+
86
+ ## Not here
87
+
88
+ - Qoder's usage and quota display.
89
+ - Several accounts at once.
90
+ - magpie's web-search stand-in.
91
+
92
+ ## Credits
93
+
94
+ The protocol (endpoints, COSY envelope, body codec and device flow) comes
95
+ from [CLIProxyAPI](https://github.com/ufec/CLIProxyAPI)'s Qoder support, by
96
+ way of magpie. Its MIT license is in `LICENSE-CLIProxyAPI`.
package/index.mjs ADDED
@@ -0,0 +1,850 @@
1
+ // Qoder's subscription (qoder.com) as an OpenCode provider plugin.
2
+ //
3
+ // Qoder is signed in to the way its desktop client is: a PKCE device flow
4
+ // (qoder.com's page, then a poll of openapi.qoder.sh for the device token),
5
+ // whose device token is traded for the job token the model calls use. The
6
+ // job token is refreshed with its refresh token before it runs out; Qoder
7
+ // spends a refresh token once, so the new pair is saved straight away.
8
+ //
9
+ // Qoder's models are served on the API its client talks to, api3.qoder.sh's
10
+ // agent_chat_generation SSE, signed with the client's COSY envelope and sent
11
+ // in its body codec. The fetch here takes the chat completion OpenCode sends,
12
+ // writes it as Qoder's request, and turns Qoder's reply back into one; tool
13
+ // calls Qoder writes as XML in its text are lifted out as tool calls.
14
+ //
15
+ // The protocol (endpoints, COSY envelope, body codec, device flow) is ported
16
+ // from magpie's internal/qoder, which ported it from CLIProxyAPI's qoder
17
+ // support (https://github.com/ufec/CLIProxyAPI, MIT).
18
+ import { createCipheriv, createHash, publicEncrypt, randomBytes, randomUUID, constants } from "node:crypto"
19
+
20
+ const ID = "qoder"
21
+ const CLIENT_ID = "732aef47-9cf2-46a2-95fe-4cebb5d0d1fa"
22
+ const DEVICE_HOST = "https://qoder.com"
23
+ const OPENAPI = "https://openapi.qoder.sh"
24
+ const API = "https://api3.qoder.sh"
25
+ const REDIRECT = "qoder-app://"
26
+ const CHAT_URL = API + "/algo/api/v2/service/pro/sse/agent_chat_generation?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1"
27
+ const MODELS_URL = API + "/algo/api/v2/model/list?Encode=1"
28
+ const COSY_VERSION = "1.1.49"
29
+ const SIGN_IN_TIMEOUT = 15 * 60 * 1000
30
+ const REFRESH_LEAD = 5 * 60 * 1000
31
+ const DAY = 24 * 60 * 60 * 1000
32
+ const CHAT = "@ai-sdk/openai-compatible"
33
+
34
+ // Qoder's embedded 1024-bit RSA key (from the client's cosy source): it wraps
35
+ // the per-request AES key of the COSY Authorization header.
36
+ const RSA_KEY = `-----BEGIN PUBLIC KEY-----
37
+ MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDA8iMH5c02LilrsERw9t6Pv5Nc
38
+ 4k6Pz1EaDicBMpdpxKduSZu5OANqUq8er4GM95omAGIOPOh+Nx0spthYA2BqGz+l
39
+ 6HRkPJ7S236FZz73In/KVuLnwI8JJ2CbuJap8kvheCCZpmAWpb/cPx/3Vr/J6I17
40
+ XcW+ML9FoCI6AOvOzwIDAQAB
41
+ -----END PUBLIC KEY-----`
42
+
43
+ const QODER_SYS = "You are a Qoder agent. Use the instructions below and the tools available to you to assist the user."
44
+
45
+ const hexID = () => randomUUID().replaceAll("-", "")
46
+
47
+ // ---- the COSY envelope --------------------------------------------------------
48
+
49
+ // machineOS names the machine as Qoder's desktop clients do.
50
+ function machineOS() {
51
+ const arch = { x64: "x86_64", ia32: "x86", arm64: "aarch64" }[process.arch] ?? process.arch
52
+ return arch + "_" + process.platform
53
+ }
54
+
55
+ // userBlob is the account as the envelope carries it: AES-128-CBC under a
56
+ // fresh key that is its own IV, the key wrapped with Qoder's RSA key.
57
+ function userBlob(u) {
58
+ const raw = JSON.stringify({ uid: u.uid, aid: "", name: u.name ?? "", email: u.email ?? "", security_oauth_token: u.access })
59
+ const key = Buffer.from(hexID().slice(0, 16))
60
+ const c = createCipheriv("aes-128-cbc", key, key)
61
+ const info = Buffer.concat([c.update(raw, "utf8"), c.final()]).toString("base64")
62
+ const wrapped = publicEncrypt({ key: RSA_KEY, padding: constants.RSA_PKCS1_PADDING }, key).toString("base64")
63
+ return { info, key: wrapped }
64
+ }
65
+
66
+ // cosyHeaders signs a call to url whose body, in its wire form, is body.
67
+ function cosyHeaders(url, u, body, ts = Math.floor(Date.now() / 1000)) {
68
+ if (!u?.machineId) throw new Error("Qoder: the account has no machine id; sign in again")
69
+ const blob = userBlob(u)
70
+ const payload = Buffer.from(
71
+ JSON.stringify({ version: "v1", requestId: hexID(), info: blob.info, cosyVersion: COSY_VERSION, ideVersion: "" }),
72
+ ).toString("base64")
73
+ let path = new URL(url).pathname
74
+ if (path.startsWith("/algo")) path = path.slice(5)
75
+ const sig = createHash("md5").update(`${payload}\n${blob.key}\n${ts}\n${body}\n${path}`).digest("hex")
76
+ const m = u.machineId
77
+ return {
78
+ Accept: "application/json",
79
+ "Accept-Encoding": "identity",
80
+ "Content-Type": "application/json",
81
+ Authorization: `Bearer COSY.${payload}.${sig}`,
82
+ "Cosy-Business-Product": "app",
83
+ "Cosy-Business-Type": "agent",
84
+ "Cosy-ClientIp": m,
85
+ "Cosy-ClientType": "10",
86
+ "Cosy-Data-Policy": "disagree",
87
+ "Cosy-Date": String(ts),
88
+ "Cosy-Key": blob.key,
89
+ "Cosy-MachineId": m,
90
+ "Cosy-MachineToken": m,
91
+ "Cosy-MachineType": "5",
92
+ "Cosy-MachineOS": machineOS(),
93
+ "Cosy-Scene": "app",
94
+ "Cosy-User": u.uid,
95
+ "Cosy-Version": COSY_VERSION,
96
+ "Login-Version": "v2",
97
+ }
98
+ }
99
+
100
+ // ---- the body codec -----------------------------------------------------------
101
+
102
+ // Base64 through the client's shuffled alphabet ('$' pads), then the first
103
+ // and last thirds swapped.
104
+ const ALPHABET = "_doRTgHZBKcGVjlvpC,@aFSx#DPuNJme&i*MzLOEn)sUrthbf%Y^w.(kIQyXqWA!"
105
+
106
+ function segmentEncode(bytes) {
107
+ let s = ""
108
+ let acc = 0
109
+ let nb = 0
110
+ for (const b of bytes) {
111
+ acc = ((acc << 8) | b) & 0xffffff
112
+ nb += 8
113
+ while (nb >= 6) {
114
+ nb -= 6
115
+ s += ALPHABET[(acc >> nb) & 63]
116
+ }
117
+ }
118
+ if (nb > 0) s += ALPHABET[(acc << (6 - nb)) & 63]
119
+ while (s.length % 4) s += "$"
120
+ return s
121
+ }
122
+
123
+ function swapThirds(s) {
124
+ const t = Math.floor(s.length / 3)
125
+ return t ? s.slice(s.length - t) + s.slice(t, s.length - t) + s.slice(0, t) : s
126
+ }
127
+
128
+ const encodeBody = (text) => swapThirds(segmentEncode(Buffer.from(text, "utf8")))
129
+
130
+ function decodeBody(wire) {
131
+ const s = swapThirds(wire)
132
+ const out = []
133
+ for (let g = 0; g + 4 <= s.length; g += 4) {
134
+ const vals = [...s.slice(g, g + 4)].filter((c) => c !== "$").map((c) => ALPHABET.indexOf(c))
135
+ let x = 0
136
+ for (const v of vals) x = x * 64 + v
137
+ const n = Math.floor((6 * vals.length) / 8)
138
+ for (let k = 0; k < n; k++) out.push(Math.floor(x / 2 ** (6 * vals.length - 8 * (k + 1))) & 255)
139
+ }
140
+ return Buffer.from(out).toString("utf8")
141
+ }
142
+
143
+ // ---- sign-in ------------------------------------------------------------------
144
+
145
+ const b64url = (buf) => buf.toString("base64url")
146
+
147
+ async function openapi(path, { method = "GET", token, body, query } = {}) {
148
+ const url = new URL(OPENAPI + path)
149
+ for (const [k, v] of Object.entries(query ?? {})) url.searchParams.set(k, v)
150
+ const headers = { Accept: "application/json" }
151
+ if (body) headers["Content-Type"] = "application/json"
152
+ if (token) headers.Authorization = `Bearer ${token}`
153
+ const res = await fetch(url, { method, headers, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(20_000) })
154
+ const text = await res.text()
155
+ let json = null
156
+ try {
157
+ json = JSON.parse(text)
158
+ } catch {}
159
+ return { status: res.status, json, text }
160
+ }
161
+
162
+ // expiresAt is when a job token Qoder gave with expires_in (milliseconds)
163
+ // runs out; a day when it said none.
164
+ const expiresAt = (jt) => Date.now() + (jt.expires_in > 0 ? jt.expires_in : DAY)
165
+
166
+ async function deviceSignIn() {
167
+ const verifier = b64url(randomBytes(64))
168
+ const challenge = b64url(createHash("sha256").update(verifier).digest())
169
+ const nonce = randomUUID()
170
+ const machineId = randomUUID()
171
+ const q = new URLSearchParams({ challenge, challenge_method: "S256", nonce, machine_id: machineId, client_id: CLIENT_ID, redirect_uri: REDIRECT })
172
+ const until = Date.now() + SIGN_IN_TIMEOUT
173
+ return {
174
+ url: `${DEVICE_HOST}/device/selectAccounts?${q}`,
175
+ instructions: "Sign in to Qoder in the browser and authorize this device",
176
+ method: "auto",
177
+ async callback() {
178
+ let dt
179
+ for (;;) {
180
+ if (Date.now() > until) throw new Error("Qoder: the sign-in timed out; start again")
181
+ try {
182
+ const r = await openapi("/api/v1/deviceToken/poll", { query: { nonce, verifier, challenge_method: "S256" } })
183
+ if (r.status === 200 && String(r.json?.token ?? "").trim()) {
184
+ dt = r.json
185
+ break
186
+ }
187
+ } catch {} // a hiccup: ask again
188
+ await new Promise((r) => setTimeout(r, 2000))
189
+ }
190
+ const jr = await openapi("/api/v1/me/jobToken", { method: "POST", token: dt.token, body: { clientId: CLIENT_ID } })
191
+ if (jr.status !== 200) throw new Error(`Qoder job token: status ${jr.status}: ${jr.text.trim().slice(0, 300)}`)
192
+ if (!String(jr.json?.token ?? "").trim()) throw new Error("Qoder job token: empty token in response")
193
+ let email = ""
194
+ let name = ""
195
+ try {
196
+ const ui = await openapi("/api/v1/userinfo", { token: dt.token })
197
+ if (ui.status >= 200 && ui.status < 300) [email, name] = [ui.json?.email ?? "", ui.json?.name ?? ""]
198
+ } catch {}
199
+ return {
200
+ type: "success",
201
+ access: jr.json.token,
202
+ refresh: jr.json.refresh_token ?? "",
203
+ expires: expiresAt(jr.json),
204
+ accountId: email || dt.user_id,
205
+ uid: dt.user_id,
206
+ email,
207
+ name,
208
+ machineId,
209
+ deviceToken: dt.token,
210
+ deviceRefresh: dt.refresh_token ?? "",
211
+ }
212
+ },
213
+ }
214
+ }
215
+
216
+ class SignInGone extends Error {}
217
+
218
+ // refreshJob trades the job token's refresh token for a new pair; the old
219
+ // one is spent.
220
+ async function refreshJob(refresh) {
221
+ if (!String(refresh ?? "").trim()) throw new SignInGone("Qoder: the sign-in lapsed; sign in again")
222
+ const r = await openapi("/api/v1/jobToken/refresh", { method: "POST", body: { refresh_token: refresh } })
223
+ if (r.status === 401 || r.status === 403) throw new SignInGone("Qoder: the sign-in has expired — sign in again")
224
+ if (r.status !== 200) throw new Error(`Qoder job token refresh: status ${r.status}`)
225
+ if (!String(r.json?.token ?? "").trim() || !String(r.json?.refresh_token ?? "").trim())
226
+ throw new Error("Qoder job token refresh: incomplete token pair")
227
+ return r.json
228
+ }
229
+
230
+ // ---- models -------------------------------------------------------------------
231
+
232
+ const EFFORT_ORDER = ["minimal", "low", "medium", "high", "xhigh", "max"]
233
+ const EFF5 = ["low", "medium", "high", "xhigh", "max"]
234
+
235
+ // Qoder's list when the account's can't be asked (as it was on 2026-09-30).
236
+ const MODELS = [
237
+ { id: "ultimate", name: "Ultimate", context: 1_000_000, efforts: EFF5 },
238
+ { id: "performance", name: "Performance", context: 1_000_000, efforts: EFF5 },
239
+ { id: "efficient", name: "Efficient", context: 200_000 },
240
+ { id: "smodel", name: "Sonus", context: 180_000, efforts: EFF5 },
241
+ { id: "cmodel", name: "Cantus", context: 180_000, efforts: EFF5 },
242
+ { id: "qmodel_38max", name: "Qwen3.8-Max", context: 180_000, efforts: ["low", "medium", "xhigh"] },
243
+ { id: "qfmodel", name: "Qwen3.8-Flash", context: 180_000, efforts: ["low", "medium", "xhigh"] },
244
+ { id: "qmodel_latest", name: "Qwen3.7-Max", context: 1_000_000 },
245
+ { id: "qmodel", name: "Qwen3.7-Plus", context: 1_000_000 },
246
+ { id: "kmodel_latest", name: "Kimi-K3", context: 180_000, efforts: ["low", "high", "max"] },
247
+ { id: "kmodel", name: "Kimi-K2.8-Preview", efforts: ["low", "high", "max"] },
248
+ { id: "gmodel", name: "GLM-5.3", context: 180_000, efforts: ["low", "high", "max"] },
249
+ { id: "gfmodel", name: "GLM-5.3-Flash", context: 1_000_000, efforts: ["high", "max"] },
250
+ { id: "dmodel", name: "DeepSeek-V4-Pro", context: 1_000_000, efforts: ["high", "max"] },
251
+ { id: "dfmodel", name: "DeepSeek-Flash", context: 1_000_000, efforts: ["low", "high", "max"] },
252
+ { id: "mmodel", name: "MiniMax-M3", context: 180_000 },
253
+ ].map((m) => ({ images: true, ...m }))
254
+
255
+ // modelInfo reads one entry of the listing's "chat" array: its efforts and
256
+ // default from thinking_config (is_reasoning alone when it has none).
257
+ function modelInfo(raw) {
258
+ const m = {
259
+ key: raw.key,
260
+ source: raw.source ?? "",
261
+ name: raw.display_name || raw.key,
262
+ images: !!raw.is_vl,
263
+ context: raw.max_input_tokens || 0,
264
+ thinks: !!raw.is_reasoning,
265
+ alwaysThinks: false,
266
+ efforts: Array.isArray(raw.reasoning_efforts) ? [...raw.reasoning_efforts] : [],
267
+ defaultEffort: "",
268
+ config: raw,
269
+ }
270
+ const tc = raw.thinking_config
271
+ if (tc && typeof tc === "object") {
272
+ m.thinks = tc.enabled != null
273
+ m.alwaysThinks = m.thinks && tc.disabled == null
274
+ m.efforts = []
275
+ if (m.thinks) {
276
+ for (const [name, e] of Object.entries(tc.enabled.efforts ?? {})) {
277
+ m.efforts.push(name)
278
+ if (e?.is_default) m.defaultEffort = name
279
+ }
280
+ const rank = (s) => (EFFORT_ORDER.includes(s) ? EFFORT_ORDER.indexOf(s) : EFFORT_ORDER.length)
281
+ m.efforts.sort((a, b) => rank(a) - rank(b) || (a < b ? -1 : a > b ? 1 : 0))
282
+ }
283
+ }
284
+ return m
285
+ }
286
+
287
+ // modelInfos is the listing's enabled chat models an agent can pick: "auto"
288
+ // and "default" route inside Qoder and aren't one model.
289
+ function modelInfos(listing) {
290
+ return (listing?.chat ?? [])
291
+ .filter((m) => m?.enable && !["", "auto", "default"].includes(String(m.key ?? "").trim()))
292
+ .map(modelInfo)
293
+ }
294
+
295
+ async function fetchListing(cred) {
296
+ const res = await fetch(MODELS_URL, { headers: cosyHeaders(MODELS_URL, cred, ""), signal: AbortSignal.timeout(15_000) })
297
+ const text = await res.text()
298
+ if (!res.ok) throw new Error(`Qoder models: HTTP ${res.status}: ${text.trim().slice(0, 512)}`)
299
+ return JSON.parse(text)
300
+ }
301
+
302
+ function configModel(m) {
303
+ return {
304
+ name: m.name,
305
+ limit: { context: m.context ?? 0, output: 0 },
306
+ ...(m.images ? { attachment: true, modalities: { input: ["text", "image"], output: ["text"] } } : {}),
307
+ ...(m.efforts?.length ? { reasoning: true, variants: Object.fromEntries(m.efforts.map((e) => [e, { reasoningEffort: e }])) } : {}),
308
+ tool_call: true,
309
+ }
310
+ }
311
+
312
+ function runtimeModel(m) {
313
+ return {
314
+ id: m.id,
315
+ providerID: ID,
316
+ name: m.name ?? m.id,
317
+ api: { id: m.id, url: API, npm: CHAT },
318
+ status: "active",
319
+ headers: {},
320
+ options: {},
321
+ cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
322
+ limit: { context: m.context ?? 0, output: 0 },
323
+ capabilities: {
324
+ temperature: true,
325
+ reasoning: !!m.efforts?.length,
326
+ attachment: !!m.images,
327
+ toolcall: true,
328
+ input: { text: true, image: !!m.images, audio: false, video: false, pdf: false },
329
+ output: { text: true, image: false, audio: false, video: false, pdf: false },
330
+ interleaved: false,
331
+ },
332
+ release_date: "",
333
+ variants: Object.fromEntries((m.efforts ?? []).map((e) => [e, { reasoningEffort: e }])),
334
+ }
335
+ }
336
+
337
+ // ---- the request --------------------------------------------------------------
338
+
339
+ const EFFORT_RANK = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]
340
+
341
+ function fitEffort(want, levels) {
342
+ if (want === "ultra" && !levels.includes(want)) want = "max"
343
+ if (!levels.length || levels.includes(want)) return want
344
+ const at = EFFORT_RANK.indexOf(want)
345
+ if (at < 0) return want
346
+ let best = want
347
+ let dist = EFFORT_RANK.length
348
+ for (const l of levels) {
349
+ const i = EFFORT_RANK.indexOf(l)
350
+ if (i < 0 || l === "none") continue
351
+ const d = Math.abs(i - at)
352
+ if (d < dist || (d === dist && i > at)) [best, dist] = [l, d]
353
+ }
354
+ return best
355
+ }
356
+
357
+ // effortFor is whether to think and at which of the model's efforts ("" for
358
+ // none): asked for none, a model that always thinks thinks at its lowest;
359
+ // asked for nothing, or a level Qoder doesn't name, at its own default.
360
+ function effortFor(asked, m) {
361
+ let want = String(asked ?? "").toLowerCase()
362
+ if (!EFFORT_RANK.includes(want)) want = ""
363
+ if (!m.thinks || (want === "none" && !m.alwaysThinks)) return [false, ""]
364
+ if (!m.efforts.length) return [true, ""]
365
+ if (want === "") return [true, m.defaultEffort]
366
+ if (want === "none") return [true, m.efforts[0]]
367
+ return [true, fitEffort(want, m.efforts)]
368
+ }
369
+
370
+ function imageBlock(p) {
371
+ const url = typeof p.image_url === "string" ? p.image_url : p.image_url?.url
372
+ return url ? { type: "image_url", image_url: { url } } : null
373
+ }
374
+
375
+ function blocksOf(content) {
376
+ if (typeof content === "string") return content ? [{ type: "text", text: content }] : []
377
+ const out = []
378
+ for (const p of content ?? []) {
379
+ if (p?.type === "text" && p.text != null) out.push({ type: "text", text: p.text })
380
+ else if (p?.type === "image_url") {
381
+ const b = imageBlock(p)
382
+ if (b) out.push(b)
383
+ }
384
+ }
385
+ return out
386
+ }
387
+
388
+ const textOf = (c) => (typeof c === "string" ? c : blocksOf(c).filter((b) => b.type === "text").map((b) => b.text).join(""))
389
+
390
+ // qoderMessages is the chat's turns as Qoder takes them: text and image
391
+ // blocks, an assistant's tool calls as tool_calls, results as tool turns by
392
+ // tool_call_id. A tool turn holds text only, so the images a tool returned
393
+ // follow in a user turn — the start of the user's own when one comes next.
394
+ function qoderMessages(msgs) {
395
+ const out = []
396
+ const names = {}
397
+ let seen = []
398
+ const showSeen = () => {
399
+ if (seen.length) out.push({ role: "user", content: seen })
400
+ seen = []
401
+ }
402
+ for (const m of msgs) {
403
+ if (m.role === "system" || m.role === "developer") continue
404
+ if (m.role !== "user" && m.role !== "tool") showSeen()
405
+ if (m.role === "tool") {
406
+ let txt = textOf(m.content)
407
+ const ims = Array.isArray(m.content) ? m.content.filter((p) => p?.type === "image_url").map(imageBlock).filter(Boolean) : []
408
+ if (ims.length) {
409
+ let of = "tool call " + m.tool_call_id
410
+ const name = names[m.tool_call_id] || m.name
411
+ if (name) of = `${name} (${of})`
412
+ seen.push({ type: "text", text: `[From the result of ${of}:]` }, ...ims)
413
+ const note = ims.length === 1 ? "[The tool returned an image; it follows in the next message.]" : `[The tool returned ${ims.length} images; they follow in the next message.]`
414
+ txt = txt.trim() ? txt + "\n\n" + note : txt + note
415
+ }
416
+ out.push({ role: "tool", tool_call_id: m.tool_call_id, content: txt })
417
+ continue
418
+ }
419
+ if (m.role === "assistant" && m.tool_calls?.length) {
420
+ const calls = m.tool_calls.map((c) => {
421
+ const id = c.id || "call_" + hexID()
422
+ names[id] = c.function?.name
423
+ const args = c.function?.arguments
424
+ return { id, type: "function", function: { name: c.function?.name, arguments: typeof args === "string" ? args : JSON.stringify(args ?? {}) } }
425
+ })
426
+ out.push({ role: "assistant", content: textOf(m.content), tool_calls: calls })
427
+ continue
428
+ }
429
+ let blocks = blocksOf(m.content)
430
+ if (m.role === "assistant") blocks = blocks.filter((b) => b.type === "text")
431
+ if (!blocks.length) continue
432
+ if (m.role === "user" && seen.length) [blocks, seen] = [[...seen, ...blocks], []]
433
+ out.push({ role: m.role, content: blocks })
434
+ }
435
+ showSeen()
436
+ return out
437
+ }
438
+
439
+ // qoderBody is the plaintext agent_chat_generation takes for a chat
440
+ // completion request, for model m.
441
+ function qoderBody(chat, m) {
442
+ const system = (chat.messages ?? []).filter((x) => x.role === "system" || x.role === "developer").map((x) => textOf(x.content)).filter(Boolean).join("\n\n")
443
+ const tools = chat.tool_choice !== "none" ? (chat.tools ?? []).filter((t) => t?.type === "function" && t.function?.name) : []
444
+ let sysText = system ? QODER_SYS + "\n\n" + system : QODER_SYS
445
+ if (chat.tool_choice === "required" && tools.length) sysText += "\nYou must call an available function in this response."
446
+ const sys = { type: "text", text: sysText }
447
+ const [thinking, effort] = effortFor(chat.reasoning_effort, m)
448
+ const params = { enable_thinking: thinking, max_tokens: chat.max_completion_tokens || chat.max_tokens || 32000 }
449
+ if (effort) params.reasoning_effort = effort
450
+ if (m.context > 0) params.context_length = m.context
451
+ const body = {
452
+ parameters: params,
453
+ business: { product: "app", version: COSY_VERSION, type: "agent", id: hexID(), name: "magpie session", begin_at: Date.now(), stage: "start" },
454
+ agent_id: "agent_common",
455
+ task_id: "common",
456
+ session_type: "app",
457
+ model_config: m.config,
458
+ system: [sys],
459
+ messages: [{ role: "system", content: [sys] }, ...qoderMessages(chat.messages ?? [])],
460
+ }
461
+ if (tools.length)
462
+ body.tools = tools.map((t) => ({
463
+ type: "function",
464
+ function: { name: t.function.name, description: t.function.description ?? "", parameters: t.function.parameters ?? { type: "object", properties: {} } },
465
+ }))
466
+ return body
467
+ }
468
+
469
+ // failure is the status and message for a Qoder failure: a refused sign-in
470
+ // asks for another, a quota is a 429.
471
+ function failure(status, text) {
472
+ if (status < 400 || status > 599) status = 502
473
+ let msg = String(text ?? "").trim()
474
+ try {
475
+ const j = JSON.parse(text)
476
+ if (j?.message) msg = j.message
477
+ const d = typeof j?.details === "string" ? JSON.parse(j.details) : null
478
+ if (d?.error?.message) msg += ": " + d.error.message
479
+ } catch {}
480
+ msg ||= `HTTP ${status}`
481
+ if (status === 401 || status === 403) return { status: 401, message: "Qoder: the sign-in lapsed — sign in again" }
482
+ if (status === 429 || msg.toLowerCase().includes("quota")) return { status: 429, message: "usage limit reached: " + msg }
483
+ return { status, message: "Qoder: " + msg }
484
+ }
485
+
486
+ const errorResponse = ({ status, message }) =>
487
+ new Response(JSON.stringify({ error: { message, type: "qoder_error", code: status } }), { status, headers: { "Content-Type": "application/json" } })
488
+
489
+ // ---- the reply ----------------------------------------------------------------
490
+
491
+ const CALL_OPEN = "\x3ctool_call\x3e"
492
+ const CALL_CLOSE = "\x3c/tool_call\x3e"
493
+ const FUNC = /\x3cfunction=([^>]+)\x3e([\s\S]*?)\x3c\/function\x3e/
494
+ const PARAM = /\x3cparameter=([^>]+)\x3e([\s\S]*?)\x3c\/parameter\x3e/g
495
+
496
+ // parseCall reads one tool-call block: JSON {name, arguments}, or the XML
497
+ // function/parameter form.
498
+ function parseCall(v) {
499
+ try {
500
+ const f = JSON.parse(v.trim())
501
+ if (String(f?.name ?? "").trim() && f.arguments && typeof f.arguments === "object" && !Array.isArray(f.arguments))
502
+ return { name: f.name.trim(), args: JSON.stringify(f.arguments) }
503
+ } catch {}
504
+ const m = FUNC.exec(v)
505
+ if (!m || !m[1].trim()) return null
506
+ const args = {}
507
+ for (const pm of m[2].matchAll(PARAM)) {
508
+ const key = pm[1].trim()
509
+ if (!key) continue
510
+ const val = pm[2].trim()
511
+ try {
512
+ args[key] = JSON.parse(val)
513
+ } catch {
514
+ args[key] = val
515
+ }
516
+ }
517
+ return { name: m[1].trim(), args: JSON.stringify(args) }
518
+ }
519
+
520
+ // Splitter holds text until a whole tool-call block has come, then gives
521
+ // it as a call.
522
+ class Splitter {
523
+ textBuf = ""
524
+ callBuf = ""
525
+ inCall = false
526
+ sawTool = false
527
+ feed(input) {
528
+ const out = []
529
+ while (input) {
530
+ if (!this.inCall) {
531
+ const comb = this.textBuf + input
532
+ this.textBuf = ""
533
+ const i = comb.indexOf(CALL_OPEN)
534
+ if (i < 0) {
535
+ let keep = 0
536
+ for (let n = CALL_OPEN.length - 1; n > 0; n--)
537
+ if (comb.endsWith(CALL_OPEN.slice(0, n))) {
538
+ keep = n
539
+ break
540
+ }
541
+ if (keep < comb.length) out.push({ text: comb.slice(0, comb.length - keep) })
542
+ this.textBuf = comb.slice(comb.length - keep)
543
+ break
544
+ }
545
+ if (i > 0) out.push({ text: comb.slice(0, i) })
546
+ input = comb.slice(i + CALL_OPEN.length)
547
+ this.inCall = true
548
+ continue
549
+ }
550
+ const comb = this.callBuf + input
551
+ this.callBuf = ""
552
+ const j = comb.indexOf(CALL_CLOSE)
553
+ if (j < 0) {
554
+ this.callBuf = comb
555
+ break
556
+ }
557
+ const c = parseCall(comb.slice(0, j))
558
+ if (c) {
559
+ this.sawTool = true
560
+ out.push({ call: c })
561
+ } else out.push({ text: CALL_OPEN + comb.slice(0, j) + CALL_CLOSE })
562
+ input = comb.slice(j + CALL_CLOSE.length)
563
+ this.inCall = false
564
+ }
565
+ return out
566
+ }
567
+ flush() {
568
+ if (this.inCall) {
569
+ const t = CALL_OPEN + this.callBuf
570
+ this.inCall = false
571
+ this.callBuf = ""
572
+ return [{ text: t }]
573
+ }
574
+ if (this.textBuf) {
575
+ const t = this.textBuf
576
+ this.textBuf = ""
577
+ return [{ text: t }]
578
+ }
579
+ return []
580
+ }
581
+ }
582
+
583
+ async function* sseLines(body) {
584
+ const dec = new TextDecoder()
585
+ let buf = ""
586
+ for await (const chunk of body) {
587
+ buf += dec.decode(chunk, { stream: true })
588
+ let n
589
+ while ((n = buf.indexOf("\n")) >= 0) {
590
+ yield buf.slice(0, n).trim()
591
+ buf = buf.slice(n + 1)
592
+ }
593
+ }
594
+ if (buf.trim()) yield buf.trim()
595
+ }
596
+
597
+ // events turns Qoder's SSE into chat completion pieces: each data line's
598
+ // envelope has a "body" that is an OpenAI chunk, whose content may hold
599
+ // tool calls to lift out.
600
+ async function* events(body) {
601
+ const a = new Splitter()
602
+ let index = -1 // the tool call being given
603
+ let native = -1 // the native tool call being assembled
604
+ const flushed = function* () {
605
+ for (const f of a.flush()) if (f.text) yield { text: f.text }
606
+ }
607
+ let usage
608
+ let fr = ""
609
+ for await (const line of sseLines(body)) {
610
+ if (!line.startsWith("data:")) continue
611
+ const payload = line.slice(5).trim()
612
+ let env
613
+ try {
614
+ env = JSON.parse(payload)
615
+ } catch {
616
+ continue
617
+ }
618
+ const inner = typeof env?.body === "string" ? env.body : ""
619
+ if (env?.statusCodeValue != null && env.statusCodeValue !== 200) {
620
+ yield { error: failure(Number(env.statusCodeValue), inner || payload) }
621
+ return
622
+ }
623
+ let chunk
624
+ try {
625
+ chunk = JSON.parse(inner)
626
+ } catch {
627
+ continue
628
+ }
629
+ const delta = chunk?.choices?.[0]?.delta ?? {}
630
+ if (delta.reasoning_content) yield { reasoning: delta.reasoning_content }
631
+ if (typeof delta.content === "string")
632
+ for (const f of a.feed(delta.content)) {
633
+ if (f.call) yield { tool: { index: ++index, id: "call_" + hexID(), name: f.call.name, args: f.call.args } }
634
+ else if (f.text) yield { text: f.text }
635
+ }
636
+ // tool calls served the OpenAI way keep Qoder's id: a result answers it
637
+ for (const tc of delta.tool_calls ?? []) {
638
+ const i = Number(tc.index ?? 0)
639
+ if (i !== native) {
640
+ native = i
641
+ yield* flushed()
642
+ a.sawTool = true
643
+ yield { tool: { index: ++index, id: tc.id || "call_" + hexID(), name: tc.function?.name ?? "", args: tc.function?.arguments ?? "" } }
644
+ } else if (tc.function?.arguments) yield { args: { index, text: tc.function.arguments } }
645
+ }
646
+ fr = chunk?.choices?.[0]?.finish_reason ?? ""
647
+ if (fr) {
648
+ const u = chunk.usage
649
+ if (u) usage = { prompt_tokens: u.prompt_tokens ?? 0, completion_tokens: u.completion_tokens ?? 0, total_tokens: (u.prompt_tokens ?? 0) + (u.completion_tokens ?? 0) }
650
+ break
651
+ }
652
+ }
653
+ yield* flushed()
654
+ yield { stop: a.sawTool ? "tool_calls" : fr === "length" ? "length" : "stop", usage }
655
+ }
656
+
657
+ // answer turns Qoder's reply into the chat completion OpenAI's API gives.
658
+ // A failure before the answer keeps its status; one after it ends the
659
+ // stream with an error.
660
+ async function answer(res, chat) {
661
+ const id = "chatcmpl-" + hexID()
662
+ const created = Math.floor(Date.now() / 1000)
663
+ const it = events(res.body)
664
+ const first = await it.next()
665
+ if (first.value?.error) return errorResponse(first.value.error)
666
+ if (first.done) return errorResponse({ status: 502, message: "Qoder ended without an answer" })
667
+
668
+ if (!chat.stream) {
669
+ const msg = { role: "assistant", content: "" }
670
+ let reasoning = ""
671
+ const calls = []
672
+ let stop = "stop"
673
+ let usage
674
+ for (let r = first; !r.done; r = await it.next()) {
675
+ const e = r.value
676
+ if (e.error) return errorResponse(e.error)
677
+ if (e.text) msg.content += e.text
678
+ if (e.reasoning) reasoning += e.reasoning
679
+ if (e.tool) calls.push({ id: e.tool.id, type: "function", function: { name: e.tool.name, arguments: e.tool.args } })
680
+ if (e.args) calls[calls.length - 1].function.arguments += e.args.text
681
+ if (e.stop) [stop, usage] = [e.stop, e.usage]
682
+ }
683
+ if (reasoning) msg.reasoning_content = reasoning
684
+ if (calls.length) msg.tool_calls = calls
685
+ return Response.json({ id, object: "chat.completion", created, model: chat.model, choices: [{ index: 0, message: msg, finish_reason: stop }], ...(usage ? { usage } : {}) })
686
+ }
687
+
688
+ const enc = new TextEncoder()
689
+ const chunk = (delta, finish_reason = null, extra = {}) =>
690
+ enc.encode(`data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model: chat.model, choices: [{ index: 0, delta, finish_reason }], ...extra })}\n\n`)
691
+ let pending = first
692
+ let began = false
693
+ const stream = new ReadableStream({
694
+ async pull(ctl) {
695
+ const r = pending ?? (await it.next())
696
+ pending = null
697
+ if (!began) {
698
+ began = true
699
+ ctl.enqueue(chunk({ role: "assistant", content: "" }))
700
+ }
701
+ if (r.done) {
702
+ ctl.enqueue(enc.encode("data: [DONE]\n\n"))
703
+ return ctl.close()
704
+ }
705
+ const e = r.value
706
+ if (e.text) ctl.enqueue(chunk({ content: e.text }))
707
+ else if (e.reasoning) ctl.enqueue(chunk({ reasoning_content: e.reasoning }))
708
+ else if (e.tool) ctl.enqueue(chunk({ tool_calls: [{ index: e.tool.index, id: e.tool.id, type: "function", function: { name: e.tool.name, arguments: e.tool.args } }] }))
709
+ else if (e.args) ctl.enqueue(chunk({ tool_calls: [{ index: e.args.index, function: { arguments: e.args.text } }] }))
710
+ else if (e.stop) ctl.enqueue(chunk({}, e.stop, e.usage ? { usage: e.usage } : {}))
711
+ else if (e.error) {
712
+ ctl.enqueue(enc.encode(`data: ${JSON.stringify({ error: { message: e.error.message, code: e.error.status } })}\n\n`))
713
+ ctl.close()
714
+ }
715
+ },
716
+ cancel() {
717
+ it.return?.()
718
+ },
719
+ })
720
+ return new Response(stream, { status: 200, headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" } })
721
+ }
722
+
723
+ // ---- the plugin ---------------------------------------------------------------
724
+
725
+ async function bodyText(input, init) {
726
+ const b = init.body ?? (input instanceof Request ? await input.clone().text() : undefined)
727
+ return typeof b === "string" ? b : new TextDecoder().decode(b)
728
+ }
729
+
730
+ export async function QoderAuthPlugin({ client }) {
731
+ // serializes checking, rotating and saving tokens: a refresh token is
732
+ // spent once, so two refreshes would spend it twice
733
+ let lock = Promise.resolve()
734
+ const locked = (fn) => {
735
+ const run = lock.then(fn, fn)
736
+ lock = run.catch(() => {})
737
+ return run
738
+ }
739
+ // each account's listing, the model configs a request carries
740
+ const listings = new Map()
741
+
742
+ // fresh is the account with a live job token, refreshed and saved near
743
+ // its end.
744
+ const fresh = (getAuth) =>
745
+ locked(async () => {
746
+ const a = await getAuth()
747
+ if (a?.type !== "oauth" || !a.access || !a.uid) throw new SignInGone("Qoder: not signed in")
748
+ if (a.expires - Date.now() > REFRESH_LEAD) return a
749
+ const jt = await refreshJob(a.refresh)
750
+ const next = { ...a, access: jt.token, refresh: jt.refresh_token, expires: expiresAt(jt) }
751
+ await client.auth.set({ path: { id: ID }, body: next })
752
+ return next
753
+ })
754
+
755
+ const models = async (cred, again = false) => {
756
+ let l = listings.get(cred.uid)
757
+ if (!l || again) {
758
+ l = modelInfos(await fetchListing(cred))
759
+ listings.set(cred.uid, l)
760
+ }
761
+ return l
762
+ }
763
+
764
+ const signedInError = (e) =>
765
+ errorResponse(e instanceof SignInGone ? { status: 401, message: e.message } : { status: 502, message: "Qoder: " + (e?.message ?? e) })
766
+
767
+ return {
768
+ auth: {
769
+ provider: ID,
770
+ async loader(getAuth) {
771
+ const a = await getAuth()
772
+ if (a?.type !== "oauth") return {}
773
+ return {
774
+ baseURL: API,
775
+ apiKey: "qoder",
776
+ // every chat completion written as Qoder's own request
777
+ async fetch(input, init = {}) {
778
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
779
+ if (!/\/chat\/completions$/.test(new URL(url).pathname))
780
+ return errorResponse({ status: 404, message: "Qoder: only chat completions are served" })
781
+ let chat
782
+ try {
783
+ chat = JSON.parse(await bodyText(input, init))
784
+ } catch {
785
+ return errorResponse({ status: 400, message: "Qoder: a request that isn't JSON" })
786
+ }
787
+ let cred
788
+ try {
789
+ cred = await fresh(getAuth)
790
+ } catch (e) {
791
+ return signedInError(e)
792
+ }
793
+ let m
794
+ try {
795
+ m = (await models(cred)).find((x) => x.key === chat.model) ?? (await models(cred, true)).find((x) => x.key === chat.model)
796
+ } catch (e) {
797
+ return errorResponse({ status: 502, message: e.message })
798
+ }
799
+ if (!m) return errorResponse({ status: 400, message: `Qoder: unknown or disabled model "${chat.model}"` })
800
+ const wire = encodeBody(JSON.stringify(qoderBody(chat, m)))
801
+ const headers = {
802
+ ...cosyHeaders(CHAT_URL, cred, wire),
803
+ Accept: "text/event-stream",
804
+ "Cache-Control": "no-cache",
805
+ "X-Model-Key": m.key,
806
+ "X-Model-Source": m.source,
807
+ }
808
+ let res
809
+ try {
810
+ res = await fetch(CHAT_URL, { method: "POST", headers, body: wire, signal: init.signal })
811
+ } catch (e) {
812
+ return errorResponse({ status: 502, message: "Qoder: " + e.message })
813
+ }
814
+ if (!res.ok) return errorResponse(failure(res.status, (await res.text()).slice(0, 1 << 20)))
815
+ return answer(res, chat)
816
+ },
817
+ }
818
+ },
819
+ methods: [{ type: "oauth", label: "Sign in with Qoder", authorize: deviceSignIn }],
820
+ },
821
+ async config(config) {
822
+ config.provider ??= {}
823
+ const was = config.provider[ID] ?? {}
824
+ config.provider[ID] = {
825
+ name: "Qoder",
826
+ npm: CHAT,
827
+ api: API,
828
+ ...was,
829
+ models: { ...Object.fromEntries(MODELS.map((m) => [m.id, configModel(m)])), ...(was.models ?? {}) },
830
+ }
831
+ },
832
+ // the account's own list, as Qoder's client asks it
833
+ provider: {
834
+ id: ID,
835
+ async models(provider, { auth } = {}) {
836
+ if (auth?.type !== "oauth") return provider.models
837
+ try {
838
+ const cred = await fresh(async () => auth)
839
+ const ms = await models(cred, true)
840
+ if (!ms.length) return provider.models
841
+ return Object.fromEntries(
842
+ ms.map((m) => [m.key, runtimeModel({ id: m.key, name: m.name, context: m.context, images: m.images, efforts: m.thinks ? m.efforts : [] })]),
843
+ )
844
+ } catch {
845
+ return provider.models
846
+ }
847
+ },
848
+ },
849
+ }
850
+ }
package/package.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "@magpie-community/opencode-qoder-auth",
3
+ "version": "0.1.0",
4
+ "description": "Qoder subscription in OpenCode and magpie",
5
+ "type": "module",
6
+ "main": "./index.mjs",
7
+ "exports": "./index.mjs",
8
+ "files": ["index.mjs", "README.md", "LICENSE-CLIProxyAPI"],
9
+ "keywords": ["opencode", "opencode-plugin", "magpie", "qoder"],
10
+ "license": "MIT"
11
+ }