@magpie-community/opencode-qoder-auth 0.2.7 → 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 +31 -0
  2. package/index.mjs +139 -15
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -151,6 +151,37 @@ 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
+
154
185
  ## Daily check-in
155
186
 
156
187
  Qoder gives credits for a daily claim (the Qoder client's campaign
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
  }
@@ -969,6 +1040,21 @@ function campaignPage(site, url) {
969
1040
  }
970
1041
  }
971
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
+
972
1058
  // campaignCall is url asked with the device token; Qoder's answer comes back
973
1059
  // as it is.
974
1060
  async function campaignCall(url, method, body, deviceToken, signal) {
@@ -1080,13 +1166,15 @@ const SITES = {
1080
1166
  api: "https://gateway.qoder.com.cn",
1081
1167
  redirect: "", // Qoder CN's CLI sends none
1082
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",
1083
1172
  models: CN_MODELS,
1084
1173
  },
1085
1174
  }
1086
1175
 
1087
1176
  const makePlugin = (site) => async ({ client }) => {
1088
1177
  const ID = site.id
1089
- const CHAT_URL = site.api + CHAT_PATH
1090
1178
  // serializes checking, rotating and saving tokens: a refresh token is
1091
1179
  // spent once, so two refreshes would spend it twice
1092
1180
  let lock = Promise.resolve()
@@ -1118,13 +1206,14 @@ const makePlugin = (site) => async ({ client }) => {
1118
1206
  // renew spends a's chat refresh token, under the lock: the fields it
1119
1207
  // changes, recorded in spent.
1120
1208
  const renew = async (a) => {
1209
+ const s = siteOf(site, a)
1121
1210
  if (a.deviceChat) {
1122
1211
  // the device token is the chat token: renewed as a device token,
1123
1212
  // both pairs being the one pair
1124
- const dt = await refreshDevice(site, a.refresh, true)
1213
+ const dt = await refreshDevice(s, a.refresh, true)
1125
1214
  return spend(a.refresh, { access: dt.token, refresh: dt.refresh, expires: dt.expires, deviceToken: dt.token, deviceRefresh: dt.refresh })
1126
1215
  }
1127
- 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)
1128
1217
  return spend(a.refresh, { access: jt.token, refresh: jt.refresh_token, expires: expiresAt(jt) })
1129
1218
  }
1130
1219
  // each account's listing, the model configs a request carries
@@ -1147,6 +1236,7 @@ const makePlugin = (site) => async ({ client }) => {
1147
1236
  locked(async () => {
1148
1237
  const stored = await getAuth()
1149
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
1150
1240
  // renewed already (by magpie's auth.refresh), the store not yet saying so
1151
1241
  const a = latest(stored)
1152
1242
  if (a.expires - Date.now() > REFRESH_LEAD) return a
@@ -1159,7 +1249,7 @@ const makePlugin = (site) => async ({ client }) => {
1159
1249
  const models = async (cred, again = false) => {
1160
1250
  let l = listings.get(cred.uid)
1161
1251
  if (!l || again) {
1162
- l = modelInfos(await fetchListing(site, cred))
1252
+ l = modelInfos(await fetchListing(siteOf(site, cred), cred))
1163
1253
  listings.set(cred.uid, l)
1164
1254
  }
1165
1255
  return l
@@ -1171,7 +1261,7 @@ const makePlugin = (site) => async ({ client }) => {
1171
1261
  locked(async () => {
1172
1262
  const a = latest(await getAuth())
1173
1263
  if (a?.deviceToken !== attempted) return a?.deviceToken
1174
- const dt = await refreshDevice(site, a.deviceRefresh, !!a.deviceChat)
1264
+ const dt = await refreshDevice(siteOf(site, a), a.deviceRefresh, !!a.deviceChat)
1175
1265
  const next = { ...a, deviceToken: dt.token, deviceRefresh: dt.refresh }
1176
1266
  // the pair it spent was the chat pair too
1177
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 }))
@@ -1190,10 +1280,10 @@ const makePlugin = (site) => async ({ client }) => {
1190
1280
  if (renewed(cred)) signIn = "renewed"
1191
1281
  let env
1192
1282
  try {
1193
- env = await fetchUsage(site, cred.deviceToken)
1283
+ env = await fetchUsage(siteOf(site, cred), cred.deviceToken)
1194
1284
  } catch (e) {
1195
1285
  if (!(e instanceof UsageStatus) || (e.status !== 401 && e.status !== 403)) throw e
1196
- env = await fetchUsage(site, await deviceToken(getAuth, cred.deviceToken))
1286
+ env = await fetchUsage(siteOf(site, cred), await deviceToken(getAuth, cred.deviceToken))
1197
1287
  }
1198
1288
  return { ...parseUsage(env), signIn }
1199
1289
  } catch (e) {
@@ -1208,9 +1298,11 @@ const makePlugin = (site) => async ({ client }) => {
1208
1298
  let cred
1209
1299
  try {
1210
1300
  cred = await fresh(getAuth)
1301
+ url = campaignTarget(site, siteOf(site, cred), url)
1211
1302
  } catch (e) {
1212
1303
  return signedInError(e)
1213
1304
  }
1305
+ if (!url) return errorResponse({ status: 404, message: "only chat completions are served" })
1214
1306
  const method = String(init.method ?? input?.method ?? "GET").toUpperCase()
1215
1307
  try {
1216
1308
  let res = await campaignCall(url, method, body, cred.deviceToken, init.signal)
@@ -1240,6 +1332,7 @@ const makePlugin = (site) => async ({ client }) => {
1240
1332
  return errorResponse({ status: 400, message: e.message })
1241
1333
  }
1242
1334
  if (!m) return errorResponse({ status: 400, message: `unknown or disabled model "${chat.model}"` })
1335
+ const CHAT_URL = siteOf(site, cred).api + CHAT_PATH
1243
1336
  const wire = encodeBody(JSON.stringify(qoderBody(chat, m)))
1244
1337
  const headers = {
1245
1338
  ...cosyHeaders(CHAT_URL, cred, wire),
@@ -1286,13 +1379,17 @@ const makePlugin = (site) => async ({ client }) => {
1286
1379
  async loader(getAuth) {
1287
1380
  const a = await getAuth()
1288
1381
  if (a?.type !== "oauth") return {}
1382
+ let base = site
1383
+ try {
1384
+ base = siteOf(site, a)
1385
+ } catch {} // its requests say so
1289
1386
  return {
1290
- baseURL: site.api,
1387
+ baseURL: base.api,
1291
1388
  apiKey: "qoder",
1292
1389
  // every chat completion written as Qoder's own request
1293
1390
  async fetch(input, init = {}) {
1294
1391
  const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
1295
- if (campaignPage(site, url)) return campaign(getAuth, input, url, init)
1392
+ if (campaignPage(site, url) || campaignPage(base, url)) return campaign(getAuth, input, url, init)
1296
1393
  if (!/\/chat\/completions$/.test(new URL(url).pathname))
1297
1394
  return errorResponse({ status: 404, message: "only chat completions are served" })
1298
1395
  let chat
@@ -1311,7 +1408,33 @@ const makePlugin = (site) => async ({ client }) => {
1311
1408
  },
1312
1409
  }
1313
1410
  },
1314
- 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
+ ],
1315
1438
  usage,
1316
1439
  },
1317
1440
  async config(config) {
@@ -1343,8 +1466,9 @@ const makePlugin = (site) => async ({ client }) => {
1343
1466
  try {
1344
1467
  const ms = await models(cred, true)
1345
1468
  if (!ms.length) return provider.models
1469
+ const s = siteOf(site, cred)
1346
1470
  return Object.fromEntries(
1347
- 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 })]),
1348
1472
  )
1349
1473
  } catch {
1350
1474
  return provider.models
@@ -1358,4 +1482,4 @@ export const QoderAuthPlugin = makePlugin(SITES.qoder)
1358
1482
  export const QoderCNAuthPlugin = makePlugin(SITES["qoder-cn"])
1359
1483
 
1360
1484
  // for tests
1361
- export const _internal = { campaignPage, 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.7",
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",