@mangoc/dsh-sub2api-sync 0.0.0-stage → 0.1.0

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/lib/index.js ADDED
@@ -0,0 +1,2836 @@
1
+ import { credentialRef } from "@deepseek-ai/dsh-credentials";
2
+ import z from "@deepseek-ai/schemastery";
3
+ import { readFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ //#region src/types.ts
8
+ const PLATFORMS = [
9
+ "anthropic",
10
+ "openai",
11
+ "gemini",
12
+ "antigravity",
13
+ "grok",
14
+ "kimi",
15
+ "zhipu",
16
+ "deepseek",
17
+ "composite"
18
+ ];
19
+ const DEFAULT_PROVIDER_NAME_TEMPLATE = "{siteName}({keyName})";
20
+ const API_PROTOCOLS = [
21
+ "openai-completions",
22
+ "openai-responses",
23
+ "anthropic-messages"
24
+ ];
25
+ const ROUTE_PREFIX = "sub2api-";
26
+ /** A new-shape route suffix: 10 lowercase hex chars (40-bit SHA-256 truncation). */
27
+ const SLUG_ROUTE = /^sub2api-[0-9a-f]{10}$/;
28
+ /** A legacy route suffix: the raw panel key id (pre-rev.5 deployments). */
29
+ const LEGACY_ROUTE = /^sub2api-\d+$/;
30
+ const OWNED_ROUTE = /^(?:sub2api-[0-9a-f]{10}|sub2api-\d+)$/;
31
+ function isOwnedRoute(route) {
32
+ return OWNED_ROUTE.test(route);
33
+ }
34
+ function sha256Hex(input) {
35
+ const data = new TextEncoder().encode(input);
36
+ const bitLength = data.length * 8;
37
+ const padded = new Uint8Array((data.length + 8 >> 6) + 1 << 6);
38
+ padded.set(data);
39
+ padded[data.length] = 128;
40
+ const view = new DataView(padded.buffer);
41
+ view.setUint32(padded.length - 8, Math.floor(bitLength / 4294967296));
42
+ view.setUint32(padded.length - 4, bitLength >>> 0);
43
+ let h0 = 1779033703, h1 = 3144134277, h2 = 1013904242, h3 = 2773480762;
44
+ let h4 = 1359893119, h5 = 2600822924, h6 = 528734635, h7 = 1541459225;
45
+ const w = new Uint32Array(64);
46
+ const rotr = (x, n) => (x >>> n | x << 32 - n) >>> 0;
47
+ for (let offset = 0; offset < padded.length; offset += 64) {
48
+ for (let i = 0; i < 16; i++) w[i] = view.getUint32(offset + i * 4);
49
+ for (let i = 16; i < 64; i++) {
50
+ const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ w[i - 15] >>> 3;
51
+ const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ w[i - 2] >>> 10;
52
+ w[i] = w[i - 16] + s0 + w[i - 7] + s1 >>> 0;
53
+ }
54
+ let a = h0, b = h1, c = h2, d = h3, e = h4, f = h5, g = h6, h = h7;
55
+ for (let i = 0; i < 64; i++) {
56
+ const s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
57
+ const ch = e & f ^ ~e & g;
58
+ const t1 = h + s1 + ch + SHA256_K[i] + w[i] >>> 0;
59
+ const s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
60
+ const maj = a & b ^ a & c ^ b & c;
61
+ const t2 = s0 + maj >>> 0;
62
+ h = g;
63
+ g = f;
64
+ f = e;
65
+ e = d + t1 >>> 0;
66
+ d = c;
67
+ c = b;
68
+ b = a;
69
+ a = t1 + t2 >>> 0;
70
+ }
71
+ h0 = h0 + a >>> 0;
72
+ h1 = h1 + b >>> 0;
73
+ h2 = h2 + c >>> 0;
74
+ h3 = h3 + d >>> 0;
75
+ h4 = h4 + e >>> 0;
76
+ h5 = h5 + f >>> 0;
77
+ h6 = h6 + g >>> 0;
78
+ h7 = h7 + h >>> 0;
79
+ }
80
+ return [
81
+ h0,
82
+ h1,
83
+ h2,
84
+ h3,
85
+ h4,
86
+ h5,
87
+ h6,
88
+ h7
89
+ ].map((word) => word.toString(16).padStart(8, "0")).join("");
90
+ }
91
+ /** Round constants for SHA-256 (FIPS 180-4 §4.2.2). */
92
+ const SHA256_K = new Uint32Array([
93
+ 1116352408,
94
+ 1899447441,
95
+ 3049323471,
96
+ 3921009573,
97
+ 961987163,
98
+ 1508970993,
99
+ 2453635748,
100
+ 2870763221,
101
+ 3624381080,
102
+ 310598401,
103
+ 607225278,
104
+ 1426881987,
105
+ 1925078388,
106
+ 2162078206,
107
+ 2614888103,
108
+ 3248222580,
109
+ 3835390401,
110
+ 4022224774,
111
+ 264347078,
112
+ 604807628,
113
+ 770255983,
114
+ 1249150122,
115
+ 1555081692,
116
+ 1996064986,
117
+ 2554220882,
118
+ 2821834349,
119
+ 2952996808,
120
+ 3210313671,
121
+ 3336571891,
122
+ 3584528711,
123
+ 113926993,
124
+ 338241895,
125
+ 666307205,
126
+ 773529912,
127
+ 1294757372,
128
+ 1396182291,
129
+ 1695183700,
130
+ 1986661051,
131
+ 2177026350,
132
+ 2456956037,
133
+ 2730485921,
134
+ 2820302411,
135
+ 3259730800,
136
+ 3345764771,
137
+ 3516065817,
138
+ 3600352804,
139
+ 4094571909,
140
+ 275423344,
141
+ 430227734,
142
+ 506948616,
143
+ 659060556,
144
+ 883997877,
145
+ 958139571,
146
+ 1322822218,
147
+ 1537002063,
148
+ 1747873779,
149
+ 1955562222,
150
+ 2024104815,
151
+ 2227730452,
152
+ 2361852424,
153
+ 2428436474,
154
+ 2756734187,
155
+ 3204031479,
156
+ 3329325298
157
+ ]);
158
+ function keyRouteSlug(baseURL, keyId) {
159
+ return sha256Hex(`${instanceKey(baseURL)}\n${keyId}`).slice(0, 10);
160
+ }
161
+ function routeForKey(baseURL, keyId) {
162
+ return `${ROUTE_PREFIX}${keyRouteSlug(baseURL, keyId)}`;
163
+ }
164
+ function suffixOfRoute(route) {
165
+ if (SLUG_ROUTE.test(route) || LEGACY_ROUTE.test(route)) return route.slice(ROUTE_PREFIX.length);
166
+ return undefined;
167
+ }
168
+ function legacyKeyIdOfRoute(route) {
169
+ if (!LEGACY_ROUTE.test(route)) return undefined;
170
+ const id = Number(route.slice(ROUTE_PREFIX.length));
171
+ return Number.isSafeInteger(id) && id > 0 ? id : undefined;
172
+ }
173
+ function hashString(input) {
174
+ let hash = 2166136261;
175
+ for (let index = 0; index < input.length; index++) {
176
+ hash ^= input.charCodeAt(index);
177
+ hash = Math.imul(hash, 16777619);
178
+ }
179
+ return hash >>> 0 || 1;
180
+ }
181
+ function instanceKey(baseURL) {
182
+ return baseURL.trim().replace(/\/+$/, "").toLowerCase();
183
+ }
184
+ function panelTokenRef(baseURL) {
185
+ return `DSH_SUB2API_TOKEN_${hashString(instanceKey(baseURL))}`;
186
+ }
187
+ function panelRefreshRef(baseURL) {
188
+ return `DSH_SUB2API_REFRESH_${hashString(instanceKey(baseURL))}`;
189
+ }
190
+ function panelPasswordRef(baseURL) {
191
+ return `DSH_SUB2API_PASSWORD_${hashString(instanceKey(baseURL))}`;
192
+ }
193
+ function oauthTokenRef(baseURL) {
194
+ return `DSH_SUB2API_OAUTH_TOKEN_${hashString(instanceKey(baseURL))}`;
195
+ }
196
+ function oauthRefreshRef(baseURL) {
197
+ return `DSH_SUB2API_OAUTH_REFRESH_${hashString(instanceKey(baseURL))}`;
198
+ }
199
+ function routeKeyRef(route) {
200
+ const suffix = suffixOfRoute(route);
201
+ if (suffix === undefined) throw new Error(`sub2api-sync: "${route}" 不是本插件的路由名`);
202
+ return `DSH_SUB2API_KEY_${suffix}`;
203
+ }
204
+ function legacyKeyRef(keyId) {
205
+ return `DSH_SUB2API_KEY_${keyId}`;
206
+ }
207
+ function parseGroupId(input) {
208
+ if (typeof input === "number") return Number.isSafeInteger(input) && input > 0 ? input : undefined;
209
+ if (typeof input !== "string") return undefined;
210
+ if (!/^\d+$/.test(input)) return undefined;
211
+ const id = Number(input);
212
+ return Number.isSafeInteger(id) && id > 0 ? id : undefined;
213
+ }
214
+
215
+ //#endregion
216
+ //#region src/protocols.ts
217
+ const SUB2API_RETRY_POLICY = {
218
+ mode: "normal",
219
+ maxRetries: 5,
220
+ retryableCodes: [
221
+ "RATE_LIMIT",
222
+ "SERVER",
223
+ "TIMEOUT",
224
+ "TRANSPORT",
225
+ "EMPTY_RESPONSE"
226
+ ],
227
+ backoff: {
228
+ initialDelayMs: 1e3,
229
+ maxDelayMs: 12e4,
230
+ jitterRatio: .2
231
+ }
232
+ };
233
+ function guessPlatformFromModels(modelIds) {
234
+ const families = new Set();
235
+ for (const raw of modelIds) {
236
+ const id = raw.toLowerCase();
237
+ if (/(^|[/:.-])claude/.test(id)) families.add("anthropic");
238
+ else if (/(^|[/:.-])(gpt|o[1345]|codex|chatgpt)/.test(id)) families.add("openai");
239
+ else if (/(^|[/:.-])grok/.test(id)) families.add("grok");
240
+ else if (/(^|[/:.-])gemini/.test(id)) families.add("gemini");
241
+ else if (/(^|[/:.-])(kimi|moonshot)/.test(id) || /(^|[/:.-])k[23]($|[.:.-])/.test(id)) families.add("kimi");
242
+ else if (/(^|[/:.-])(glm|chatglm|cogview|cogvideo)/.test(id)) families.add("zhipu");
243
+ else if (/(^|[/:.-])deepseek/.test(id)) families.add("deepseek");
244
+ else families.add("unknown");
245
+ }
246
+ const known = [...families].filter((family) => family !== "unknown");
247
+ if (known.length === 0) return undefined;
248
+ return known.length === 1 ? known[0] : "composite";
249
+ }
250
+ function protocolForPlatform(platform) {
251
+ switch (platform) {
252
+ case "anthropic": return "anthropic-messages";
253
+ case "openai":
254
+ case "grok":
255
+ case "kimi":
256
+ case "zhipu":
257
+ case "deepseek": return "openai-responses";
258
+ case "gemini":
259
+ case "antigravity":
260
+ case "composite": return "openai-completions";
261
+ default: return undefined;
262
+ }
263
+ }
264
+ function renderProviderName(template, keyName, siteName) {
265
+ const site = siteName !== undefined && siteName.length > 0 ? siteName : "sub2api";
266
+ if (template !== undefined && template.length > 0 && (template.includes("{keyName}") || template.includes("{siteName}"))) return template.replaceAll("{siteName}", site).replaceAll("{keyName}", keyName);
267
+ return `${site}(${keyName})`;
268
+ }
269
+ function baseURLForProtocol(instance, protocol) {
270
+ const cleaned = instance.trim().replace(/\/+$/, "");
271
+ if (protocol === "anthropic-messages") return cleaned.replace(/\/v1$/i, "");
272
+ return /\/v1$/i.test(cleaned) ? cleaned : `${cleaned}/v1`;
273
+ }
274
+ const THINKING_LEVELS = [
275
+ "off",
276
+ "minimal",
277
+ "low",
278
+ "medium",
279
+ "high",
280
+ "xhigh",
281
+ "max"
282
+ ];
283
+ function buildProviderProfile(input) {
284
+ const compat = input.template?.compat;
285
+ const reasoning = input.template?.reasoningEfforts;
286
+ return {
287
+ displayName: input.displayName.length > 0 ? input.displayName : `Group ${input.groupId}`,
288
+ apiKeyEnv: input.apiKeyEnv,
289
+ api: input.protocol,
290
+ baseURL: baseURLForProtocol(input.instance, input.protocol),
291
+ models: input.models.map((model) => ({
292
+ id: model.id,
293
+ ...model.name !== undefined ? { name: model.name } : {},
294
+ ...model.contextWindow !== undefined ? { contextWindow: model.contextWindow } : {},
295
+ ...model.maxTokens !== undefined ? { maxTokens: model.maxTokens } : {},
296
+ ...model.imageInput === true ? { input: ["text", "image"] } : {},
297
+ ...compat !== undefined ? { compat: { ...compat } } : {},
298
+ ...reasoning === false ? { reasoningEfforts: false } : reasoning !== undefined ? { reasoningEfforts: { ...reasoning } } : model.reasoningEfforts !== undefined ? { reasoningEfforts: { ...model.reasoningEfforts } } : {}
299
+ })),
300
+ ...input.template?.reasoning !== undefined ? { reasoning: input.template.reasoning } : {},
301
+ defaultContextWindow: 128e3,
302
+ defaultMaxTokens: 8192,
303
+ defaultInput: ["text"],
304
+ retryPolicy: {
305
+ ...SUB2API_RETRY_POLICY,
306
+ retryableCodes: [...SUB2API_RETRY_POLICY.retryableCodes],
307
+ backoff: { ...SUB2API_RETRY_POLICY.backoff }
308
+ }
309
+ };
310
+ }
311
+
312
+ //#endregion
313
+ //#region src/config.ts
314
+ const apiProtocolSchema = z.union([
315
+ z.const("openai-completions"),
316
+ z.const("openai-responses"),
317
+ z.const("anthropic-messages")
318
+ ]);
319
+ const Config = z.object({
320
+ baseURL: z.string().volatile(),
321
+ email: z.string().volatile(),
322
+ providerNameTemplate: z.string().default(DEFAULT_PROVIDER_NAME_TEMPLATE).volatile(),
323
+ selectedKeys: z.array(z.number().step(1)).default([]).volatile(),
324
+ modelListOverrides: z.dict(z.array(z.string())).default({}).volatile(),
325
+ protocolOverrides: z.dict(apiProtocolSchema).default({}).volatile(),
326
+ routeKeyIds: z.dict(z.number().step(1)).default({}).volatile(),
327
+ compatTemplate: z.object({
328
+ compat: z.dict(z.any()),
329
+ reasoningEfforts: z.union([z.const(false), z.dict(z.union([z.string(), z.const(null)]))]),
330
+ reasoning: z.union([
331
+ z.const("off"),
332
+ z.const("minimal"),
333
+ z.const("low"),
334
+ z.const("medium"),
335
+ z.const("high"),
336
+ z.const("xhigh"),
337
+ z.const("max")
338
+ ])
339
+ }).volatile(),
340
+ enrichModelMeta: z.boolean().default(true).volatile(),
341
+ keyQuota: z.number().volatile(),
342
+ keyExpiresInDays: z.number().step(1).volatile(),
343
+ deleteOrphanKeys: z.boolean().default(false).volatile(),
344
+ syncIntervalMs: z.number().step(1).volatile(),
345
+ allowRemoteActions: z.boolean().default(false).volatile()
346
+ });
347
+ /** Unwrap one live cell into its current snapshot; plain values pass through. */
348
+ function unwrap$1(field) {
349
+ if (field !== null && typeof field === "object" && typeof field.get === "function") return field.get();
350
+ return field;
351
+ }
352
+ function readConfig(config) {
353
+ return Object.fromEntries(Object.entries(config).map(([key, field]) => [key, unwrap$1(field)]));
354
+ }
355
+ function validateConfig(config) {
356
+ if (config.baseURL !== undefined && config.baseURL.length > 0 && !/^https?:\/\//i.test(config.baseURL.trim())) throw new Error("sub2api-sync: baseURL 必须以 http(s):// 开头");
357
+ const compat = config.compatTemplate;
358
+ if (compat?.reasoning !== undefined && !THINKING_LEVELS.includes(compat.reasoning)) throw new Error(`sub2api-sync: compatTemplate.reasoning 必须是 ${THINKING_LEVELS.join("/")} 之一`);
359
+ const template = config.providerNameTemplate;
360
+ if (template !== undefined && template.length > 0 && !template.includes("{keyName}") && !template.includes("{siteName}")) throw new Error("sub2api-sync: providerNameTemplate 必须包含 {keyName} 或 {siteName} 占位符");
361
+ for (const id of config.selectedKeys ?? []) if (!Number.isSafeInteger(id) || id <= 0) throw new Error(`sub2api-sync: selectedKeys 含非法 key id ${String(id)}`);
362
+ }
363
+
364
+ //#endregion
365
+ //#region src/client.ts
366
+ function gatewayApiRoot(baseURL) {
367
+ const cleaned = (baseURL ?? "").trim().replace(/\/+$/, "");
368
+ if (cleaned.length === 0) return "";
369
+ return /\/v1$/i.test(cleaned) ? cleaned : `${cleaned}/v1`;
370
+ }
371
+ var Sub2apiClient = class {
372
+ root;
373
+ fetchImpl;
374
+ constructor(options) {
375
+ const base = options.baseURL?.trim() ?? "";
376
+ if (!/^https?:\/\//i.test(base)) throw new Error("sub2api: baseURL 必须是 http(s) 地址");
377
+ this.root = gatewayApiRoot(base);
378
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
379
+ if (fetchImpl === undefined) throw new Error("sub2api: 当前环境没有 fetch 可用");
380
+ this.fetchImpl = fetchImpl.bind(globalThis);
381
+ }
382
+ /**
383
+ * Discover the model ids one relay key can serve (`GET /v1/models`). The
384
+ * gateway answers an OpenAI-style list whose element shape varies per
385
+ * platform — grok embeds reasoning fields, a `client_version` query
386
+ * switches to the Codex manifest — so the reader takes `id` and tolerates
387
+ * everything else (verified: `handler/gateway_handler.go`).
388
+ */
389
+ async listModels(relayKey, options) {
390
+ const response = await this.fetchImpl(`${this.root}/models`, {
391
+ headers: { authorization: `Bearer ${relayKey}` },
392
+ signal: options?.signal
393
+ });
394
+ if (!response.ok) throw new Error(`sub2api 网关 GET ${this.root}/models 返回 HTTP ${response.status}${await refusalDetail(response)}`);
395
+ const payload = await response.json();
396
+ return readModelList(payload);
397
+ }
398
+ };
399
+ async function refusalDetail(response) {
400
+ let payload;
401
+ try {
402
+ payload = await response.json();
403
+ } catch {
404
+ return "";
405
+ }
406
+ if (typeof payload !== "object" || payload === null) return "";
407
+ const record = payload;
408
+ const nested = typeof record.error === "object" && record.error !== null ? record.error : undefined;
409
+ const message = [nested?.message, record.message].find((candidate) => typeof candidate === "string" && candidate.length > 0);
410
+ const code = [nested?.code, record.code].find((candidate) => typeof candidate === "string" && candidate.length > 0);
411
+ if (message === undefined && code === undefined) return "";
412
+ const label = code !== undefined && message !== undefined && message !== code ? `${code}: ${message}` : code ?? message ?? "";
413
+ return label.length > 0 ? `(${label})` : "";
414
+ }
415
+ function readModelList(payload) {
416
+ const data = payload?.data;
417
+ if (!Array.isArray(data)) throw new Error("sub2api 网关 /models 响应不是模型列表(缺少 data 数组)");
418
+ const models = [];
419
+ for (const item of data) {
420
+ if (typeof item !== "object" || item === null) continue;
421
+ const id = item.id;
422
+ if (typeof id !== "string" || id.length === 0) continue;
423
+ const displayName = item.display_name;
424
+ models.push({
425
+ id,
426
+ ...typeof displayName === "string" && displayName.length > 0 && displayName !== id ? { displayName } : {}
427
+ });
428
+ }
429
+ return models;
430
+ }
431
+
432
+ //#endregion
433
+ //#region src/enrich.ts
434
+ const SNAPSHOT_PATH = join(dirname(fileURLToPath(import.meta.url)), "../data/models-dev.snapshot.json");
435
+ const SOURCE_URL = "https://models.dev/api.json";
436
+ const TTL = 864e5;
437
+ const EFFORT_VOCABULARY = [
438
+ "none",
439
+ "minimal",
440
+ "low",
441
+ "medium",
442
+ "high",
443
+ "xhigh",
444
+ "max"
445
+ ];
446
+ const FIRST_PARTY_PROVIDERS = new Set([
447
+ "openai",
448
+ "anthropic",
449
+ "google",
450
+ "deepseek",
451
+ "xai",
452
+ "moonshotai",
453
+ "moonshotai-cn",
454
+ "kimi-code-plan-cn",
455
+ "kimi-code-plan-global",
456
+ "zhipuai",
457
+ "zhipuai-coding-plan",
458
+ "zai",
459
+ "zai-coding-plan",
460
+ "alibaba",
461
+ "alibaba-cn",
462
+ "alibaba-coding-plan",
463
+ "alibaba-coding-plan-cn",
464
+ "alibaba-token-plan",
465
+ "alibaba-token-plan-cn",
466
+ "minimax",
467
+ "minimax-cn",
468
+ "minimax-coding-plan",
469
+ "minimax-cn-coding-plan",
470
+ "stepfun",
471
+ "stepfun-ai",
472
+ "stepfun-step-plan",
473
+ "stepfun-ai-step-plan",
474
+ "xiaomi",
475
+ "xiaomi-token-plan-ams",
476
+ "xiaomi-token-plan-cn",
477
+ "xiaomi-token-plan-sgp",
478
+ "longcat",
479
+ "volcengine",
480
+ "volcengine-coding-plan",
481
+ "tencent-coding-plan",
482
+ "tencent-token-plan",
483
+ "tencent-tokenhub",
484
+ "mistral",
485
+ "cohere",
486
+ "ai21",
487
+ "upstage",
488
+ "sarvam",
489
+ "google-vertex",
490
+ "google-vertex-anthropic",
491
+ "amazon-bedrock",
492
+ "azure",
493
+ "azure-cognitive-services",
494
+ "snowflake-cortex",
495
+ "watsonx",
496
+ "github-copilot"
497
+ ]);
498
+ /**
499
+ * One model's effort levels from `reasoning_options`, normalized to the
500
+ * vocabulary in canonical order (`none` kept — it becomes pi-ai's `off`).
501
+ * `undefined` when the entry carries no effort options: toggle/budget-only
502
+ * reasoning cannot travel through `reasoningEfforts` (its wire shape is a
503
+ * boolean flag or a numeric budget, not an effort string).
504
+ */
505
+ function effortLevelsOf(model) {
506
+ const options = model.reasoning_options;
507
+ if (!Array.isArray(options)) return undefined;
508
+ const seen = new Set();
509
+ for (const option of options) {
510
+ if (option === null || typeof option !== "object") continue;
511
+ const record = option;
512
+ if (record.type !== "effort" || !Array.isArray(record.values)) continue;
513
+ for (const value of record.values) if (typeof value === "string" && EFFORT_VOCABULARY.includes(value)) seen.add(value);
514
+ }
515
+ if (seen.size === 0) return undefined;
516
+ return EFFORT_VOCABULARY.filter((level) => seen.has(level));
517
+ }
518
+ /**
519
+ * Build the pi-ai `reasoningEfforts` map from normalized levels: same-name
520
+ * passthrough (pi-ai's default wire spelling), `none` → `off: null` (dispatch
521
+ * sends nothing). `undefined` when nothing remains beyond `off` — pi-ai rejects
522
+ * a map that offers no thinking level.
523
+ */
524
+ function reasoningEffortsOf(levels) {
525
+ const map = {};
526
+ let beyondOff = false;
527
+ for (const level of levels) if (level === "none") map.off = null;
528
+ else {
529
+ map[level] = level;
530
+ beyondOff = true;
531
+ }
532
+ return beyondOff ? map : undefined;
533
+ }
534
+ function displayNameOf(raw, id) {
535
+ if (typeof raw !== "string") return undefined;
536
+ const name$1 = raw.trim().replace(/\s*\(latest\)$/i, "").trim();
537
+ if (name$1.length === 0) return undefined;
538
+ if (id !== undefined && name$1.toLowerCase() === id.toLowerCase()) return undefined;
539
+ return name$1;
540
+ }
541
+ /** Defensive snapshot re-normalization (already normalized by the generator). */
542
+ function effortsMetaOf(levels) {
543
+ if (!Array.isArray(levels)) return undefined;
544
+ const normalized = EFFORT_VOCABULARY.filter((level) => levels.includes(level));
545
+ return reasoningEffortsOf(normalized);
546
+ }
547
+ /** Levels every source claims, in canonical order; empty when they disagree. */
548
+ function intersectLevels(sources) {
549
+ if (sources.length === 0) return [];
550
+ const [first, ...rest] = sources;
551
+ return first.filter((level) => rest.every((source) => source.includes(level)));
552
+ }
553
+ /** Attach the resolved efforts map to an id's meta, creating the entry if new. */
554
+ function attachEfforts(index, key, sources) {
555
+ const map = reasoningEffortsOf(intersectLevels(sources));
556
+ if (map === undefined) return;
557
+ const meta = index.get(key) ?? {};
558
+ meta.reasoningEfforts = map;
559
+ index.set(key, meta);
560
+ }
561
+ let networkCache;
562
+ let bundledCache;
563
+ function parseSnapshot(raw) {
564
+ const index = new Map();
565
+ let parsed;
566
+ try {
567
+ parsed = JSON.parse(raw);
568
+ } catch {
569
+ return index;
570
+ }
571
+ const models = parsed?.models;
572
+ if (models === null || models === undefined || typeof models !== "object") return index;
573
+ for (const [id, tuple] of Object.entries(models)) {
574
+ if (!Array.isArray(tuple)) continue;
575
+ const [context, output, image, reasoning, levels, name$1] = tuple;
576
+ const efforts = effortsMetaOf(levels);
577
+ index.set(id.toLowerCase(), {
578
+ ...typeof context === "number" && context > 0 ? { contextWindow: context } : {},
579
+ ...typeof output === "number" && output > 0 ? { maxTokens: output } : {},
580
+ ...image === 1 ? { imageInput: true } : {},
581
+ ...reasoning === 1 ? { reasoningModel: true } : {},
582
+ ...efforts !== undefined ? { reasoningEfforts: efforts } : {},
583
+ ...typeof name$1 === "string" && name$1.length > 0 ? { name: name$1 } : {}
584
+ });
585
+ }
586
+ return index;
587
+ }
588
+ function parseCatalog(catalog) {
589
+ const index = new Map();
590
+ if (catalog === null || typeof catalog !== "object") return index;
591
+ const firstPartySources = new Map();
592
+ const aggregatorSources = new Map();
593
+ const firstPartyNames = new Map();
594
+ const aggregatorNames = new Map();
595
+ const firstPartyIds = new Set();
596
+ for (const [providerName, provider] of Object.entries(catalog)) {
597
+ if (provider === null || typeof provider !== "object") continue;
598
+ const models = provider.models;
599
+ if (models === null || typeof models !== "object") continue;
600
+ const firstParty = FIRST_PARTY_PROVIDERS.has(providerName);
601
+ for (const [id, raw] of Object.entries(models)) {
602
+ if (raw === null || typeof raw !== "object") continue;
603
+ const model = raw;
604
+ const limit = model.limit ?? undefined;
605
+ const modalities = model.modalities ?? undefined;
606
+ const context = limit?.context;
607
+ const output = limit?.output;
608
+ const input = modalities?.input;
609
+ const image = Array.isArray(input) && input.includes("image") || model.attachment === true;
610
+ const key = id.toLowerCase();
611
+ if (firstParty) firstPartyIds.add(key);
612
+ const levels = effortLevelsOf(model);
613
+ if (levels !== undefined) {
614
+ const sources = firstParty ? firstPartySources : aggregatorSources;
615
+ const group = sources.get(key);
616
+ if (group === undefined) sources.set(key, [levels]);
617
+ else group.push(levels);
618
+ }
619
+ const displayName = displayNameOf(model.name, key);
620
+ if (displayName !== undefined) {
621
+ const names = firstParty ? firstPartyNames : aggregatorNames;
622
+ if (!names.has(key)) names.set(key, displayName);
623
+ }
624
+ const current = index.get(key);
625
+ const next = {
626
+ ...typeof context === "number" && context > 0 ? { contextWindow: context } : {},
627
+ ...typeof output === "number" && output > 0 ? { maxTokens: output } : {},
628
+ ...image ? { imageInput: true } : {},
629
+ ...model.reasoning === true ? { reasoningModel: true } : {}
630
+ };
631
+ if (current === undefined) {
632
+ if (next.contextWindow !== undefined || next.maxTokens !== undefined || next.imageInput === true || next.reasoningModel === true) index.set(key, next);
633
+ continue;
634
+ }
635
+ index.set(key, {
636
+ contextWindow: pickGreater(current.contextWindow, next.contextWindow),
637
+ maxTokens: pickGreater(current.maxTokens, next.maxTokens),
638
+ imageInput: current.imageInput === true || next.imageInput === true,
639
+ reasoningModel: current.reasoningModel === true || next.reasoningModel === true
640
+ });
641
+ }
642
+ }
643
+ for (const [key, sources] of firstPartySources) attachEfforts(index, key, sources);
644
+ for (const [key, sources] of aggregatorSources) if (!firstPartyIds.has(key)) attachEfforts(index, key, sources);
645
+ const attachName = (key, name$1, override) => {
646
+ const meta = index.get(key);
647
+ if (meta === undefined) {
648
+ index.set(key, { name: name$1 });
649
+ return;
650
+ }
651
+ if (meta.name !== undefined && !override) return;
652
+ meta.name = name$1;
653
+ index.set(key, meta);
654
+ };
655
+ for (const [key, name$1] of aggregatorNames) attachName(key, name$1, false);
656
+ for (const [key, name$1] of firstPartyNames) attachName(key, name$1, true);
657
+ return index;
658
+ }
659
+ function pickGreater(a, b) {
660
+ if (a === undefined) return b;
661
+ if (b === undefined) return a;
662
+ return Math.max(a, b);
663
+ }
664
+ function bundledIndex() {
665
+ bundledCache ??= (() => {
666
+ try {
667
+ return parseSnapshot(readFileSync(SNAPSHOT_PATH, "utf8"));
668
+ } catch {
669
+ return new Map();
670
+ }
671
+ })();
672
+ return bundledCache;
673
+ }
674
+ async function loadModelIndex(options) {
675
+ const now = (options.now ?? Date.now)();
676
+ if (networkCache !== undefined && now - networkCache.at < TTL) return networkCache.index;
677
+ if (options.enrich) try {
678
+ const doFetch = options.fetchImpl ?? globalThis.fetch;
679
+ if (doFetch === undefined) throw new Error("no fetch available");
680
+ const response = await doFetch(SOURCE_URL);
681
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
682
+ const catalog = await response.json();
683
+ const index = parseCatalog(catalog);
684
+ networkCache = {
685
+ index,
686
+ at: now
687
+ };
688
+ return index;
689
+ } catch {}
690
+ const bundled = bundledIndex();
691
+ if (options.enrich) networkCache = {
692
+ index: bundled,
693
+ at: now
694
+ };
695
+ return bundled;
696
+ }
697
+
698
+ //#endregion
699
+ //#region src/engine.ts
700
+ const PI_AI_NS = "llm-pi-ai";
701
+ const OWN_NS = "sub2api-sync";
702
+ function createSyncReportStore() {
703
+ let report;
704
+ let at;
705
+ return {
706
+ record: (value) => {
707
+ report = value;
708
+ at = Date.now();
709
+ },
710
+ clear: () => {
711
+ report = undefined;
712
+ at = undefined;
713
+ },
714
+ report: () => report,
715
+ at: () => at
716
+ };
717
+ }
718
+ var Sub2apiSyncError = class extends Error {
719
+ constructor(message) {
720
+ super(message);
721
+ this.name = "Sub2apiSyncError";
722
+ }
723
+ };
724
+ function isSettingsConflict(error) {
725
+ const shape = error;
726
+ return shape?.code === "SETTINGS_CONFLICT" || shape?.name === "SettingsConflictError";
727
+ }
728
+ function messageOf(error) {
729
+ return error instanceof Error ? error.message : String(error);
730
+ }
731
+ /** Order-insensitive JSON comparison — profiles are plain JSON values. */
732
+ function deepEqual(a, b) {
733
+ if (a === b) return true;
734
+ if (typeof a !== typeof b) return false;
735
+ if (a === null || b === null) return false;
736
+ if (Array.isArray(a) || Array.isArray(b)) {
737
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
738
+ return a.every((item, index) => deepEqual(item, b[index]));
739
+ }
740
+ if (typeof a === "object") {
741
+ const left = a;
742
+ const right = b;
743
+ const leftKeys = Object.keys(left);
744
+ const rightKeys = Object.keys(right);
745
+ if (leftKeys.length !== rightKeys.length) return false;
746
+ return leftKeys.every((key) => key in right && deepEqual(left[key], right[key]));
747
+ }
748
+ return false;
749
+ }
750
+ function resolveTargets(config, keyRows) {
751
+ const byId = new Map(keyRows.map((key) => [key.id, key]));
752
+ const targets = [];
753
+ for (const id of config.selectedKeys ?? []) {
754
+ const key = byId.get(id);
755
+ if (key === undefined) {
756
+ targets.push({
757
+ invalid: id,
758
+ error: `key #${id} 在面板不存在(可能已删除)——请在 Keys 卡刷新后重新勾选`
759
+ });
760
+ continue;
761
+ }
762
+ targets.push({ key });
763
+ }
764
+ return targets;
765
+ }
766
+ var Sub2apiSyncEngine = class {
767
+ settings;
768
+ credentials;
769
+ gateway;
770
+ fetchImpl;
771
+ modelIndexLoader;
772
+ siteName;
773
+ queue = Promise.resolve();
774
+ running = false;
775
+ constructor(options) {
776
+ this.settings = options.settings;
777
+ this.credentials = options.credentials;
778
+ this.gateway = options.gateway;
779
+ this.fetchImpl = options.fetchImpl;
780
+ this.modelIndexLoader = options.loadModelIndex ?? loadModelIndex;
781
+ this.siteName = options.siteName;
782
+ }
783
+ /** Whether a sync is currently executing (UI busy marker). */
784
+ isRunning() {
785
+ return this.running;
786
+ }
787
+ /**
788
+ * Run one sync. At most one runs at a time — later callers queue behind
789
+ * the previous one (the settings-card button, the tool, and the timer all
790
+ * share this queue).
791
+ */
792
+ sync(config, keyIds) {
793
+ const run = this.queue.then(() => this.runSync(config, keyIds), () => this.runSync(config, keyIds));
794
+ this.queue = run.then(() => undefined, () => undefined);
795
+ return run;
796
+ }
797
+ async runSync(config, keyIds) {
798
+ this.running = true;
799
+ try {
800
+ return await this.syncNow(config, keyIds);
801
+ } finally {
802
+ this.running = false;
803
+ }
804
+ }
805
+ async syncNow(config, selection) {
806
+ const instance = config.baseURL?.trim() ?? "";
807
+ if (!/^https?:\/\//i.test(instance)) throw new Sub2apiSyncError("sub2api-sync: baseURL 未配置或不是 http(s) 地址");
808
+ const entries = [];
809
+ const desired = new Map();
810
+ const { groups, keyRows } = await this.gateway.use(async (panel, token) => {
811
+ const groupList = await panel.listAvailableGroups(token);
812
+ const rows = [];
813
+ for (let page = 1; page <= 10; page++) {
814
+ const result = await panel.listKeys(token, {
815
+ page,
816
+ pageSize: 100
817
+ });
818
+ rows.push(...result.items);
819
+ if (page >= result.pages || result.items.length === 0) break;
820
+ }
821
+ return {
822
+ groups: groupList,
823
+ keyRows: rows
824
+ };
825
+ });
826
+ const groupById = new Map(groups.map((group) => [group.id, group]));
827
+ const modelIndex = await this.modelIndexLoader({
828
+ enrich: config.enrichModelMeta !== false,
829
+ ...this.fetchImpl !== undefined ? { fetchImpl: this.fetchImpl } : {}
830
+ }).catch(() => new Map());
831
+ const site = await this.siteName?.().catch(() => undefined);
832
+ const syncWarnings = [];
833
+ const effective = {
834
+ ...config,
835
+ selectedKeys: selection ?? config.selectedKeys
836
+ };
837
+ for (const target of resolveTargets(effective, keyRows)) {
838
+ if ("invalid" in target) {
839
+ entries.push({
840
+ groupId: target.invalid,
841
+ route: `${target.invalid}`,
842
+ action: "failed",
843
+ error: target.error,
844
+ invalid: true
845
+ });
846
+ continue;
847
+ }
848
+ const key = target.key;
849
+ const route = routeForKey(instance, key.id);
850
+ try {
851
+ const group = groupById.get(key.groupId);
852
+ if (group === undefined) throw new Sub2apiSyncError(`key #${key.id}(${key.name})的分组 ${key.groupId} 在面板不可用`);
853
+ const { profile, warnings: profileWarnings } = await this.buildKeyProfile(config, key, group, instance, modelIndex, site);
854
+ desired.set(route, profile);
855
+ syncWarnings.push(...profileWarnings);
856
+ entries.push({
857
+ groupId: group.id,
858
+ route,
859
+ keyId: key.id,
860
+ displayName: profile.displayName,
861
+ action: "unchanged",
862
+ models: profile.models?.length ?? 0
863
+ });
864
+ } catch (error) {
865
+ entries.push({
866
+ groupId: key.groupId,
867
+ route,
868
+ keyId: key.id,
869
+ action: "failed",
870
+ error: messageOf(error)
871
+ });
872
+ }
873
+ }
874
+ let attempts = 0;
875
+ const removed = [];
876
+ let ops = [];
877
+ let routeActions = new Map();
878
+ for (;;) {
879
+ attempts++;
880
+ const now = this.readCurrentProviders();
881
+ const plan = planOps(now, desired);
882
+ ops = plan.ops;
883
+ routeActions = plan.actions;
884
+ removed.length = 0;
885
+ for (const op of ops) if (op.op === "unset") removed.push(op.path[1]);
886
+ const revision = this.revisionOf(PI_AI_NS);
887
+ try {
888
+ if (ops.length > 0) await this.settings.mutate(PI_AI_NS, ops, revision);
889
+ break;
890
+ } catch (error) {
891
+ if (isSettingsConflict(error) && attempts < 3) continue;
892
+ throw error;
893
+ }
894
+ }
895
+ for (const entry of entries) {
896
+ const action = routeActions.get(entry.route);
897
+ if (action !== undefined) entry.action = action;
898
+ }
899
+ const keyIdByRoute = new Map(keyRows.map((row) => [routeForKey(instance, row.id), row.id]));
900
+ const warnings = [...syncWarnings];
901
+ for (const route of removed) {
902
+ try {
903
+ await this.credentials.unset(routeKeyRef(route));
904
+ } catch (error) {
905
+ warnings.push(`key ${route} 的凭据清理失败:${messageOf(error)}`);
906
+ }
907
+ const keyId = keyIdByRoute.get(route) ?? legacyKeyIdOfRoute(route);
908
+ if (config.deleteOrphanKeys === true && keyId !== undefined) try {
909
+ await this.gateway.use((panel, token) => panel.deleteKey(token, keyId));
910
+ } catch (error) {
911
+ warnings.push(`key ${route} 的远端撤销失败:${messageOf(error)}`);
912
+ }
913
+ }
914
+ try {
915
+ const mapOps = [];
916
+ for (const entry of entries) if (entry.keyId !== undefined && entry.action !== "failed") mapOps.push({
917
+ op: "set",
918
+ path: ["routeKeyIds", entry.route],
919
+ value: entry.keyId
920
+ });
921
+ for (const route of removed) mapOps.push({
922
+ op: "unset",
923
+ path: ["routeKeyIds", route]
924
+ });
925
+ if (mapOps.length > 0) await this.settings.mutate(OWN_NS, mapOps, this.revisionOf(OWN_NS));
926
+ } catch {}
927
+ return {
928
+ entries,
929
+ removed,
930
+ writes: ops.length,
931
+ attempts,
932
+ warnings
933
+ };
934
+ }
935
+ /** Protocol + models + naming for one selected key; the plaintext comes
936
+ * from the panel row when present, else from the stored credential. */
937
+ async buildKeyProfile(config, key, group, instance, modelIndex, site) {
938
+ const protocol = protocolForPlatformWithOverride(group.platform, config.protocolOverrides?.[String(group.id)]);
939
+ if (protocol === undefined) throw new Sub2apiSyncError(`key #${key.id}(${key.name})所属分组 ${group.id} 的平台 "${group.platform}" 没有协议映射(可用 protocolOverrides 显式指定)`);
940
+ const route = routeForKey(instance, key.id);
941
+ const ref = routeKeyRef(route);
942
+ const usable = (value) => value !== undefined && value.length > 0 && !value.includes("****");
943
+ let source = "panel";
944
+ let revealFailure;
945
+ let plaintext = key.key;
946
+ if (!usable(plaintext)) {
947
+ plaintext = undefined;
948
+ const stored = await this.credentials.resolve(ref);
949
+ if (usable(stored?.value)) {
950
+ plaintext = stored?.value;
951
+ source = "stored";
952
+ } else if (stored?.value !== undefined) await this.credentials.unset(ref).catch(() => undefined);
953
+ }
954
+ if (!usable(plaintext) && ref !== legacyKeyRef(key.id)) {
955
+ const legacy = await this.credentials.resolve(legacyKeyRef(key.id));
956
+ if (usable(legacy?.value)) {
957
+ plaintext = legacy?.value;
958
+ source = "legacy";
959
+ } else if (legacy?.value !== undefined) await this.credentials.unset(legacyKeyRef(key.id)).catch(() => undefined);
960
+ }
961
+ if (!usable(plaintext)) try {
962
+ const revealed = await this.gateway.use((panel, token) => panel.revealKey(token, key.id));
963
+ plaintext = !usable(revealed.key) ? undefined : revealed.key;
964
+ if (plaintext === undefined) revealFailure = "reveal 未返回可用明文";
965
+ } catch (error) {
966
+ revealFailure = messageOf(error);
967
+ }
968
+ if (!usable(plaintext)) {
969
+ const revealNote = revealFailure !== undefined ? `;reveal 接口也失败:${revealFailure}` : "";
970
+ throw new Sub2apiSyncError(`key #${key.id}(${key.name})拿不到明文(面板列表不回明文且凭据库没有可用存底${revealNote})——请在面板删除这把 key 后用插件「创建 key」重建,重建时会自动保存明文`);
971
+ }
972
+ let models;
973
+ try {
974
+ models = await this.discoverModels(config, group.id, instance, plaintext);
975
+ } catch (error) {
976
+ if (source !== "stored" && source !== "legacy" || !/HTTP 401(?![0-9])/.test(messageOf(error))) throw error;
977
+ await this.credentials.unset(ref).catch(() => undefined);
978
+ if (ref !== legacyKeyRef(key.id)) await this.credentials.unset(legacyKeyRef(key.id)).catch(() => undefined);
979
+ let revealed;
980
+ try {
981
+ revealed = (await this.gateway.use((panel, token) => panel.revealKey(token, key.id)))?.key;
982
+ } catch (revealError) {
983
+ throw new Sub2apiSyncError(`${messageOf(error)};本地存底已作废,但 reveal 也失败:${messageOf(revealError)}——请在面板删除这把 key 后用插件「创建 key」重建`);
984
+ }
985
+ if (revealed === undefined || revealed.length === 0 || revealed.includes("****")) throw new Sub2apiSyncError(`${messageOf(error)};本地存底已作废,但 reveal 未返回明文——请在面板删除这把 key 后用插件「创建 key」重建`);
986
+ plaintext = revealed;
987
+ models = await this.discoverModels(config, group.id, instance, plaintext);
988
+ }
989
+ if (this.credentials.resolve !== undefined) {
990
+ const stored = await this.credentials.resolve(ref);
991
+ if (stored?.value !== plaintext) await this.credentials.set(ref, plaintext);
992
+ }
993
+ const enriched = models.map((model) => enrichModel(model, modelIndex));
994
+ let template = config.compatTemplate;
995
+ const warnings = [];
996
+ const requestedDefault = template?.reasoning;
997
+ if (requestedDefault !== undefined) {
998
+ const lacking = enriched.filter((model) => {
999
+ const wire = model.reasoningEfforts?.[requestedDefault];
1000
+ return model.reasoningEfforts === undefined || wire === undefined || wire === null;
1001
+ }).map((model) => model.id);
1002
+ if (lacking.length > 0) {
1003
+ template = {
1004
+ ...template,
1005
+ reasoning: undefined
1006
+ };
1007
+ const preview = lacking.length > 5 ? `${lacking.slice(0, 5).join("、")} 等` : lacking.join("、");
1008
+ warnings.push(`compatTemplate.reasoning=${requestedDefault} 未写入默认档位:${lacking.length} 个模型不支持该档位(${preview})——未手动选择档位的请求会被拒绝,故整组回退为不设默认`);
1009
+ }
1010
+ }
1011
+ return {
1012
+ profile: buildProviderProfile({
1013
+ groupId: group.id,
1014
+ displayName: renderProviderName(config.providerNameTemplate, key.name, site),
1015
+ protocol,
1016
+ instance,
1017
+ models: enriched,
1018
+ apiKeyEnv: ref,
1019
+ template
1020
+ }),
1021
+ warnings
1022
+ };
1023
+ }
1024
+ /**
1025
+ * Model source precedence: a non-empty `modelListOverrides` entry is the
1026
+ * user's explicit selection from the card picker and wins outright (no
1027
+ * fetch — the entry contains exactly the chosen ids). Otherwise discover
1028
+ * via `/v1/models`; discovery failure falls back to the overrides loudly.
1029
+ */
1030
+ async discoverModels(config, groupId, instance, relayKey) {
1031
+ const selection = config.modelListOverrides?.[String(groupId)] ?? [];
1032
+ if (selection.length > 0) return selection.map((id) => ({ id }));
1033
+ try {
1034
+ const client = new Sub2apiClient({
1035
+ baseURL: instance,
1036
+ fetchImpl: this.fetchImpl
1037
+ });
1038
+ return await client.listModels(relayKey);
1039
+ } catch (error) {
1040
+ throw new Sub2apiSyncError(`分组 ${groupId} 模型发现失败:${messageOf(error)};且未配置 modelListOverrides 兜底`);
1041
+ }
1042
+ }
1043
+ readCurrentProviders() {
1044
+ const descriptor = this.settings.describe().find((candidate) => candidate.ns === PI_AI_NS);
1045
+ if (descriptor === undefined) throw new Sub2apiSyncError("llm-pi-ai 设置条目未激活——需要 dsh-llm-pi-ai 挂载后才能写入 provider 路由");
1046
+ const providers = descriptor.value?.providers;
1047
+ if (providers === undefined || providers === null) return {};
1048
+ if (typeof providers !== "object" || Array.isArray(providers)) throw new Sub2apiSyncError("llm-pi-ai.providers 形状异常(不是对象)");
1049
+ return providers;
1050
+ }
1051
+ revisionOf(ns) {
1052
+ const descriptor = this.settings.describe().find((candidate) => candidate.ns === ns);
1053
+ if (descriptor === undefined) throw new Sub2apiSyncError(`设置命名空间 "${ns}" 未注册,无法取得 revision`);
1054
+ return descriptor.revision;
1055
+ }
1056
+ };
1057
+ /** Attach the enriched facts (display name, capacity, efforts) to one discovered model. */
1058
+ function enrichModel(model, index) {
1059
+ const meta = index.get(model.id.toLowerCase());
1060
+ if (meta === undefined) return { id: model.id };
1061
+ return {
1062
+ id: model.id,
1063
+ ...meta.name !== undefined ? { name: meta.name } : {},
1064
+ ...meta.contextWindow !== undefined ? { contextWindow: meta.contextWindow } : {},
1065
+ ...meta.maxTokens !== undefined ? { maxTokens: meta.maxTokens } : {},
1066
+ ...meta.imageInput === true ? { imageInput: true } : {},
1067
+ ...meta.reasoningEfforts !== undefined ? { reasoningEfforts: { ...meta.reasoningEfforts } } : {}
1068
+ };
1069
+ }
1070
+ /** Platform mapping with a per-group override applied last. */
1071
+ function protocolForPlatformWithOverride(platform, override) {
1072
+ return override ?? protocolForPlatform(platform);
1073
+ }
1074
+ /** Pure diff of one current/desired snapshot → ops plus per-route actions. */
1075
+ function planOps(current, desired) {
1076
+ const ops = [];
1077
+ const actions = new Map();
1078
+ const routes = new Set([...Object.keys(current).filter(isOwnedRoute), ...desired.keys()]);
1079
+ for (const route of routes) {
1080
+ const want = desired.get(route);
1081
+ const have = current[route];
1082
+ if (want !== undefined) if (have === undefined) {
1083
+ ops.push({
1084
+ op: "set",
1085
+ path: ["providers", route],
1086
+ value: want
1087
+ });
1088
+ actions.set(route, "created");
1089
+ } else if (!deepEqual(have, want)) {
1090
+ ops.push({
1091
+ op: "set",
1092
+ path: ["providers", route],
1093
+ value: want
1094
+ });
1095
+ actions.set(route, "updated");
1096
+ } else actions.set(route, "unchanged");
1097
+ else if (isOwnedRoute(route)) {
1098
+ ops.push({
1099
+ op: "unset",
1100
+ path: ["providers", route]
1101
+ });
1102
+ actions.set(route, "unchanged");
1103
+ }
1104
+ }
1105
+ return {
1106
+ ops,
1107
+ actions
1108
+ };
1109
+ }
1110
+
1111
+ //#endregion
1112
+ //#region src/icon.ts
1113
+ /** How long a resolved icon URL is trusted before re-reading the HTML. */
1114
+ const URL_TTL_MS = 6e5;
1115
+ /** Discovery + byte fetches cap, so an offline instance cannot stall callers. */
1116
+ const TIMEOUT_MS = 2500;
1117
+ /** Refuse to proxy absurd payloads. */
1118
+ const MAX_BYTES = 1e6;
1119
+ /** Sub2api's backend always appends this suffix to the rewritten title. */
1120
+ const TITLE_SUFFIX = " - AI API Gateway";
1121
+ /** Header layout guard: site names beyond this render truncated anyway. */
1122
+ const MAX_TITLE_LENGTH = 60;
1123
+ function pickIconHref(html$1) {
1124
+ for (const match of html$1.matchAll(/<link\b[^>]*>/gi)) {
1125
+ const tag = match[0];
1126
+ const rel = attr(tag, "rel")?.toLowerCase() ?? "";
1127
+ if (!rel.split(/\s+/).includes("icon") || rel.includes("mask")) continue;
1128
+ const href = attr(tag, "href");
1129
+ if (href === undefined || href.length === 0) continue;
1130
+ return href.replace(/&amp;/gi, "&");
1131
+ }
1132
+ return undefined;
1133
+ }
1134
+ function parseSiteTitle(html$1) {
1135
+ const match = /<title\b[^>]*>([^<]*)<\/title>/i.exec(html$1);
1136
+ if (match === null) return undefined;
1137
+ const decoded = match[1].replace(/&amp;/gi, "&").replace(/&lt;/gi, "<").replace(/&gt;/gi, ">").replace(/&quot;/gi, "\"").replace(/&#39;/gi, "'").trim();
1138
+ const stripped = decoded.toLowerCase().endsWith(TITLE_SUFFIX.toLowerCase()) ? decoded.slice(0, decoded.length - TITLE_SUFFIX.length).trim() : decoded;
1139
+ if (stripped.length === 0) return undefined;
1140
+ return stripped.length > MAX_TITLE_LENGTH ? `${stripped.slice(0, MAX_TITLE_LENGTH)}…` : stripped;
1141
+ }
1142
+ /** One attribute value out of a tag string (double/single/unquoted). */
1143
+ function attr(tag, name$1) {
1144
+ const pattern = new RegExp(`\\b${name$1}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i");
1145
+ const match = pattern.exec(tag);
1146
+ return match?.[1] ?? match?.[2] ?? match?.[3];
1147
+ }
1148
+ /** Fetch with a hard timeout; doubles (tests) may ignore the signal. */
1149
+ async function fetchCapped(url, fetchImpl) {
1150
+ const controller = new AbortController();
1151
+ const timer = setTimeout(() => {
1152
+ controller.abort();
1153
+ }, TIMEOUT_MS);
1154
+ try {
1155
+ return await fetchImpl(url, {
1156
+ signal: controller.signal,
1157
+ redirect: "follow"
1158
+ });
1159
+ } finally {
1160
+ clearTimeout(timer);
1161
+ }
1162
+ }
1163
+ /** Resolve the icon URL + site title from one instance's root HTML. */
1164
+ async function probeInstance(base, fetchImpl) {
1165
+ const fallback = { iconUrl: `${base}/favicon.ico` };
1166
+ try {
1167
+ const response = await fetchCapped(`${base}/`, fetchImpl);
1168
+ const type = response.headers.get("content-type") ?? "";
1169
+ if (!response.ok || !(/text\/html/i.test(type) || type.length === 0)) return fallback;
1170
+ const html$1 = await response.text();
1171
+ const href = pickIconHref(html$1);
1172
+ const iconUrl = href !== undefined ? (() => {
1173
+ try {
1174
+ return new URL(href, `${base}/`).toString();
1175
+ } catch {
1176
+ return fallback.iconUrl;
1177
+ }
1178
+ })() : fallback.iconUrl;
1179
+ return {
1180
+ iconUrl,
1181
+ title: parseSiteTitle(html$1)
1182
+ };
1183
+ } catch {
1184
+ return fallback;
1185
+ }
1186
+ }
1187
+ /** Guess a content type from the URL extension when upstream omits it. */
1188
+ function guessContentType(url) {
1189
+ const path = new URL(url).pathname.toLowerCase();
1190
+ if (path.endsWith(".svg")) return "image/svg+xml";
1191
+ if (path.endsWith(".png")) return "image/png";
1192
+ if (path.endsWith(".jpg") || path.endsWith(".jpeg")) return "image/jpeg";
1193
+ if (path.endsWith(".webp")) return "image/webp";
1194
+ if (path.endsWith(".gif")) return "image/gif";
1195
+ return "image/x-icon";
1196
+ }
1197
+ function createInstanceProbe(options = {}) {
1198
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
1199
+ const cache = new Map();
1200
+ const metaOf = async (instance) => {
1201
+ const base = instance.trim().replace(/\/+$/, "");
1202
+ if (!/^https?:\/\//i.test(base)) return undefined;
1203
+ const now = Date.now();
1204
+ const hit = cache.get(base);
1205
+ if (hit !== undefined && now - hit.at < URL_TTL_MS) return hit.meta;
1206
+ const meta = await probeInstance(base, fetchImpl);
1207
+ cache.set(base, {
1208
+ meta,
1209
+ at: now
1210
+ });
1211
+ return meta;
1212
+ };
1213
+ return {
1214
+ icon: async (instance) => {
1215
+ const meta = await metaOf(instance);
1216
+ if (meta === undefined) return undefined;
1217
+ try {
1218
+ const response = await fetchCapped(meta.iconUrl, fetchImpl);
1219
+ if (!response.ok) return undefined;
1220
+ const body = Buffer.from(await response.arrayBuffer());
1221
+ if (body.length === 0 || body.length > MAX_BYTES) return undefined;
1222
+ const upstream = response.headers.get("content-type")?.split(";")[0]?.trim() ?? "";
1223
+ return {
1224
+ contentType: upstream.length > 0 ? upstream : guessContentType(meta.iconUrl),
1225
+ body
1226
+ };
1227
+ } catch {
1228
+ return undefined;
1229
+ }
1230
+ },
1231
+ siteName: async (instance) => (await metaOf(instance))?.title
1232
+ };
1233
+ }
1234
+
1235
+ //#endregion
1236
+ //#region src/oauth.ts
1237
+ const OAUTH_CLIENT_ID = "dsh-sub2api-sync";
1238
+ const OAUTH_CALLBACK_PATH = "/plugins/dsh-sub2api-sync/oauth/callback";
1239
+ const OAUTH_ATTEMPT_TTL_MS = 6e5;
1240
+ function oauthScopesFor(deleteOrphanKeys) {
1241
+ return deleteOrphanKeys === true ? "openid profile groups:read keys:read keys:create keys:revoke" : "openid profile groups:read keys:read keys:create";
1242
+ }
1243
+ var OAuthError = class extends Error {
1244
+ code;
1245
+ constructor(code, message) {
1246
+ super(message);
1247
+ this.name = "OAuthError";
1248
+ this.code = code;
1249
+ }
1250
+ };
1251
+ const discoveryCache = new Map();
1252
+ const DISCOVERY_TTL_MS = 3e5;
1253
+ function absoluteHttpUrl(value) {
1254
+ if (typeof value !== "string" || value.length === 0) return undefined;
1255
+ try {
1256
+ const url = new URL(value);
1257
+ return url.protocol === "http:" || url.protocol === "https:" ? url.toString().replace(/\/$/, "") : undefined;
1258
+ } catch {
1259
+ return undefined;
1260
+ }
1261
+ }
1262
+ async function discoverOAuthEndpoints(instance, fetchImpl = globalThis.fetch, now = Date.now()) {
1263
+ const key = instance.trim().replace(/\/+$/, "").toLowerCase();
1264
+ const cached = discoveryCache.get(key);
1265
+ if (cached !== undefined && now - cached.fetchedAt < DISCOVERY_TTL_MS) {
1266
+ if (cached.endpoints !== undefined) return cached.endpoints;
1267
+ throw new OAuthError("discovery_failed", "sub2api OAuth 发现失败(此前已失败,稍后重试)");
1268
+ }
1269
+ const base = instance.trim().replace(/\/+$/, "");
1270
+ const candidates = [`${base}/.well-known/oauth-authorization-server`, `${base}/api/v1/oauth/.well-known`];
1271
+ let endpoints;
1272
+ let lastError = "";
1273
+ for (const candidate of candidates) try {
1274
+ const response = await fetchImpl(candidate, { headers: { accept: "application/json" } });
1275
+ if (!response.ok) {
1276
+ lastError = `HTTP ${response.status} at ${candidate}`;
1277
+ continue;
1278
+ }
1279
+ const payload = await response.json();
1280
+ const authorizationEndpoint = absoluteHttpUrl(payload.authorization_endpoint);
1281
+ const tokenEndpoint = absoluteHttpUrl(payload.token_endpoint);
1282
+ if (authorizationEndpoint === undefined || tokenEndpoint === undefined) {
1283
+ lastError = `incomplete metadata at ${candidate}`;
1284
+ continue;
1285
+ }
1286
+ const revocationEndpoint = absoluteHttpUrl(payload.revocation_endpoint);
1287
+ endpoints = {
1288
+ authorizationEndpoint,
1289
+ tokenEndpoint,
1290
+ ...revocationEndpoint !== undefined ? { revocationEndpoint } : {}
1291
+ };
1292
+ break;
1293
+ } catch (error) {
1294
+ lastError = `${candidate}: ${error instanceof Error ? error.message : String(error)}`;
1295
+ }
1296
+ discoveryCache.set(key, {
1297
+ endpoints,
1298
+ fetchedAt: now
1299
+ });
1300
+ if (endpoints === undefined) throw new OAuthError("discovery_failed", `sub2api OAuth 发现失败:${lastError}`);
1301
+ return endpoints;
1302
+ }
1303
+ function resetDiscoveryCache() {
1304
+ discoveryCache.clear();
1305
+ }
1306
+ function randomBase64url(bytes) {
1307
+ const buffer = new Uint8Array(bytes);
1308
+ crypto.getRandomValues(buffer);
1309
+ let binary = "";
1310
+ for (const byte of buffer) binary += String.fromCharCode(byte);
1311
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
1312
+ }
1313
+ async function createPkcePair() {
1314
+ const verifier = randomBase64url(48);
1315
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
1316
+ let binary = "";
1317
+ for (const byte of new Uint8Array(digest)) binary += String.fromCharCode(byte);
1318
+ const challenge = btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
1319
+ return {
1320
+ verifier,
1321
+ challenge
1322
+ };
1323
+ }
1324
+ function loopbackCallbackOrigin(origin) {
1325
+ let parsed;
1326
+ try {
1327
+ parsed = new URL(origin);
1328
+ } catch {
1329
+ throw new OAuthError("invalid_request", "无法解析回调地址 origin");
1330
+ }
1331
+ const host = parsed.hostname.toLowerCase();
1332
+ if (host !== "127.0.0.1" && host !== "::1" && host !== "[::1]") throw new OAuthError("invalid_request", `OAuth 回调只支持本机回环地址(127.0.0.1 / ::1),当前 origin 是 ${parsed.host} —— 请在本机页面上发起网页授权`);
1333
+ return `${parsed.protocol}//${parsed.host}${OAUTH_CALLBACK_PATH}`;
1334
+ }
1335
+ function buildAuthorizeUrl(endpoints, input) {
1336
+ const url = new URL(endpoints.authorizationEndpoint);
1337
+ url.searchParams.set("response_type", "code");
1338
+ url.searchParams.set("client_id", OAUTH_CLIENT_ID);
1339
+ url.searchParams.set("redirect_uri", input.redirectUri);
1340
+ url.searchParams.set("scope", input.scope);
1341
+ url.searchParams.set("state", input.state);
1342
+ url.searchParams.set("code_challenge", input.challenge);
1343
+ url.searchParams.set("code_challenge_method", "S256");
1344
+ return url.toString();
1345
+ }
1346
+ function readTokenPair$1(payload) {
1347
+ if (typeof payload !== "object" || payload === null) throw new OAuthError("server_error", "token 端点响应形状异常");
1348
+ const record = payload;
1349
+ if (typeof record.error === "string" && record.error.length > 0) throw new OAuthError(record.error, `token 端点返回错误:${record.error}`);
1350
+ const accessToken = record.access_token;
1351
+ if (typeof accessToken !== "string" || accessToken.length === 0) throw new OAuthError("server_error", "token 端点响应缺少 access_token");
1352
+ const refreshToken = record.refresh_token;
1353
+ const expiresIn = record.expires_in;
1354
+ const scope = record.scope;
1355
+ return {
1356
+ accessToken,
1357
+ ...typeof refreshToken === "string" && refreshToken.length > 0 ? { refreshToken } : {},
1358
+ ...typeof expiresIn === "number" ? { expiresIn } : {},
1359
+ ...typeof scope === "string" && scope.length > 0 ? { scope } : {}
1360
+ };
1361
+ }
1362
+ async function tokenRequest(tokenEndpoint, params, fetchImpl) {
1363
+ const response = await fetchImpl(tokenEndpoint, {
1364
+ method: "POST",
1365
+ headers: {
1366
+ "content-type": "application/x-www-form-urlencoded",
1367
+ accept: "application/json"
1368
+ },
1369
+ body: new URLSearchParams(params).toString()
1370
+ });
1371
+ let payload;
1372
+ try {
1373
+ payload = await response.json();
1374
+ } catch {
1375
+ throw new OAuthError("server_error", `token 端点响应不是 JSON(HTTP ${response.status})`);
1376
+ }
1377
+ if (!response.ok) {
1378
+ const code = payload?.error;
1379
+ throw new OAuthError(typeof code === "string" ? code : "server_error", `token 端点失败(HTTP ${response.status})`);
1380
+ }
1381
+ return readTokenPair$1(payload);
1382
+ }
1383
+ function exchangeAuthorizationCode(endpoints, input, fetchImpl = globalThis.fetch) {
1384
+ return tokenRequest(endpoints.tokenEndpoint, {
1385
+ grant_type: "authorization_code",
1386
+ client_id: OAUTH_CLIENT_ID,
1387
+ code: input.code,
1388
+ redirect_uri: input.redirectUri,
1389
+ code_verifier: input.verifier
1390
+ }, fetchImpl);
1391
+ }
1392
+ function refreshAccessToken(endpoints, refreshToken, fetchImpl = globalThis.fetch) {
1393
+ return tokenRequest(endpoints.tokenEndpoint, {
1394
+ grant_type: "refresh_token",
1395
+ client_id: OAUTH_CLIENT_ID,
1396
+ refresh_token: refreshToken
1397
+ }, fetchImpl);
1398
+ }
1399
+ async function revokeToken(endpoints, token, fetchImpl = globalThis.fetch) {
1400
+ if (endpoints.revocationEndpoint === undefined) return;
1401
+ await fetchImpl(endpoints.revocationEndpoint, {
1402
+ method: "POST",
1403
+ headers: {
1404
+ "content-type": "application/x-www-form-urlencoded",
1405
+ accept: "application/json"
1406
+ },
1407
+ body: new URLSearchParams({ token }).toString()
1408
+ }).catch(() => undefined);
1409
+ }
1410
+ var OAuthAttemptStore = class {
1411
+ attempts = new Map();
1412
+ byState = new Map();
1413
+ constructor(ttlMs = OAUTH_ATTEMPT_TTL_MS) {
1414
+ this.ttlMs = ttlMs;
1415
+ }
1416
+ create(input, now = Date.now()) {
1417
+ const attempt = {
1418
+ id: randomBase64url(24),
1419
+ state: randomBase64url(32),
1420
+ verifier: input.verifier,
1421
+ redirectUri: input.redirectUri,
1422
+ scope: input.scope,
1423
+ phase: "pending",
1424
+ createdAt: now
1425
+ };
1426
+ this.attempts.set(attempt.id, attempt);
1427
+ this.byState.set(attempt.state, attempt.id);
1428
+ return attempt;
1429
+ }
1430
+ expired(attempt, now) {
1431
+ return now - attempt.createdAt > this.ttlMs;
1432
+ }
1433
+ /**
1434
+ * Take the attempt for one browser redirect — single-use: the `state`
1435
+ * binding is consumed by this call, so a replayed redirect finds nothing.
1436
+ * The attempt itself stays in the registry for status polls by id until
1437
+ * it settles or expires.
1438
+ */
1439
+ takeByState(state, now = Date.now()) {
1440
+ const id = this.byState.get(state);
1441
+ if (id === undefined) return undefined;
1442
+ this.byState.delete(state);
1443
+ const attempt = this.attempts.get(id);
1444
+ if (attempt === undefined) return undefined;
1445
+ if (attempt.phase !== "pending" || this.expired(attempt, now)) {
1446
+ this.drop(attempt);
1447
+ return undefined;
1448
+ }
1449
+ return attempt;
1450
+ }
1451
+ /** Mark a consumed attempt terminal (keeps it readable for status polls). */
1452
+ settle(id, phase, error) {
1453
+ const attempt = this.attempts.get(id);
1454
+ if (attempt === undefined) return;
1455
+ attempt.phase = phase;
1456
+ if (error !== undefined) attempt.error = error;
1457
+ }
1458
+ /** Read-only projection for the dialog's poll; unknown id → undefined. */
1459
+ peek(id, now = Date.now()) {
1460
+ const attempt = this.attempts.get(id);
1461
+ if (attempt === undefined) return undefined;
1462
+ if (this.expired(attempt, now)) {
1463
+ this.drop(attempt);
1464
+ return undefined;
1465
+ }
1466
+ return {
1467
+ phase: attempt.phase,
1468
+ ...attempt.error !== undefined ? { error: attempt.error } : {}
1469
+ };
1470
+ }
1471
+ cancel(id) {
1472
+ const attempt = this.attempts.get(id);
1473
+ if (attempt === undefined) return false;
1474
+ this.drop(attempt);
1475
+ return true;
1476
+ }
1477
+ drop(attempt) {
1478
+ this.attempts.delete(attempt.id);
1479
+ this.byState.delete(attempt.state);
1480
+ }
1481
+ };
1482
+ function callbackPageHtml(outcome, message, lang = "zh") {
1483
+ const copy = {
1484
+ zh: {
1485
+ successTitle: "授权成功",
1486
+ failureTitle: "授权失败",
1487
+ openApp: "打开应用",
1488
+ htmlLang: "zh"
1489
+ },
1490
+ en: {
1491
+ successTitle: "Authorization complete",
1492
+ failureTitle: "Authorization failed",
1493
+ openApp: "Open app",
1494
+ htmlLang: "en"
1495
+ }
1496
+ }[lang];
1497
+ const title = outcome === "success" ? copy.successTitle : copy.failureTitle;
1498
+ const detail = message.replace(/[&<>"']/g, (ch) => `&#${ch.charCodeAt(0)};`);
1499
+ const styles = `
1500
+ body { font-family: system-ui, -apple-system, "PingFang SC", sans-serif; display: flex; min-height: 100vh;
1501
+ margin: 0; background: #fff; color: #1a1a1e; }
1502
+ .side { flex: 1 1 65%; display: flex; align-items: center; justify-content: center; min-width: 0; }
1503
+ main { text-align: center; max-width: 30rem; padding: 2.5rem 1.5rem; }
1504
+ .brand { display: flex; align-items: center; justify-content: center; gap: 0.55rem; margin-bottom: 4rem; }
1505
+ .wordmark { font-size: 1.65rem; font-weight: 700; letter-spacing: -0.02em; }
1506
+ .badge { background: #1a1a1e; color: #fff; font-size: 0.7rem; font-weight: 700; letter-spacing: 0.08em;
1507
+ padding: 0.3rem 0.55rem; border-radius: 0.4rem; }
1508
+ h1 { font-size: 1.5rem; font-weight: 600; margin: 0 0 1rem; }
1509
+ p { font-size: 0.95rem; color: #8a8f9c; line-height: 1.7; margin: 0; }
1510
+ .fail { color: #d54941; }
1511
+ .art { flex: 1 1 35%; min-width: 0; display: flex; align-items: center; color: #fff;
1512
+ background: radial-gradient(120% 90% at 78% 18%, rgba(255,255,255,0.26) 0%, rgba(255,255,255,0) 55%),
1513
+ linear-gradient(155deg, #6b7c92 0%, #46586e 38%, #2c3b4f 72%, #1f2b3a 100%); }
1514
+ .art p { color: #fff; font-size: 1.75rem; line-height: 1.8; font-weight: 500; margin: 0; padding: 0 4.5rem;
1515
+ max-width: 34rem; text-align: left; }
1516
+ @media (max-width: 56rem) { .art { display: none; } }`;
1517
+ const brand = `<div class="brand" aria-hidden="true"><span class="wordmark">Sub2API</span><span class="badge">SYNC</span></div>`;
1518
+ const art = `<div class="art" aria-hidden="true"><p>欢迎对话 DeepSeek Harness,<br>共探智能上限</p></div>`;
1519
+ if (outcome !== "success") return `<!doctype html>
1520
+ <html lang="${copy.htmlLang}">
1521
+ <head>
1522
+ <meta charset="utf-8">
1523
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1524
+ <title>${title}</title>
1525
+ <style>${styles}</style>
1526
+ </head>
1527
+ <body>
1528
+ <div class="side">
1529
+ <main>
1530
+ ${brand}
1531
+ <h1>${title}</h1>
1532
+ <p class="fail">${detail}</p>
1533
+ </main>
1534
+ </div>
1535
+ ${art}
1536
+ <script>try { setTimeout(function () { window.close() }, 1500) } catch (e) {}</script>
1537
+ </body>
1538
+ </html>`;
1539
+ return `<!doctype html>
1540
+ <html lang="${copy.htmlLang}">
1541
+ <head>
1542
+ <meta charset="utf-8">
1543
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1544
+ <title>${title}</title>
1545
+ <style>${styles}
1546
+ button { margin-top: 3rem; font: inherit; font-size: 0.95rem; padding: 0.85rem 2.4rem; border-radius: 0.75rem;
1547
+ border: 0; background: #1a1a1e; color: #fff; cursor: pointer; }
1548
+ button:hover { background: #33343a; }
1549
+ </style>
1550
+ </head>
1551
+ <body>
1552
+ <div class="side">
1553
+ <main>
1554
+ ${brand}
1555
+ <h1>${title}</h1>
1556
+ <p>${detail}</p>
1557
+ <button type="button" id="s2s-back">${copy.openApp}</button>
1558
+ </main>
1559
+ </div>
1560
+ ${art}
1561
+ <script>
1562
+ (function () {
1563
+ function pullApp() {
1564
+ try {
1565
+ var frame = document.createElement('iframe');
1566
+ frame.style.display = 'none';
1567
+ frame.src = 'dsh://open';
1568
+ document.body.appendChild(frame);
1569
+ setTimeout(function () { frame.remove() }, 4000);
1570
+ } catch (e) {}
1571
+ }
1572
+ setTimeout(pullApp, 800);
1573
+ var button = document.getElementById('s2s-back');
1574
+ if (button) button.addEventListener('click', function () { location.href = 'dsh://open' });
1575
+ try { setTimeout(function () { window.close() }, 30000) } catch (e) {}
1576
+ })();
1577
+ </script>
1578
+ </body>
1579
+ </html>`;
1580
+ }
1581
+
1582
+ //#endregion
1583
+ //#region src/panel.ts
1584
+ var PanelError = class extends Error {
1585
+ status;
1586
+ code;
1587
+ constructor(status, message, code) {
1588
+ super(message);
1589
+ this.name = "PanelError";
1590
+ this.status = status;
1591
+ this.code = code;
1592
+ }
1593
+ };
1594
+ /** The deployment's display timezone for the panel's list endpoints. */
1595
+ function timeZoneOf() {
1596
+ try {
1597
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
1598
+ } catch {
1599
+ return "UTC";
1600
+ }
1601
+ }
1602
+ /** Extract the envelope payload or throw a loud PanelError. */
1603
+ function unwrap(response, payload) {
1604
+ const envelope = payload;
1605
+ if (envelope === null || envelope === undefined || typeof envelope !== "object") throw new PanelError(response.status, `面板响应不是标准信封(HTTP ${response.status})`);
1606
+ const code = envelope.code;
1607
+ if (code !== 0 && code !== "0") {
1608
+ const message = typeof envelope.message === "string" && envelope.message.length > 0 ? envelope.message : "未知错误";
1609
+ throw new PanelError(response.status, `面板错误:${message}(code=${String(code)})`, code);
1610
+ }
1611
+ return envelope.data;
1612
+ }
1613
+ /** Best-effort captcha interception check (A4: contract still unverified). */
1614
+ function looksLikeCaptcha(status, payload) {
1615
+ if (status !== 403 && status !== 400) return false;
1616
+ const text = JSON.stringify(payload ?? "").toLowerCase();
1617
+ return text.includes("turnstile") || text.includes("captcha");
1618
+ }
1619
+ async function readBody(response) {
1620
+ const text = await response.text().catch(() => "");
1621
+ if (text.length === 0) return undefined;
1622
+ try {
1623
+ return JSON.parse(text);
1624
+ } catch {
1625
+ return text;
1626
+ }
1627
+ }
1628
+ /** Loud failure for non-envelope HTTP failures (Cloudflare pages, 500s, …). */
1629
+ async function fail(response, what) {
1630
+ const payload = await readBody(response);
1631
+ const message = typeof payload === "string" && payload.length > 0 ? payload.slice(0, 200) : payload?.message;
1632
+ const hint = response.status === 403 ? "(若实例前置 Cloudflare,检查 /api/v1/* 的 WAF 放行规则)" : "";
1633
+ throw new PanelError(response.status, `${what} 失败:HTTP ${response.status} ${typeof message === "string" ? message : ""}${hint}`);
1634
+ }
1635
+ /** Tolerant group reader. */
1636
+ function readGroup(item) {
1637
+ if (typeof item !== "object" || item === null) return undefined;
1638
+ const record = item;
1639
+ const id = record.id;
1640
+ const name$1 = record.name;
1641
+ if (typeof id !== "number" || !Number.isSafeInteger(id) || typeof name$1 !== "string") return undefined;
1642
+ return {
1643
+ id,
1644
+ name: name$1,
1645
+ ...typeof record.description === "string" ? { description: record.description } : {},
1646
+ platform: typeof record.platform === "string" ? record.platform : "",
1647
+ ...typeof record.rate_multiplier === "number" ? { rateMultiplier: record.rate_multiplier } : {},
1648
+ ...typeof record.status === "string" ? { status: record.status } : {},
1649
+ ...typeof record.is_exclusive === "boolean" ? { isExclusive: record.is_exclusive } : {},
1650
+ ...typeof record.claude_code_only === "boolean" ? { claudeCodeOnly: record.claude_code_only } : {}
1651
+ };
1652
+ }
1653
+ /** Tolerant key reader; plaintext kept verbatim when present. */
1654
+ function readKey(item) {
1655
+ if (typeof item !== "object" || item === null) return undefined;
1656
+ const record = item;
1657
+ const id = record.id;
1658
+ const name$1 = record.name;
1659
+ const groupId = record.group_id;
1660
+ if (typeof id !== "number" || !Number.isSafeInteger(id) || typeof name$1 !== "string") return undefined;
1661
+ if (typeof groupId !== "number" || !Number.isSafeInteger(groupId)) return undefined;
1662
+ return {
1663
+ id,
1664
+ name: name$1,
1665
+ groupId,
1666
+ ...typeof record.key === "string" ? { key: record.key } : {},
1667
+ ...typeof record.status === "string" ? { status: record.status } : {},
1668
+ ...typeof record.quota === "number" ? { quota: record.quota } : {},
1669
+ ...typeof record.expires_at === "string" ? { expiresAt: record.expires_at } : {}
1670
+ };
1671
+ }
1672
+ function readTokenPair(data) {
1673
+ if (typeof data !== "object" || data === null) throw new PanelError(500, "登录响应缺少 data");
1674
+ const record = data;
1675
+ const accessToken = record.access_token;
1676
+ if (typeof accessToken !== "string" || accessToken.length === 0) throw new PanelError(500, "登录响应缺少 access_token");
1677
+ const refreshToken = record.refresh_token;
1678
+ return {
1679
+ accessToken,
1680
+ ...typeof refreshToken === "string" && refreshToken.length > 0 ? { refreshToken } : {}
1681
+ };
1682
+ }
1683
+ var PanelClient = class {
1684
+ baseURL;
1685
+ fetchImpl;
1686
+ constructor(options) {
1687
+ const base = options.baseURL.trim().replace(/\/+$/, "");
1688
+ if (!/^https?:\/\//i.test(base)) throw new Error("sub2api: baseURL 必须是 http(s) 地址");
1689
+ this.baseURL = base;
1690
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
1691
+ if (fetchImpl === undefined) throw new Error("sub2api: 当前环境没有 fetch 可用");
1692
+ this.fetchImpl = fetchImpl.bind(globalThis);
1693
+ }
1694
+ async call(token, method, path, body) {
1695
+ const response = await this.fetchImpl(`${this.baseURL}/api/v1${path}`, {
1696
+ method,
1697
+ headers: {
1698
+ "content-type": "application/json",
1699
+ accept: "application/json",
1700
+ "x-user-ui-request": "1",
1701
+ ...token !== undefined ? { authorization: `Bearer ${token}` } : {}
1702
+ },
1703
+ ...body !== undefined ? { body: JSON.stringify(body) } : {}
1704
+ });
1705
+ const payload = await readBody(response);
1706
+ if (!response.ok) {
1707
+ if (looksLikeCaptcha(response.status, payload)) {
1708
+ const message = payload?.message;
1709
+ throw Object.assign(new PanelError(response.status, `实例开启了人机验证(Turnstile/TCaptcha)——请改用「网页授权」通道`, "captcha"), {
1710
+ kind: "captcha",
1711
+ detail: typeof message === "string" ? message : undefined
1712
+ });
1713
+ }
1714
+ if (typeof payload === "object" && payload !== null && payload.code !== undefined) unwrap(response, payload);
1715
+ await fail(response, `面板 ${method} ${path}`);
1716
+ }
1717
+ return unwrap(response, payload);
1718
+ }
1719
+ /** Password login; classifies 2FA and captcha outcomes. */
1720
+ async login(email, password) {
1721
+ let payload;
1722
+ try {
1723
+ payload = await this.call(undefined, "POST", "/auth/login", {
1724
+ email,
1725
+ password
1726
+ });
1727
+ } catch (error) {
1728
+ if (error instanceof PanelError) throw error;
1729
+ throw error;
1730
+ }
1731
+ const data = payload;
1732
+ if (data !== null && typeof data === "object" && data.requires_2fa === true) {
1733
+ const tempToken = data.temp_token;
1734
+ if (typeof tempToken !== "string" || tempToken.length === 0) throw new PanelError(500, "2FA 响应缺少 temp_token");
1735
+ return {
1736
+ kind: "2fa",
1737
+ tempToken
1738
+ };
1739
+ }
1740
+ const tokens = readTokenPair(payload);
1741
+ return {
1742
+ kind: "ok",
1743
+ accessToken: tokens.accessToken,
1744
+ refreshToken: tokens.refreshToken
1745
+ };
1746
+ }
1747
+ /** Second factor for the pending login. */
1748
+ async login2FA(tempToken, totpCode) {
1749
+ const payload = await this.call(undefined, "POST", "/auth/login/2fa", {
1750
+ temp_token: tempToken,
1751
+ totp_code: totpCode
1752
+ });
1753
+ return readTokenPair(payload);
1754
+ }
1755
+ /** Exchange a refresh token for a fresh pair. */
1756
+ async refresh(refreshToken) {
1757
+ const payload = await this.call(undefined, "POST", "/auth/refresh", { refresh_token: refreshToken });
1758
+ return readTokenPair(payload);
1759
+ }
1760
+ /** Groups the account can bind; `data` is a bare array. */
1761
+ async listAvailableGroups(token) {
1762
+ const data = await this.call(token, "GET", "/groups/available");
1763
+ if (!Array.isArray(data)) throw new PanelError(500, "groups/available 响应不是数组");
1764
+ return data.map(readGroup).filter((group) => group !== undefined);
1765
+ }
1766
+ /**
1767
+ * One page of the panel's native key listing (newest first upstream:
1768
+ * `sort_by=created_at&sort_order=desc`, mirroring the console call shape).
1769
+ * Callers that need every key walk `pages` themselves.
1770
+ */
1771
+ async listKeys(token, options) {
1772
+ const page = options?.page ?? 1;
1773
+ const pageSize = options?.pageSize ?? 20;
1774
+ const query = new URLSearchParams({
1775
+ page: String(page),
1776
+ page_size: String(pageSize),
1777
+ sort_by: options?.sortBy ?? "created_at",
1778
+ sort_order: options?.sortOrder ?? "desc",
1779
+ timezone: timeZoneOf()
1780
+ });
1781
+ if (options?.groupId !== undefined) query.set("group_id", String(options.groupId));
1782
+ const data = await this.call(token, "GET", `/keys?${query.toString()}`);
1783
+ const record = data;
1784
+ const items = record?.items;
1785
+ if (!Array.isArray(items)) throw new PanelError(500, "keys 响应缺少 items 数组");
1786
+ const mapped = items.map(readKey).filter((key) => key !== undefined);
1787
+ const total = typeof record?.total === "number" ? record.total : mapped.length;
1788
+ const pages = typeof record?.pages === "number" && record.pages > 0 ? record.pages : Math.max(1, Math.ceil(total / pageSize));
1789
+ return {
1790
+ items: mapped,
1791
+ total,
1792
+ pages
1793
+ };
1794
+ }
1795
+ /** Create one relay key. */
1796
+ async createKey(token, input) {
1797
+ const payload = await this.call(token, "POST", "/keys", {
1798
+ name: input.name,
1799
+ group_id: input.groupId,
1800
+ ...input.quota !== undefined && input.quota > 0 ? { quota: input.quota } : {},
1801
+ ...input.expiresinDays !== undefined && input.expiresinDays > 0 ? { expires_in_days: input.expiresinDays } : {}
1802
+ });
1803
+ const key = readKey(payload);
1804
+ if (key === undefined) throw new PanelError(500, "创建 key 响应形状异常");
1805
+ return key;
1806
+ }
1807
+ /** Revoke one relay key. */
1808
+ async deleteKey(token, id) {
1809
+ await this.call(token, "DELETE", `/keys/${id}`);
1810
+ }
1811
+ /**
1812
+ * Controlled plaintext reveal for one client-owned relay key (OAuth only):
1813
+ * the regular OAuth list/get answers are masked, this endpoint is the
1814
+ * documented exception — same ownership checks server-side, audited.
1815
+ */
1816
+ async revealKey(token, id) {
1817
+ const payload = await this.call(token, "POST", `/keys/${id}/reveal`);
1818
+ const key = readKey(payload);
1819
+ if (key === undefined) throw new PanelError(500, "reveal 响应形状异常");
1820
+ return key;
1821
+ }
1822
+ /**
1823
+ * The signed-in account's panel identity via the OAuth userinfo endpoint
1824
+ * (`GET /auth/me`, scope `profile` — the plugin always requests it). The
1825
+ * richer `/user/profile` sits OUTSIDE the OAuth token's path whitelist
1826
+ * (`requiredOAuthScope`: only /auth/me, /groups/*, /keys/*) and answers
1827
+ * 403 INSUFFICIENT_SCOPE for OAuth sessions. Fields: username, email,
1828
+ * balance — all optional.
1829
+ */
1830
+ async getProfile(token) {
1831
+ const payload = await this.call(token, "GET", "/auth/me");
1832
+ if (payload === null || typeof payload !== "object") throw new PanelError(500, "profile 响应形状异常");
1833
+ const data = payload;
1834
+ const pick = (key) => {
1835
+ const value = data[key];
1836
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
1837
+ };
1838
+ const balance = data.balance;
1839
+ return {
1840
+ username: pick("username"),
1841
+ email: pick("email"),
1842
+ ...typeof balance === "number" && Number.isFinite(balance) ? { balance } : {}
1843
+ };
1844
+ }
1845
+ /** OAuth token exchange against the discovered provider (host-side only). */
1846
+ async oauthTokenExchange(endpoints, input) {
1847
+ return exchangeAuthorizationCode(endpoints, input, this.fetchImpl);
1848
+ }
1849
+ /** OAuth discovery passthrough for route handlers that need endpoints. */
1850
+ async discoverOAuth() {
1851
+ return discoverOAuthEndpoints(this.baseURL, this.fetchImpl);
1852
+ }
1853
+ };
1854
+ var PanelGateway = class {
1855
+ baseURLSource;
1856
+ credentials;
1857
+ fetchImpl;
1858
+ pending2FA;
1859
+ refreshInFlight;
1860
+ oauthRefreshInFlight;
1861
+ constructor(options) {
1862
+ this.baseURLSource = options.baseURL;
1863
+ this.credentials = options.credentials;
1864
+ this.fetchImpl = options.fetchImpl;
1865
+ this.onAuthLost = options.onAuthLost;
1866
+ }
1867
+ /**
1868
+ * Fired when the panel session is definitively gone — no refresh token, or
1869
+ * the refresh call itself was refused. Never on network failures. The host
1870
+ * hooks this to purge the site's sync state (logout/expiry policy).
1871
+ */
1872
+ onAuthLost;
1873
+ /** The instance address, re-read per call so a baseURL edit takes effect. */
1874
+ get baseURL() {
1875
+ return typeof this.baseURLSource === "function" ? this.baseURLSource() : this.baseURLSource;
1876
+ }
1877
+ client() {
1878
+ return new PanelClient({
1879
+ baseURL: this.baseURL,
1880
+ fetchImpl: this.fetchImpl
1881
+ });
1882
+ }
1883
+ tokenRef() {
1884
+ return panelTokenRef(this.baseURL);
1885
+ }
1886
+ refreshRef() {
1887
+ return panelRefreshRef(this.baseURL);
1888
+ }
1889
+ oauthAccessRef() {
1890
+ return oauthTokenRef(this.baseURL);
1891
+ }
1892
+ oauthRefresh() {
1893
+ return oauthRefreshRef(this.baseURL);
1894
+ }
1895
+ /** Current auth state; email rides the caller's config, not credentials. */
1896
+ async authState(email) {
1897
+ const oauthAccess = await this.credentials.resolve(this.oauthAccessRef());
1898
+ if (oauthAccess !== undefined && oauthAccess.value.length > 0) return {
1899
+ signedIn: true,
1900
+ kind: "oauth",
1901
+ ...email !== undefined && email.length > 0 ? { email } : {}
1902
+ };
1903
+ const access = await this.credentials.resolve(this.tokenRef());
1904
+ if (access === undefined || access.value.length === 0) return {
1905
+ signedIn: false,
1906
+ kind: "none"
1907
+ };
1908
+ return {
1909
+ signedIn: true,
1910
+ kind: "panel",
1911
+ ...email !== undefined && email.length > 0 ? { email } : {}
1912
+ };
1913
+ }
1914
+ /** Password login; stores the pair and the password for silent re-login. */
1915
+ async login(email, password) {
1916
+ const outcome = await this.client().login(email, password);
1917
+ if (outcome.kind === "2fa") {
1918
+ this.pending2FA = {
1919
+ tempToken: outcome.tempToken,
1920
+ email
1921
+ };
1922
+ return outcome;
1923
+ }
1924
+ if (outcome.kind === "captcha") return outcome;
1925
+ await this.storeTokens(outcome.accessToken, outcome.refreshToken);
1926
+ return outcome;
1927
+ }
1928
+ /** Complete the pending 2FA login. */
1929
+ async login2FA(code) {
1930
+ const pending = this.pending2FA;
1931
+ if (pending === undefined) throw new Error("sub2api-sync: 没有待完成的 2FA 登录——请先发起登录");
1932
+ const tokens = await this.client().login2FA(pending.tempToken, code);
1933
+ await this.storeTokens(tokens.accessToken, tokens.refreshToken);
1934
+ this.pending2FA = undefined;
1935
+ }
1936
+ /** Sign out: revoke OAuth tokens server-side when present, then drop both channels' secrets. */
1937
+ async logout() {
1938
+ await this.oauthSignOut();
1939
+ await this.credentials.unset(this.tokenRef());
1940
+ await this.credentials.unset(this.refreshRef());
1941
+ this.pending2FA = undefined;
1942
+ }
1943
+ /**
1944
+ * Run one authed panel call. Tokens resolve per channel — OAuth access
1945
+ * token first, then the panel JWT — and a 401 triggers a single-flight
1946
+ * refresh (channel-appropriate) and exactly one retry before failing loud.
1947
+ */
1948
+ async use(fn) {
1949
+ const oauthAccess = await this.credentials.resolve(this.oauthAccessRef());
1950
+ if (oauthAccess !== undefined && oauthAccess.value.length > 0) {
1951
+ const panel$1 = this.client();
1952
+ try {
1953
+ return await fn(panel$1, oauthAccess.value);
1954
+ } catch (error) {
1955
+ const status = error instanceof PanelError ? error.status : undefined;
1956
+ if (status !== 401) throw error;
1957
+ const token = await this.refreshOAuthSingleFlight();
1958
+ return await fn(panel$1, token);
1959
+ }
1960
+ }
1961
+ const access = await this.credentials.resolve(this.tokenRef());
1962
+ if (access === undefined || access.value.length === 0) throw new Error("sub2api-sync: 尚未登录 sub2api 面板——请先在设置卡完成登录");
1963
+ const panel = this.client();
1964
+ try {
1965
+ return await fn(panel, access.value);
1966
+ } catch (error) {
1967
+ const status = error instanceof PanelError ? error.status : undefined;
1968
+ if (status !== 401) throw error;
1969
+ const token = await this.refreshSingleFlight();
1970
+ return await fn(panel, token);
1971
+ }
1972
+ }
1973
+ /** Store the pair from a completed browser authorization (C2). */
1974
+ async storeOAuthTokens(pair) {
1975
+ await this.credentials.set(this.oauthAccessRef(), pair.accessToken);
1976
+ if (pair.refreshToken !== undefined) await this.credentials.set(this.oauthRefresh(), pair.refreshToken);
1977
+ else await this.credentials.unset(this.oauthRefresh());
1978
+ }
1979
+ /** Revoke OAuth tokens at the provider (best effort) and drop the stored secrets. */
1980
+ async oauthSignOut() {
1981
+ const access = await this.credentials.resolve(this.oauthAccessRef());
1982
+ const refresh = await this.credentials.resolve(this.oauthRefresh());
1983
+ if (access !== undefined || refresh !== undefined) try {
1984
+ const endpoints = await discoverOAuthEndpoints(this.baseURL, this.fetchImpl);
1985
+ if (refresh !== undefined) await revokeToken(endpoints, refresh.value, this.fetchImpl);
1986
+ if (access !== undefined) await revokeToken(endpoints, access.value, this.fetchImpl);
1987
+ } catch {}
1988
+ await this.credentials.unset(this.oauthAccessRef());
1989
+ await this.credentials.unset(this.oauthRefresh());
1990
+ }
1991
+ /** Single-flight OAuth refresh (rotating); concurrent 401s share one wire call. */
1992
+ refreshOAuthSingleFlight() {
1993
+ this.oauthRefreshInFlight ??= this.refreshOAuth().finally(() => {
1994
+ this.oauthRefreshInFlight = undefined;
1995
+ });
1996
+ return this.oauthRefreshInFlight;
1997
+ }
1998
+ async refreshOAuth() {
1999
+ const stored = await this.credentials.resolve(this.oauthRefresh());
2000
+ if (stored === undefined || stored.value.length === 0) {
2001
+ await this.credentials.unset(this.oauthAccessRef());
2002
+ this.onAuthLost?.();
2003
+ throw new Error("sub2api-sync: OAuth 登录已过期且无 refresh token——请重新进行网页授权");
2004
+ }
2005
+ try {
2006
+ const endpoints = await discoverOAuthEndpoints(this.baseURL, this.fetchImpl);
2007
+ const pair = await refreshAccessToken(endpoints, stored.value, this.fetchImpl);
2008
+ await this.storeOAuthTokens(pair);
2009
+ return pair.accessToken;
2010
+ } catch (error) {
2011
+ await this.credentials.unset(this.oauthAccessRef());
2012
+ await this.credentials.unset(this.oauthRefresh());
2013
+ this.onAuthLost?.();
2014
+ const reason = error instanceof Error ? error.message : String(error);
2015
+ throw new Error(`sub2api-sync: OAuth 登录已过期且刷新失败——请重新进行网页授权(${reason})`);
2016
+ }
2017
+ }
2018
+ /** Single-flight refresh; concurrent 401s share one wire call. */
2019
+ refreshSingleFlight() {
2020
+ this.refreshInFlight ??= this.doRefresh().finally(() => {
2021
+ this.refreshInFlight = undefined;
2022
+ });
2023
+ return this.refreshInFlight;
2024
+ }
2025
+ async doRefresh() {
2026
+ const stored = await this.credentials.resolve(this.refreshRef());
2027
+ if (stored === undefined || stored.value.length === 0) {
2028
+ await this.credentials.unset(this.tokenRef());
2029
+ this.onAuthLost?.();
2030
+ throw new Error("sub2api-sync: 登录已过期且无 refresh token——请重新登录");
2031
+ }
2032
+ try {
2033
+ const tokens = await this.client().refresh(stored.value);
2034
+ await this.storeTokens(tokens.accessToken, tokens.refreshToken);
2035
+ return tokens.accessToken;
2036
+ } catch (error) {
2037
+ await this.credentials.unset(this.tokenRef());
2038
+ await this.credentials.unset(this.refreshRef());
2039
+ this.onAuthLost?.();
2040
+ const reason = error instanceof Error ? error.message : String(error);
2041
+ throw new Error(`sub2api-sync: 登录已过期且刷新失败——请重新登录(${reason})`);
2042
+ }
2043
+ }
2044
+ async storeTokens(accessToken, refreshToken) {
2045
+ await this.credentials.set(this.tokenRef(), accessToken);
2046
+ if (refreshToken !== undefined) await this.credentials.set(this.refreshRef(), refreshToken);
2047
+ else await this.credentials.unset(this.refreshRef());
2048
+ }
2049
+ };
2050
+
2051
+ //#endregion
2052
+ //#region src/routes.ts
2053
+ function callbackLangOf(acceptLanguage) {
2054
+ const first = acceptLanguage?.split(",")[0]?.trim().toLowerCase() ?? "";
2055
+ return first.startsWith("en") ? "en" : "zh";
2056
+ }
2057
+ const ROUTES = {
2058
+ sync: "/plugins/dsh-sub2api-sync/sync",
2059
+ status: "/plugins/dsh-sub2api-sync/status",
2060
+ login: "/plugins/dsh-sub2api-sync/login",
2061
+ login2fa: "/plugins/dsh-sub2api-sync/login/2fa",
2062
+ logout: "/plugins/dsh-sub2api-sync/logout",
2063
+ groups: "/plugins/dsh-sub2api-sync/groups",
2064
+ keys: "/plugins/dsh-sub2api-sync/keys",
2065
+ icon: "/plugins/dsh-sub2api-sync/icon",
2066
+ site: "/plugins/dsh-sub2api-sync/site",
2067
+ account: "/plugins/dsh-sub2api-sync/account",
2068
+ resetSync: "/plugins/dsh-sub2api-sync/reset-sync",
2069
+ removeKey: "/plugins/dsh-sub2api-sync/remove-key",
2070
+ oauthStart: "/plugins/dsh-sub2api-sync/oauth/start",
2071
+ oauthProbe: "/plugins/dsh-sub2api-sync/oauth/probe",
2072
+ oauthCallback: "/plugins/dsh-sub2api-sync/oauth/callback",
2073
+ oauthStatus: "/plugins/dsh-sub2api-sync/oauth/status",
2074
+ oauthCancel: "/plugins/dsh-sub2api-sync/oauth/cancel"
2075
+ };
2076
+ function trustedRequest(req, allowRemote = false) {
2077
+ const remote = req.socket.remoteAddress;
2078
+ if (remote === undefined) return false;
2079
+ const loopback = remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1";
2080
+ const privatePeer = loopback || allowRemote && isPrivateAddress(remote);
2081
+ if (!privatePeer) return false;
2082
+ if (req.headers["sec-fetch-site"] === "cross-site") return false;
2083
+ const host = req.headers.host;
2084
+ if (host === undefined) return false;
2085
+ const origin = req.headers.origin;
2086
+ if (origin === undefined) return true;
2087
+ try {
2088
+ return new URL(origin).host === new URL(`http://${host}`).host;
2089
+ } catch {
2090
+ return false;
2091
+ }
2092
+ }
2093
+ /** RFC 1918 / ULA ranges only — never public addresses. */
2094
+ function isPrivateAddress(address) {
2095
+ const ipv4 = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(address)?.[1] ?? address;
2096
+ const v4 = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(ipv4);
2097
+ if (v4 !== null) {
2098
+ const [a, b] = [Number(v4[1]), Number(v4[2])];
2099
+ if (a === 10 || a === 127) return true;
2100
+ if (a === 172 && b >= 16 && b <= 31) return true;
2101
+ if (a === 192 && b === 168) return true;
2102
+ return false;
2103
+ }
2104
+ const lowered = address.toLowerCase();
2105
+ if (lowered === "::1") return true;
2106
+ return lowered.startsWith("fc") || lowered.startsWith("fd");
2107
+ }
2108
+ async function readJson(req) {
2109
+ const chunks = [];
2110
+ for await (const chunk of req) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
2111
+ const text = Buffer.concat(chunks).toString("utf8").trim();
2112
+ if (text.length === 0) return {};
2113
+ const value = JSON.parse(text);
2114
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
2115
+ }
2116
+ function json(res, status, value) {
2117
+ res.writeHead(status, {
2118
+ "content-type": "application/json; charset=utf-8",
2119
+ "cache-control": "no-store",
2120
+ "x-content-type-options": "nosniff"
2121
+ });
2122
+ res.end(JSON.stringify(value));
2123
+ }
2124
+ /** The browser-facing hand-back page for the provider's redirect. */
2125
+ function html(res, body) {
2126
+ res.writeHead(200, {
2127
+ "content-type": "text/html; charset=utf-8",
2128
+ "cache-control": "no-store",
2129
+ "x-content-type-options": "nosniff"
2130
+ });
2131
+ res.end(body);
2132
+ }
2133
+ /** The harness origin this request arrived on (host + proto, no path). */
2134
+ function requestOrigin(req) {
2135
+ const forwarded = req.headers["x-forwarded-proto"];
2136
+ const proto = (Array.isArray(forwarded) ? forwarded[0] : forwarded) ?? "http";
2137
+ const host = req.headers.host ?? "127.0.0.1";
2138
+ return `${proto}://${host}`;
2139
+ }
2140
+ function safeMessage(error) {
2141
+ return error instanceof Error ? error.message : String(error);
2142
+ }
2143
+ function conflictStatus(error) {
2144
+ const shape = error;
2145
+ return shape?.code === "SETTINGS_CONFLICT" || shape?.name === "SettingsConflictError" ? 409 : undefined;
2146
+ }
2147
+ function registerRoutes(ctx, deps) {
2148
+ ctx.inject(["webServer"], (webCtx) => {
2149
+ const route = (path, handler) => {
2150
+ webCtx.webServer.register({
2151
+ kind: "exact",
2152
+ path,
2153
+ handler
2154
+ });
2155
+ };
2156
+ const guard = (req, res) => {
2157
+ if (!trustedRequest(req, deps.allowRemote())) {
2158
+ json(res, 403, { error: "forbidden" });
2159
+ return false;
2160
+ }
2161
+ return true;
2162
+ };
2163
+ const method = (expected, req, res) => {
2164
+ if (req.method !== expected) {
2165
+ json(res, 405, { error: "method not allowed" });
2166
+ return false;
2167
+ }
2168
+ return true;
2169
+ };
2170
+ route(ROUTES.status, async (req, res) => {
2171
+ if (!guard(req, res)) return;
2172
+ if (!method("GET", req, res)) return;
2173
+ const auth = await deps.authState();
2174
+ json(res, 200, {
2175
+ ok: true,
2176
+ auth,
2177
+ instance: deps.instance(),
2178
+ syncRunning: deps.isSyncRunning(),
2179
+ routes: deps.ownedRoutes(),
2180
+ lastReport: deps.lastReport(),
2181
+ lastReportAt: deps.lastReportAt()
2182
+ });
2183
+ });
2184
+ route(ROUTES.sync, async (req, res) => {
2185
+ if (!guard(req, res)) return;
2186
+ if (!method("POST", req, res)) return;
2187
+ try {
2188
+ const body = await readJson(req).catch(() => ({}));
2189
+ const keyIds = Array.isArray(body.keyIds) ? body.keyIds.filter((id) => typeof id === "number" && Number.isSafeInteger(id) && id > 0) : undefined;
2190
+ const report = await deps.sync(keyIds);
2191
+ json(res, 200, {
2192
+ ok: true,
2193
+ report
2194
+ });
2195
+ } catch (error) {
2196
+ json(res, conflictStatus(error) ?? 500, {
2197
+ ok: false,
2198
+ error: safeMessage(error)
2199
+ });
2200
+ }
2201
+ });
2202
+ route(ROUTES.login, async (req, res) => {
2203
+ if (!guard(req, res)) return;
2204
+ if (!method("POST", req, res)) return;
2205
+ let body;
2206
+ try {
2207
+ body = await readJson(req);
2208
+ } catch {
2209
+ return json(res, 400, { error: "invalid JSON body" });
2210
+ }
2211
+ const email = typeof body.email === "string" ? body.email.trim() : "";
2212
+ const password = typeof body.password === "string" ? body.password : undefined;
2213
+ if (email.length === 0) return json(res, 400, { error: "email 必填" });
2214
+ try {
2215
+ json(res, 200, await deps.login(email, password));
2216
+ } catch (error) {
2217
+ const kind = error?.kind;
2218
+ json(res, 400, {
2219
+ ok: false,
2220
+ error: safeMessage(error),
2221
+ ...kind === "captcha" ? { kind: "captcha" } : {}
2222
+ });
2223
+ }
2224
+ });
2225
+ route(ROUTES.login2fa, async (req, res) => {
2226
+ if (!guard(req, res)) return;
2227
+ if (!method("POST", req, res)) return;
2228
+ let body;
2229
+ try {
2230
+ body = await readJson(req);
2231
+ } catch {
2232
+ return json(res, 400, { error: "invalid JSON body" });
2233
+ }
2234
+ const code = typeof body.code === "string" ? body.code.trim() : "";
2235
+ if (!/^\d{6}$/.test(code)) return json(res, 400, { error: "code 必须是 6 位数字" });
2236
+ try {
2237
+ await deps.login2FA(code);
2238
+ json(res, 200, {
2239
+ ok: true,
2240
+ kind: "ok"
2241
+ });
2242
+ } catch (error) {
2243
+ json(res, 400, {
2244
+ ok: false,
2245
+ error: safeMessage(error)
2246
+ });
2247
+ }
2248
+ });
2249
+ route(ROUTES.logout, async (req, res) => {
2250
+ if (!guard(req, res)) return;
2251
+ if (!method("POST", req, res)) return;
2252
+ try {
2253
+ await deps.logout();
2254
+ json(res, 200, { ok: true });
2255
+ } catch (error) {
2256
+ json(res, 500, {
2257
+ ok: false,
2258
+ error: safeMessage(error)
2259
+ });
2260
+ }
2261
+ });
2262
+ route(ROUTES.groups, async (req, res) => {
2263
+ if (!guard(req, res)) return;
2264
+ if (!method("GET", req, res)) return;
2265
+ try {
2266
+ json(res, 200, {
2267
+ ok: true,
2268
+ groups: await deps.listGroups()
2269
+ });
2270
+ } catch (error) {
2271
+ json(res, 400, {
2272
+ ok: false,
2273
+ error: safeMessage(error)
2274
+ });
2275
+ }
2276
+ });
2277
+ route(ROUTES.keys, async (req, res) => {
2278
+ if (!guard(req, res)) return;
2279
+ if (req.method === "GET") {
2280
+ try {
2281
+ const url = new URL(req.url ?? "/", "http://localhost");
2282
+ const rawPage = Number(url.searchParams.get("page") ?? "1");
2283
+ const rawSize = Number(url.searchParams.get("pageSize") ?? "5");
2284
+ const page = Number.isSafeInteger(rawPage) && rawPage > 0 ? rawPage : 1;
2285
+ const pageSize = Number.isSafeInteger(rawSize) && rawSize > 0 ? Math.min(rawSize, 50) : 5;
2286
+ const result = await deps.listKeys(page, pageSize);
2287
+ json(res, 200, {
2288
+ ok: true,
2289
+ keys: result.keys,
2290
+ total: result.total,
2291
+ pages: result.pages
2292
+ });
2293
+ } catch (error) {
2294
+ json(res, 400, {
2295
+ ok: false,
2296
+ error: safeMessage(error)
2297
+ });
2298
+ }
2299
+ return;
2300
+ }
2301
+ if (!method("POST", req, res)) return;
2302
+ try {
2303
+ const body = await readJson(req);
2304
+ const groupId = parseGroupIdFrom(body.groupId);
2305
+ const name$1 = typeof body.name === "string" ? body.name.trim() : "";
2306
+ if (groupId === undefined) return json(res, 400, {
2307
+ ok: false,
2308
+ error: "groupId 必须是正整数分组 id"
2309
+ });
2310
+ if (name$1.length === 0 || name$1.length > 128) return json(res, 400, {
2311
+ ok: false,
2312
+ error: "key 名称必须是 1-128 个字符"
2313
+ });
2314
+ json(res, 200, {
2315
+ ok: true,
2316
+ key: await deps.createKey({
2317
+ groupId,
2318
+ name: name$1
2319
+ })
2320
+ });
2321
+ } catch (error) {
2322
+ json(res, 400, {
2323
+ ok: false,
2324
+ error: safeMessage(error)
2325
+ });
2326
+ }
2327
+ });
2328
+ route(ROUTES.icon, async (req, res) => {
2329
+ if (!guard(req, res)) return;
2330
+ if (!method("GET", req, res)) return;
2331
+ const icon = await deps.icon().catch(() => undefined);
2332
+ if (icon === undefined) return json(res, 404, { error: "icon unavailable" });
2333
+ res.writeHead(200, {
2334
+ "content-type": icon.contentType,
2335
+ "cache-control": "private, max-age=600",
2336
+ "x-content-type-options": "nosniff"
2337
+ });
2338
+ res.end(icon.body);
2339
+ });
2340
+ route(ROUTES.site, async (req, res) => {
2341
+ if (!guard(req, res)) return;
2342
+ if (!method("GET", req, res)) return;
2343
+ const name$1 = await deps.siteName().catch(() => undefined);
2344
+ json(res, 200, {
2345
+ ok: true,
2346
+ name: name$1
2347
+ });
2348
+ });
2349
+ route(ROUTES.account, async (req, res) => {
2350
+ if (!guard(req, res)) return;
2351
+ if (!method("GET", req, res)) return;
2352
+ try {
2353
+ const profile = await deps.account();
2354
+ json(res, 200, {
2355
+ ok: true,
2356
+ ...profile?.username !== undefined ? { username: profile.username } : {},
2357
+ ...profile?.email !== undefined ? { email: profile.email } : {},
2358
+ ...profile?.balance !== undefined ? { balance: profile.balance } : {}
2359
+ });
2360
+ } catch (error) {
2361
+ json(res, 200, {
2362
+ ok: true,
2363
+ error: safeMessage(error)
2364
+ });
2365
+ }
2366
+ });
2367
+ route(ROUTES.resetSync, async (req, res) => {
2368
+ if (!guard(req, res)) return;
2369
+ if (!method("POST", req, res)) return;
2370
+ try {
2371
+ const body = await readJson(req);
2372
+ const previous = typeof body.previousBaseURL === "string" && body.previousBaseURL.length > 0 ? body.previousBaseURL : undefined;
2373
+ await deps.resetSync(previous);
2374
+ json(res, 200, { ok: true });
2375
+ } catch (error) {
2376
+ json(res, 500, {
2377
+ ok: false,
2378
+ error: safeMessage(error)
2379
+ });
2380
+ }
2381
+ });
2382
+ route(ROUTES.removeKey, async (req, res) => {
2383
+ if (!guard(req, res)) return;
2384
+ if (!method("POST", req, res)) return;
2385
+ try {
2386
+ const body = await readJson(req);
2387
+ const keyId = parseGroupIdFrom(body.keyId);
2388
+ if (keyId === undefined) return json(res, 400, {
2389
+ ok: false,
2390
+ error: "keyId 必须是正整数 key id"
2391
+ });
2392
+ await deps.removeSyncedKey(keyId);
2393
+ json(res, 200, { ok: true });
2394
+ } catch (error) {
2395
+ json(res, 500, {
2396
+ ok: false,
2397
+ error: safeMessage(error)
2398
+ });
2399
+ }
2400
+ });
2401
+ route(ROUTES.oauthStart, async (req, res) => {
2402
+ if (!guard(req, res)) return;
2403
+ if (!method("POST", req, res)) return;
2404
+ try {
2405
+ const started = await deps.oauthStart(requestOrigin(req));
2406
+ json(res, 200, {
2407
+ ok: true,
2408
+ attemptId: started.attemptId,
2409
+ authorizeUrl: started.authorizeUrl
2410
+ });
2411
+ } catch (error) {
2412
+ json(res, 400, {
2413
+ ok: false,
2414
+ error: safeMessage(error)
2415
+ });
2416
+ }
2417
+ });
2418
+ route(ROUTES.oauthProbe, async (req, res) => {
2419
+ if (!guard(req, res)) return;
2420
+ if (!method("GET", req, res)) return;
2421
+ const url = new URL(req.url ?? "/", "http://localhost");
2422
+ const address = url.searchParams.get("address") ?? "";
2423
+ let supported = false;
2424
+ if (/^https?:\/\//i.test(address.trim())) supported = await deps.oauthProbe(address).catch(() => false);
2425
+ json(res, 200, {
2426
+ ok: true,
2427
+ supported
2428
+ });
2429
+ });
2430
+ route(ROUTES.oauthCallback, async (req, res) => {
2431
+ if (!method("GET", req, res)) return;
2432
+ try {
2433
+ const query = new URL(req.url ?? "/", "http://localhost").searchParams;
2434
+ const answer = await deps.oauthCallback(query, callbackLangOf(req.headers["accept-language"]));
2435
+ html(res, answer.html);
2436
+ } catch (error) {
2437
+ html(res, oauthFailurePage(safeMessage(error)));
2438
+ }
2439
+ });
2440
+ route(ROUTES.oauthStatus, async (req, res) => {
2441
+ if (!guard(req, res)) return;
2442
+ if (!method("GET", req, res)) return;
2443
+ try {
2444
+ const url = new URL(req.url ?? "/", "http://localhost");
2445
+ const attemptId = url.searchParams.get("attempt") ?? undefined;
2446
+ json(res, 200, await deps.oauthStatus(attemptId));
2447
+ } catch (error) {
2448
+ json(res, 400, {
2449
+ ok: false,
2450
+ error: safeMessage(error)
2451
+ });
2452
+ }
2453
+ });
2454
+ route(ROUTES.oauthCancel, async (req, res) => {
2455
+ if (!guard(req, res)) return;
2456
+ if (!method("POST", req, res)) return;
2457
+ try {
2458
+ const body = await readJson(req);
2459
+ if (typeof body.attemptId !== "string" || body.attemptId.length === 0) return json(res, 400, {
2460
+ ok: false,
2461
+ error: "attemptId 必填"
2462
+ });
2463
+ await deps.oauthCancel(body.attemptId);
2464
+ json(res, 200, { ok: true });
2465
+ } catch (error) {
2466
+ json(res, 400, {
2467
+ ok: false,
2468
+ error: safeMessage(error)
2469
+ });
2470
+ }
2471
+ });
2472
+ });
2473
+ }
2474
+ /** Standalone failure page when the callback handler itself throws. Inline on
2475
+ * purpose: routes.ts rides the client bundle, so it must not value-import
2476
+ * the host-only oauth module. */
2477
+ function oauthFailurePage(message) {
2478
+ const detail = message.replace(/[&<>"']/g, (ch) => `&#${ch.charCodeAt(0)};`);
2479
+ return `<!doctype html><html lang="zh"><head><meta charset="utf-8"><title>授权失败 · Authorization failed</title></head><body><main><h1>授权失败 · Authorization failed</h1><p>${detail}</p></main></body></html>`;
2480
+ }
2481
+ /** Shared group-id parser over route input (number or decimal string). */
2482
+ function parseGroupIdFrom(input) {
2483
+ if (typeof input === "number") return Number.isSafeInteger(input) && input > 0 ? input : undefined;
2484
+ if (typeof input !== "string") return undefined;
2485
+ if (!/^\d+$/.test(input)) return undefined;
2486
+ const id = Number(input);
2487
+ return Number.isSafeInteger(id) && id > 0 ? id : undefined;
2488
+ }
2489
+ function describeOwnedRoutes(section) {
2490
+ const providers = section?.providers ?? {};
2491
+ const routes = [];
2492
+ for (const [route, profile] of Object.entries(providers)) {
2493
+ if (!isOwnedRoute(route)) continue;
2494
+ const models = profile?.models ?? [];
2495
+ routes.push({
2496
+ route,
2497
+ displayName: profile?.displayName,
2498
+ api: profile?.api,
2499
+ models: models.length,
2500
+ modelsPreview: models.slice(0, 20).map((model) => model.id)
2501
+ });
2502
+ }
2503
+ return routes;
2504
+ }
2505
+
2506
+ //#endregion
2507
+ //#region src/index.ts
2508
+ const name = "sub2api-sync";
2509
+ const inject = ["settings", "credentials"];
2510
+ function apply(ctx, config) {
2511
+ const source = () => readConfig(config);
2512
+ const judge = () => {
2513
+ try {
2514
+ validateConfig(source());
2515
+ } catch (error) {
2516
+ ctx.logger.error(error);
2517
+ }
2518
+ };
2519
+ judge();
2520
+ /** Conflict-retried write into the llm-pi-ai section (purge path mirrors
2521
+ * the engine's conflict policy without touching its queue). */
2522
+ const mutatePiAi = async (ops) => {
2523
+ let attempts = 0;
2524
+ for (;;) {
2525
+ attempts++;
2526
+ const revision = ctx.settings.describe().find((descriptor) => descriptor.ns === PI_AI_NS)?.revision;
2527
+ if (revision === undefined) throw new Error("sub2api-sync: llm-pi-ai 设置条目未注册");
2528
+ try {
2529
+ await ctx.settings.mutate(PI_AI_NS, ops, revision);
2530
+ return;
2531
+ } catch (error) {
2532
+ const shape = error;
2533
+ const conflict = shape?.code === "SETTINGS_CONFLICT" || shape?.name === "SettingsConflictError";
2534
+ if (conflict && attempts < 3) continue;
2535
+ throw error;
2536
+ }
2537
+ }
2538
+ };
2539
+ const credentials = {
2540
+ resolve: (ref) => ctx.credentials.resolve(credentialRef(ref)),
2541
+ set: (ref, value) => ctx.credentials.set(credentialRef(ref), value),
2542
+ unset: (ref) => ctx.credentials.unset(credentialRef(ref))
2543
+ };
2544
+ const reports = createSyncReportStore();
2545
+ /**
2546
+ * Purge this plugin's sync state: every owned provider route leaves the
2547
+ * llm-pi-ai section and its relay-key credential is dropped. Logout,
2548
+ * definitive auth loss, and instance switches all funnel here.
2549
+ */
2550
+ const purgeSyncState = async () => {
2551
+ reports.clear();
2552
+ const section = ctx.settings.describe().find((descriptor) => descriptor.ns === PI_AI_NS)?.value;
2553
+ const routes = describeOwnedRoutes(section).map((route) => route.route);
2554
+ if (routes.length > 0) await mutatePiAi(routes.map((route) => ({
2555
+ op: "unset",
2556
+ path: ["providers", route]
2557
+ })));
2558
+ for (const route of routes) await credentials.unset(routeKeyRef(route)).catch(() => undefined);
2559
+ await mutateOwn([{
2560
+ op: "unset",
2561
+ path: ["routeKeyIds"]
2562
+ }]).catch(() => undefined);
2563
+ };
2564
+ /** Drop one instance's unreachable panel secrets (token/refresh/OAuth pair). */
2565
+ const dropInstanceSecrets = async (baseURL) => {
2566
+ for (const ref of [
2567
+ panelTokenRef(baseURL),
2568
+ panelRefreshRef(baseURL),
2569
+ oauthTokenRef(baseURL),
2570
+ oauthRefreshRef(baseURL)
2571
+ ]) await credentials.unset(ref).catch(() => undefined);
2572
+ };
2573
+ const gateway = new PanelGateway({
2574
+ baseURL: () => source().baseURL?.trim() ?? "",
2575
+ credentials,
2576
+ onAuthLost: () => void purgeSyncState().catch(() => undefined)
2577
+ });
2578
+ const engine = new Sub2apiSyncEngine({
2579
+ settings: ctx.settings,
2580
+ credentials,
2581
+ gateway,
2582
+ siteName: () => instanceProbe.siteName(instance())
2583
+ });
2584
+ const ownRevision = () => ctx.settings.describe().find((descriptor) => descriptor.ns === OWN_NS)?.revision;
2585
+ /** Conflict-retried write into this plugin's own section (best effort). */
2586
+ const mutateOwn = async (ops) => {
2587
+ let attempts = 0;
2588
+ for (;;) {
2589
+ attempts++;
2590
+ const revision = ownRevision();
2591
+ if (revision === undefined) throw new Error("sub2api-sync: 自身 settings 命名空间未注册");
2592
+ try {
2593
+ await ctx.settings.mutate(OWN_NS, ops, revision);
2594
+ return;
2595
+ } catch (error) {
2596
+ const shape = error;
2597
+ const conflict = shape?.code === "SETTINGS_CONFLICT" || shape?.name === "SettingsConflictError";
2598
+ if (conflict && attempts < 3) continue;
2599
+ throw error;
2600
+ }
2601
+ }
2602
+ };
2603
+ const instance = () => source().baseURL?.trim() ?? "";
2604
+ const instanceProbe = createInstanceProbe();
2605
+ const oauthAttempts = new OAuthAttemptStore();
2606
+ const deps = {
2607
+ instance,
2608
+ authState: () => gateway.authState(source().email),
2609
+ login: async (email, password) => {
2610
+ let pwd = password;
2611
+ if (pwd === undefined || pwd.length === 0) pwd = (await credentials.resolve(panelPasswordRef(instance())))?.value;
2612
+ if (pwd === undefined || pwd.length === 0) throw new Error("请输入面板密码(首次登录后密码会保存到凭据存储,之后可免输入)");
2613
+ if (password !== undefined && password.length > 0) await credentials.set(panelPasswordRef(instance()), password);
2614
+ await mutateOwn([{
2615
+ op: "set",
2616
+ path: ["email"],
2617
+ value: email
2618
+ }]).catch(() => undefined);
2619
+ const outcome = await gateway.login(email, pwd);
2620
+ if (outcome.kind === "2fa") return {
2621
+ ok: true,
2622
+ kind: "2fa",
2623
+ requires2FA: true
2624
+ };
2625
+ return {
2626
+ ok: true,
2627
+ kind: "ok"
2628
+ };
2629
+ },
2630
+ login2FA: (code) => gateway.login2FA(code),
2631
+ logout: async () => {
2632
+ await gateway.logout();
2633
+ await purgeSyncState().catch(() => undefined);
2634
+ },
2635
+ listGroups: async () => {
2636
+ const groups = await gateway.use((panel, token) => panel.listAvailableGroups(token));
2637
+ return groups.map((group) => ({
2638
+ id: group.id,
2639
+ name: group.name,
2640
+ platform: group.platform,
2641
+ status: group.status,
2642
+ rateMultiplier: group.rateMultiplier,
2643
+ isExclusive: group.isExclusive,
2644
+ claudeCodeOnly: group.claudeCodeOnly,
2645
+ enabled: false
2646
+ }));
2647
+ },
2648
+ sync: async (keyIds) => {
2649
+ const report = await engine.sync(source(), keyIds);
2650
+ reports.record(report);
2651
+ return report;
2652
+ },
2653
+ lastReport: () => reports.report(),
2654
+ lastReportAt: () => reports.at(),
2655
+ listKeys: async (page, pageSize) => {
2656
+ const result = await gateway.use((panel, token) => panel.listKeys(token, {
2657
+ page,
2658
+ pageSize
2659
+ }));
2660
+ return {
2661
+ keys: result.items.map((key) => ({
2662
+ id: key.id,
2663
+ name: key.name,
2664
+ groupId: key.groupId,
2665
+ ...key.status !== undefined ? { status: key.status } : {}
2666
+ })),
2667
+ total: result.total,
2668
+ pages: result.pages
2669
+ };
2670
+ },
2671
+ createKey: async (input) => {
2672
+ const config$1 = source();
2673
+ const key = await gateway.use((panel, token) => panel.createKey(token, {
2674
+ name: input.name,
2675
+ groupId: input.groupId,
2676
+ ...config$1.keyQuota !== undefined ? { quota: config$1.keyQuota } : {},
2677
+ ...config$1.keyExpiresInDays !== undefined ? { expiresinDays: config$1.keyExpiresInDays } : {}
2678
+ }));
2679
+ const baseURL = config$1.baseURL?.trim() ?? "";
2680
+ if (key.key !== undefined && key.key.length > 0 && !key.key.includes("****") && /^https?:\/\//i.test(baseURL)) await credentials.set(routeKeyRef(routeForKey(baseURL, key.id)), key.key).catch(() => undefined);
2681
+ return {
2682
+ id: key.id,
2683
+ name: key.name,
2684
+ groupId: key.groupId,
2685
+ ...key.status !== undefined ? { status: key.status } : {}
2686
+ };
2687
+ },
2688
+ isSyncRunning: () => engine.isRunning(),
2689
+ icon: () => instanceProbe.icon(instance()),
2690
+ siteName: () => instanceProbe.siteName(instance()),
2691
+ account: () => gateway.use((panel, token) => panel.getProfile(token)),
2692
+ resetSync: async (previousBaseURL) => {
2693
+ await purgeSyncState();
2694
+ if (previousBaseURL !== undefined && previousBaseURL.trim().length > 0) await dropInstanceSecrets(previousBaseURL);
2695
+ },
2696
+ removeSyncedKey: async (keyId) => {
2697
+ const baseURL = instance();
2698
+ if (!/^https?:\/\//i.test(baseURL)) throw new Error("sub2api-sync: baseURL 未配置或不是 http(s) 地址");
2699
+ const entry = reports.report()?.entries.find((candidate) => candidate.keyId === keyId && candidate.invalid !== true);
2700
+ const route = entry?.route ?? routeForKey(baseURL, keyId);
2701
+ if (!isOwnedRoute(route)) throw new Error(`sub2api-sync: 路由 ${route} 不在本插件的所有权边界内`);
2702
+ await mutatePiAi([{
2703
+ op: "unset",
2704
+ path: ["providers", route]
2705
+ }]);
2706
+ await credentials.unset(routeKeyRef(route)).catch(() => undefined);
2707
+ await mutateOwn([{
2708
+ op: "unset",
2709
+ path: ["routeKeyIds", route]
2710
+ }]).catch(() => undefined);
2711
+ const current = reports.report();
2712
+ if (current !== undefined && current.entries.some((candidate) => candidate.route === route)) reports.record({
2713
+ ...current,
2714
+ entries: current.entries.filter((candidate) => candidate.route !== route),
2715
+ removed: [...current.removed, route],
2716
+ writes: current.writes + 1
2717
+ });
2718
+ },
2719
+ ownedRoutes: () => {
2720
+ const descriptors = ctx.settings.describe();
2721
+ const routes = describeOwnedRoutes(descriptors.find((descriptor) => descriptor.ns === PI_AI_NS)?.value);
2722
+ const map = (descriptors.find((descriptor) => descriptor.ns === OWN_NS)?.value)?.routeKeyIds ?? {};
2723
+ return routes.map((route) => map[route.route] !== undefined ? {
2724
+ ...route,
2725
+ keyId: map[route.route]
2726
+ } : route);
2727
+ },
2728
+ allowRemote: () => source().allowRemoteActions === true,
2729
+ oauthProbe: async (address) => {
2730
+ try {
2731
+ await discoverOAuthEndpoints(address);
2732
+ return true;
2733
+ } catch {
2734
+ resetDiscoveryCache();
2735
+ return false;
2736
+ }
2737
+ },
2738
+ oauthStart: async (callbackOrigin) => {
2739
+ const instanceValue = instance();
2740
+ if (!/^https?:\/\//i.test(instanceValue)) throw new Error("请先填写 sub2api 实例地址,再发起网页授权");
2741
+ const redirectUri = loopbackCallbackOrigin(callbackOrigin);
2742
+ const endpoints = await discoverOAuthEndpoints(instanceValue);
2743
+ const { verifier, challenge } = await createPkcePair();
2744
+ const attempt = oauthAttempts.create({
2745
+ verifier,
2746
+ redirectUri,
2747
+ scope: oauthScopesFor(source().deleteOrphanKeys)
2748
+ });
2749
+ const authorizeUrl = buildAuthorizeUrl(endpoints, {
2750
+ redirectUri,
2751
+ scope: attempt.scope,
2752
+ state: attempt.state,
2753
+ challenge
2754
+ });
2755
+ return {
2756
+ attemptId: attempt.id,
2757
+ authorizeUrl
2758
+ };
2759
+ },
2760
+ oauthCallback: async (query, lang = "zh") => {
2761
+ const state = query.get("state") ?? "";
2762
+ const attempt = state.length > 0 ? oauthAttempts.takeByState(state) : undefined;
2763
+ if (attempt === undefined) return { html: callbackPageHtml("failure", "授权请求不存在、已过期或已被使用——请回到应用重新发起网页授权", lang) };
2764
+ const providerError = query.get("error");
2765
+ if (providerError !== null && providerError.length > 0) {
2766
+ const description = query.get("error_description") ?? "";
2767
+ oauthAttempts.settle(attempt.id, "failed", providerError);
2768
+ return { html: callbackPageHtml("failure", `授权被拒绝(${providerError})${description.length > 0 ? `:${description}` : ""}`, lang) };
2769
+ }
2770
+ const code = query.get("code") ?? "";
2771
+ if (code.length === 0) {
2772
+ oauthAttempts.settle(attempt.id, "failed", "回调缺少授权码");
2773
+ return { html: callbackPageHtml("failure", "授权回调缺少授权码(code)——请回到应用重新发起", lang) };
2774
+ }
2775
+ try {
2776
+ const endpoints = await discoverOAuthEndpoints(instance());
2777
+ const pair = await exchangeAuthorizationCode(endpoints, {
2778
+ code,
2779
+ redirectUri: attempt.redirectUri,
2780
+ verifier: attempt.verifier
2781
+ });
2782
+ await gateway.storeOAuthTokens(pair);
2783
+ oauthAttempts.settle(attempt.id, "completed");
2784
+ const successMessage = lang === "en" ? "You can close this page and return to the desktop app to continue." : "你可以关闭此页面,返回桌面端继续工作。";
2785
+ return { html: callbackPageHtml("success", successMessage, lang) };
2786
+ } catch (cause) {
2787
+ const message = cause instanceof Error ? cause.message : String(cause);
2788
+ oauthAttempts.settle(attempt.id, "failed", message);
2789
+ return { html: callbackPageHtml("failure", message, lang) };
2790
+ }
2791
+ },
2792
+ oauthStatus: async (attemptId) => {
2793
+ const attempt = attemptId !== undefined ? oauthAttempts.peek(attemptId) : undefined;
2794
+ const email = source().email;
2795
+ return {
2796
+ ok: true,
2797
+ attempt,
2798
+ auth: await gateway.authState(email)
2799
+ };
2800
+ },
2801
+ oauthCancel: async (attemptId) => {
2802
+ oauthAttempts.cancel(attemptId);
2803
+ }
2804
+ };
2805
+ registerRoutes(ctx, deps);
2806
+ let timer;
2807
+ let armedFor;
2808
+ const disarm = () => {
2809
+ if (timer !== undefined) clearInterval(timer);
2810
+ timer = undefined;
2811
+ armedFor = undefined;
2812
+ };
2813
+ const arm = () => {
2814
+ const interval = source().syncIntervalMs;
2815
+ if (interval === undefined || interval < 6e4) {
2816
+ disarm();
2817
+ return;
2818
+ }
2819
+ if (armedFor === interval) return;
2820
+ disarm();
2821
+ timer = setInterval(() => {
2822
+ if (engine.isRunning()) return;
2823
+ void engine.sync(source()).catch((error) => ctx.logger.error(error));
2824
+ }, interval);
2825
+ armedFor = interval;
2826
+ };
2827
+ arm();
2828
+ ctx.on("loader/volatile-update", () => {
2829
+ judge();
2830
+ arm();
2831
+ });
2832
+ ctx.effect(() => () => disarm());
2833
+ }
2834
+
2835
+ //#endregion
2836
+ export { API_PROTOCOLS, Config, DEFAULT_PROVIDER_NAME_TEMPLATE, OAUTH_CALLBACK_PATH, OAUTH_CLIENT_ID, OAuthAttemptStore, OAuthError, OWN_NS, PI_AI_NS, PLATFORMS, PanelClient, PanelError, PanelGateway, ROUTES, SUB2API_RETRY_POLICY, Sub2apiSyncEngine, Sub2apiSyncError, apply, baseURLForProtocol, buildAuthorizeUrl, buildProviderProfile, callbackLangOf, callbackPageHtml, createPkcePair, discoverOAuthEndpoints, exchangeAuthorizationCode, guessPlatformFromModels, inject, instanceKey, isOwnedRoute, legacyKeyIdOfRoute, legacyKeyRef, loopbackCallbackOrigin, name, oauthRefreshRef, oauthScopesFor, oauthTokenRef, panelPasswordRef, panelRefreshRef, panelTokenRef, parseGroupId, protocolForPlatform, refreshAccessToken, renderProviderName, revokeToken, routeForKey, routeKeyRef, trustedRequest, validateConfig };