@magpie-community/opencode-qoder-auth 0.2.5 → 0.2.7

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 +21 -0
  2. package/index.mjs +57 -5
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -151,6 +151,27 @@ Neither magpie's built-in Qoder CN nor this plugin has been checked against
151
151
  every kind of qoder.cn account. If a sign-in or a chat fails, please open
152
152
  an issue with the error.
153
153
 
154
+ ## Daily check-in
155
+
156
+ Qoder gives credits for a daily claim (the Qoder client's campaign
157
+ `CLAIM_BENEFIT`, 100 Credits a day as reported), on both sites. magpie can
158
+ press it once a day for each account when its Qoder check-in switch is on
159
+ (Settings). It asks these pages through this plugin's fetch (since 0.2.7),
160
+ which sends them as the account, on the device token (`Bearer`,
161
+ `Cosy-ClientType: 10`) as usage is read; a refused device token is
162
+ rotated once and the page asked again.
163
+
164
+ - **List:** `GET <openapi>/sash/api/v1/me/campaigns`: `campaigns[]` with
165
+ `campaignId`, `actionType`, `claimStatus` (`CLAIMABLE`, `CLAIMED`),
166
+ `startAt`, `endAt`, `benefit.amount`.
167
+ - **Claim:** `POST <openapi>/sash/api/v1/me/campaigns/{id}/claim`, body
168
+ `{}`: `status` `CLAIMED` (or under `data`).
169
+ - `<openapi>` is `openapi.qoder.sh` (Qoder) or `openapi.qoder.com.cn`
170
+ (Qoder CN). Other pages on those hosts are still refused.
171
+
172
+ Taken from [wallechfox/qoder-checkin](https://github.com/wallechfox/qoder-checkin),
173
+ which a user reports works. Not yet tried here with a real Qoder account.
174
+
154
175
  ## Not here
155
176
 
156
177
  - Qoder's usage and quota display.
package/index.mjs CHANGED
@@ -576,11 +576,14 @@ function qoderMessages(msgs) {
576
576
  // (Qoder: changing the Context "may change the rate"), so it is asked for
577
577
  // only when the conversation needs it. The request's size is counted
578
578
  // generously, a token for every 3 bytes of its UTF-8 JSON, so a window is
579
- // raised a little early rather than late.
580
- function windowFor(chat, m) {
579
+ // raised a little early rather than late, and the reply's max_tokens is
580
+ // counted in it: a window holds the prompt and the reply, so a
581
+ // conversation of 180K asking 32K for the reply is sent 400K, not the 200K
582
+ // it would overflow while magpie says the model takes 1M (yetone/magpie#700).
583
+ function windowFor(chat, m, reply = 0) {
581
584
  const base = m.defaultWindow || (m.config?.max_input_tokens > 0 ? m.config.max_input_tokens : 0) || m.context
582
585
  if (!m.windows?.length) return m.context
583
- const need = Math.ceil(Buffer.byteLength(JSON.stringify({ m: chat.messages ?? [], t: chat.tools ?? [] })) / 3)
586
+ const need = Math.ceil(Buffer.byteLength(JSON.stringify({ m: chat.messages ?? [], t: chat.tools ?? [] })) / 3) + Math.max(0, reply)
584
587
  if (base > 0 && need <= base) return base
585
588
  return m.windows.find((w) => w >= need) ?? m.windows[m.windows.length - 1]
586
589
  }
@@ -596,7 +599,7 @@ function qoderBody(chat, m) {
596
599
  const [thinking, effort] = effortFor(chat.reasoning_effort, m)
597
600
  const params = { enable_thinking: thinking, max_tokens: chat.max_completion_tokens || chat.max_tokens || 32000 }
598
601
  if (effort) params.reasoning_effort = effort
599
- const window = windowFor(chat, m)
602
+ const window = windowFor(chat, m, params.max_tokens)
600
603
  if (window > 0) params.context_length = window
601
604
  const body = {
602
605
  parameters: params,
@@ -950,6 +953,33 @@ async function fetchUsage(site, deviceToken) {
950
953
  return env
951
954
  }
952
955
 
956
+ // CAMPAIGNS is where Qoder lists the account's campaigns — the daily
957
+ // credits among them (actionType CLAIM_BENEFIT) — and claims one
958
+ // (POST …/{id}/claim), as Qoder's client does, on the device token.
959
+ const CAMPAIGNS = "/sash/api/v1/me/campaigns"
960
+
961
+ // campaignPage: url is the site's campaigns page or a claim under it, which
962
+ // the account's fetch sends as the account rather than as a chat.
963
+ function campaignPage(site, url) {
964
+ try {
965
+ const u = new URL(url)
966
+ return u.origin === new URL(site.openapi).origin && (u.pathname === CAMPAIGNS || /^\/sash\/api\/v1\/me\/campaigns\/[^/]+\/claim$/.test(u.pathname))
967
+ } catch {
968
+ return false
969
+ }
970
+ }
971
+
972
+ // campaignCall is url asked with the device token; Qoder's answer comes back
973
+ // as it is.
974
+ async function campaignCall(url, method, body, deviceToken, signal) {
975
+ return fetch(url, {
976
+ method,
977
+ headers: { Accept: "application/json", "Content-Type": "application/json", Authorization: `Bearer ${deviceToken}`, "Cosy-ClientType": "10", "User-Agent": "Qoder" },
978
+ body: method === "GET" ? undefined : body || "{}",
979
+ signal: signal ?? AbortSignal.timeout(20_000),
980
+ })
981
+ }
982
+
953
983
  // refreshDevice trades the device refresh token for a new pair; it rotates
954
984
  // too, so the caller saves it.
955
985
  async function refreshDevice(site, refresh, chat = false) {
@@ -1171,6 +1201,27 @@ const makePlugin = (site) => async ({ client }) => {
1171
1201
  }
1172
1202
  }
1173
1203
 
1204
+ // campaign is a campaigns page (the daily check-in) asked as the account:
1205
+ // a refused device token is rotated once, as usage does, and asked again
1206
+ const campaign = async (getAuth, input, url, init) => {
1207
+ const body = await bodyText(input, init)
1208
+ let cred
1209
+ try {
1210
+ cred = await fresh(getAuth)
1211
+ } catch (e) {
1212
+ return signedInError(e)
1213
+ }
1214
+ const method = String(init.method ?? input?.method ?? "GET").toUpperCase()
1215
+ try {
1216
+ let res = await campaignCall(url, method, body, cred.deviceToken, init.signal)
1217
+ if (res.status === 401 || res.status === 403) res = await campaignCall(url, method, body, await deviceToken(getAuth, cred.deviceToken), init.signal)
1218
+ return signed(res, renewed(cred))
1219
+ } catch (e) {
1220
+ if (e?.expired) return signedInError(e)
1221
+ return errorResponse({ status: 502, message: e?.message ?? String(e) })
1222
+ }
1223
+ }
1224
+
1174
1225
  const signedInError = (e) =>
1175
1226
  errorResponse({
1176
1227
  status: e instanceof SignInGone ? 401 : 502,
@@ -1241,6 +1292,7 @@ const makePlugin = (site) => async ({ client }) => {
1241
1292
  // every chat completion written as Qoder's own request
1242
1293
  async fetch(input, init = {}) {
1243
1294
  const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
1295
+ if (campaignPage(site, url)) return campaign(getAuth, input, url, init)
1244
1296
  if (!/\/chat\/completions$/.test(new URL(url).pathname))
1245
1297
  return errorResponse({ status: 404, message: "only chat completions are served" })
1246
1298
  let chat
@@ -1306,4 +1358,4 @@ export const QoderAuthPlugin = makePlugin(SITES.qoder)
1306
1358
  export const QoderCNAuthPlugin = makePlugin(SITES["qoder-cn"])
1307
1359
 
1308
1360
  // for tests
1309
- export const _internal = { parseUsage, gfmt, when, failure, modelInfo, qoderBody, SITES, decodeBody, events }
1361
+ export const _internal = { campaignPage, parseUsage, gfmt, when, failure, modelInfo, qoderBody, SITES, decodeBody, events }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-qoder-auth",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
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",