@magpie-community/opencode-qoder-auth 0.2.6 → 0.2.8

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 +52 -0
  2. package/index.mjs +187 -14
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -151,6 +151,58 @@ 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
+ ### Enterprise VPC
155
+
156
+ A Qoder CN enterprise on its own VPC instance signs in with **Sign in with
157
+ Qoder CN Enterprise (VPC)** (since 0.2.8). It asks for the instance: its
158
+ name (`acme`) or one of its addresses (`acme.vpc.qoder.com.cn`,
159
+ `https://acme.vpc.qoder.com.cn`). Only https and hosts under
160
+ `vpc.qoder.com.cn` are taken.
161
+
162
+ The account then uses the instance's own hosts, as Qoder CN's CLI
163
+ (`@qodercn-ai/qoderclicn` 1.1.64) does once a VPC instance is set. The
164
+ paths, client id and protocol are the public ones:
165
+
166
+ | | Public Qoder CN | VPC instance `acme` |
167
+ |---|---|---|
168
+ | Sign-in page | qoder.cn | acme.vpc.qoder.com.cn |
169
+ | Accounts: poll, tokens, user info, usage | openapi.qoder.com.cn | acme-openapi.vpc.qoder.com.cn |
170
+ | Models and chat | gateway.qoder.com.cn | acme-gateway.vpc.qoder.com.cn |
171
+
172
+ - The instance is kept with the account, so its refreshes, models, usage
173
+ and check-in go to the instance too. An account kept with an instance
174
+ that isn't one is asked to sign in again; it is never sent to the public
175
+ hosts.
176
+ - The account is named `<email> (<instance>)`, so it is listed apart from
177
+ the same person's public account.
178
+ - An enterprise's usage shows as the Enterprise plan.
179
+ - **Sign in with Qoder CN** is unchanged and still the first way. An
180
+ account signed in without an instance works as before.
181
+
182
+ The hosts and reply shapes come from a real VPC account's report
183
+ (yetone/magpie#312). This plugin has not been tried with a VPC account.
184
+
185
+ ## Daily check-in
186
+
187
+ Qoder gives credits for a daily claim (the Qoder client's campaign
188
+ `CLAIM_BENEFIT`, 100 Credits a day as reported), on both sites. magpie can
189
+ press it once a day for each account when its Qoder check-in switch is on
190
+ (Settings). It asks these pages through this plugin's fetch (since 0.2.7),
191
+ which sends them as the account, on the device token (`Bearer`,
192
+ `Cosy-ClientType: 10`) as usage is read; a refused device token is
193
+ rotated once and the page asked again.
194
+
195
+ - **List:** `GET <openapi>/sash/api/v1/me/campaigns`: `campaigns[]` with
196
+ `campaignId`, `actionType`, `claimStatus` (`CLAIMABLE`, `CLAIMED`),
197
+ `startAt`, `endAt`, `benefit.amount`.
198
+ - **Claim:** `POST <openapi>/sash/api/v1/me/campaigns/{id}/claim`, body
199
+ `{}`: `status` `CLAIMED` (or under `data`).
200
+ - `<openapi>` is `openapi.qoder.sh` (Qoder) or `openapi.qoder.com.cn`
201
+ (Qoder CN). Other pages on those hosts are still refused.
202
+
203
+ Taken from [wallechfox/qoder-checkin](https://github.com/wallechfox/qoder-checkin),
204
+ which a user reports works. Not yet tried here with a real Qoder account.
205
+
154
206
  ## Not here
155
207
 
156
208
  - Qoder's usage and quota display.
package/index.mjs CHANGED
@@ -25,6 +25,15 @@
25
25
  // gateway.qoder.com.cn, with the device-flow client id that CLI sends in
26
26
  // production and no redirect_uri. That CLI chats on the device token itself;
27
27
  // if qoder.cn won't trade it for a job token, the account does the same.
28
+ //
29
+ // A Qoder CN enterprise VPC account (yetone/magpie#312) is signed in to with
30
+ // its own method, which asks for the enterprise's instance name. Its calls go
31
+ // to the same paths on the instance's own hosts, as Qoder CN's CLI 1.1.64
32
+ // rewrites them once a VPC instance is set: qoder.cn becomes
33
+ // <instance>.vpc.qoder.com.cn, openapi.qoder.com.cn becomes
34
+ // <instance>-openapi.vpc.qoder.com.cn, and gateway.qoder.com.cn becomes
35
+ // <instance>-gateway.vpc.qoder.com.cn. The instance is kept on the account,
36
+ // and an account with none is served as before.
28
37
  import { createCipheriv, createHash, publicEncrypt, randomBytes, randomUUID, constants } from "node:crypto"
29
38
  import { STATUS_CODES } from "node:http"
30
39
 
@@ -180,6 +189,59 @@ function deviceExpiry(dt) {
180
189
  return Date.now() + DAY
181
190
  }
182
191
 
192
+ // ---- enterprise VPC (Qoder CN) --------------------------------------------------
193
+
194
+ const VPC_NAME = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/i
195
+
196
+ // vpcInstance is the instance named by what the user entered, as Qoder CN's
197
+ // CLI reads its VPC endpoint: a bare name ("acme"), or one of the instance's
198
+ // hosts (acme.vpc.qoder.com.cn, acme-openapi.…, acme-gateway.…), here also as
199
+ // an https:// address with no path. Anything else (http://, an IP, another
200
+ // domain, a path or query) names none: undefined.
201
+ function vpcInstance(input, domain) {
202
+ let e = String(input ?? "").trim().toLowerCase()
203
+ if (!e || !domain) return undefined
204
+ if (/^[a-z][a-z\d+.-]*:\/\//.test(e)) {
205
+ let u
206
+ try {
207
+ u = new URL(e)
208
+ } catch {
209
+ return undefined
210
+ }
211
+ if (u.protocol !== "https:" || u.username || u.password || u.port || u.search || u.hash || (u.pathname !== "" && u.pathname !== "/")) return undefined
212
+ if (e.endsWith("/")) e = e.slice(0, -1)
213
+ if (u.hostname + "" !== e.slice("https://".length)) return undefined
214
+ e = u.hostname
215
+ }
216
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(e)) return undefined
217
+ let name = e
218
+ if (e.endsWith("." + domain)) {
219
+ name = e.slice(0, e.length - domain.length - 1)
220
+ if (name.endsWith("-openapi") || name.endsWith("-gateway")) name = name.slice(0, -8)
221
+ } else if (e.includes(".")) return undefined
222
+ return VPC_NAME.test(name) ? name : undefined
223
+ }
224
+
225
+ // vpcSite is site served from a VPC instance's own hosts, the paths
226
+ // unchanged; undefined when the site has no VPC or instance names none.
227
+ function vpcSite(site, instance) {
228
+ const name = vpcInstance(instance, site.vpcDomain)
229
+ if (!name) return undefined
230
+ const d = site.vpcDomain
231
+ return { ...site, web: `https://${name}.${d}`, openapi: `https://${name}-openapi.${d}`, api: `https://${name}-gateway.${d}`, vpc: name }
232
+ }
233
+
234
+ // siteOf is the site an account is served from: its VPC instance's when it
235
+ // was signed in to one, else site. An instance on record that names none
236
+ // is not read as "no VPC", which would send the enterprise's tokens to the
237
+ // public hosts: the account has to be signed in to again.
238
+ function siteOf(site, a) {
239
+ if (a?.vpc === undefined || a?.vpc === null || a?.vpc === "") return site
240
+ const s = vpcSite(site, a.vpc)
241
+ if (!s) throw new SignInGone(`${site.name}: the account's VPC instance "${a.vpc}" isn't one; sign in again`)
242
+ return s
243
+ }
244
+
183
245
  async function deviceSignIn(site) {
184
246
  const verifier = b64url(randomBytes(64))
185
247
  const challenge = b64url(createHash("sha256").update(verifier).digest())
@@ -217,20 +279,29 @@ async function deviceSignIn(site) {
217
279
  } else throw new Error(`${site.name} job token: status ${jr.status}: ${jr.text.trim().slice(0, 300)}`)
218
280
  let email = ""
219
281
  let name = ""
282
+ let uid = dt.user_id
220
283
  try {
221
284
  const ui = await openapi(site, "/api/v1/userinfo", { token: dt.token })
222
- if (ui.status >= 200 && ui.status < 300) [email, name] = [ui.json?.email ?? "", ui.json?.name ?? ""]
285
+ if (ui.status >= 200 && ui.status < 300) {
286
+ ;[email, name] = [ui.json?.email ?? "", ui.json?.name ?? ""]
287
+ // a VPC poll may give the token alone: the user is then the one
288
+ // user info names (id, or its user_id / uid aliases)
289
+ if (site.vpc && !String(uid ?? "").trim()) uid = String(ui.json?.id ?? ui.json?.user_id ?? ui.json?.uid ?? "") || undefined
290
+ }
223
291
  } catch {}
292
+ if (site.vpc && !String(uid ?? "").trim()) throw new Error(`${site.name} (${site.vpc}): the sign-in named no user`)
224
293
  return {
225
294
  type: "success",
226
295
  ...chat,
227
- accountId: email || dt.user_id,
228
- uid: dt.user_id,
296
+ // a VPC account is told apart from the same person's public one
297
+ accountId: site.vpc ? `${email || uid} (${site.vpc})` : email || dt.user_id,
298
+ uid,
229
299
  email,
230
300
  name: name || dt.user_name || "",
231
301
  machineId,
232
302
  deviceToken: dt.token,
233
303
  deviceRefresh: dt.refresh_token ?? "",
304
+ ...(site.vpc ? { vpc: site.vpc } : {}),
234
305
  }
235
306
  },
236
307
  }
