@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.
- package/README.md +55 -2
- package/index.mjs +218 -19
- 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
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
1217
|
-
|
|
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:
|
|
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: [
|
|
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(
|
|
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