@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.
- package/README.md +15 -4
- package/index.mjs +120 -21
- 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
|
-
|
|
30
|
-
refresh token once, so the
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
295
|
-
//
|
|
296
|
-
//
|
|
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
|
-
|
|
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
|
|
974
|
-
if (
|
|
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
|
-
|
|
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.
|
|
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
|
}
|