@magpie-community/opencode-qoder-auth 0.2.0 → 0.2.3

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 +15 -4
  2. package/index.mjs +120 -21
  3. package/package.json +3 -2
package/README.md CHANGED
@@ -26,10 +26,21 @@ What is kept: the job token, its refresh token and expiry, the uid, the
26
26
  device token and a machine id made for this sign-in. OpenCode keeps them in
27
27
  `auth.json`; magpie keeps them in `plugin-auth.json`.
28
28
 
29
- The job token is refreshed 5 minutes before it runs out. Qoder spends a
30
- refresh token once, so the new pair is saved straight away, and refreshes
31
- never run at the same time. When Qoder refuses a refresh (401 or 403), the
32
- request says to sign in again.
29
+ Refreshing:
30
+ - Qoder spends a refresh token once, so the plugin never sends one twice.
31
+ - magpie renews the job token 10 minutes before it runs out, through the
32
+ plugin's `auth.refresh`, once for the account and before its requests,
33
+ models and usage need it; magpie saves the new pair. OpenCode doesn't
34
+ call that hook: there the job token is refreshed 5 minutes before it runs
35
+ out, before a request, and the new pair is saved straight away.
36
+ - Only one refresh runs at a time. A request that reads the sign-in before
37
+ magpie saved a renewal uses the renewed pair, and a renewal handed a pair
38
+ a request already spent gives what that request got.
39
+ - The device token, which reads usage, has no end on record: it is rotated
40
+ when Qoder refuses it. On a [Qoder CN](#qoder-cn) device-token account it
41
+ is the chat token too, and is renewed as above.
42
+ - When Qoder refuses a refresh (401 or 403), the account needs signing in
43
+ again. Any other failure is tried again later.
33
44
 
34
45
  ## Requests
35
46
 
package/index.mjs CHANGED
@@ -32,7 +32,9 @@ const CHAT_PATH = "/algo/api/v2/service/pro/sse/agent_chat_generation?FetchKeys=
32
32
  const MODELS_PATH = "/algo/api/v2/model/list?Encode=1"
33
33
  const COSY_VERSION = "1.1.49"
34
34
  const SIGN_IN_TIMEOUT = 15 * 60 * 1000
35
- const REFRESH_LEAD = 5 * 60 * 1000
35
+ const REFRESH_LEAD = 5 * 60 * 1000 // a chat token this close to its end is refreshed before a request
36
+ const RENEW_LEAD = 10 * 60 * 1000 // and this close, magpie renews it ahead of time (auth.refresh)
37
+ const KEEP_SPENT_MS = 60 * 60 * 1000 // how long what a spent refresh token gave is remembered
36
38
  const DAY = 24 * 60 * 60 * 1000
37
39
  const CHAT = "@ai-sdk/openai-compatible"
38
40
 
@@ -291,13 +293,51 @@ const CN_MODELS = [
291
293
  { id: "lite", name: "Lite" },
292
294
  ]
293
295
 
294
- // freeOf reads whether the listing marks a model free, as Qoder's client
295
- // reads it (internal/qoder/models.go): is_free true, or a price_factor (the
296
- // credits a request costs, as a multiple) of 0, either in snake or camel case.
296
+ // freeOf reads whether a model costs the plan no credits: a price_factor
297
+ // (the credits a request costs, as a multiple; priceFactor in camel case)
298
+ // of 0, the price Qoder's own client shows ("0×"). is_free is no word on
299
+ // that: Qoder's listing has it true on Qwen3.8-Max at 0.5×, an off-peak
300
+ // discount (错峰 4 折) on it, and Qoder's client shows the price, not it.
301
+ // It is taken only from a listing with no price at all. A price of 0 for
302
+ // the while an active promotion lasts, with a price before it, is a
303
+ // discount, not a free model; a limited-time free model (Qwen3.8-Flash:
304
+ // 0×, its original_price_factor 0.1 struck through) is free while it is.
297
305
  function freeOf(raw) {
298
- const is = raw.is_free ?? raw.isFree
299
306
  const price = raw.price_factor ?? raw.priceFactor
300
- return is === true || (typeof price === "number" && price === 0)
307
+ if (typeof price === "number" && Number.isFinite(price)) {
308
+ if (price !== 0) return false
309
+ const p = raw.promotion ?? raw.prommotion
310
+ const before = p?.before_promotion_price_factor ?? p?.beforePromotionPriceFactor
311
+ return !(p?.active === true && typeof before === "number" && before > 0)
312
+ }
313
+ return (raw.is_free ?? raw.isFree) === true
314
+ }
315
+
316
+ // rateOf reads a model's price as Qoder's client shows it beside the model
317
+ // (magpie shows it in its lists, so the cheap ones can be picked without
318
+ // opening Qoder): rate, the price_factor (0.5×), and rateWas, the price
319
+ // struck through beside it while a discount runs: an active promotion's
320
+ // before_promotion_price_factor, else an original_price_factor above the
321
+ // price (Qwen3.8-Flash: 0×, 0.1× struck through). A promotion's 0 that
322
+ // isn't free is its price before times its discount_factor. A listing with
323
+ // no price gives none (0). The same rule as magpie's built-in
324
+ // (internal/qoder/models.go, ModelInfo.free).
325
+ function rateOf(raw) {
326
+ const price = raw.price_factor ?? raw.priceFactor
327
+ if (typeof price !== "number" || !Number.isFinite(price)) return { rate: 0, rateWas: 0 }
328
+ const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : 0)
329
+ const p = raw.promotion ?? raw.prommotion
330
+ const before = p?.before_promotion_price_factor ?? p?.beforePromotionPriceFactor
331
+ const discount = num(p?.discount_factor) || num(p?.discountFactor)
332
+ const discounted = p?.active === true && typeof before === "number" && before > 0
333
+ let rate = Math.max(price, 0)
334
+ let rateWas = Math.max(num(raw.original_price_factor), num(raw.originalPriceFactor))
335
+ if (discounted) {
336
+ rateWas = before
337
+ if (rate === 0) rate = before * discount
338
+ }
339
+ if (rateWas <= rate) rateWas = 0
340
+ return { rate, rateWas }
301
341
  }
