@mangoc/dsh-sub2api-sync 0.2.8 → 0.3.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/README.md +62 -80
- package/README.zh.md +15 -3
- package/docs/DETAIL.md +1 -0
- package/docs/DETAIL.zh.md +1 -0
- package/docs/preview.svg +36 -0
- package/docs/preview.zh.svg +36 -0
- package/icon.svg +10 -0
- package/lib/client.cjs +239 -36
- package/lib/client.js +239 -36
- package/lib/config.d.ts +12 -2
- package/lib/dshgo.d.ts +35 -0
- package/lib/index.js +329 -32
- package/lib/oauth.d.ts +15 -6
- package/lib/types.d.ts +7 -0
- package/locale/en.json +2 -1
- package/locale/zh.json +2 -1
- package/package.json +36 -2
package/lib/index.js
CHANGED
|
@@ -190,6 +190,7 @@ function panelRefreshRef(baseURL) {
|
|
|
190
190
|
function panelPasswordRef(baseURL) {
|
|
191
191
|
return `DSH_SUB2API_PASSWORD_${hashString(instanceKey(baseURL))}`;
|
|
192
192
|
}
|
|
193
|
+
const NATIVE_PANEL_PASSWORD_REF = "DSH_SUB2API_PANEL_PASSWORD";
|
|
193
194
|
function oauthTokenRef(baseURL) {
|
|
194
195
|
return `DSH_SUB2API_OAUTH_TOKEN_${hashString(instanceKey(baseURL))}`;
|
|
195
196
|
}
|
|
@@ -376,14 +377,30 @@ const apiProtocolSchema = z.union([
|
|
|
376
377
|
z.const("openai-responses"),
|
|
377
378
|
z.const("anthropic-messages")
|
|
378
379
|
]);
|
|
380
|
+
/**
|
|
381
|
+
* dsh-Go native settings: a fixed-ref credential slot for the panel
|
|
382
|
+
* password (the official dsh-media shape). The `credential-ref` role marks
|
|
383
|
+
* the binding — the App reads it, shows 已设置/未设置 and writes the secret
|
|
384
|
+
* through its credentials transaction; the `secret` sentinel refuses any
|
|
385
|
+
* value on the ordinary config write path, so a password can never land in
|
|
386
|
+
* the profile document. Empty default keeps the form write a no-op.
|
|
387
|
+
*/
|
|
388
|
+
const secretSentinel = z.transform(z.any(), (value) => {
|
|
389
|
+
if (value !== "") throw new Error("面板密码只能通过凭据服务写入(本字段仅作占位)。");
|
|
390
|
+
return "";
|
|
391
|
+
}, true).default("").role("secret");
|
|
392
|
+
const panelCredentialSlot = z.object({
|
|
393
|
+
credentialRef: z.const(NATIVE_PANEL_PASSWORD_REF).default(NATIVE_PANEL_PASSWORD_REF).role("credential-ref"),
|
|
394
|
+
password: secretSentinel.description("面板密码(写入凭据库,手机原生设置可填) / Panel password (stored in the credential store)")
|
|
395
|
+
}).description("面板密码凭据(免输登录用,与网页卡共用) / Panel password credential").collapse();
|
|
379
396
|
const Config = z.object({
|
|
380
|
-
baseURL: z.string().volatile(),
|
|
381
|
-
email: z.string().volatile(),
|
|
382
|
-
providerNameTemplate: z.string().default(DEFAULT_PROVIDER_NAME_TEMPLATE).volatile(),
|
|
383
|
-
selectedKeys: z.array(z.number().step(1)).default([]).volatile(),
|
|
384
|
-
modelListOverrides: z.dict(z.array(z.string())).default({}).volatile(),
|
|
385
|
-
protocolOverrides: z.dict(apiProtocolSchema).default({}).volatile(),
|
|
386
|
-
routeKeyIds: z.dict(z.number().step(1)).default({}).volatile(),
|
|
397
|
+
baseURL: z.string().description("sub2api 实例地址(含或不含 /v1) / Instance URL (with or without /v1)").volatile(),
|
|
398
|
+
email: z.string().description("面板账号邮箱(密码登录用) / Panel account email (password login)").volatile(),
|
|
399
|
+
providerNameTemplate: z.string().default(DEFAULT_PROVIDER_NAME_TEMPLATE).description("供应商显示名模板,须含 {keyName} 或 {siteName} / Provider display-name template (must keep {keyName} or {siteName})").volatile(),
|
|
400
|
+
selectedKeys: z.array(z.number().step(1)).default([]).description("待同步的中转 key 列表(网页卡勾选写入) / Relay keys selected for sync (card-managed)").collapse().volatile(),
|
|
401
|
+
modelListOverrides: z.dict(z.array(z.string())).default({}).description("分组手动模型列表(/v1/models 不可用时) / Manual model lists when /v1/models is unavailable").collapse().volatile(),
|
|
402
|
+
protocolOverrides: z.dict(apiProtocolSchema).default({}).description("分组线协议覆盖 / Per-group wire-protocol override").collapse().volatile(),
|
|
403
|
+
routeKeyIds: z.dict(z.number().step(1)).default({}).description("(引擎维护,请勿手改)路由 → 面板 key id 映射 / Engine-maintained route→keyId map — do not edit").hidden(true).volatile(),
|
|
387
404
|
compatTemplate: z.object({
|
|
388
405
|
compat: z.dict(z.any()),
|
|
389
406
|
reasoningEfforts: z.union([z.const(false), z.dict(z.union([z.string(), z.const(null)]))]),
|
|
@@ -396,12 +413,12 @@ const Config = z.object({
|
|
|
396
413
|
z.const("xhigh"),
|
|
397
414
|
z.const("max")
|
|
398
415
|
])
|
|
399
|
-
}).volatile(),
|
|
400
|
-
enrichModelMeta: z.boolean().default(true).volatile(),
|
|
401
|
-
keyQuota: z.number().volatile(),
|
|
402
|
-
keyExpiresInDays: z.number().step(1).volatile(),
|
|
403
|
-
deleteOrphanKeys: z.boolean().default(false).volatile(),
|
|
404
|
-
syncIntervalMs: z.number().step(1).volatile(),
|
|
416
|
+
}).description("同步模型继承的兼容模板 / Per-model template inherited by synced models").collapse().volatile(),
|
|
417
|
+
enrichModelMeta: z.boolean().default(true).description("models.dev 元数据增强(显示名/上下文窗口) / models.dev metadata enrichment").volatile(),
|
|
418
|
+
keyQuota: z.number().description("新建 key 的额度(USD,可选) / Optional quota (USD) for created keys").volatile(),
|
|
419
|
+
keyExpiresInDays: z.number().step(1).description("新建 key 的有效天数(可选) / Optional expiry in days for created keys").volatile(),
|
|
420
|
+
deleteOrphanKeys: z.boolean().default(false).description("移除路由时同时吊销面板 key / Also revoke the panel key when removing a route").volatile(),
|
|
421
|
+
syncIntervalMs: z.number().step(1).description("定时同步间隔 ms(删除字段即关闭,下限 60000) / Scheduled resync interval ms (unset = off; floor 60000)").volatile(),
|
|
405
422
|
retryPolicy: z.object({
|
|
406
423
|
mode: z.union([z.const("normal"), z.const("always")]),
|
|
407
424
|
maxRetries: z.number().step(1),
|
|
@@ -411,10 +428,11 @@ const Config = z.object({
|
|
|
411
428
|
maxDelayMs: z.number(),
|
|
412
429
|
jitterRatio: z.number()
|
|
413
430
|
})
|
|
414
|
-
}).volatile(),
|
|
415
|
-
defaultContextWindow: z.number().step(1).volatile(),
|
|
416
|
-
defaultMaxTokens: z.number().step(1).volatile(),
|
|
417
|
-
allowRemoteActions: z.boolean().default(false).volatile()
|
|
431
|
+
}).description("同步路由的重试策略覆盖(缺省继承内置宽容策略) / Retry-policy overrides (defaults to the built-in lenient policy)").collapse().volatile(),
|
|
432
|
+
defaultContextWindow: z.number().step(1).description("元数据无法匹配时的默认上下文窗口 / Default context window when enrichment cannot match").volatile(),
|
|
433
|
+
defaultMaxTokens: z.number().step(1).description("元数据无法匹配时的默认最大输出 / Default max output tokens when enrichment cannot match").volatile(),
|
|
434
|
+
allowRemoteActions: z.boolean().default(false).description("允许局域网内其他设备操作面板(默认关;开启后同一局域网的设备可修改模型路由与登录信息,仅限可信内网) / Let other devices on your local network operate the panel (default off — they could change model routes and sign-in details; trusted networks only)").volatile(),
|
|
435
|
+
panelCredential: panelCredentialSlot.volatile()
|
|
418
436
|
});
|
|
419
437
|
/** Unwrap one live cell into its current snapshot; plain values pass through. */
|
|
420
438
|
function unwrap$1(field) {
|
|
@@ -820,7 +838,7 @@ var Sub2apiSyncError = class extends Error {
|
|
|
820
838
|
this.name = "Sub2apiSyncError";
|
|
821
839
|
}
|
|
822
840
|
};
|
|
823
|
-
function messageOf(error) {
|
|
841
|
+
function messageOf$1(error) {
|
|
824
842
|
return error instanceof Error ? error.message : String(error);
|
|
825
843
|
}
|
|
826
844
|
/** Order-insensitive JSON comparison — profiles are plain JSON values. */
|
|
@@ -966,7 +984,7 @@ var Sub2apiSyncEngine = class {
|
|
|
966
984
|
route,
|
|
967
985
|
keyId: key.id,
|
|
968
986
|
action: "failed",
|
|
969
|
-
error: messageOf(error)
|
|
987
|
+
error: messageOf$1(error)
|
|
970
988
|
});
|
|
971
989
|
}
|
|
972
990
|
}
|
|
@@ -994,13 +1012,13 @@ var Sub2apiSyncEngine = class {
|
|
|
994
1012
|
try {
|
|
995
1013
|
await this.credentials.unset(routeKeyRef(route));
|
|
996
1014
|
} catch (error) {
|
|
997
|
-
warnings.push(`key ${route} 的凭据清理失败:${messageOf(error)}`);
|
|
1015
|
+
warnings.push(`key ${route} 的凭据清理失败:${messageOf$1(error)}`);
|
|
998
1016
|
}
|
|
999
1017
|
const keyId = keyIdByRoute.get(route) ?? legacyKeyIdOfRoute(route);
|
|
1000
1018
|
if (config.deleteOrphanKeys === true && keyId !== undefined) try {
|
|
1001
1019
|
await this.gateway.use((panel, token) => panel.deleteKey(token, keyId));
|
|
1002
1020
|
} catch (error) {
|
|
1003
|
-
warnings.push(`key ${route} 的远端撤销失败:${messageOf(error)}`);
|
|
1021
|
+
warnings.push(`key ${route} 的远端撤销失败:${messageOf$1(error)}`);
|
|
1004
1022
|
}
|
|
1005
1023
|
}
|
|
1006
1024
|
try {
|
|
@@ -1055,7 +1073,7 @@ var Sub2apiSyncEngine = class {
|
|
|
1055
1073
|
plaintext = !usable(revealed.key) ? undefined : revealed.key;
|
|
1056
1074
|
if (plaintext === undefined) revealFailure = "reveal 未返回可用明文";
|
|
1057
1075
|
} catch (error) {
|
|
1058
|
-
revealFailure = messageOf(error);
|
|
1076
|
+
revealFailure = messageOf$1(error);
|
|
1059
1077
|
}
|
|
1060
1078
|
if (!usable(plaintext)) {
|
|
1061
1079
|
const revealNote = revealFailure !== undefined ? `;reveal 接口也失败:${revealFailure}` : "";
|
|
@@ -1065,19 +1083,23 @@ var Sub2apiSyncEngine = class {
|
|
|
1065
1083
|
try {
|
|
1066
1084
|
models = await this.discoverModels(config, group.id, instance, plaintext);
|
|
1067
1085
|
} catch (error) {
|
|
1068
|
-
if (source !== "stored" && source !== "legacy" || !/HTTP 401(?![0-9])/.test(messageOf(error))) throw error;
|
|
1086
|
+
if (source !== "stored" && source !== "legacy" || !/HTTP 401(?![0-9])/.test(messageOf$1(error))) throw error;
|
|
1069
1087
|
await this.credentials.unset(ref).catch(() => undefined);
|
|
1070
1088
|
if (ref !== legacyKeyRef(key.id)) await this.credentials.unset(legacyKeyRef(key.id)).catch(() => undefined);
|
|
1071
1089
|
let revealed;
|
|
1072
1090
|
try {
|
|
1073
1091
|
revealed = (await this.gateway.use((panel, token) => panel.revealKey(token, key.id)))?.key;
|
|
1074
1092
|
} catch (revealError) {
|
|
1075
|
-
throw new Sub2apiSyncError(`${messageOf(error)};本地存底已作废,但 reveal 也失败:${messageOf(revealError)}——请在面板删除这把 key 后用插件「创建 key」重建`);
|
|
1093
|
+
throw new Sub2apiSyncError(`${messageOf$1(error)};本地存底已作废,但 reveal 也失败:${messageOf$1(revealError)}——请在面板删除这把 key 后用插件「创建 key」重建`);
|
|
1076
1094
|
}
|
|
1077
|
-
if (revealed === undefined || revealed.length === 0 || revealed.includes("****")) throw new Sub2apiSyncError(`${messageOf(error)};本地存底已作废,但 reveal 未返回明文——请在面板删除这把 key 后用插件「创建 key」重建`);
|
|
1095
|
+
if (revealed === undefined || revealed.length === 0 || revealed.includes("****")) throw new Sub2apiSyncError(`${messageOf$1(error)};本地存底已作废,但 reveal 未返回明文——请在面板删除这把 key 后用插件「创建 key」重建`);
|
|
1078
1096
|
plaintext = revealed;
|
|
1079
1097
|
models = await this.discoverModels(config, group.id, instance, plaintext);
|
|
1080
1098
|
}
|
|
1099
|
+
if (models.length === 0) {
|
|
1100
|
+
const groupLabel = group.name.length > 0 ? `${group.name}(#${group.id})` : `#${group.id}`;
|
|
1101
|
+
throw new Sub2apiSyncError(`key #${key.id}(${key.name})经网关 /v1/models 发现到 0 个模型(分组 ${groupLabel})——中转站没有向这把 key 暴露任何模型;请在中转站确认该分组的模型配置与这把 key 的权限,或在卡片用「模型清单覆盖」显式指定模型。这把 key 已跳过,其余 key 照常同步`);
|
|
1102
|
+
}
|
|
1081
1103
|
if (this.credentials.resolve !== undefined) {
|
|
1082
1104
|
const stored = await this.credentials.resolve(ref);
|
|
1083
1105
|
if (stored?.value !== plaintext) await this.credentials.set(ref, plaintext);
|
|
@@ -1132,7 +1154,7 @@ var Sub2apiSyncEngine = class {
|
|
|
1132
1154
|
});
|
|
1133
1155
|
return await client.listModels(relayKey);
|
|
1134
1156
|
} catch (error) {
|
|
1135
|
-
throw new Sub2apiSyncError(`分组 ${groupId} 模型发现失败:${messageOf(error)};且未配置 modelListOverrides 兜底`);
|
|
1157
|
+
throw new Sub2apiSyncError(`分组 ${groupId} 模型发现失败:${messageOf$1(error)};且未配置 modelListOverrides 兜底`);
|
|
1136
1158
|
}
|
|
1137
1159
|
}
|
|
1138
1160
|
readCurrentProviders() {
|
|
@@ -1382,6 +1404,10 @@ function absoluteHttpUrl(value) {
|
|
|
1382
1404
|
return undefined;
|
|
1383
1405
|
}
|
|
1384
1406
|
}
|
|
1407
|
+
/** The provider issuer path under the instance root; the sub2api provider
|
|
1408
|
+
* issues `issuer = {instance}/api/v1/oauth` (no trailing slash). Every
|
|
1409
|
+
* discovery URL below is derived from this value, never guessed. */
|
|
1410
|
+
const PROVIDER_ISSUER_PATH = "/api/v1/oauth";
|
|
1385
1411
|
async function discoverOAuthEndpoints(instance, fetchImpl = globalThis.fetch, now = Date.now()) {
|
|
1386
1412
|
const key = instance.trim().replace(/\/+$/, "").toLowerCase();
|
|
1387
1413
|
const cached = discoveryCache.get(key);
|
|
@@ -1390,20 +1416,44 @@ async function discoverOAuthEndpoints(instance, fetchImpl = globalThis.fetch, no
|
|
|
1390
1416
|
throw new OAuthError("discovery_failed", "sub2api OAuth 发现失败(此前已失败,稍后重试)");
|
|
1391
1417
|
}
|
|
1392
1418
|
const base = instance.trim().replace(/\/+$/, "");
|
|
1393
|
-
const
|
|
1419
|
+
const expectedIssuer = `${base}${PROVIDER_ISSUER_PATH}`;
|
|
1420
|
+
const candidates = [
|
|
1421
|
+
{
|
|
1422
|
+
url: `${base}/.well-known/oauth-authorization-server${PROVIDER_ISSUER_PATH}`,
|
|
1423
|
+
requireIssuerField: true
|
|
1424
|
+
},
|
|
1425
|
+
{
|
|
1426
|
+
url: `${base}/.well-known/oauth-authorization-server`,
|
|
1427
|
+
requireIssuerField: false
|
|
1428
|
+
},
|
|
1429
|
+
{
|
|
1430
|
+
url: `${expectedIssuer}/.well-known`,
|
|
1431
|
+
requireIssuerField: false
|
|
1432
|
+
}
|
|
1433
|
+
];
|
|
1394
1434
|
let endpoints;
|
|
1395
1435
|
let lastError = "";
|
|
1396
1436
|
for (const candidate of candidates) try {
|
|
1397
|
-
const response = await fetchImpl(candidate, { headers: { accept: "application/json" } });
|
|
1437
|
+
const response = await fetchImpl(candidate.url, { headers: { accept: "application/json" } });
|
|
1398
1438
|
if (!response.ok) {
|
|
1399
|
-
lastError = `HTTP ${response.status} at ${candidate}`;
|
|
1439
|
+
lastError = `HTTP ${response.status} at ${candidate.url}`;
|
|
1400
1440
|
continue;
|
|
1401
1441
|
}
|
|
1402
1442
|
const payload = await response.json();
|
|
1443
|
+
const documentIssuer = typeof payload.issuer === "string" ? payload.issuer : undefined;
|
|
1444
|
+
if (documentIssuer === undefined) {
|
|
1445
|
+
if (candidate.requireIssuerField) {
|
|
1446
|
+
lastError = `metadata at ${candidate.url} omits the required issuer field`;
|
|
1447
|
+
continue;
|
|
1448
|
+
}
|
|
1449
|
+
} else if (documentIssuer !== expectedIssuer) {
|
|
1450
|
+
lastError = `issuer mismatch at ${candidate.url}: ${documentIssuer} (expected ${expectedIssuer})`;
|
|
1451
|
+
continue;
|
|
1452
|
+
}
|
|
1403
1453
|
const authorizationEndpoint = absoluteHttpUrl(payload.authorization_endpoint);
|
|
1404
1454
|
const tokenEndpoint = absoluteHttpUrl(payload.token_endpoint);
|
|
1405
1455
|
if (authorizationEndpoint === undefined || tokenEndpoint === undefined) {
|
|
1406
|
-
lastError = `incomplete metadata at ${candidate}`;
|
|
1456
|
+
lastError = `incomplete metadata at ${candidate.url}`;
|
|
1407
1457
|
continue;
|
|
1408
1458
|
}
|
|
1409
1459
|
const revocationEndpoint = absoluteHttpUrl(payload.revocation_endpoint);
|
|
@@ -1414,7 +1464,7 @@ async function discoverOAuthEndpoints(instance, fetchImpl = globalThis.fetch, no
|
|
|
1414
1464
|
};
|
|
1415
1465
|
break;
|
|
1416
1466
|
} catch (error) {
|
|
1417
|
-
lastError = `${candidate}: ${error instanceof Error ? error.message : String(error)}`;
|
|
1467
|
+
lastError = `${candidate.url}: ${error instanceof Error ? error.message : String(error)}`;
|
|
1418
1468
|
}
|
|
1419
1469
|
discoveryCache.set(key, {
|
|
1420
1470
|
endpoints,
|
|
@@ -2168,6 +2218,252 @@ else await this.credentials.unset(this.refreshRef());
|
|
|
2168
2218
|
}
|
|
2169
2219
|
};
|
|
2170
2220
|
|
|
2221
|
+
//#endregion
|
|
2222
|
+
//#region src/dshgo.ts
|
|
2223
|
+
const ZH = {
|
|
2224
|
+
signedInPrefix: "已登录",
|
|
2225
|
+
signedOut: "未登录:在设置里填写实例地址与邮箱,凭据槽写入面板密码",
|
|
2226
|
+
noEmail: "请先在设置里填写面板邮箱",
|
|
2227
|
+
loginAction: "登录面板",
|
|
2228
|
+
logoutAction: "退出登录",
|
|
2229
|
+
syncAction: "立即同步",
|
|
2230
|
+
syncing: "同步中",
|
|
2231
|
+
idle: "空闲",
|
|
2232
|
+
instanceLabel: "实例",
|
|
2233
|
+
stateLabel: "状态",
|
|
2234
|
+
routesLabel: "路由数",
|
|
2235
|
+
lastSyncLabel: "上次同步",
|
|
2236
|
+
never: "尚未同步",
|
|
2237
|
+
loginStarted: "正在登录…",
|
|
2238
|
+
logoutStarted: "正在退出…",
|
|
2239
|
+
twoFaHint: "该账号开启了两步验证:请在网页版设置卡完成第二因子",
|
|
2240
|
+
hint: "勾选 key、两步验证与逐路由移除请在网页版设置卡操作",
|
|
2241
|
+
justNow: "刚刚",
|
|
2242
|
+
minutesAgo: (n) => n + " 分钟前",
|
|
2243
|
+
hoursAgo: (n) => n + " 小时前",
|
|
2244
|
+
daysAgo: (n) => n + " 天前"
|
|
2245
|
+
};
|
|
2246
|
+
const EN = {
|
|
2247
|
+
signedInPrefix: "Signed in",
|
|
2248
|
+
signedOut: "Signed out: fill the instance URL and email in settings, write the panel password into the credential slot",
|
|
2249
|
+
noEmail: "Fill the panel email in settings first",
|
|
2250
|
+
loginAction: "Sign in",
|
|
2251
|
+
logoutAction: "Sign out",
|
|
2252
|
+
syncAction: "Sync now",
|
|
2253
|
+
syncing: "Syncing",
|
|
2254
|
+
idle: "Idle",
|
|
2255
|
+
instanceLabel: "Instance",
|
|
2256
|
+
stateLabel: "State",
|
|
2257
|
+
routesLabel: "Routes",
|
|
2258
|
+
lastSyncLabel: "Last sync",
|
|
2259
|
+
never: "Never",
|
|
2260
|
+
loginStarted: "Signing in…",
|
|
2261
|
+
logoutStarted: "Signing out…",
|
|
2262
|
+
twoFaHint: "Two-factor is enabled: finish the second factor in the web settings card",
|
|
2263
|
+
hint: "Key selection, two-factor and per-route removal live in the web settings card",
|
|
2264
|
+
justNow: "just now",
|
|
2265
|
+
minutesAgo: (n) => n + " min ago",
|
|
2266
|
+
hoursAgo: (n) => n + " h ago",
|
|
2267
|
+
daysAgo: (n) => n + " d ago"
|
|
2268
|
+
};
|
|
2269
|
+
const textOf = (locale) => typeof locale === "string" && locale.toLowerCase().startsWith("en") ? EN : ZH;
|
|
2270
|
+
function messageOf(error) {
|
|
2271
|
+
return error instanceof Error ? error.message : String(error);
|
|
2272
|
+
}
|
|
2273
|
+
function relativeTime(at, t) {
|
|
2274
|
+
const elapsed = Date.now() - at;
|
|
2275
|
+
if (elapsed < 6e4) return t.justNow;
|
|
2276
|
+
if (elapsed < 36e5) return t.minutesAgo(Math.floor(elapsed / 6e4));
|
|
2277
|
+
if (elapsed < 864e5) return t.hoursAgo(Math.floor(elapsed / 36e5));
|
|
2278
|
+
return t.daysAgo(Math.floor(elapsed / 864e5));
|
|
2279
|
+
}
|
|
2280
|
+
function registerDshGo(ctx, deps, hooks) {
|
|
2281
|
+
ctx.inject(["dshGo"], (host) => {
|
|
2282
|
+
const dshGo = host.dshGo;
|
|
2283
|
+
if (dshGo === undefined) return;
|
|
2284
|
+
/** Transient feedback from background actions; cleared by the next success. */
|
|
2285
|
+
let lastError;
|
|
2286
|
+
let hint;
|
|
2287
|
+
let activeLocale = "zh-CN";
|
|
2288
|
+
const buildData = async () => {
|
|
2289
|
+
const t = textOf(activeLocale);
|
|
2290
|
+
let signedIn = false;
|
|
2291
|
+
let email;
|
|
2292
|
+
try {
|
|
2293
|
+
const auth = await deps.authState();
|
|
2294
|
+
signedIn = auth.signedIn;
|
|
2295
|
+
email = auth.email;
|
|
2296
|
+
} catch {
|
|
2297
|
+
signedIn = false;
|
|
2298
|
+
}
|
|
2299
|
+
const routes = deps.ownedRoutes();
|
|
2300
|
+
const lastSyncAt = deps.lastReportAt();
|
|
2301
|
+
const statusLine = lastError !== undefined ? {
|
|
2302
|
+
text: lastError,
|
|
2303
|
+
tone: "bad"
|
|
2304
|
+
} : hint !== undefined ? {
|
|
2305
|
+
text: hint,
|
|
2306
|
+
tone: "info"
|
|
2307
|
+
} : signedIn ? {
|
|
2308
|
+
text: t.signedInPrefix + (email !== undefined && email.length > 0 ? " · " + email : ""),
|
|
2309
|
+
tone: "good"
|
|
2310
|
+
} : {
|
|
2311
|
+
text: t.signedOut,
|
|
2312
|
+
tone: "warn"
|
|
2313
|
+
};
|
|
2314
|
+
return {
|
|
2315
|
+
statusText: statusLine.text,
|
|
2316
|
+
statusTone: statusLine.tone,
|
|
2317
|
+
signedIn,
|
|
2318
|
+
instance: deps.instance(),
|
|
2319
|
+
stateText: deps.isSyncRunning() ? t.syncing : signedIn ? t.idle : t.signedOut,
|
|
2320
|
+
routeCount: routes.length,
|
|
2321
|
+
lastSyncText: lastSyncAt === undefined ? t.never : relativeTime(lastSyncAt, t),
|
|
2322
|
+
routes: routes.map((route) => ({
|
|
2323
|
+
title: route.displayName ?? route.route,
|
|
2324
|
+
...route.api !== undefined ? { subtitle: route.api } : {},
|
|
2325
|
+
trailing: String(route.models)
|
|
2326
|
+
})),
|
|
2327
|
+
actions: signedIn ? [{
|
|
2328
|
+
label: t.syncAction,
|
|
2329
|
+
style: "primary",
|
|
2330
|
+
action: "sync"
|
|
2331
|
+
}, {
|
|
2332
|
+
label: t.logoutAction,
|
|
2333
|
+
action: "logout"
|
|
2334
|
+
}] : [{
|
|
2335
|
+
label: t.loginAction,
|
|
2336
|
+
style: "primary",
|
|
2337
|
+
action: "login"
|
|
2338
|
+
}]
|
|
2339
|
+
};
|
|
2340
|
+
};
|
|
2341
|
+
/** Broadcast one fresh model to every live data/strip source. */
|
|
2342
|
+
const sinks = new Set();
|
|
2343
|
+
const publish = (data) => {
|
|
2344
|
+
for (const sink of [...sinks]) sink(data);
|
|
2345
|
+
};
|
|
2346
|
+
const schedule = () => {
|
|
2347
|
+
void buildData().then(publish).catch(() => undefined);
|
|
2348
|
+
};
|
|
2349
|
+
/** Emit now, on every settle, and on a bounded tick until cancelled. */
|
|
2350
|
+
const watch = (sink) => {
|
|
2351
|
+
sinks.add(sink);
|
|
2352
|
+
schedule();
|
|
2353
|
+
const timer = setInterval(schedule, 15e3);
|
|
2354
|
+
return () => {
|
|
2355
|
+
sinks.delete(sink);
|
|
2356
|
+
clearInterval(timer);
|
|
2357
|
+
};
|
|
2358
|
+
};
|
|
2359
|
+
const noteError = (cause) => {
|
|
2360
|
+
lastError = messageOf(cause);
|
|
2361
|
+
schedule();
|
|
2362
|
+
};
|
|
2363
|
+
dshGo.panel("sync-status", {
|
|
2364
|
+
view: (_scope, caps) => {
|
|
2365
|
+
const t = textOf(caps?.locale);
|
|
2366
|
+
return {
|
|
2367
|
+
type: "stack",
|
|
2368
|
+
children: [
|
|
2369
|
+
{
|
|
2370
|
+
type: "callout",
|
|
2371
|
+
text: { $: "data.statusText" },
|
|
2372
|
+
tone: { $: "data.statusTone" }
|
|
2373
|
+
},
|
|
2374
|
+
{
|
|
2375
|
+
type: "row",
|
|
2376
|
+
children: [{
|
|
2377
|
+
type: "metric",
|
|
2378
|
+
label: t.routesLabel,
|
|
2379
|
+
value: { $: "data.routeCount" },
|
|
2380
|
+
tone: "info"
|
|
2381
|
+
}, {
|
|
2382
|
+
type: "metric",
|
|
2383
|
+
label: t.lastSyncLabel,
|
|
2384
|
+
value: { $: "data.lastSyncText" },
|
|
2385
|
+
tone: "info"
|
|
2386
|
+
}]
|
|
2387
|
+
},
|
|
2388
|
+
{
|
|
2389
|
+
type: "keyValue",
|
|
2390
|
+
items: [[t.instanceLabel, { $: "data.instance" }], [t.stateLabel, { $: "data.stateText" }]]
|
|
2391
|
+
},
|
|
2392
|
+
{
|
|
2393
|
+
type: "list",
|
|
2394
|
+
items: { $: "data.routes" }
|
|
2395
|
+
},
|
|
2396
|
+
{
|
|
2397
|
+
type: "actions",
|
|
2398
|
+
items: { $: "data.actions" }
|
|
2399
|
+
},
|
|
2400
|
+
{
|
|
2401
|
+
type: "text",
|
|
2402
|
+
tone: "muted",
|
|
2403
|
+
text: t.hint
|
|
2404
|
+
}
|
|
2405
|
+
]
|
|
2406
|
+
};
|
|
2407
|
+
},
|
|
2408
|
+
data: (_scope, emit, caps) => {
|
|
2409
|
+
activeLocale = caps?.locale ?? activeLocale;
|
|
2410
|
+
let alive = true;
|
|
2411
|
+
const cancel = watch((data) => {
|
|
2412
|
+
if (alive) emit(data);
|
|
2413
|
+
});
|
|
2414
|
+
return () => {
|
|
2415
|
+
alive = false;
|
|
2416
|
+
cancel();
|
|
2417
|
+
};
|
|
2418
|
+
},
|
|
2419
|
+
actions: {
|
|
2420
|
+
sync: () => {
|
|
2421
|
+
const t = textOf(activeLocale);
|
|
2422
|
+
if (deps.isSyncRunning()) return { toast: t.syncing + "…" };
|
|
2423
|
+
lastError = undefined;
|
|
2424
|
+
void deps.sync().then(schedule).catch(noteError);
|
|
2425
|
+
return { toast: t.syncAction + " · " + t.syncing + "…" };
|
|
2426
|
+
},
|
|
2427
|
+
login: () => {
|
|
2428
|
+
const t = textOf(activeLocale);
|
|
2429
|
+
const email = hooks.email()?.trim() ?? "";
|
|
2430
|
+
if (email.length === 0) return { toast: t.noEmail };
|
|
2431
|
+
hint = undefined;
|
|
2432
|
+
lastError = undefined;
|
|
2433
|
+
void deps.login(email, undefined).then((response) => {
|
|
2434
|
+
hint = response.kind === "2fa" ? t.twoFaHint : undefined;
|
|
2435
|
+
schedule();
|
|
2436
|
+
}).catch(noteError);
|
|
2437
|
+
return { toast: t.loginStarted };
|
|
2438
|
+
},
|
|
2439
|
+
logout: () => {
|
|
2440
|
+
const t = textOf(activeLocale);
|
|
2441
|
+
lastError = undefined;
|
|
2442
|
+
void deps.logout().then(schedule).catch(noteError);
|
|
2443
|
+
return { toast: t.logoutStarted };
|
|
2444
|
+
}
|
|
2445
|
+
}
|
|
2446
|
+
});
|
|
2447
|
+
dshGo.strip("sync-strip", (_scope, emit, caps) => {
|
|
2448
|
+
activeLocale = caps?.locale ?? activeLocale;
|
|
2449
|
+
let alive = true;
|
|
2450
|
+
const cancel = watch((data) => {
|
|
2451
|
+
if (!alive) return;
|
|
2452
|
+
const t = textOf(activeLocale);
|
|
2453
|
+
emit({
|
|
2454
|
+
text: data.signedIn ? data.routeCount + " · " + data.lastSyncText : "Sub2API · " + t.signedOut,
|
|
2455
|
+
icon: "chart",
|
|
2456
|
+
...data.statusTone === "bad" ? { tone: "bad" } : {}
|
|
2457
|
+
});
|
|
2458
|
+
});
|
|
2459
|
+
return () => {
|
|
2460
|
+
alive = false;
|
|
2461
|
+
cancel();
|
|
2462
|
+
};
|
|
2463
|
+
});
|
|
2464
|
+
});
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2171
2467
|
//#endregion
|
|
2172
2468
|
//#region src/routes.ts
|
|
2173
2469
|
function callbackLangOf(acceptLanguage) {
|
|
@@ -2717,7 +3013,7 @@ function apply(ctx, config) {
|
|
|
2717
3013
|
authState: () => gateway.authState(source().email),
|
|
2718
3014
|
login: async (email, password) => {
|
|
2719
3015
|
const typed = password !== undefined && password.length > 0 ? password : undefined;
|
|
2720
|
-
let pwd = typed ?? (await credentials.resolve(panelPasswordRef(instance())))?.value;
|
|
3016
|
+
let pwd = typed ?? (await credentials.resolve(panelPasswordRef(instance())))?.value ?? (await credentials.resolve(NATIVE_PANEL_PASSWORD_REF))?.value;
|
|
2721
3017
|
if (pwd === undefined || pwd.length === 0) throw new Error("请输入面板密码(首次登录后密码会保存到凭据存储,之后可免输入)");
|
|
2722
3018
|
await mutateOwn([{
|
|
2723
3019
|
op: "set",
|
|
@@ -2926,6 +3222,7 @@ function apply(ctx, config) {
|
|
|
2926
3222
|
}
|
|
2927
3223
|
};
|
|
2928
3224
|
registerRoutes(ctx, deps);
|
|
3225
|
+
registerDshGo(ctx, deps, { email: () => source().email });
|
|
2929
3226
|
let timer;
|
|
2930
3227
|
let armedFor;
|
|
2931
3228
|
const disarm = () => {
|
package/lib/oauth.d.ts
CHANGED
|
@@ -2,9 +2,12 @@
|
|
|
2
2
|
* OAuth 2.1 Authorization Code + PKCE (S256) client for the sub2api provider.
|
|
3
3
|
*
|
|
4
4
|
* Implements the plugin-side half of the 2026-10-01 design:
|
|
5
|
-
* - endpoint discovery
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* - endpoint discovery per RFC 8414: the metadata URL is derived by inserting
|
|
6
|
+
* `/.well-known/oauth-authorization-server` between the host and the
|
|
7
|
+
* provider issuer path (§3), the document's `issuer` field must match the
|
|
8
|
+
* expected provider issuer exactly (§3.3), and the deployed host-root
|
|
9
|
+
* registration plus the `/api/v1/oauth/.well-known` compatibility alias
|
|
10
|
+
* serve as gated fallbacks — discovery failure means OAuth is unavailable;
|
|
8
11
|
* - PKCE: 64-char base64url verifier, S256 challenge only (never `plain`);
|
|
9
12
|
* - one-shot authorization attempts: `state` is single-use and expires with
|
|
10
13
|
* the attempt, the verifier never leaves the host process, and the final
|
|
@@ -35,9 +38,15 @@ export interface OAuthEndpoints {
|
|
|
35
38
|
revocationEndpoint?: string;
|
|
36
39
|
}
|
|
37
40
|
/**
|
|
38
|
-
* Resolve the provider metadata
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
+
* Resolve the provider metadata per RFC 8414. The strict §3 location (the
|
|
42
|
+
* metadata URL derived from the provider issuer) is tried first and must echo
|
|
43
|
+
* the expected issuer (§3.3 — issuer field required); the deployed host-root
|
|
44
|
+
* registration and the versioned compatibility alias follow as gated
|
|
45
|
+
* fallbacks. Issuer validation is what keeps a complete document served by a
|
|
46
|
+
* *different* authorization server (e.g. the upstream MCP issuer at the host
|
|
47
|
+
* root after route B) from being adopted just because it parses. A failed or
|
|
48
|
+
* incomplete hunt is a loud failure — the caller hides the OAuth channel
|
|
49
|
+
* instead of guessing URLs.
|
|
41
50
|
*/
|
|
42
51
|
export declare function discoverOAuthEndpoints(instance: string, fetchImpl?: typeof fetch, now?: number): Promise<OAuthEndpoints>;
|
|
43
52
|
/** Test seam: drop the process-wide discovery cache. */
|
package/lib/types.d.ts
CHANGED
|
@@ -76,6 +76,13 @@ export declare function panelTokenRef(baseURL: string): string;
|
|
|
76
76
|
export declare function panelRefreshRef(baseURL: string): string;
|
|
77
77
|
/** Panel password credential reference for one instance (Mode A, C3). */
|
|
78
78
|
export declare function panelPasswordRef(baseURL: string): string;
|
|
79
|
+
/**
|
|
80
|
+
* Fixed credential slot behind the dsh-Go native settings page: a password
|
|
81
|
+
* set on the phone lands here. The per-instance ref above remains the
|
|
82
|
+
* gateway's own storage for proven-good passwords; login resolution reads
|
|
83
|
+
* the instance ref first and this slot as the final fallback.
|
|
84
|
+
*/
|
|
85
|
+
export declare const NATIVE_PANEL_PASSWORD_REF: "DSH_SUB2API_PANEL_PASSWORD";
|
|
79
86
|
/** OAuth access-token credential reference for one instance (C2). */
|
|
80
87
|
export declare function oauthTokenRef(baseURL: string): string;
|
|
81
88
|
/** OAuth refresh-token credential reference for one instance (C2). */
|
package/locale/en.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "Sub2API Sync",
|
|
4
|
-
"description": "Sync
|
|
4
|
+
"description": "Sync a sub2api instance's groups, keys, and models into dsh provider routes: pick relay keys on the panel, one key becomes one provider, models usable on the next request. Naming, protocol mapping, and scheduled resync are tuned on the plugin page. Requires a reachable sub2api instance and panel account.",
|
|
5
|
+
"detail": "docs/DETAIL.md"
|
|
5
6
|
}
|
|
6
7
|
}
|
package/locale/zh.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "Sub2API 同步",
|
|
4
|
-
"description": "
|
|
4
|
+
"description": "把 sub2api 实例的分组、密钥与模型同步为 dsh 的供应商路由:在面板里勾选 relay key,一把 key 生成一个供应商,模型下一请求即可用。供应商命名、协议映射与定时同步都可在插件页调整。使用前需要一个可访问的 sub2api 实例和面板账号。",
|
|
5
|
+
"detail": "docs/DETAIL.zh.md"
|
|
5
6
|
}
|
|
6
7
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mangoc/dsh-sub2api-sync",
|
|
3
|
-
"description": "Sync one sub2api instance's groups, keys, and models into dsh-llm-pi-ai provider routes: panel-driven login (password / OAuth), one relay key
|
|
4
|
-
"version": "0.
|
|
3
|
+
"description": "Sync one sub2api instance's groups, keys, and models into dsh-llm-pi-ai provider routes: panel-driven login (password / OAuth), one provider per relay key.",
|
|
4
|
+
"version": "0.3.0",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Shuai-S",
|
|
7
7
|
"homepage": "https://github.com/Shuai-S/dsh-sub2api-sync#readme",
|
|
@@ -39,6 +39,8 @@
|
|
|
39
39
|
"lib",
|
|
40
40
|
"data",
|
|
41
41
|
"locale",
|
|
42
|
+
"docs",
|
|
43
|
+
"icon.svg",
|
|
42
44
|
"cordis.patch.yml",
|
|
43
45
|
"README.md",
|
|
44
46
|
"README.zh.md"
|
|
@@ -58,6 +60,37 @@
|
|
|
58
60
|
"platform": "web"
|
|
59
61
|
}
|
|
60
62
|
},
|
|
63
|
+
"dshGo": {
|
|
64
|
+
"v": 1,
|
|
65
|
+
"title": {
|
|
66
|
+
"zh": "Sub2API 同步",
|
|
67
|
+
"en": "Sub2API Sync"
|
|
68
|
+
},
|
|
69
|
+
"icon": "chart",
|
|
70
|
+
"contributes": {
|
|
71
|
+
"composer.strip": [
|
|
72
|
+
{
|
|
73
|
+
"id": "sync-strip",
|
|
74
|
+
"icon": "chart",
|
|
75
|
+
"panel": "sync-status"
|
|
76
|
+
}
|
|
77
|
+
],
|
|
78
|
+
"panels": [
|
|
79
|
+
{
|
|
80
|
+
"id": "sync-status",
|
|
81
|
+
"title": {
|
|
82
|
+
"zh": "同步状态",
|
|
83
|
+
"en": "Sync status"
|
|
84
|
+
},
|
|
85
|
+
"icon": "chart",
|
|
86
|
+
"placement": [
|
|
87
|
+
"session.tab",
|
|
88
|
+
"features"
|
|
89
|
+
]
|
|
90
|
+
}
|
|
91
|
+
]
|
|
92
|
+
}
|
|
93
|
+
},
|
|
61
94
|
"dependencies": {
|
|
62
95
|
"@deepseek-ai/dsh-credentials": "0.2.0-rc.2",
|
|
63
96
|
"@deepseek-ai/schemastery": "^3.18.4"
|
|
@@ -112,6 +145,7 @@
|
|
|
112
145
|
"typescript": "^5.7.0",
|
|
113
146
|
"vitest": "^3.2.4"
|
|
114
147
|
},
|
|
148
|
+
"icon": "./icon.svg",
|
|
115
149
|
"scripts": {
|
|
116
150
|
"build": "tsdown && node scripts/wrap-client.mjs",
|
|
117
151
|
"pack:all": "node scripts/pack-all.mjs",
|