@magpie-community/opencode-qoder-auth 0.2.2 → 0.2.4

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 +86 -22
  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
 
@@ -724,6 +726,7 @@ async function* events(body) {
724
726
  yield { error: failure(Number(env.statusCodeValue), inner || payload) }
725
727
  return
726
728
  }
729
+ if (inner.trim() === "[DONE]") break
727
730
  let chunk
728
731
  try {
729
732
  chunk = JSON.parse(inner)
@@ -747,12 +750,19 @@ async function* events(body) {
747
750
  yield { tool: { index: ++index, id: tc.id || "call_" + hexID(), name: tc.function?.name ?? "", args: tc.function?.arguments ?? "" } }
748
751
  } else if (tc.function?.arguments) yield { args: { index, text: tc.function.arguments } }
749
752
  }
750
- fr = chunk?.choices?.[0]?.finish_reason ?? ""
751
- if (fr) {
752
- const u = chunk.usage
753
- 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) }
754
- break
755
- }
753
+ fr = chunk?.choices?.[0]?.finish_reason || fr
754
+ // the usage comes in a chunk of its own after the finish, with no
755
+ // choices, as the built-in read it (qoder.go): every request was
756
+ // counted as 0 tokens when the finish ended the reading
757
+ const u = chunk?.usage
758
+ if (u)
759
+ usage = {
760
+ prompt_tokens: u.prompt_tokens ?? 0,
761
+ completion_tokens: u.completion_tokens ?? 0,
762
+ total_tokens: (u.prompt_tokens ?? 0) + (u.completion_tokens ?? 0),
763
+ ...(u.prompt_tokens_details?.cached_tokens ? { prompt_tokens_details: { cached_tokens: u.prompt_tokens_details.cached_tokens } } : {}),
764
+ ...(u.completion_tokens_details?.reasoning_tokens ? { completion_tokens_details: { reasoning_tokens: u.completion_tokens_details.reasoning_tokens } } : {}),
765
+ }
756
766
  }
757
767
  yield* flushed()
758
768
  yield { stop: a.sawTool ? "tool_calls" : fr === "length" ? "length" : "stop", usage }
@@ -842,6 +852,13 @@ function signed(res, renewed) {
842
852
  return new Response(res.body, { status: res.status, statusText: res.statusText, headers })
843
853
  }
844
854
 
855
+ // changed is the fields of b that differ from a's: what auth.refresh gives.
856
+ function changed(a, b) {
857
+ const out = {}
858
+ for (const k of ["access", "refresh", "expires", "deviceToken", "deviceRefresh"]) if (b[k] !== a[k]) out[k] = b[k]
859
+ return out
860
+ }
861
+
845
862
  // ---- usage --------------------------------------------------------------------
846
863
  //
847
864
  // The account's allowance as magpie's built-in Qoder account shows it
@@ -995,6 +1012,38 @@ const makePlugin = (site) => async ({ client }) => {
995
1012
  lock = run.catch(() => {})
996
1013
  return run
997
1014
  }
1015
+ // spent is what each refresh token this process spent gave, by that
1016
+ // token: the chat pair it renewed. magpie saves what auth.refresh gives
1017
+ // only once the hook has returned, and a request may read the account in
1018
+ // between, or the hook be handed the account as it was before a request
1019
+ // renewed it: either follows spent rather than spending the token again.
1020
+ const spent = new Map()
1021
+ const latest = (a) => {
1022
+ for (let i = 0; i < 16 && a?.refresh; i++) {
1023
+ const s = spent.get(a.refresh)
1024
+ if (!s) break
1025
+ a = { ...a, ...s.got }
1026
+ }
1027
+ return a
1028
+ }
1029
+ const spend = (refresh, got) => {
1030
+ const now = Date.now()
1031
+ for (const [k, v] of spent) if (now - v.at > KEEP_SPENT_MS) spent.delete(k)
1032
+ spent.set(refresh, { got, at: now })
1033
+ return got
1034
+ }
1035
+ // renew spends a's chat refresh token, under the lock: the fields it
1036
+ // changes, recorded in spent.
1037
+ const renew = async (a) => {
1038
+ if (a.deviceChat) {
1039
+ // the device token is the chat token: renewed as a device token,
1040
+ // both pairs being the one pair
1041
+ const dt = await refreshDevice(site, a.refresh, true)
1042
+ return spend(a.refresh, { access: dt.token, refresh: dt.refresh, expires: dt.expires, deviceToken: dt.token, deviceRefresh: dt.refresh })
1043
+ }
1044
+ const jt = await refreshJob(site, a.refresh, a.accountId || a.email || a.uid)
1045
+ return spend(a.refresh, { access: jt.token, refresh: jt.refresh_token, expires: expiresAt(jt) })
1046
+ }
998
1047
  // each account's listing, the model configs a request carries
