@magpie-community/opencode-qoder-auth 0.2.2 → 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 +71 -15
  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
 
@@ -842,6 +844,13 @@ function signed(res, renewed) {
842
844
  return new Response(res.body, { status: res.status, statusText: res.statusText, headers })
843
845
  }
844
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
+
845
854
  // ---- usage --------------------------------------------------------------------
846
855
  //
847
856
  // The account's allowance as magpie's built-in Qoder account shows it
@@ -995,6 +1004,38 @@ const makePlugin = (site) => async ({ client }) => {
995
1004
  lock = run.catch(() => {})
996
1005
  return run
997
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
+ }
998
1039
  // each account's listing, the model configs a request carries
999
1040
  const listings = new Map()
1000
1041
  // the accounts fresh renewed: the built-in took the lapse mark off on a
@@ -1013,19 +1054,12 @@ const makePlugin = (site) => async ({ client }) => {
1013
1054
  // its end.
1014
1055
  const fresh = (getAuth) =>
1015
1056
  locked(async () => {
1016
- const a = await getAuth()
1017
- 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)
1018
1061
  if (a.expires - Date.now() > REFRESH_LEAD) return a
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
- }
1062
+ const next = { ...a, ...(await renew(a)) }
1029
1063
  await client.auth.set({ path: { id: ID }, body: next })
1030
1064
  renewals.add(next)
1031
1065
  return next
@@ -1044,12 +1078,12 @@ const makePlugin = (site) => async ({ client }) => {
1044
1078
  // the one saved since, else a rotated one, saved.
1045
1079
  const deviceToken = (getAuth, attempted) =>
1046
1080
  locked(async () => {
1047
- const a = await getAuth()
1081
+ const a = latest(await getAuth())
1048
1082
  if (a?.deviceToken !== attempted) return a?.deviceToken
1049
1083
  const dt = await refreshDevice(site, a.deviceRefresh, !!a.deviceChat)
1050
1084
  const next = { ...a, deviceToken: dt.token, deviceRefresh: dt.refresh }
1051
1085
  // 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 })
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 }))
1053
1087
  await client.auth.set({ path: { id: ID }, body: next })
1054
1088
  return dt.token
1055
1089
  })
@@ -1115,6 +1149,28 @@ const makePlugin = (site) => async ({ client }) => {
1115
1149
  return {
1116
1150
  auth: {
1117
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
+ },
1118
1174
  async loader(getAuth) {
1119
1175
  const a = await getAuth()
1120
1176
  if (a?.type !== "oauth") return {}
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-qoder-auth",
3
- "version": "0.2.2",
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
  }