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

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 +55 -2
  2. package/index.mjs +218 -19
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -151,16 +151,65 @@ 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
157
188
  `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
189
+ claim it for each account when its Qoder check-in switch is on
159
190
  (Settings). It asks these pages through this plugin's fetch (since 0.2.7),
160
191
  which sends them as the account, on the device token (`Bearer`,
161
192
  `Cosy-ClientType: 10`) as usage is read; a refused device token is
162
193
  rotated once and the page asked again.
163
194
 
195
+ International campaigns also need Qoder's native device identity:
196
+ `Cosy-MachineToken` and `Cosy-MachineType`. Without them, the server can
197
+ return HTTP 200 but omit the daily claim. The plugin reads them from
198
+ Qoder's own `runtime-info` helper for the signed-in uid, sharing an
199
+ in-flight read and caching it in memory for an hour. The same identity
200
+ goes on the list, the claim and a device-token retry; it is not saved in
201
+ the account's credentials.
202
+
203
+ - Install Qoder desktop or run Qoder CLI to make its native runtime
204
+ available. The plugin finds the desktop's standard Windows/macOS
205
+ location or the CLI's matching `~/.qoder/.bin/umid-*` cache.
206
+ - For another installation location, set `QODER_RUNTIME_INFO` to the
207
+ absolute path of Qoder's installed `runtime-info` executable
208
+ (`runtime-info.exe` on Windows), with its accompanying SDK files.
209
+ - A missing helper or invalid identity is an error, rather than a false
210
+ "no activity" result. Qoder CN keeps its existing requests and does
211
+ not need this local helper.
212
+
164
213
  - **List:** `GET <openapi>/sash/api/v1/me/campaigns`: `campaigns[]` with
165
214
  `campaignId`, `actionType`, `claimStatus` (`CLAIMABLE`, `CLAIMED`),
166
215
  `startAt`, `endAt`, `benefit.amount`.
@@ -170,7 +219,11 @@ rotated once and the page asked again.
170
219
  (Qoder CN). Other pages on those hosts are still refused.
171
220
 
172
221
  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.
222
+ which a user reports works, and Qoder desktop 0.4.3's campaign requests.
223
+ International campaign discovery was checked with a real account on
224
+ 2026-10-08: the native identity exposed the claimable 100 credits, and
225
+ omitting either header hid them. This verification only queried the
226
+ activity; a live claim was not performed.
174
227
 
175
228
  ## Not here
176
229
 
package/index.mjs CHANGED
@@ -25,8 +25,21 @@
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"
38
+ import { execFile } from "node:child_process"
39
+ import { readdir, stat } from "node:fs/promises"
29
40
  import { STATUS_CODES } from "node:http"
41
+ import { homedir } from "node:os"
42
+ import { join, resolve } from "node:path"
30
43
 
31
44
  const CHAT_PATH = "/algo/api/v2/service/pro/sse/agent_chat_generation?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1"
32
45
  const MODELS_PATH = "/algo/api/v2/model/list?Encode=1"
@@ -180,6 +193,59 @@ function deviceExpiry(dt) {
180
193
  return Date.now() + DAY
181
194
  }
182
195
 
196
+ // ---- enterprise VPC (Qoder CN) --------------------------------------------------
197
+
198
+ const VPC_NAME = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/i
199
+
200
+ // vpcInstance is the instance named by what the user entered, as Qoder CN's
201
+ // CLI reads its VPC endpoint: a bare name ("acme"), or one of the instance's
202
+ // hosts (acme.vpc.qoder.com.cn, acme-openapi.…, acme-gateway.…), here also as
203
+ // an https:// address with no path. Anything else (http://, an IP, another
204
+ // domain, a path or query) names none: undefined.
205
+ function vpcInstance(input, domain) {
206
+ let e = String(input ?? "").trim().toLowerCase()
207
+ if (!e || !domain) return undefined
208
+ if (/^[a-z][a-z\d+.-]*:\/\//.test(e)) {
209
+ let u
210
+ try {
211
+ u = new URL(e)
212
+ } catch {
213
+ return undefined
214
+ }
215
+ if (u.protocol !== "https:" || u.username || u.password || u.port || u.search || u.hash || (u.pathname !== "" && u.pathname !== "/")) return undefined
216
+ if (e.endsWith("/")) e = e.slice(0, -1)
217
+ if (u.hostname + "" !== e.slice("https://".length)) return undefined
218
+ e = u.hostname
219
+ }
220
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(e)) return undefined
221
+ let name = e
222
+ if (e.endsWith("." + domain)) {
223
+ name = e.slice(0, e.length - domain.length - 1)
224
+ if (name.endsWith("-openapi") || name.endsWith("-gateway")) name = name.slice(0, -8)
225
+ } else if (e.includes(".")) return undefined
226
+ return VPC_NAME.test(name) ? name : undefined
227
+ }
228
+
229
+ // vpcSite is site served from a VPC instance's own hosts, the paths
230
+ // unchanged; undefined when the site has no VPC or instance names none.
231
+ function vpcSite(site, instance) {
232
+ const name = vpcInstance(instance, site.vpcDomain)
233
+ if (!name) return undefined
234
+ const d = site.vpcDomain
235
+ return { ...site, web: `https://${name}.${d}`, openapi: `https://${name}-openapi.${d}`, api: `https://${name}-gateway.${d}`, vpc: name }
236
+ }
237
+
238
+ // siteOf is the site an account is served from: its VPC instance's when it
239
+ // was signed in to one, else site. An instance on record that names none
240
+ // is not read as "no VPC", which would send the enterprise's tokens to the
241
+ // public hosts: the account has to be signed in to again.
242
+ function siteOf(site, a) {
243
+ if (a?.vpc === undefined || a?.vpc === null || a?.vpc === "") return site
244
+ const s = vpcSite(site, a.vpc)
245
+ if (!s) throw new SignInGone(`${site.name}: the account's VPC instance "${a.vpc}" isn't one; sign in again`)
246
+ return s
247
+ }
248
+
183
249
  async function deviceSignIn(site) {
184
250
  const verifier = b64url(randomBytes(64))
185
251
  const challenge = b64url(createHash("sha256").update(verifier).digest())
@@ -217,20 +283,29 @@ async function deviceSignIn(site) {
217
283
  } else throw new Error(`${site.name} job token: status ${jr.status}: ${jr.text.trim().slice(0, 300)}`)
218
284
  let email = ""
219
285
  let name = ""
286
+ let uid = dt.user_id
220
287
  try {
221
288
  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 ?? ""]
289
+ if (ui.status >= 200 && ui.status < 300) {
290
+ ;[email, name] = [ui.json?.email ?? "", ui.json?.name ?? ""]
291
+ // a VPC poll may give the token alone: the user is then the one
292
+ // user info names (id, or its user_id / uid aliases)
293
+ if (site.vpc && !String(uid ?? "").trim()) uid = String(ui.json?.id ?? ui.json?.user_id ?? ui.json?.uid ?? "") || undefined
294
+ }
223
295
  } catch {}