999
1048
  const listings = new Map()
1000
1049
  // the accounts fresh renewed: the built-in took the lapse mark off on a
@@ -1013,19 +1062,12 @@ const makePlugin = (site) => async ({ client }) => {
1013
1062
  // its end.
1014
1063
  const fresh = (getAuth) =>
1015
1064
  locked(async () => {
1016
- const a = await getAuth()
1017
- if (a?.type !== "oauth" || !a.access || !a.uid) throw new SignInGone(`${site.name}: not signed in`)
1065
+ const stored = await getAuth()
1066
+ if (stored?.type !== "oauth" || !stored.access || !stored.uid) throw new SignInGone(`${site.name}: not signed in`)
1067
+ // renewed already (by magpie's auth.refresh), the store not yet saying so
1068
+ const a = latest(stored)
1018
1069
  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
- }
1070
+ const next = { ...a, ...(await renew(a)) }
1029
1071
  await client.auth.set({ path: { id: ID }, body: next })
1030
1072
  renewals.add(next)
1031
1073
  return next
@@ -1044,12 +1086,12 @@ const makePlugin = (site) => async ({ client }) => {
1044
1086
  // the one saved since, else a rotated one, saved.
1045
1087
  const deviceToken = (getAuth, attempted) =>
1046
1088
  locked(async () => {
1047
- const a = await getAuth()
1089
+ const a = latest(await getAuth())
1048
1090
  if (a?.deviceToken !== attempted) return a?.deviceToken
1049
1091
  const dt = await refreshDevice(site, a.deviceRefresh, !!a.deviceChat)
1050
1092
  const next = { ...a, deviceToken: dt.token, deviceRefresh: dt.refresh }
1051
1093
  // 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 })
1094
+ 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
1095
  await client.auth.set({ path: { id: ID }, body: next })
1054
1096
  return dt.token
1055
1097
  })
@@ -1115,6 +1157,28 @@ const makePlugin = (site) => async ({ client }) => {
1115
1157
  return {
1116
1158
  auth: {
1117
1159
  provider: ID,
1160
+ // magpie renews the chat token RENEW_LEAD before its end, once for
1161
+ // the account, before its requests, models and usage ask for it; the
1162
+ // check before each request (REFRESH_LEAD) stays for OpenCode, which
1163
+ // doesn't call this. The device token of a job-token account has no
1164
+ // end on record: usage rotates it once Qoder refuses it, as before.
1165
+ refreshLead: RENEW_LEAD,
1166
+ async refresh(auth) {
1167
+ if (auth?.type !== "oauth" || !auth.access || !auth.uid || !String(auth.refresh ?? "").trim()) return undefined
1168
+ return locked(async () => {
1169
+ // a request renewed it meanwhile (or magpie hasn't saved this
1170
+ // hook's last answer): what that gave, not a second refresh
1171
+ const a = latest(auth)
1172
+ if (a.access !== auth.access && a.expires - Date.now() > RENEW_LEAD) return changed(auth, a)
1173
+ try {
1174
+ // magpie saves what this gives
1175
+ return changed(auth, { ...a, ...(await renew(a)) })
1176
+ } catch (e) {
1177
+ if (e?.expired) throw Object.assign(new Error(e.message), { signIn: "expired" })
1178
+ throw e
1179
+ }
1180
+ })
1181
+ },
1118
1182
  async loader(getAuth) {
1119
1183
  const a = await getAuth()
1120
1184
  if (a?.type !== "oauth") return {}
@@ -1189,4 +1253,4 @@ export const QoderAuthPlugin = makePlugin(SITES.qoder)
1189
1253
  export const QoderCNAuthPlugin = makePlugin(SITES["qoder-cn"])
1190
1254
 
1191
1255
  // for tests
1192
- export const _internal = { parseUsage, gfmt, when, failure, modelInfo, SITES, decodeBody }
1256
+ export const _internal = { parseUsage, gfmt, when, failure, modelInfo, SITES, decodeBody, events }
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.4",
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
  }