@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.
Files changed (3) hide show
  1. package/README.md +50 -1
  2. package/index.mjs +187 -67
  3. 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), with provider id `qoder`.
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 subscription (qoder.com) as an OpenCode provider plugin.
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 ID = "qoder"
22
- const CLIENT_ID = "732aef47-9cf2-46a2-95fe-4cebb5d0d1fa"
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(OPENAPI + path)
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
- async function deviceSignIn() {
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: CLIENT_ID, redirect_uri: REDIRECT })
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: `${DEVICE_HOST}/device/selectAccounts?${q}`,
176
- instructions: "Sign in to Qoder in the browser and authorize this device",
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("Qoder: the sign-in timed out; start again")
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: CLIENT_ID } })
192
- if (jr.status !== 200) throw new Error(`Qoder job token: status ${jr.status}: ${jr.text.trim().slice(0, 300)}`)
193
- if (!String(jr.json?.token ?? "").trim()) throw new Error("Qoder job token: empty token in response")
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
- access: jr.json.token,
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("Qoder: the sign-in lapsed; sign in again")
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 Qoder sign-in has expired — sign in again (qoder job token refresh: status ${r.status})`, true)
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
- // freeOf reads whether the listing marks a model free, as Qoder's client
267
- // reads it (internal/qoder/models.go): is_free true, or a price_factor (the
268
- // credits a request costs, as a multiple) of 0, either in snake or camel case.
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
- return is === true || (typeof price === "number" && price === 0)
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 res = await fetch(MODELS_URL, { headers: cosyHeaders(MODELS_URL, cred, ""), signal: AbortSignal.timeout(15_000) })
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(`Qoder models: HTTP ${res.status}: ${text.trim().slice(0, 512)}`)
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: ID,
404
+ providerID: site.id,
337
405
  name: m.name ?? m.id,
338
- api: { id: m.id, url: API, npm: CHAT },
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(OPENAPI + "/sash/api/v2/me/usage", {
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()) throw new Error("qoder device token refresh: missing refresh token; sign in again")
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
- // the device token serves only the account pages (usage); chat runs on
824
- // the job token, so a refused one doesn't lapse the account
825
- if (r.status === 401 || r.status === 403)
826
- throw new Error(`Qoder usage is unavailable: Qoder refused the account-page sign-in (chat still works) — sign in again to see usage (${err})`)
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
- export async function QoderAuthPlugin({ client }) {
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("Qoder: not signed in")
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
- const jt = await refreshJob(a.refresh, a.accountId || a.email || a.uid)
914
- const next = { ...a, access: jt.token, refresh: jt.refresh_token, expires: expiresAt(jt) }
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
- await client.auth.set({ path: { id: ID }, body: { ...a, deviceToken: dt.token, deviceRefresh: dt.refresh } })
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(/^Qoder: /, ""),
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: API,
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: "Sign in with Qoder", authorize: deviceSignIn }],
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: "Qoder",
1152
+ name: site.name,
1036
1153
  npm: CHAT,
1037
- api: API,
1154
+ api: site.api,
1038
1155
  ...was,
1039
- models: { ...Object.fromEntries(MODELS.map((m) => [m.id, configModel(m)])), ...(was.models ?? {}) },
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.1.4",
4
- "description": "Qoder subscription in OpenCode and magpie",
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
  }