302
342
 
303
343
  // modelInfo reads one entry of the listing's "chat" array: its efforts and
@@ -314,6 +354,7 @@ function modelInfo(raw) {
314
354
  efforts: Array.isArray(raw.reasoning_efforts) ? [...raw.reasoning_efforts] : [],
315
355
  defaultEffort: "",
316
356
  free: freeOf(raw),
357
+ ...rateOf(raw),
317
358
  config: raw,
318
359
  }
319
360
  const tc = raw.thinking_config
@@ -383,6 +424,10 @@ function runtimeModel(site, m) {
383
424
  variants: Object.fromEntries((m.efforts ?? []).map((e) => [e, { reasoningEffort: e }])),
384
425
  // magpie's own: served at no cost to the plan's credits
385
426
  free: !!m.free,
427
+ // magpie's own: the credits a request costs, as a multiple, and the
428
+ // price before a discount running now (0: none)
429
+ rate: m.rate ?? 0,
430
+ rateWas: m.rateWas ?? 0,
386
431
  }
387
432
  }
388
433
 
@@ -799,6 +844,13 @@ function signed(res, renewed) {
799
844
  return new Response(res.body, { status: res.status, statusText: res.statusText, headers })
800
845
  }
801
846
 
847
+ // changed is the fields of b that differ from a's: what auth.refresh gives.
848
+ function changed(a, b) {
849
+ const out = {}
850
+ for (const k of ["access", "refresh", "expires", "deviceToken", "deviceRefresh"]) if (b[k] !== a[k]) out[k] = b[k]
851
+ return out
852
+ }
853
+
802
854
  // ---- usage --------------------------------------------------------------------
803
855
  //
804
856
  // The account's allowance as magpie's built-in Qoder account shows it
@@ -952,6 +1004,38 @@ const makePlugin = (site) => async ({ client }) => {
952
1004
  lock = run.catch(() => {})
953
1005
  return run
954
1006
  }
1007
+ // spent is what each refresh token this process spent gave, by that
1008
+ // token: the chat pair it renewed. magpie saves what auth.refresh gives
1009
+ // only once the hook has returned, and a request may read the account in
1010
+ // between, or the hook be handed the account as it was before a request
1011
+ // renewed it: either follows spent rather than spending the token again.
1012
+ const spent = new Map()
1013
+ const latest = (a) => {
1014
+ for (let i = 0; i < 16 && a?.refresh; i++) {
1015
+ const s = spent.get(a.refresh)
1016
+ if (!s) break
1017
+ a = { ...a, ...s.got }
1018
+ }
1019
+ return a
1020
+ }
1021
+ const spend = (refresh, got) => {
1022
+ const now = Date.now()
1023
+ for (const [k, v] of spent) if (now - v.at > KEEP_SPENT_MS) spent.delete(k)
1024
+ spent.set(refresh, { got, at: now })
1025
+ return got
1026
+ }
1027
+ // renew spends a's chat refresh token, under the lock: the fields it
1028
+ // changes, recorded in spent.
1029
+ const renew = async (a) => {
1030
+ if (a.deviceChat) {
1031
+ // the device token is the chat token: renewed as a device token,
1032
+ // both pairs being the one pair
1033
+ const dt = await refreshDevice(site, a.refresh, true)
1034
+ return spend(a.refresh, { access: dt.token, refresh: dt.refresh, expires: dt.expires, deviceToken: dt.token, deviceRefresh: dt.refresh })
1035
+ }
1036
+ const jt = await refreshJob(site, a.refresh, a.accountId || a.email || a.uid)
1037
+ return spend(a.refresh, { access: jt.token, refresh: jt.refresh_token, expires: expiresAt(jt) })
1038
+ }
955
1039
  // each account's listing, the model configs a request carries
956
1040
  const listings = new Map()
957
1041
  // the accounts fresh renewed: the built-in took the lapse mark off on a