@@ -953,6 +1024,48 @@ async function fetchUsage(site, deviceToken) {
953
1024
  return env
954
1025
  }
955
1026
 
1027
+ // CAMPAIGNS is where Qoder lists the account's campaigns — the daily
1028
+ // credits among them (actionType CLAIM_BENEFIT) — and claims one
1029
+ // (POST …/{id}/claim), as Qoder's client does, on the device token.
1030
+ const CAMPAIGNS = "/sash/api/v1/me/campaigns"
1031
+
1032
+ // campaignPage: url is the site's campaigns page or a claim under it, which
1033
+ // the account's fetch sends as the account rather than as a chat.
1034
+ function campaignPage(site, url) {
1035
+ try {
1036
+ const u = new URL(url)
1037
+ return u.origin === new URL(site.openapi).origin && (u.pathname === CAMPAIGNS || /^\/sash\/api\/v1\/me\/campaigns\/[^/]+\/claim$/.test(u.pathname))
1038
+ } catch {
1039
+ return false
1040
+ }
1041
+ }
1042
+
1043
+ // campaignTarget is where a campaigns page url is asked for an account
1044
+ // served from s: url itself on s's accounts host, or, on a VPC account, the
1045
+ // same page on its instance when url names the public host (magpie's
1046
+ // check-in knows only that), as Qoder CN's CLI rewrites it. null: url isn't
1047
+ // one.
1048
+ function campaignTarget(base, s, url) {
1049
+ if (campaignPage(s, url)) return url
1050
+ if (s === base || !campaignPage(base, url)) return null
1051
+ const u = new URL(url)
1052
+ const to = new URL(s.openapi)
1053
+ u.protocol = to.protocol
1054
+ u.host = to.host
1055
+ return u.href
1056
+ }
1057
+
1058
+ // campaignCall is url asked with the device token; Qoder's answer comes back
1059
+ // as it is.
1060
+ async function campaignCall(url, method, body, deviceToken, signal) {
1061
+ return fetch(url, {
1062
+ method,
1063
+ headers: { Accept: "application/json", "Content-Type": "application/json", Authorization: `Bearer ${deviceToken}`, "Cosy-ClientType": "10", "User-Agent": "Qoder" },
1064
+ body: method === "GET" ? undefined : body || "{}",
1065
+ signal: signal ?? AbortSignal.timeout(20_000),
1066
+ })
1067
+ }
1068
+
956
1069
  // refreshDevice trades the device refresh token for a new pair; it rotates