296
+ if (site.vpc && !String(uid ?? "").trim()) throw new Error(`${site.name} (${site.vpc}): the sign-in named no user`)
224
297
  return {
225
298
  type: "success",
226
299
  ...chat,
227
- accountId: email || dt.user_id,
228
- uid: dt.user_id,
300
+ // a VPC account is told apart from the same person's public one
301
+ accountId: site.vpc ? `${email || uid} (${site.vpc})` : email || dt.user_id,
302
+ uid,
229
303
  email,
230
304
  name: name || dt.user_name || "",
231
305
  machineId,
232
306
  deviceToken: dt.token,
233
307
  deviceRefresh: dt.refresh_token ?? "",
308
+ ...(site.vpc ? { vpc: site.vpc } : {}),
234
309
  }
235
310
  },
236
311
  }
@@ -957,6 +1032,59 @@ async function fetchUsage(site, deviceToken) {
957
1032
  // credits among them (actionType CLAIM_BENEFIT) — and claims one
958
1033
  // (POST …/{id}/claim), as Qoder's client does, on the device token.
959
1034
  const CAMPAIGNS = "/sash/api/v1/me/campaigns"
1035
+ const CAMPAIGN_MACHINE_TTL = 60 * 60 * 1000 // Qoder's desktop client renews its native identity hourly
1036
+
1037
+ // campaignRuntime uses Qoder's own native device identity helper, installed
1038
+ // by its desktop client or cached by its CLI. A made-up token (including the
1039
+ // chat envelope's machine id and type 5) doesn't reveal international claims.
1040
+ async function campaignRuntime({ home = homedir(), platform = process.platform, arch = process.arch, env = process.env } = {}) {
1041
+ if (env.QODER_RUNTIME_INFO) return resolve(env.QODER_RUNTIME_INFO)
1042
+ const name = platform === "win32" ? "runtime-info.exe" : "runtime-info"
1043
+ const paths = []
1044
+ if (platform === "win32") {
1045
+ const local = env.LOCALAPPDATA || join(home, "AppData", "Local")
1046
+ paths.push(join(local, "Programs", "Qoder", "resources", "umid", name))
1047
+ } else if (platform === "darwin") {
1048
+ for (const dir of ["/Applications", join(home, "Applications")]) paths.push(join(dir, "Qoder.app", "Contents", "Resources", "umid", name))
1049
+ }
1050
+ for (const path of paths) if ((await stat(path).catch(() => null))?.isFile()) return path
1051
+ const cache = join(home, ".qoder", ".bin")
1052
+ const dirs = (await readdir(cache, { withFileTypes: true }).catch(() => []))
1053
+ .filter((d) => d.isDirectory() && d.name.startsWith(`umid-${platform}-${arch}-`))
1054
+ const cached = await Promise.all(dirs.map(async (d) => ({ path: join(cache, d.name, name), time: (await stat(join(cache, d.name))).mtimeMs })))
1055
+ for (const { path } of cached.sort((a, b) => b.time - a.time)) if ((await stat(path).catch(() => null))?.isFile()) return path
1056
+ throw new Error("Qoder check-in: device identity runtime not found; install Qoder desktop or run Qoder CLI, or set QODER_RUNTIME_INFO to its runtime-info executable")
1057
+ }
1058
+
1059
+ // Read the SDK identity as Qoder does: environment 3 is international; on
1060
+ // Windows and macOS the uid goes over stdin, never into the command line.
1061
+ async function campaignMachine(uid) {
1062
+ if (!uid) throw new Error("Qoder check-in: device identity needs the account uid; sign in again")
1063
+ const file = await campaignRuntime()
1064
+ const args = process.platform === "linux" ? ["3"] : ["3", "--account-stdin"]
1065
+ const text = await new Promise((resolve, reject) => {
1066
+ const child = execFile(file, args, { timeout: 20_000, windowsHide: true, maxBuffer: 1 << 20 }, (err, out) => {
1067
+ if (err) reject(err)
1068
+ else resolve(out)
1069
+ })
1070
+ child.stdin?.on("error", () => {})
1071
+ child.stdin?.end(process.platform === "linux" ? undefined : JSON.stringify({ account: uid }) + "\n")
1072
+ }).catch(() => {
1073
+ throw new Error("Qoder check-in: could not read device identity; restart or update Qoder, or check QODER_RUNTIME_INFO")
1074
+ })
1075
+ try {
1076
+ const identity = JSON.parse(text.trim().split(/\r?\n/, 1)[0])
1077
+ const headers = {}
1078
+ for (const [key, field] of [["Cosy-MachineToken", "machineToken"], ["Cosy-MachineType", "machineType"]]) {
1079
+ const value = identity?.[field]?.trim()
1080
+ if (!value || value.length > 4096 || !/^[\x20-\x7e]+$/.test(value)) throw new Error("invalid identity")
1081
+ headers[key] = value
1082
+ }
1083
+ return headers
1084
+ } catch {
1085
+ throw new Error("Qoder check-in: invalid device identity from Qoder; restart or update Qoder")
1086
+ }
1087
+ }
960
1088
 
961
1089
  // campaignPage: url is the site's campaigns page or a claim under it, which
962
1090
  // the account's fetch sends as the account rather than as a chat.
@@ -969,12 +1097,27 @@ function campaignPage(site, url) {
969
1097
  }
970
1098
  }
