@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.
- package/README.md +31 -0
- package/index.mjs +139 -15
- 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)
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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:
|
|
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: [
|
|
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(
|
|
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