@magpie-community/opencode-qoder-auth 0.1.4 → 0.2.2
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 +50 -1
- package/index.mjs +187 -67
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
# @magpie-community/opencode-qoder-auth
|
|
2
2
|
|
|
3
3
|
Your [Qoder](https://qoder.com) subscription in OpenCode and
|
|
4
|
-
[magpie](https://usemagpie.ai)
|
|
4
|
+
[magpie](https://usemagpie.ai). The package serves Qoder's two sites, whose
|
|
5
|
+
accounts exist only on their own:
|
|
6
|
+
|
|
7
|
+
| Provider id | Site | Accounts | Plugin export |
|
|
8
|
+
|---|---|---|---|
|
|
9
|
+
| `qoder` | qoder.com (international) | Google, GitHub or email | `QoderAuthPlugin` |
|
|
10
|
+
| `qoder-cn` | qoder.cn ([Qoder CN](#qoder-cn)) | Alibaba Cloud or phone number | `QoderCNAuthPlugin` |
|
|
11
|
+
|
|
12
|
+
What follows is about `qoder`. Qoder CN works the same way on its own
|
|
13
|
+
hosts; [its section](#qoder-cn) says what differs.
|
|
5
14
|
|
|
6
15
|
## Sign-in
|
|
7
16
|
|
|
@@ -83,6 +92,46 @@ The `config` hook declares the list as it was on 2026-09-30:
|
|
|
83
92
|
|
|
84
93
|
Every model speaks chat completions (`@ai-sdk/openai-compatible`).
|
|
85
94
|
|
|
95
|
+
## Qoder CN
|
|
96
|
+
|
|
97
|
+
`qoder-cn` is for accounts on [qoder.cn](https://qoder.cn), the China site.
|
|
98
|
+
Those accounts sign in with Alibaba Cloud or a phone number and can't sign
|
|
99
|
+
in through `qoder`. Qoder CN speaks the same protocol as qoder.com:
|
|
100
|
+
|
|
101
|
+
- the same device flow, poll, job token and refresh paths;
|
|
102
|
+
- the same COSY envelope and body codec;
|
|
103
|
+
- the same model list, chat and usage paths.
|
|
104
|
+
|
|
105
|
+
Only the hosts and the sign-in's client id differ. The plugin uses the ones
|
|
106
|
+
Qoder CN's own CLI builds in for its `cn` build (`@qodercn-ai/qoderclicn`
|
|
107
|
+
1.1.65):
|
|
108
|
+
|
|
109
|
+
| | `qoder` | `qoder-cn` |
|
|
110
|
+
|---|---|---|
|
|
111
|
+
| Sign-in page | qoder.com | qoder.cn |
|
|
112
|
+
| Accounts: poll, tokens, user info, usage | openapi.qoder.sh | openapi.qoder.com.cn |
|
|
113
|
+
| Models and chat | api3.qoder.sh | gateway.qoder.com.cn |
|
|
114
|
+
| Device-flow client id | Qoder's desktop client's | Qoder CN's CLI's (production) |
|
|
115
|
+
| `redirect_uri` on the sign-in page | `qoder-app://` | none, as the CLI sends |
|
|
116
|
+
|
|
117
|
+
Qoder CN's CLI chats with the device token itself and never makes a job
|
|
118
|
+
token. The plugin asks qoder.cn for a job token first, as it does on
|
|
119
|
+
qoder.com. If qoder.cn refuses it (a 4xx), the account works the way the
|
|
120
|
+
CLI does:
|
|
121
|
+
|
|
122
|
+
- the device token signs the model calls;
|
|
123
|
+
- it is renewed with `/api/v1/deviceToken/refresh`, the chat and account
|
|
124
|
+
tokens being one pair;
|
|
125
|
+
- a refused renewal (401 or 403) means signing in again.
|
|
126
|
+
|
|
127
|
+
Until you sign in, the `config` hook declares the tiers Qoder CN's CLI names
|
|
128
|
+
(Ultimate, Performance, Efficient, Lite). Once you are signed in, the
|
|
129
|
+
account's own list from gateway.qoder.com.cn replaces them.
|
|
130
|
+
|
|
131
|
+
Neither magpie's built-in Qoder CN nor this plugin has been checked against
|
|
132
|
+
every kind of qoder.cn account. If a sign-in or a chat fails, please open
|
|
133
|
+
an issue with the error.
|
|
134
|
+
|
|
86
135
|
## Not here
|
|
87
136
|
|
|
88
137
|
- Qoder's usage and quota display.
|
package/index.mjs
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
// Qoder's
|
|
1
|
+
// Qoder's subscriptions as OpenCode provider plugins, one for each of its
|
|
2
|
+
// sites, whose accounts exist only on their own:
|
|
3
|
+
// - qoder: the international site, qoder.com
|
|
4
|
+
// - qoder-cn: Qoder CN, qoder.cn (Alibaba Cloud or phone sign-in)
|
|
2
5
|
//
|
|
3
6
|
// Qoder is signed in to the way its desktop client is: a PKCE device flow
|
|
4
7
|
// (qoder.com's page, then a poll of openapi.qoder.sh for the device token),
|
|
@@ -15,17 +18,18 @@
|
|
|
15
18
|
// The protocol (endpoints, COSY envelope, body codec, device flow) is ported
|
|
16
19
|
// from magpie's internal/qoder, which ported it from CLIProxyAPI's qoder
|
|
17
20
|
// support (https://github.com/ufec/CLIProxyAPI, MIT).
|
|
21
|
+
//
|
|
22
|
+
// Qoder CN speaks the same protocol on its own hosts, as Qoder CN's CLI
|
|
23
|
+
// (@qodercn-ai/qoderclicn 1.1.65) builds them in for its "cn" build: the
|
|
24
|
+
// sign-in page on qoder.cn, accounts on openapi.qoder.com.cn and models on
|
|
25
|
+
// gateway.qoder.com.cn, with the device-flow client id that CLI sends in
|
|
26
|
+
// production and no redirect_uri. That CLI chats on the device token itself;
|
|
27
|
+
// if qoder.cn won't trade it for a job token, the account does the same.
|
|
18
28
|
import { createCipheriv, createHash, publicEncrypt, randomBytes, randomUUID, constants } from "node:crypto"
|
|
19
29
|
import { STATUS_CODES } from "node:http"
|
|
20
30
|
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
const DEVICE_HOST = "https://qoder.com"
|
|
24
|
-
const OPENAPI = "https://openapi.qoder.sh"
|
|
25
|
-
const API = "https://api3.qoder.sh"
|
|
26
|
-
const REDIRECT = "qoder-app://"
|
|
27
|
-
const CHAT_URL = API + "/algo/api/v2/service/pro/sse/agent_chat_generation?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1"
|
|
28
|
-
const MODELS_URL = API + "/algo/api/v2/model/list?Encode=1"
|
|
31
|
+
const CHAT_PATH = "/algo/api/v2/service/pro/sse/agent_chat_generation?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1"
|
|
32
|
+
const MODELS_PATH = "/algo/api/v2/model/list?Encode=1"
|
|
29
33
|
const COSY_VERSION = "1.1.49"
|
|
30
34
|
const SIGN_IN_TIMEOUT = 15 * 60 * 1000
|
|
31
35
|
const REFRESH_LEAD = 5 * 60 * 1000
|
|
@@ -145,8 +149,8 @@ function decodeBody(wire) {
|
|
|
145
149
|
|
|
146
150
|
const b64url = (buf) => buf.toString("base64url")
|
|
147
151
|
|
|
148
|
-
async function openapi(path, { method = "GET", token, body, query } = {}) {
|
|
149
|
-
const url = new URL(
|
|
152
|
+
async function openapi(site, path, { method = "GET", token, body, query } = {}) {
|
|
153
|
+
const url = new URL(site.openapi + path)
|
|
150
154
|
for (const [k, v] of Object.entries(query ?? {})) url.searchParams.set(k, v)
|
|
151
155
|
const headers = { Accept: "application/json" }
|
|
152
156
|
if (body) headers["Content-Type"] = "application/json"
|
|
@@ -164,23 +168,34 @@ async function openapi(path, { method = "GET", token, body, query } = {}) {
|
|
|
164
168
|
// runs out; a day when it said none.
|
|
165
169
|
const expiresAt = (jt) => Date.now() + (jt.expires_in > 0 ? jt.expires_in : DAY)
|
|
166
170
|
|
|
167
|
-
|
|
171
|
+
// deviceExpiry is when a device token used for chat runs out: its
|
|
172
|
+
// expires_at (a date) or expires_in (seconds), as Qoder's CLI reads it; a
|
|
173
|
+
// day when it said neither.
|
|
174
|
+
function deviceExpiry(dt) {
|
|
175
|
+
const at = Date.parse(String(dt?.expires_at ?? "").trim())
|
|
176
|
+
if (!isNaN(at)) return at
|
|
177
|
+
if (dt?.expires_in > 0) return Date.now() + dt.expires_in * 1000
|
|
178
|
+
return Date.now() + DAY
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function deviceSignIn(site) {
|
|
168
182
|
const verifier = b64url(randomBytes(64))
|
|
169
183
|
const challenge = b64url(createHash("sha256").update(verifier).digest())
|
|
170
184
|
const nonce = randomUUID()
|
|
171
185
|
const machineId = randomUUID()
|
|
172
|
-
const q = new URLSearchParams({ challenge, challenge_method: "S256", nonce, machine_id: machineId, client_id:
|
|
186
|
+
const q = new URLSearchParams({ challenge, challenge_method: "S256", nonce, machine_id: machineId, client_id: site.clientId })
|
|
187
|
+
if (site.redirect) q.set("redirect_uri", site.redirect)
|
|
173
188
|
const until = Date.now() + SIGN_IN_TIMEOUT
|
|
174
189
|
return {
|
|
175
|
-
url: `${
|
|
176
|
-
instructions:
|
|
190
|
+
url: `${site.web}/device/selectAccounts?${q}`,
|
|
191
|
+
instructions: `Sign in to ${site.name} in the browser and authorize this device`,
|
|
177
192
|
method: "auto",
|
|
178
193
|
async callback() {
|
|
179
194
|
let dt
|
|
180
195
|
for (;;) {
|
|
181
|
-
if (Date.now() > until) throw new Error(
|
|
196
|
+
if (Date.now() > until) throw new Error(`${site.name}: the sign-in timed out; start again`)
|
|
182
197
|
try {
|
|
183
|
-
const r = await openapi("/api/v1/deviceToken/poll", { query: { nonce, verifier, challenge_method: "S256" } })
|
|
198
|
+
const r = await openapi(site, "/api/v1/deviceToken/poll", { query: { nonce, verifier, challenge_method: "S256" } })
|
|
184
199
|
if (r.status === 200 && String(r.json?.token ?? "").trim()) {
|
|
185
200
|
dt = r.json
|
|
186
201
|
break
|
|
@@ -188,24 +203,29 @@ async function deviceSignIn() {
|
|
|
188
203
|
} catch {} // a hiccup: ask again
|
|
189
204
|
await new Promise((r) => setTimeout(r, 2000))
|
|
190
205
|
}
|
|
191
|
-
const jr = await openapi("/api/v1/me/jobToken", { method: "POST", token: dt.token, body: { clientId:
|
|
192
|
-
|
|
193
|
-
if (
|
|
206
|
+
const jr = await openapi(site, "/api/v1/me/jobToken", { method: "POST", token: dt.token, body: { clientId: site.clientId } })
|
|
207
|
+
let chat
|
|
208
|
+
if (jr.status === 200) {
|
|
209
|
+
if (!String(jr.json?.token ?? "").trim()) throw new Error(`${site.name} job token: empty token in response`)
|
|
210
|
+
chat = { access: jr.json.token, refresh: jr.json.refresh_token ?? "", expires: expiresAt(jr.json) }
|
|
211
|
+
} else if (site.deviceChat && jr.status >= 400 && jr.status < 500) {
|
|
212
|
+
// Qoder CN's CLI never makes a job token: its device token is what
|
|
213
|
+
// signs the model calls, renewed as a device token
|
|
214
|
+
chat = { access: dt.token, refresh: dt.refresh_token ?? "", expires: deviceExpiry(dt), deviceChat: true }
|
|
215
|
+
} else throw new Error(`${site.name} job token: status ${jr.status}: ${jr.text.trim().slice(0, 300)}`)
|
|
194
216
|
let email = ""
|
|
195
217
|
let name = ""
|
|
196
218
|
try {
|
|
197
|
-
const ui = await openapi("/api/v1/userinfo", { token: dt.token })
|
|
219
|
+
const ui = await openapi(site, "/api/v1/userinfo", { token: dt.token })
|
|
198
220
|
if (ui.status >= 200 && ui.status < 300) [email, name] = [ui.json?.email ?? "", ui.json?.name ?? ""]
|
|
199
221
|
} catch {}
|
|
200
222
|
return {
|
|
201
223
|
type: "success",
|
|
202
|
-
|
|
203
|
-
refresh: jr.json.refresh_token ?? "",
|
|
204
|
-
expires: expiresAt(jr.json),
|
|
224
|
+
...chat,
|
|
205
225
|
accountId: email || dt.user_id,
|
|
206
226
|
uid: dt.user_id,
|
|
207
227
|
email,
|
|
208
|
-
name,
|
|
228
|
+
name: name || dt.user_name || "",
|
|
209
229
|
machineId,
|
|
210
230
|
deviceToken: dt.token,
|
|
211
231
|
deviceRefresh: dt.refresh_token ?? "",
|
|
@@ -227,11 +247,11 @@ class SignInGone extends Error {
|
|
|
227
247
|
|
|
228
248
|
// refreshJob trades the job token's refresh token for a new pair; the old
|
|
229
249
|
// one is spent.
|
|
230
|
-
async function refreshJob(refresh, user) {
|
|
231
|
-
if (!String(refresh ?? "").trim()) throw new SignInGone(
|
|
232
|
-
const r = await openapi("/api/v1/jobToken/refresh", { method: "POST", body: { refresh_token: refresh } })
|
|
250
|
+
async function refreshJob(site, refresh, user) {
|
|
251
|
+
if (!String(refresh ?? "").trim()) throw new SignInGone(`${site.name}: the sign-in lapsed; sign in again`)
|
|
252
|
+
const r = await openapi(site, "/api/v1/jobToken/refresh", { method: "POST", body: { refresh_token: refresh } })
|
|
233
253
|
if (r.status === 401 || r.status === 403)
|
|
234
|
-
throw new SignInGone(`${user}'s
|
|
254
|
+
throw new SignInGone(`${user}'s ${site.name} sign-in has expired — sign in again (qoder job token refresh: status ${r.status})`, true)
|
|
235
255
|
if (r.status !== 200) throw new Error(`qoder job token refresh: status ${r.status}`)
|
|
236
256
|
if (!String(r.json?.token ?? "").trim() || !String(r.json?.refresh_token ?? "").trim())
|
|
237
257
|
throw new Error("qoder job token refresh: incomplete token pair")
|
|
@@ -263,13 +283,59 @@ const MODELS = [
|
|
|
263
283
|
{ id: "mmodel", name: "MiniMax-M3", context: 180_000 },
|
|
264
284
|
].map((m) => ({ images: true, ...m }))
|
|
265
285
|
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
286
|
+
// Qoder CN's list when the account's can't be asked: the tiers Qoder CN's
|
|
287
|
+
// CLI names (auto routes inside Qoder and isn't one model). The account's
|
|
288
|
+
// own list, read once signed in, takes over from it.
|
|
289
|
+
const CN_MODELS = [
|
|
290
|
+
...MODELS.filter((m) => ["ultimate", "performance", "efficient"].includes(m.id)),
|
|
291
|
+
{ id: "lite", name: "Lite" },
|
|
292
|
+
]
|
|
293
|
+
|
|
294
|
+
// freeOf reads whether a model costs the plan no credits: a price_factor
|
|
295
|
+
// (the credits a request costs, as a multiple; priceFactor in camel case)
|
|
296
|
+
// of 0, the price Qoder's own client shows ("0×"). is_free is no word on
|
|
297
|
+
// that: Qoder's listing has it true on Qwen3.8-Max at 0.5×, an off-peak
|
|
298
|
+
// discount (错峰 4 折) on it, and Qoder's client shows the price, not it.
|
|
299
|
+
// It is taken only from a listing with no price at all. A price of 0 for
|
|
300
|
+
// the while an active promotion lasts, with a price before it, is a
|
|
301
|
+
// discount, not a free model; a limited-time free model (Qwen3.8-Flash:
|
|
302
|
+
// 0×, its original_price_factor 0.1 struck through) is free while it is.
|
|
269
303
|
function freeOf(raw) {
|
|
270
|
-
const is = raw.is_free ?? raw.isFree
|
|
271
304
|
const price = raw.price_factor ?? raw.priceFactor
|
|
272
|
-
|
|
305
|
+
if (typeof price === "number" && Number.isFinite(price)) {
|
|
306
|
+
if (price !== 0) return false
|
|
307
|
+
const p = raw.promotion ?? raw.prommotion
|
|
308
|
+
const before = p?.before_promotion_price_factor ?? p?.beforePromotionPriceFactor
|
|
309
|
+
return !(p?.active === true && typeof before === "number" && before > 0)
|
|
310
|
+
}
|
|
311
|
+
return (raw.is_free ?? raw.isFree) === true
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// rateOf reads a model's price as Qoder's client shows it beside the model
|
|
315
|
+
// (magpie shows it in its lists, so the cheap ones can be picked without
|
|
316
|
+
// opening Qoder): rate, the price_factor (0.5×), and rateWas, the price
|
|
317
|
+
// struck through beside it while a discount runs: an active promotion's
|
|
318
|
+
// before_promotion_price_factor, else an original_price_factor above the
|
|
319
|
+
// price (Qwen3.8-Flash: 0×, 0.1× struck through). A promotion's 0 that
|
|
320
|
+
// isn't free is its price before times its discount_factor. A listing with
|
|
321
|
+
// no price gives none (0). The same rule as magpie's built-in
|
|
322
|
+
// (internal/qoder/models.go, ModelInfo.free).
|
|
323
|
+
function rateOf(raw) {
|
|
324
|
+
const price = raw.price_factor ?? raw.priceFactor
|
|
325
|
+
if (typeof price !== "number" || !Number.isFinite(price)) return { rate: 0, rateWas: 0 }
|
|
326
|
+
const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : 0)
|
|
327
|
+
const p = raw.promotion ?? raw.prommotion
|
|
328
|
+
const before = p?.before_promotion_price_factor ?? p?.beforePromotionPriceFactor
|
|
329
|
+
const discount = num(p?.discount_factor) || num(p?.discountFactor)
|
|
330
|
+
const discounted = p?.active === true && typeof before === "number" && before > 0
|
|
331
|
+
let rate = Math.max(price, 0)
|
|
332
|
+
let rateWas = Math.max(num(raw.original_price_factor), num(raw.originalPriceFactor))
|
|
333
|
+
if (discounted) {
|
|
334
|
+
rateWas = before
|
|
335
|
+
if (rate === 0) rate = before * discount
|
|
336
|
+
}
|
|
337
|
+
if (rateWas <= rate) rateWas = 0
|
|
338
|
+
return { rate, rateWas }
|
|
273
339
|
}
|
|
274
340
|
|
|
275
341
|
// modelInfo reads one entry of the listing's "chat" array: its efforts and
|
|
@@ -286,6 +352,7 @@ function modelInfo(raw) {
|
|
|
286
352
|
efforts: Array.isArray(raw.reasoning_efforts) ? [...raw.reasoning_efforts] : [],
|
|
287
353
|
defaultEffort: "",
|
|
288
354
|
free: freeOf(raw),
|
|
355
|
+
...rateOf(raw),
|
|
289
356
|
config: raw,
|
|
290
357
|
}
|
|
291
358
|
const tc = raw.thinking_config
|
|
@@ -313,10 +380,11 @@ function modelInfos(listing) {
|
|
|
313
380
|
.map(modelInfo)
|
|
314
381
|
}
|
|
315
382
|
|
|
316
|
-
async function fetchListing(cred) {
|
|
317
|
-
const
|
|
383
|
+
async function fetchListing(site, cred) {
|
|
384
|
+
const url = site.api + MODELS_PATH
|
|
385
|
+
const res = await fetch(url, { headers: cosyHeaders(url, cred, ""), signal: AbortSignal.timeout(15_000) })
|
|
318
386
|
const text = await res.text()
|
|
319
|
-
if (!res.ok) throw new Error(
|
|
387
|
+
if (!res.ok) throw new Error(`${site.name} models: HTTP ${res.status}: ${text.trim().slice(0, 512)}`)
|
|
320
388
|
return JSON.parse(text)
|
|
321
389
|
}
|
|
322
390
|
|
|
@@ -330,12 +398,12 @@ function configModel(m) {
|
|
|
330
398
|
}
|
|
331
399
|
}
|
|
332
400
|
|
|
333
|
-
function runtimeModel(m) {
|
|
401
|
+
function runtimeModel(site, m) {
|
|
334
402
|
return {
|
|
335
403
|
id: m.id,
|
|
336
|
-
providerID:
|
|
404
|
+
providerID: site.id,
|
|
337
405
|
name: m.name ?? m.id,
|
|
338
|
-
api: { id: m.id, url:
|
|
406
|
+
api: { id: m.id, url: site.api, npm: CHAT },
|
|
339
407
|
status: "active",
|
|
340
408
|
headers: {},
|
|
341
409
|
options: {},
|
|
@@ -354,6 +422,10 @@ function runtimeModel(m) {
|
|
|
354
422
|
variants: Object.fromEntries((m.efforts ?? []).map((e) => [e, { reasoningEffort: e }])),
|
|
355
423
|
// magpie's own: served at no cost to the plan's credits
|
|
356
424
|
free: !!m.free,
|
|
425
|
+
// magpie's own: the credits a request costs, as a multiple, and the
|
|
426
|
+
// price before a discount running now (0: none)
|
|
427
|
+
rate: m.rate ?? 0,
|
|
428
|
+
rateWas: m.rateWas ?? 0,
|
|
357
429
|
}
|
|
358
430
|
}
|
|
359
431
|
|
|
@@ -785,11 +857,11 @@ class UsageStatus extends Error {
|
|
|
785
857
|
}
|
|
786
858
|
|
|
787
859
|
// fetchUsage is the usage envelope, {displayMode, qoderUsage}.
|
|
788
|
-
async function fetchUsage(deviceToken) {
|
|
860
|
+
async function fetchUsage(site, deviceToken) {
|
|
789
861
|
if (!String(deviceToken ?? "").trim()) throw new Error("qoder usage: missing device token")
|
|
790
862
|
let res
|
|
791
863
|
try {
|
|
792
|
-
res = await fetch(
|
|
864
|
+
res = await fetch(site.openapi + "/sash/api/v2/me/usage", {
|
|
793
865
|
headers: { Accept: "application/json", Authorization: `Bearer ${deviceToken}`, "Cosy-ClientType": "10", "User-Agent": "Qoder" },
|
|
794
866
|
signal: AbortSignal.timeout(20_000),
|
|
795
867
|
})
|
|
@@ -810,25 +882,31 @@ async function fetchUsage(deviceToken) {
|
|
|
810
882
|
|
|
811
883
|
// refreshDevice trades the device refresh token for a new pair; it rotates
|
|
812
884
|
// too, so the caller saves it.
|
|
813
|
-
async function refreshDevice(refresh) {
|
|
814
|
-
if (!String(refresh ?? "").trim())
|
|
885
|
+
async function refreshDevice(site, refresh, chat = false) {
|
|
886
|
+
if (!String(refresh ?? "").trim()) {
|
|
887
|
+
if (chat) throw new SignInGone(`${site.name}: the sign-in lapsed; sign in again`)
|
|
888
|
+
throw new Error("qoder device token refresh: missing refresh token; sign in again")
|
|
889
|
+
}
|
|
815
890
|
let r
|
|
816
891
|
try {
|
|
817
|
-
r = await openapi("/api/v1/deviceToken/refresh", { method: "POST", body: { refresh_token: refresh } })
|
|
892
|
+
r = await openapi(site, "/api/v1/deviceToken/refresh", { method: "POST", body: { refresh_token: refresh } })
|
|
818
893
|
} catch (e) {
|
|
819
894
|
throw new Error(`qoder device token refresh: request failed: ${e?.message ?? e}`)
|
|
820
895
|
}
|
|
821
896
|
if (r.status !== 200) {
|
|
822
897
|
const err = `qoder device token refresh: upstream HTTP ${r.status}`
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
898
|
+
if (r.status === 401 || r.status === 403) {
|
|
899
|
+
// a device token that is the chat token too: refused, the sign-in is gone
|
|
900
|
+
if (chat) throw new SignInGone(`${site.name} sign-in has expired — sign in again (${err})`, true)
|
|
901
|
+
// otherwise it serves only the account pages (usage); chat runs on
|
|
902
|
+
// the job token, so a refused one doesn't lapse the account
|
|
903
|
+
throw new Error(`${site.name} usage is unavailable: ${site.name} refused the account-page sign-in (chat still works) — sign in again to see usage (${err})`)
|
|
904
|
+
}
|
|
827
905
|
throw new Error(err)
|
|
828
906
|
}
|
|
829
907
|
const token = r.json?.token || r.json?.device_token || ""
|
|
830
908
|
if (!String(token).trim() || !String(r.json?.refresh_token ?? "").trim()) throw new Error("qoder device token refresh: incomplete token pair")
|
|
831
|
-
return { token, refresh: r.json.refresh_token }
|
|
909
|
+
return { token, refresh: r.json.refresh_token, expires: deviceExpiry(r.json) }
|
|
832
910
|
}
|
|
833
911
|
|
|
834
912
|
// gfmt is a number as Go's %g writes it.
|
|
@@ -880,7 +958,35 @@ function parseUsage(env) {
|
|
|
880
958
|
return out
|
|
881
959
|
}
|
|
882
960
|
|
|
883
|
-
|
|
961
|
+
// SITES are Qoder's two sites, each a subscription of its own.
|
|
962
|
+
const SITES = {
|
|
963
|
+
qoder: {
|
|
964
|
+
id: "qoder",
|
|
965
|
+
name: "Qoder",
|
|
966
|
+
clientId: "732aef47-9cf2-46a2-95fe-4cebb5d0d1fa",
|
|
967
|
+
web: "https://qoder.com",
|
|
968
|
+
openapi: "https://openapi.qoder.sh",
|
|
969
|
+
api: "https://api3.qoder.sh",
|
|
970
|
+
redirect: "qoder-app://",
|
|
971
|
+
models: MODELS,
|
|
972
|
+
},
|
|
973
|
+
"qoder-cn": {
|
|
974
|
+
id: "qoder-cn",
|
|
975
|
+
name: "Qoder CN",
|
|
976
|
+
// the production client id of Qoder CN's CLI (@qodercn-ai/qoderclicn)
|
|
977
|
+
clientId: "e883ade2-e6e3-4d6d-adf7-f92ceff5fdcb",
|
|
978
|
+
web: "https://qoder.cn",
|
|
979
|
+
openapi: "https://openapi.qoder.com.cn",
|
|
980
|
+
api: "https://gateway.qoder.com.cn",
|
|
981
|
+
redirect: "", // Qoder CN's CLI sends none
|
|
982
|
+
deviceChat: true, // a refused job token falls back to the CLI's device-token chat
|
|
983
|
+
models: CN_MODELS,
|
|
984
|
+
},
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
const makePlugin = (site) => async ({ client }) => {
|
|
988
|
+
const ID = site.id
|
|
989
|
+
const CHAT_URL = site.api + CHAT_PATH
|
|
884
990
|
// serializes checking, rotating and saving tokens: a refresh token is
|
|
885
991
|
// spent once, so two refreshes would spend it twice
|
|
886
992
|
let lock = Promise.resolve()
|
|
@@ -908,10 +1014,18 @@ export async function QoderAuthPlugin({ client }) {
|
|
|
908
1014
|
const fresh = (getAuth) =>
|
|
909
1015
|
locked(async () => {
|
|
910
1016
|
const a = await getAuth()
|
|
911
|
-
if (a?.type !== "oauth" || !a.access || !a.uid) throw new SignInGone(
|
|
1017
|
+
if (a?.type !== "oauth" || !a.access || !a.uid) throw new SignInGone(`${site.name}: not signed in`)
|
|
912
1018
|
if (a.expires - Date.now() > REFRESH_LEAD) return a
|
|
913
|
-
|
|
914
|
-
|
|
1019
|
+
let next
|
|
1020
|
+
if (a.deviceChat) {
|
|
1021
|
+
// the device token is the chat token: renewed as a device token,
|
|
1022
|
+
// both pairs being the one pair
|
|
1023
|
+
const dt = await refreshDevice(site, a.refresh, true)
|
|
1024
|
+
next = { ...a, access: dt.token, refresh: dt.refresh, expires: dt.expires, deviceToken: dt.token, deviceRefresh: dt.refresh }
|
|
1025
|
+
} else {
|
|
1026
|
+
const jt = await refreshJob(site, a.refresh, a.accountId || a.email || a.uid)
|
|
1027
|
+
next = { ...a, access: jt.token, refresh: jt.refresh_token, expires: expiresAt(jt) }
|
|
1028
|
+
}
|
|
915
1029
|
await client.auth.set({ path: { id: ID }, body: next })
|
|
916
1030
|
renewals.add(next)
|
|
917
1031
|
return next
|
|
@@ -920,7 +1034,7 @@ export async function QoderAuthPlugin({ client }) {
|
|
|
920
1034
|
const models = async (cred, again = false) => {
|
|
921
1035
|
let l = listings.get(cred.uid)
|
|
922
1036
|
if (!l || again) {
|
|
923
|
-
l = modelInfos(await fetchListing(cred))
|
|
1037
|
+
l = modelInfos(await fetchListing(site, cred))
|
|
924
1038
|
listings.set(cred.uid, l)
|
|
925
1039
|
}
|
|
926
1040
|
return l
|
|
@@ -932,8 +1046,11 @@ export async function QoderAuthPlugin({ client }) {
|
|
|
932
1046
|
locked(async () => {
|
|
933
1047
|
const a = await getAuth()
|
|
934
1048
|
if (a?.deviceToken !== attempted) return a?.deviceToken
|
|
935
|
-
const dt = await refreshDevice(a.deviceRefresh)
|
|
936
|
-
|
|
1049
|
+
const dt = await refreshDevice(site, a.deviceRefresh, !!a.deviceChat)
|
|
1050
|
+
const next = { ...a, deviceToken: dt.token, deviceRefresh: dt.refresh }
|
|
1051
|
+
// the pair it spent was the chat pair too
|
|
1052
|
+
if (a.deviceChat) Object.assign(next, { access: dt.token, refresh: dt.refresh, expires: dt.expires })
|
|
1053
|
+
await client.auth.set({ path: { id: ID }, body: next })
|
|
937
1054
|
return dt.token
|
|
938
1055
|
})
|
|
939
1056
|
|
|
@@ -948,10 +1065,10 @@ export async function QoderAuthPlugin({ client }) {
|
|
|
948
1065
|
if (renewed(cred)) signIn = "renewed"
|
|
949
1066
|
let env
|
|
950
1067
|
try {
|
|
951
|
-
env = await fetchUsage(cred.deviceToken)
|
|
1068
|
+
env = await fetchUsage(site, cred.deviceToken)
|
|
952
1069
|
} catch (e) {
|
|
953
1070
|
if (!(e instanceof UsageStatus) || (e.status !== 401 && e.status !== 403)) throw e
|
|
954
|
-
env = await fetchUsage(await deviceToken(getAuth, cred.deviceToken))
|
|
1071
|
+
env = await fetchUsage(site, await deviceToken(getAuth, cred.deviceToken))
|
|
955
1072
|
}
|
|
956
1073
|
return { ...parseUsage(env), signIn }
|
|
957
1074
|
} catch (e) {
|
|
@@ -962,7 +1079,7 @@ export async function QoderAuthPlugin({ client }) {
|
|
|
962
1079
|
const signedInError = (e) =>
|
|
963
1080
|
errorResponse({
|
|
964
1081
|
status: e instanceof SignInGone ? 401 : 502,
|
|
965
|
-
message: String(e?.message ?? e).replace(
|
|
1082
|
+
message: String(e?.message ?? e).replace(new RegExp(`^${site.name}: `), ""),
|
|
966
1083
|
signIn: e?.expired ? "expired" : undefined,
|
|
967
1084
|
})
|
|
968
1085
|
|
|
@@ -1002,7 +1119,7 @@ export async function QoderAuthPlugin({ client }) {
|
|
|
1002
1119
|
const a = await getAuth()
|
|
1003
1120
|
if (a?.type !== "oauth") return {}
|
|
1004
1121
|
return {
|
|
1005
|
-
baseURL:
|
|
1122
|
+
baseURL: site.api,
|
|
1006
1123
|
apiKey: "qoder",
|
|
1007
1124
|
// every chat completion written as Qoder's own request
|
|
1008
1125
|
async fetch(input, init = {}) {
|
|
@@ -1025,18 +1142,18 @@ export async function QoderAuthPlugin({ client }) {
|
|
|
1025
1142
|
},
|
|
1026
1143
|
}
|
|
1027
1144
|
},
|
|
1028
|
-
methods: [{ type: "oauth", label:
|
|
1145
|
+
methods: [{ type: "oauth", label: `Sign in with ${site.name}`, authorize: () => deviceSignIn(site) }],
|
|
1029
1146
|
usage,
|
|
1030
1147
|
},
|
|
1031
1148
|
async config(config) {
|
|
1032
1149
|
config.provider ??= {}
|
|
1033
1150
|
const was = config.provider[ID] ?? {}
|
|
1034
1151
|
config.provider[ID] = {
|
|
1035
|
-
name:
|
|
1152
|
+
name: site.name,
|
|
1036
1153
|
npm: CHAT,
|
|
1037
|
-
api:
|
|
1154
|
+
api: site.api,
|
|
1038
1155
|
...was,
|
|
1039
|
-
models: { ...Object.fromEntries(
|
|
1156
|
+
models: { ...Object.fromEntries(site.models.map((m) => [m.id, configModel(m)])), ...(was.models ?? {}) },
|
|
1040
1157
|
}
|
|
1041
1158
|
},
|
|
1042
1159
|
// the account's own list, as Qoder's client asks it
|
|
@@ -1058,7 +1175,7 @@ export async function QoderAuthPlugin({ client }) {
|
|
|
1058
1175
|
const ms = await models(cred, true)
|
|
1059
1176
|
if (!ms.length) return provider.models
|
|
1060
1177
|
return Object.fromEntries(
|
|
1061
|
-
ms.map((m) => [m.key, runtimeModel({ id: m.key, name: m.name, context: m.context, images: m.images, efforts: m.thinks ? m.efforts : [], free: m.free })]),
|
|
1178
|
+
ms.map((m) => [m.key, runtimeModel(site, { id: m.key, name: m.name, context: m.context, images: m.images, efforts: m.thinks ? m.efforts : [], free: m.free, rate: m.rate, rateWas: m.rateWas })]),
|
|
1062
1179
|
)
|
|
1063
1180
|
} catch {
|
|
1064
1181
|
return provider.models
|
|
@@ -1068,5 +1185,8 @@ export async function QoderAuthPlugin({ client }) {
|
|
|
1068
1185
|
}
|
|
1069
1186
|
}
|
|
1070
1187
|
|
|
1188
|
+
export const QoderAuthPlugin = makePlugin(SITES.qoder)
|
|
1189
|
+
export const QoderCNAuthPlugin = makePlugin(SITES["qoder-cn"])
|
|
1190
|
+
|
|
1071
1191
|
// for tests
|
|
1072
|
-
export const _internal = { parseUsage, gfmt, when, failure, modelInfo }
|
|
1192
|
+
export const _internal = { parseUsage, gfmt, when, failure, modelInfo, SITES, decodeBody }
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@magpie-community/opencode-qoder-auth",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Qoder
|
|
3
|
+
"version": "0.2.2",
|
|
4
|
+
"description": "Qoder subscriptions in OpenCode and magpie: Qoder (qoder.com) and Qoder CN (qoder.cn)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.mjs",
|
|
7
7
|
"exports": "./index.mjs",
|
|
8
8
|
"files": ["index.mjs", "README.md", "LICENSE-CLIProxyAPI"],
|
|
9
|
-
"keywords": ["opencode", "opencode-plugin", "magpie", "qoder"],
|
|
9
|
+
"keywords": ["opencode", "opencode-plugin", "magpie", "qoder", "qoder-cn"],
|
|
10
10
|
"license": "MIT"
|
|
11
11
|
}
|