971
1099
 
1100
+ // campaignTarget is where a campaigns page url is asked for an account
1101
+ // served from s: url itself on s's accounts host, or, on a VPC account, the
1102
+ // same page on its instance when url names the public host (magpie's
1103
+ // check-in knows only that), as Qoder CN's CLI rewrites it. null: url isn't
1104
+ // one.
1105
+ function campaignTarget(base, s, url) {
1106
+ if (campaignPage(s, url)) return url
1107
+ if (s === base || !campaignPage(base, url)) return null
1108
+ const u = new URL(url)
1109
+ const to = new URL(s.openapi)
1110
+ u.protocol = to.protocol
1111
+ u.host = to.host
1112
+ return u.href
1113
+ }
1114
+
972
1115
  // campaignCall is url asked with the device token; Qoder's answer comes back
973
1116
  // as it is.
974
- async function campaignCall(url, method, body, deviceToken, signal) {
1117
+ async function campaignCall(url, method, body, deviceToken, machine, signal) {
975
1118
  return fetch(url, {
976
1119
  method,
977
- headers: { Accept: "application/json", "Content-Type": "application/json", Authorization: `Bearer ${deviceToken}`, "Cosy-ClientType": "10", "User-Agent": "Qoder" },
1120
+ headers: { Accept: "application/json", "Content-Type": "application/json", Authorization: `Bearer ${deviceToken}`, "Cosy-ClientType": "10", "User-Agent": "Qoder", ...machine },
978
1121
  body: method === "GET" ? undefined : body || "{}",
979
1122
  signal: signal ?? AbortSignal.timeout(20_000),
980
1123
  })
@@ -1080,13 +1223,32 @@ const SITES = {
1080
1223
  api: "https://gateway.qoder.com.cn",
1081
1224
  redirect: "", // Qoder CN's CLI sends none
1082
1225
  deviceChat: true, // a refused job token falls back to the CLI's device-token chat
1226
+ // an enterprise's own instance, <instance>.vpc.qoder.com.cn and its
1227
+ // -openapi and -gateway hosts, as Qoder CN's CLI builds it in
1228
+ vpcDomain: "vpc.qoder.com.cn",
1083
1229
  models: CN_MODELS,
1084
1230
  },
1085
1231
  }
1086
1232
 
1087
1233
  const makePlugin = (site) => async ({ client }) => {
1088
1234
  const ID = site.id
1089
- const CHAT_URL = site.api + CHAT_PATH
1235
+ // The SDK identity is account-specific. Share an in-flight read, refresh
1236
+ // after an hour, and leave a failed read available for the next attempt.
1237
+ const campaignMachines = new Map()
1238
+ const machineForCampaign = async (uid) => {
1239
+ const now = Date.now()
1240
+ let entry = campaignMachines.get(uid)
1241
+ if (!entry || now - entry.at >= CAMPAIGN_MACHINE_TTL) {
1242
+ entry = { at: now, promise: campaignMachine(uid) }
1243
+ campaignMachines.set(uid, entry)
1244
+ }
1245
+ try {
1246
+ return await entry.promise
1247
+ } catch (e) {
1248
+ if (campaignMachines.get(uid) === entry) campaignMachines.delete(uid)
1249
+ throw e
1250
+ }
1251
+ }
1090
1252
  // serializes checking, rotating and saving tokens: a refresh token is
1091
1253
  // spent once, so two refreshes would spend it twice
1092
1254
  let lock = Promise.resolve()
@@ -1118,13 +1280,14 @@ const makePlugin = (site) => async ({ client }) => {
1118
1280
  // renew spends a's chat refresh token, under the lock: the fields it
1119
1281
  // changes, recorded in spent.
1120
1282
  const renew = async (a) => {
1283
+ const s = siteOf(site, a)
1121
1284
  if (a.deviceChat) {
1122
1285
  // the device token is the chat token: renewed as a device token,
1123
1286
  // both pairs being the one pair
1124
- const dt = await refreshDevice(site, a.refresh, true)
1287
+ const dt = await refreshDevice(s, a.refresh, true)
1125
1288
  return spend(a.refresh, { access: dt.token, refresh: dt.refresh, expires: dt.expires, deviceToken: dt.token, deviceRefresh: dt.refresh })
1126
1289
  }
1127
- const jt = await refreshJob(site, a.refresh, a.accountId || a.email || a.uid)
1290
+ const jt = await refreshJob(s, a.refresh, a.accountId || a.email || a.uid)
1128
1291
  return spend(a.refresh, { access: jt.token, refresh: jt.refresh_token, expires: expiresAt(jt) })
1129
1292
  }
1130
1293
  // each account's listing, the model configs a request carries
@@ -1147,6 +1310,7 @@ const makePlugin = (site) => async ({ client }) => {
1147
1310
  locked(async () => {
1148
1311
  const stored = await getAuth()
1149
1312
  if (stored?.type !== "oauth" || !stored.access || !stored.uid) throw new SignInGone(`${site.name}: not signed in`)
1313
+ siteOf(site, stored) // a VPC instance on record that names none is no sign-in
1150
1314
  // renewed already (by magpie's auth.refresh), the store not yet saying so
1151
1315
  const a = latest(stored)
1152
1316
  if (a.expires - Date.now() > REFRESH_LEAD) return a
@@ -1159,7 +1323,7 @@ const makePlugin = (site) => async ({ client }) => {
1159
1323
  const models = async (cred, again = false) => {
1160
1324
  let l = listings.get(cred.uid)
1161
1325
  if (!l || again) {
1162
- l = modelInfos(await fetchListing(site, cred))
1326
+ l = modelInfos(await fetchListing(siteOf(site, cred), cred))
1163
1327
  listings.set(cred.uid, l)
1164
1328
  }
1165
1329
  return l
@@ -1171,7 +1335,7 @@ const makePlugin = (site) => async ({ client }) => {
1171
1335
  locked(async () => {
1172
1336
  const a = latest(await getAuth())
1173
1337
  if (a?.deviceToken !== attempted) return a?.deviceToken
1174
- const dt = await refreshDevice(site, a.deviceRefresh, !!a.deviceChat)
1338
+ const dt = await refreshDevice(siteOf(site, a), a.deviceRefresh, !!a.deviceChat)
1175
1339
  const next = { ...a, deviceToken: dt.token, deviceRefresh: dt.refresh }
1176
1340
  // the pair it spent was the chat pair too
1177
1341
  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 +1354,10 @@ const makePlugin = (site) => async ({ client }) => {
1190
1354
  if (renewed(cred)) signIn = "renewed"
1191
1355
  let env
1192
1356
  try {
1193
- env = await fetchUsage(site, cred.deviceToken)
1357
+ env = await fetchUsage(siteOf(site, cred), cred.deviceToken)
1194
1358
  } catch (e) {
1195
1359
  if (!(e instanceof UsageStatus) || (e.status !== 401 && e.status !== 403)) throw e
1196
- env = await fetchUsage(site, await deviceToken(getAuth, cred.deviceToken))
1360
+ env = await fetchUsage(siteOf(site, cred), await deviceToken(getAuth, cred.deviceToken))
1197
1361
  }
1198
1362
  return { ...parseUsage(env), signIn }
1199
1363
  } catch (e) {
@@ -1208,13 +1372,16 @@ const makePlugin = (site) => async ({ client }) => {
1208
1372
  let cred
1209
1373
  try {
1210
1374
  cred = await fresh(getAuth)
1375
+ url = campaignTarget(site, siteOf(site, cred), url)
1211
1376
  } catch (e) {
1212
1377
  return signedInError(e)
1213
1378
  }
1379
+ if (!url) return errorResponse({ status: 404, message: "only chat completions are served" })
1214
1380
  const method = String(init.method ?? input?.method ?? "GET").toUpperCase()
1215
1381
  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)
1382
+ const machine = site.id === "qoder" ? await machineForCampaign(cred.uid) : {}
1383
+ let res = await campaignCall(url, method, body, cred.deviceToken, machine, init.signal)
1384
+ if (res.status === 401 || res.status === 403) res = await campaignCall(url, method, body, await deviceToken(getAuth, cred.deviceToken), machine, init.signal)
1218
1385
  return signed(res, renewed(cred))
1219
1386
  } catch (e) {
1220
1387
  if (e?.expired) return signedInError(e)
@@ -1240,6 +1407,7 @@ const makePlugin = (site) => async ({ client }) => {
1240
1407
  return errorResponse({ status: 400, message: e.message })
1241
1408
  }
1242
1409
  if (!m) return errorResponse({ status: 400, message: `unknown or disabled model "${chat.model}"` })
1410
+ const CHAT_URL = siteOf(site, cred).api + CHAT_PATH
1243
1411
  const wire = encodeBody(JSON.stringify(qoderBody(chat, m)))
1244
1412
  const headers = {
1245
1413
  ...cosyHeaders(CHAT_URL, cred, wire),
@@ -1286,13 +1454,17 @@ const makePlugin = (site) => async ({ client }) => {
1286
1454
  async loader(getAuth) {
1287
1455
  const a = await getAuth()
1288
1456
  if (a?.type !== "oauth") return {}
1457
+ let base = site
1458
+ try {
1459
+ base = siteOf(site, a)
1460
+ } catch {} // its requests say so
1289
1461
  return {
1290
- baseURL: site.api,
1462
+ baseURL: base.api,
1291
1463
  apiKey: "qoder",
1292
1464
  // every chat completion written as Qoder's own request
1293
1465
  async fetch(input, init = {}) {
1294
1466
  const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
1295
- if (campaignPage(site, url)) return campaign(getAuth, input, url, init)
1467
+ if (campaignPage(site, url) || campaignPage(base, url)) return campaign(getAuth, input, url, init)
1296
1468
  if (!/\/chat\/completions$/.test(new URL(url).pathname))
1297
1469
  return errorResponse({ status: 404, message: "only chat completions are served" })
1298
1470
  let chat
@@ -1311,7 +1483,33 @@ const makePlugin = (site) => async ({ client }) => {
1311
1483
  },
1312
1484
  }
1313
1485
  },
1314
- methods: [{ type: "oauth", label: `Sign in with ${site.name}`, authorize: () => deviceSignIn(site) }],
1486
+ methods: [
1487
+ { type: "oauth", label: `Sign in with ${site.name}`, authorize: () => deviceSignIn(site) },
1488
+ // an enterprise VPC instance (Qoder CN), a method of its own so
1489
+ // that the sign-in above asks nothing new
1490
+ ...(site.vpcDomain
1491
+ ? [
1492
+ {
1493
+ type: "oauth",
1494
+ label: `Sign in with ${site.name} Enterprise (VPC)`,
1495
+ prompts: [
1496
+ {
1497
+ type: "text",
1498
+ key: "vpc",
1499
+ message: `Your enterprise's VPC instance: its name, or its address (<instance>.${site.vpcDomain})`,
1500
+ placeholder: `acme or acme.${site.vpcDomain}`,
1501
+ validate: (v) => (vpcInstance(v, site.vpcDomain) ? undefined : `Not a VPC instance: enter its name (letters, digits, -) or https://<instance>.${site.vpcDomain}`),
1502
+ },
1503
+ ],
1504
+ authorize: (inputs) => {
1505
+ const s = vpcSite(site, inputs?.vpc)
1506
+ if (!s) throw new Error(`Not a VPC instance: ${String(inputs?.vpc ?? "").slice(0, 100)}`)
1507
+ return deviceSignIn(s)
1508
+ },
1509
+ },
1510
+ ]
1511
+ : []),
1512
+ ],
1315
1513
  usage,
1316
1514
  },
1317
1515
  async config(config) {
@@ -1343,8 +1541,9 @@ const makePlugin = (site) => async ({ client }) => {
1343
1541
  try {
1344
1542
  const ms = await models(cred, true)
1345
1543
  if (!ms.length) return provider.models
1544
+ const s = siteOf(site, cred)
1346
1545
  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 })]),
1546
+ 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
1547
  )
1349
1548
  } catch {
1350
1549
  return provider.models
@@ -1358,4 +1557,4 @@ export const QoderAuthPlugin = makePlugin(SITES.qoder)
1358
1557
  export const QoderCNAuthPlugin = makePlugin(SITES["qoder-cn"])
1359
1558
 
1360
1559
  // for tests
1361
- export const _internal = { campaignPage, parseUsage, gfmt, when, failure, modelInfo, qoderBody, SITES, decodeBody, events }
1560
+ export const _internal = { campaignRuntime, 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.9",
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",