@@ -970,19 +1054,12 @@ const makePlugin = (site) => async ({ client }) => {
970
1054
  // its end.
971
1055
  const fresh = (getAuth) =>
972
1056
  locked(async () => {
973
- const a = await getAuth()
974
- if (a?.type !== "oauth" || !a.access || !a.uid) throw new SignInGone(`${site.name}: not signed in`)
1057
+ const stored = await getAuth()
1058
+ if (stored?.type !== "oauth" || !stored.access || !stored.uid) throw new SignInGone(`${site.name}: not signed in`)
1059
+ // renewed already (by magpie's auth.refresh), the store not yet saying so
1060
+ const a = latest(stored)
975
1061
  if (a.expires - Date.now() > REFRESH_LEAD) return a
976
- let next
977
- if (a.deviceChat) {
978
- // the device token is the chat token: renewed as a device token,
979
- // both pairs being the one pair
980
- const dt = await refreshDevice(site, a.refresh, true)
981
- next = { ...a, access: dt.token, refresh: dt.refresh, expires: dt.expires, deviceToken: dt.token, deviceRefresh: dt.refresh }
982
- } else {
983
- const jt = await refreshJob(site, a.refresh, a.accountId || a.email || a.uid)
984
- next = { ...a, access: jt.token, refresh: jt.refresh_token, expires: expiresAt(jt) }
985
- }
1062
+ const next = { ...a, ...(await renew(a)) }
986
1063
  await client.auth.set({ path: { id: ID }, body: next })
987
1064
  renewals.add(next)
988
1065
  return next
@@ -1001,12 +1078,12 @@ const makePlugin = (site) => async ({ client }) => {
1001
1078
  // the one saved since, else a rotated one, saved.
1002
1079
  const deviceToken = (getAuth, attempted) =>
1003
1080
  locked(async () => {
1004
- const a = await getAuth()
1081
+ const a = latest(await getAuth())
1005
1082
  if (a?.deviceToken !== attempted) return a?.deviceToken
1006
1083
  const dt = await refreshDevice(site, a.deviceRefresh, !!a.deviceChat)
1007
1084
  const next = { ...a, deviceToken: dt.token, deviceRefresh: dt.refresh }
1008
1085
  // the pair it spent was the chat pair too
1009
- if (a.deviceChat) Object.assign(next, { access: dt.token, refresh: dt.refresh, expires: dt.expires })
1086
+ if (a.deviceChat) Object.assign(next, spend(a.refresh, { access: dt.token, refresh: dt.refresh, expires: dt.expires, deviceToken: dt.token, deviceRefresh: dt.refresh }))
1010
1087
  await client.auth.set({ path: { id: ID }, body: next })
1011
1088
  return dt.token
1012
1089
  })
@@ -1072,6 +1149,28 @@ const makePlugin = (site) => async ({ client }) => {
1072
1149
  return {
1073
1150
  auth: {
1074
1151
  provider: ID,
1152
+ // magpie renews the chat token RENEW_LEAD before its end, once for
1153
+ // the account, before its requests, models and usage ask for it; the
1154
+ // check before each request (REFRESH_LEAD) stays for OpenCode, which
1155
+ // doesn't call this. The device token of a job-token account has no
1156
+ // end on record: usage rotates it once Qoder refuses it, as before.
1157
+ refreshLead: RENEW_LEAD,
1158
+ async refresh(auth) {
1159
+ if (auth?.type !== "oauth" || !auth.access || !auth.uid || !String(auth.refresh ?? "").trim()) return undefined
1160
+ return locked(async () => {
1161
+ // a request renewed it meanwhile (or magpie hasn't saved this
1162
+ // hook's last answer): what that gave, not a second refresh
1163
+ const a = latest(auth)
1164
+ if (a.access !== auth.access && a.expires - Date.now() > RENEW_LEAD) return changed(auth, a)
1165
+ try {
1166
+ // magpie saves what this gives
1167
+ return changed(auth, { ...a, ...(await renew(a)) })
1168
+ } catch (e) {
1169
+ if (e?.expired) throw Object.assign(new Error(e.message), { signIn: "expired" })
1170
+ throw e
1171
+ }
1172
+ })
1173
+ },
1075
1174
  async loader(getAuth) {
1076
1175
  const a = await getAuth()
1077
1176
  if (a?.type !== "oauth") return {}
@@ -1132,7 +1231,7 @@ const makePlugin = (site) => async ({ client }) => {
1132
1231
  const ms = await models(cred, true)
1133
1232
  if (!ms.length) return provider.models
1134
1233
  return Object.fromEntries(
1135
- 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 })]),
1234
+ 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 })]),
1136
1235
  )
1137
1236
  } catch {
1138
1237
  return provider.models
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-qoder-auth",
3
- "version": "0.2.0",
3
+ "version": "0.2.3",
4
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
9
  "keywords": ["opencode", "opencode-plugin", "magpie", "qoder", "qoder-cn"],
10
- "license": "MIT"
10
+ "license": "MIT",
11
+ "repository": { "type": "git", "url": "git+https://github.com/magpie-community/plugins.git", "directory": "packages/qoder" }
11
12
  }