957
1070
  // too, so the caller saves it.
958
1071
  async function refreshDevice(site, refresh, chat = false) {
@@ -1053,13 +1166,15 @@ const SITES = {
1053
1166
  api: "https://gateway.qoder.com.cn",
1054
1167
  redirect: "", // Qoder CN's CLI sends none
1055
1168
  deviceChat: true, // a refused job token falls back to the CLI's device-token chat
1169
+ // an enterprise's own instance, <instance>.vpc.qoder.com.cn and its
1170
+ // -openapi and -gateway hosts, as Qoder CN's CLI builds it in
1171
+ vpcDomain: "vpc.qoder.com.cn",
1056
1172
  models: CN_MODELS,
1057
1173
  },
1058
1174
  }
1059
1175
 
1060
1176
  const makePlugin = (site) => async ({ client }) => {
1061
1177
  const ID = site.id
1062
- const CHAT_URL = site.api + CHAT_PATH
1063
1178
  // serializes checking, rotating and saving tokens: a refresh token is
1064
1179
  // spent once, so two refreshes would spend it twice
1065
1180
  let lock = Promise.resolve()
@@ -1091,13 +1206,14 @@ const makePlugin = (site) => async ({ client }) => {
1091
1206
  // renew spends a's chat refresh token, under the lock: the fields it
1092
1207
  // changes, recorded in spent.
1093
1208
  const renew = async (a) => {
1209
+ const s = siteOf(site, a)
1094
1210
  if (a.deviceChat) {
1095
1211
  // the device token is the chat token: renewed as a device token,
1096
1212
  // both pairs being the one pair
1097
- const dt = await refreshDevice(site, a.refresh, true)
1213
+ const dt = await refreshDevice(s, a.refresh, true)
1098
1214
  return spend(a.refresh, { access: dt.token, refresh: dt.refresh, expires: dt.expires, deviceToken: dt.token, deviceRefresh: dt.refresh })
1099
1215
  }
1100
- const jt = await refreshJob(site, a.refresh, a.accountId || a.email || a.uid)
1216
+ const jt = await refreshJob(s, a.refresh, a.accountId || a.email || a.uid)
1101
1217
  return spend(a.refresh, { access: jt.token, refresh: jt.refresh_token, expires: expiresAt(jt) })
1102
1218
  }
1103
1219
  // each account's listing, the model configs a request carries
@@ -1120,6 +1236,7 @@ const makePlugin = (site) => async ({ client }) => {
1120
1236
  locked(async () => {
1121
1237
  const stored = await getAuth()
1122
1238
  if (stored?.type !== "oauth" || !stored.access || !stored.uid) throw new SignInGone(`${site.name}: not signed in`)
1239
+ siteOf(site, stored) // a VPC instance on record that names none is no sign-in
1123
1240
  // renewed already (by magpie's auth.refresh), the store not yet saying so
1124
1241
  const a = latest(stored)
1125
1242
  if (a.expires - Date.now() > REFRESH_LEAD) return a
@@ -1132,7 +1249,7 @@ const makePlugin = (site) => async ({ client }) => {
1132
1249
  const models = async (cred, again = false) => {
1133
1250
  let l = listings.get(cred.uid)
1134
1251
  if (!l || again) {
1135
- l = modelInfos(await fetchListing(site, cred))
1252
+ l = modelInfos(await fetchListing(siteOf(site, cred), cred))
1136
1253
  listings.set(cred.uid, l)
1137
1254
  }
1138
1255
  return l
@@ -1144,7 +1261,7 @@ const makePlugin = (site) => async ({ client }) => {
1144
1261
  locked(async () => {
1145
1262
  const a = latest(await getAuth())
1146
1263
  if (a?.deviceToken !== attempted) return a?.deviceToken
1147
- const dt = await refreshDevice(site, a.deviceRefresh, !!a.deviceChat)
1264
+ const dt = await refreshDevice(siteOf(site, a), a.deviceRefresh, !!a.deviceChat)
1148
1265
  const next = { ...a, deviceToken: dt.token, deviceRefresh: dt.refresh }
1149
1266
  // the pair it spent was the chat pair too
1150
1267
  if (a.deviceChat) Object.assign(next, spend(a.refresh, { access: dt.token, refresh: dt.refresh, expires: dt.expires, deviceToken: dt.token, deviceRefresh: dt.refresh }))
@@ -1163,10 +1280,10 @@ const makePlugin = (site) => async ({ client }) => {
1163
1280
  if (renewed(cred)) signIn = "renewed"
1164
1281
  let env
1165
1282
  try {
1166
- env = await fetchUsage(site, cred.deviceToken)
1283
+ env = await fetchUsage(siteOf(site, cred), cred.deviceToken)
1167
1284
  } catch (e) {
1168
1285
  if (!(e instanceof UsageStatus) || (e.status !== 401 && e.status !== 403)) throw e
1169
- env = await fetchUsage(site, await deviceToken(getAuth, cred.deviceToken))
1286
+ env = await fetchUsage(siteOf(site, cred), await deviceToken(getAuth, cred.deviceToken))
1170
1287
  }
1171
1288
  return { ...parseUsage(env), signIn }
1172
1289
  } catch (e) {
@@ -1174,6 +1291,29 @@ const makePlugin = (site) => async ({ client }) => {
1174
1291
  }
1175
1292
  }
1176
1293
 
1294
+ // campaign is a campaigns page (the daily check-in) asked as the account:
1295
+ // a refused device token is rotated once, as usage does, and asked again
1296
+ const campaign = async (getAuth, input, url, init) => {
1297
+ const body = await bodyText(input, init)
1298
+ let cred
1299
+ try {
1300
+ cred = await fresh(getAuth)
1301
+ url = campaignTarget(site, siteOf(site, cred), url)
1302
+ } catch (e) {
1303
+ return signedInError(e)
1304
+ }
1305
+ if (!url) return errorResponse({ status: 404, message: "only chat completions are served" })
1306
+ const method = String(init.method ?? input?.method ?? "GET").toUpperCase()
1307
+ try {
1308
+ let res = await campaignCall(url, method, body, cred.deviceToken, init.signal)
1309
+ if (res.status === 401 || res.status === 403) res = await campaignCall(url, method, body, await deviceToken(getAuth, cred.deviceToken), init.signal)
1310
+ return signed(res, renewed(cred))
1311
+ } catch (e) {
1312
+ if (e?.expired) return signedInError(e)
1313
+ return errorResponse({ status: 502, message: e?.message ?? String(e) })
1314
+ }
1315
+ }
1316
+
1177
1317
  const signedInError = (e) =>
1178
1318
  errorResponse({
1179
1319
  status: e instanceof SignInGone ? 401 : 502,
@@ -1192,6 +1332,7 @@ const makePlugin = (site) => async ({ client }) => {
1192
1332
  return errorResponse({ status: 400, message: e.message })
1193
1333
  }
1194
1334
  if (!m) return errorResponse({ status: 400, message: `unknown or disabled model "${chat.model}"` })
1335
+ const CHAT_URL = siteOf(site, cred).api + CHAT_PATH
1195
1336
  const wire = encodeBody(JSON.stringify(qoderBody(chat, m)))
1196
1337
  const headers = {
1197
1338
  ...cosyHeaders(CHAT_URL, cred, wire),
@@ -1238,12 +1379,17 @@ const makePlugin = (site) => async ({ client }) => {
1238
1379
  async loader(getAuth) {
1239
1380
  const a = await getAuth()
1240
1381
  if (a?.type !== "oauth") return {}
1382
+ let base = site
1383
+ try {
1384
+ base = siteOf(site, a)
1385
+ } catch {} // its requests say so
1241
1386
  return {
1242
- baseURL: site.api,
1387
+ baseURL: base.api,
1243
1388
  apiKey: "qoder",
1244
1389
  // every chat completion written as Qoder's own request
1245
1390
  async fetch(input, init = {}) {
1246
1391
  const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
1392
+ if (campaignPage(site, url) || campaignPage(base, url)) return campaign(getAuth, input, url, init)
1247
1393
  if (!/\/chat\/completions$/.test(new URL(url).pathname))
1248
1394
  return errorResponse({ status: 404, message: "only chat completions are served" })
1249
1395
  let chat
@@ -1262,7 +1408,33 @@ const makePlugin = (site) => async ({ client }) => {
1262
1408
  },
1263
1409
  }
1264
1410
  },
1265
- methods: [{ type: "oauth", label: `Sign in with ${site.name}`, authorize: () => deviceSignIn(site) }],
1411
+ methods: [
1412
+ { type: "oauth", label: `Sign in with ${site.name}`, authorize: () => deviceSignIn(site) },
1413
+ // an enterprise VPC instance (Qoder CN), a method of its own so
1414
+ // that the sign-in above asks nothing new
1415
+ ...(site.vpcDomain
1416
+ ? [
1417
+ {
1418
+ type: "oauth",
1419
+ label: `Sign in with ${site.name} Enterprise (VPC)`,
1420
+ prompts: [
1421
+ {
1422
+ type: "text",
1423
+ key: "vpc",
1424
+ message: `Your enterprise's VPC instance: its name, or its address (<instance>.${site.vpcDomain})`,
1425
+ placeholder: `acme or acme.${site.vpcDomain}`,
1426
+ validate: (v) => (vpcInstance(v, site.vpcDomain) ? undefined : `Not a VPC instance: enter its name (letters, digits, -) or https://<instance>.${site.vpcDomain}`),
1427
+ },
1428
+ ],
1429
+ authorize: (inputs) => {
1430
+ const s = vpcSite(site, inputs?.vpc)
1431
+ if (!s) throw new Error(`Not a VPC instance: ${String(inputs?.vpc ?? "").slice(0, 100)}`)
1432
+ return deviceSignIn(s)
1433
+ },
1434
+ },
1435
+ ]
1436
+ : []),
1437
+ ],
1266
1438
  usage,
1267
1439
  },
1268
1440
  async config(config) {
@@ -1294,8 +1466,9 @@ const makePlugin = (site) => async ({ client }) => {
1294
1466
  try {
1295
1467
  const ms = await models(cred, true)
1296
1468
  if (!ms.length) return provider.models
1469
+ const s = siteOf(site, cred)
1297
1470
  return Object.fromEntries(
1298
- 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 })]),
1471
+ ms.map((m) => [m.key, runtimeModel(s, { 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 })]),
1299
1472
  )
1300
1473
  } catch {
1301
1474
  return provider.models
@@ -1309,4 +1482,4 @@ export const QoderAuthPlugin = makePlugin(SITES.qoder)
1309
1482
  export const QoderCNAuthPlugin = makePlugin(SITES["qoder-cn"])
1310
1483
 
1311
1484
  // for tests
1312
- export const _internal = { parseUsage, gfmt, when, failure, modelInfo, qoderBody, SITES, decodeBody, events }
1485
+ export const _internal = { campaignPage, campaignTarget, vpcInstance, vpcSite, 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.6",
3
+ "version": "0.2.8",
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",