dsh-sub2api-sync 0.1.0 → 0.2.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shuai-S
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -25,19 +25,13 @@ dsh plugin --profile web add @mangoc/dsh-sub2api-sync # canonical (scoped)
25
25
  dsh plugin --profile web add dsh-sub2api-sync # alias (unscoped short name)
26
26
  ```
27
27
 
28
- **GitHub Releases tarball** (no npm involved; the URLs always point to the
29
- latest release; both variants are equivalent):
28
+ **GitHub Releases tarball** (no npm involved; the URL always points to the
29
+ latest release):
30
30
 
31
31
  ```sh
32
- dsh plugin --profile web add https://github.com/Shuai-S/dsh-sub2api-sync/releases/latest/download/mangoc-dsh-sub2api-sync.tgz
33
- # or the short-name variant:
34
32
  dsh plugin --profile web add https://github.com/Shuai-S/dsh-sub2api-sync/releases/latest/download/dsh-sub2api-sync.tgz
35
33
  ```
36
34
 
37
- You can also copy a version-pinned tarball URL (e.g.
38
- `mangoc-dsh-sub2api-sync-0.1.0.tgz` / `dsh-sub2api-sync-0.1.0.tgz`) from the
39
- [Releases page](https://github.com/Shuai-S/dsh-sub2api-sync/releases).
40
-
41
35
  **From source** (pnpm runs the `prepare` build on git dependencies; needs a
42
36
  local toolchain and is slower on first install):
43
37
 
@@ -59,7 +53,7 @@ CI ([.github/workflows/release.yml](../../../.github/workflows/release.yml))
59
53
  runs the whole flow: bump `version` in
60
54
  `packages/llm/sub2api-sync/package.json` → commit →
61
55
  `git tag vX.Y.Z && git push origin vX.Y.Z` → `pnpm check` → two tarballs →
62
- GitHub Release (4 assets: 2 versioned + 2 stable-name copies) → npm dual
56
+ GitHub Release (1 stable-name tarball asset) → npm dual
63
57
  publish (requires the repo secret `NPM_TOKEN`; `publishConfig.registry` is
64
58
  pinned to the official npmjs registry, immune to mirror configs). Manual dual
65
59
  publish from a local checkout:
package/README.zh.md CHANGED
@@ -26,16 +26,12 @@ dsh plugin --profile web add @mangoc/dsh-sub2api-sync # canonical(scoped)
26
26
  dsh plugin --profile web add dsh-sub2api-sync # 别名(无 scope 短名)
27
27
  ```
28
28
 
29
- **GitHub Releases tarball**(不依赖 npm;地址始终指向最新版,两种变体内容等价):
29
+ **GitHub Releases tarball**(不依赖 npm;地址始终指向最新版):
30
30
 
31
31
  ```sh
32
- dsh plugin --profile web add https://github.com/Shuai-S/dsh-sub2api-sync/releases/latest/download/mangoc-dsh-sub2api-sync.tgz
33
- # 或短名变体:
34
32
  dsh plugin --profile web add https://github.com/Shuai-S/dsh-sub2api-sync/releases/latest/download/dsh-sub2api-sync.tgz
35
33
  ```
36
34
 
37
- 也可以从 [Releases 页](https://github.com/Shuai-S/dsh-sub2api-sync/releases) 复制带版本号的 tarball URL(形如 `mangoc-dsh-sub2api-sync-0.1.0.tgz` / `dsh-sub2api-sync-0.1.0.tgz`)以锁定版本。
38
-
39
35
  **从源码安装**(pnpm 会对 git 依赖自动执行 `prepare` 构建;需要本机构建链,首次较慢):
40
36
 
41
37
  ```sh
@@ -46,7 +42,7 @@ dsh plugin --profile web add github:Shuai-S/dsh-sub2api-sync#path:packages/llm/s
46
42
 
47
43
  ### 发布(维护者)
48
44
 
49
- 每次发版**双包同步发布**([scripts/pack-all.mjs](scripts/pack-all.mjs)):scoped canonical 与无 scope 别名各出一个 tarball,版本号完全一致;客户端 bundle 内嵌的插件名(挂载 key、详情页匹配、ModuleLoader id)与 `cordis.patch.yml` 在打包时按包名注入,两个变体都可独立安装。CI([.github/workflows/release.yml](../../../.github/workflows/release.yml))自动完成全流程:bump `packages/llm/sub2api-sync/package.json` 的 `version` → 提交 → `git tag vX.Y.Z && git push origin vX.Y.Z` → `pnpm check` → 双 tarball → GitHub Release(4 个资产:2 个版本号 tarball + 2 个固定名副本)→ npm 双发布(需要仓库 secret `NPM_TOKEN`;`publishConfig.registry` 已锁定 npmjs 官方源,不受 npmmirror 镜像影响)。本地手动双发布:
45
+ 每次发版**双包同步发布**([scripts/pack-all.mjs](scripts/pack-all.mjs)):scoped canonical 与无 scope 别名各出一个 tarball,版本号完全一致;客户端 bundle 内嵌的插件名(挂载 key、详情页匹配、ModuleLoader id)与 `cordis.patch.yml` 在打包时按包名注入,两个变体都可独立安装。CI([.github/workflows/release.yml](../../../.github/workflows/release.yml))自动完成全流程:bump `packages/llm/sub2api-sync/package.json` 的 `version` → 提交 → `git tag vX.Y.Z && git push origin vX.Y.Z` → `pnpm check` → 双 tarball → npm 双发布(需要仓库 secret `NPM_TOKEN`;`publishConfig.registry` 已锁定 npmjs 官方源,不受 npmmirror 镜像影响);GitHub Release 附短名 tarball 资产。本地手动双发布:
50
46
 
51
47
  ```sh
52
48
  pnpm pack:all # 产出 dist/ 下两个 tarball,仓库文件自动还原
package/lib/client.cjs CHANGED
@@ -125,8 +125,8 @@ const zh = {
125
125
  detailSectionEmpty: "还没有同步过——在设置 → Sub2API 同步 完成首次同步",
126
126
  activationTitle: "开始使用 Sub2API 同步",
127
127
  activationStep1: "1. 在设置中连接面板(账号密码或网页授权)",
128
- activationStep2: "2. 勾选要同步的分组(key 自动准备)",
129
- activationStep3: "3. 点击「立即同步」,模型即可使用",
128
+ activationStep2: "2. 在 Keys 卡勾选要同步的 key(可跨分组)",
129
+ activationStep3: "3. 点击「同步所选」,模型即可使用",
130
130
  activationOpenConfig: "打开配置页",
131
131
  activationDismiss: "知道了",
132
132
  keysTitle: "Keys",
@@ -235,8 +235,8 @@ const en = {
235
235
  detailSectionEmpty: "Never synced — finish the first sync in Settings → Sub2API Sync",
236
236
  activationTitle: "Get started with Sub2API Sync",
237
237
  activationStep1: "1. Connect the panel in Settings (account password or web authorization)",
238
- activationStep2: "2. Check the groups to sync (keys are prepared automatically)",
239
- activationStep3: "3. Click \"Sync now\" and the models are ready",
238
+ activationStep2: "2. Tick the keys to sync on the Keys card (cross-group)",
239
+ activationStep3: "3. Click \"Sync selected\" and the models are ready",
240
240
  activationOpenConfig: "Open configuration",
241
241
  activationDismiss: "Got it",
242
242
  keysTitle: "Keys",
@@ -1518,7 +1518,7 @@ function Sub2apiSyncSection({ t, api, config, useStatus: useStatus$1 }) {
1518
1518
  const instanceMarker = (configValues?.baseURL ?? "").trim().replace(/\/+$/, "").toLowerCase();
1519
1519
  const previousInstance = (0, react.useRef)(undefined);
1520
1520
  (0, react.useEffect)(() => {
1521
- const markerKey = "sub2api-sync:last-instance";
1521
+ const markerKey = `${PKG_NAME}:last-instance`;
1522
1522
  let stored;
1523
1523
  try {
1524
1524
  stored = window.localStorage.getItem(markerKey) ?? undefined;
@@ -2313,7 +2313,7 @@ function DetailSectionBody({ t }) {
2313
2313
  //#endregion
2314
2314
  //#region src/client/plugins/ActivationGuide.tsx
2315
2315
  function ActivationGuide(props) {
2316
- const { t, onDismiss, onOpenDetails } = props;
2316
+ const { t, onDismiss, onOpenDetails, openBundleDetails } = props;
2317
2317
  return (0, react_jsx_runtime.jsxs)("div", {
2318
2318
  className: "s2s-card",
2319
2319
  children: [
@@ -2333,7 +2333,9 @@ function ActivationGuide(props) {
2333
2333
  className: "s2s-actions",
2334
2334
  children: [(0, react_jsx_runtime.jsx)(__deepseek_ai_dsh_client_ui_primitives.Button, {
2335
2335
  variant: "primary",
2336
- onClick: onOpenDetails,
2336
+ onClick: () => {
2337
+ if (!(openBundleDetails?.() ?? false)) onOpenDetails();
2338
+ },
2337
2339
  children: t("activationOpenConfig")
2338
2340
  }), (0, react_jsx_runtime.jsx)(__deepseek_ai_dsh_client_ui_primitives.Button, {
2339
2341
  variant: "ghost",
@@ -2391,10 +2393,17 @@ function mount(ctx) {
2391
2393
  id: "sub2api-sync",
2392
2394
  locale: NS
2393
2395
  }, DetailSection));
2396
+ const activationOps = { openBundleDetails: () => {
2397
+ const nav = ctx.pluginNavigation;
2398
+ if (nav === undefined) return false;
2399
+ nav.openBundle(PKG_NAME);
2400
+ return true;
2401
+ } };
2394
2402
  ctx.slots.inject("plugins.bundle.activation", () => ctx.slots.register({
2395
2403
  name: "plugins.bundle.activation",
2396
2404
  key: PKG_NAME,
2397
- locale: NS
2405
+ locale: NS,
2406
+ inject: () => activationOps
2398
2407
  }, ActivationGuide));
2399
2408
  }
2400
2409
 
@@ -2404,7 +2413,8 @@ const name = "dsh-sub2api-sync-client";
2404
2413
  const inject = [
2405
2414
  "slots",
2406
2415
  "locale",
2407
- "configForms"
2416
+ "configForms",
2417
+ "pluginNavigation"
2408
2418
  ];
2409
2419
  function apply(ctx) {
2410
2420
  mount(ctx);
package/lib/client.js CHANGED
@@ -131,8 +131,8 @@ const zh = {
131
131
  detailSectionEmpty: "还没有同步过——在设置 → Sub2API 同步 完成首次同步",
132
132
  activationTitle: "开始使用 Sub2API 同步",
133
133
  activationStep1: "1. 在设置中连接面板(账号密码或网页授权)",
134
- activationStep2: "2. 勾选要同步的分组(key 自动准备)",
135
- activationStep3: "3. 点击「立即同步」,模型即可使用",
134
+ activationStep2: "2. 在 Keys 卡勾选要同步的 key(可跨分组)",
135
+ activationStep3: "3. 点击「同步所选」,模型即可使用",
136
136
  activationOpenConfig: "打开配置页",
137
137
  activationDismiss: "知道了",
138
138
  keysTitle: "Keys",
@@ -241,8 +241,8 @@ const en = {
241
241
  detailSectionEmpty: "Never synced — finish the first sync in Settings → Sub2API Sync",
242
242
  activationTitle: "Get started with Sub2API Sync",
243
243
  activationStep1: "1. Connect the panel in Settings (account password or web authorization)",
244
- activationStep2: "2. Check the groups to sync (keys are prepared automatically)",
245
- activationStep3: "3. Click \"Sync now\" and the models are ready",
244
+ activationStep2: "2. Tick the keys to sync on the Keys card (cross-group)",
245
+ activationStep3: "3. Click \"Sync selected\" and the models are ready",
246
246
  activationOpenConfig: "Open configuration",
247
247
  activationDismiss: "Got it",
248
248
  keysTitle: "Keys",
@@ -1524,7 +1524,7 @@ function Sub2apiSyncSection({ t, api, config, useStatus: useStatus$1 }) {
1524
1524
  const instanceMarker = (configValues?.baseURL ?? "").trim().replace(/\/+$/, "").toLowerCase();
1525
1525
  const previousInstance = (0, react.useRef)(undefined);
1526
1526
  (0, react.useEffect)(() => {
1527
- const markerKey = "sub2api-sync:last-instance";
1527
+ const markerKey = `${PKG_NAME}:last-instance`;
1528
1528
  let stored;
1529
1529
  try {
1530
1530
  stored = window.localStorage.getItem(markerKey) ?? undefined;
@@ -2319,7 +2319,7 @@ function DetailSectionBody({ t }) {
2319
2319
  //#endregion
2320
2320
  //#region src/client/plugins/ActivationGuide.tsx
2321
2321
  function ActivationGuide(props) {
2322
- const { t, onDismiss, onOpenDetails } = props;
2322
+ const { t, onDismiss, onOpenDetails, openBundleDetails } = props;
2323
2323
  return (0, react_jsx_runtime.jsxs)("div", {
2324
2324
  className: "s2s-card",
2325
2325
  children: [
@@ -2339,7 +2339,9 @@ function ActivationGuide(props) {
2339
2339
  className: "s2s-actions",
2340
2340
  children: [(0, react_jsx_runtime.jsx)(__deepseek_ai_dsh_client_ui_primitives.Button, {
2341
2341
  variant: "primary",
2342
- onClick: onOpenDetails,
2342
+ onClick: () => {
2343
+ if (!(openBundleDetails?.() ?? false)) onOpenDetails();
2344
+ },
2343
2345
  children: t("activationOpenConfig")
2344
2346
  }), (0, react_jsx_runtime.jsx)(__deepseek_ai_dsh_client_ui_primitives.Button, {
2345
2347
  variant: "ghost",
@@ -2397,10 +2399,17 @@ function mount(ctx) {
2397
2399
  id: "sub2api-sync",
2398
2400
  locale: NS
2399
2401
  }, DetailSection));
2402
+ const activationOps = { openBundleDetails: () => {
2403
+ const nav = ctx.pluginNavigation;
2404
+ if (nav === undefined) return false;
2405
+ nav.openBundle(PKG_NAME);
2406
+ return true;
2407
+ } };
2400
2408
  ctx.slots.inject("plugins.bundle.activation", () => ctx.slots.register({
2401
2409
  name: "plugins.bundle.activation",
2402
2410
  key: PKG_NAME,
2403
- locale: NS
2411
+ locale: NS,
2412
+ inject: () => activationOps
2404
2413
  }, ActivationGuide));
2405
2414
  }
2406
2415
 
@@ -2410,7 +2419,8 @@ const name = "dsh-sub2api-sync-client";
2410
2419
  const inject = [
2411
2420
  "slots",
2412
2421
  "locale",
2413
- "configForms"
2422
+ "configForms",
2423
+ "pluginNavigation"
2414
2424
  ];
2415
2425
  function apply(ctx) {
2416
2426
  mount(ctx);
package/lib/config.d.ts CHANGED
@@ -24,9 +24,22 @@
24
24
  * @module sub2api-sync/config
25
25
  */
26
26
  import z from '@deepseek-ai/schemastery';
27
- import type { ModelTemplate } from './protocols.ts';
27
+ import type { ModelTemplate, PiAiRetryPolicy } from './protocols.ts';
28
28
  ./protocols.jsApiProtocol } from './types.ts';
29
- /** ./types.jskey-source options; `keyId` absent = 自动(组名建/复用). */
29
+ /** ./types.jsn overrides over the plugin's built-in lenient retry policy
30
+ * (`SUB2API_RETRY_POLICY`): an absent key inherits the built-in value.
31
+ * YAML-only advanced field (一字段一写面 — the tuning card does not render it). */
32
+ export interface RetryPolicyOverride {
33
+ mode?: PiAiRetryPolicy['mode'];
34
+ maxRetries?: number;
35
+ retryableCodes?: string[];
36
+ backoff?: {
37
+ initialDelayMs?: number;
38
+ maxDelayMs?: number;
39
+ jitterRatio?: number;
40
+ };
41
+ }
42
+ /** Per-group key-source options; `keyId` absent = 自动(组名建/复用). */
30
43
  export interface Sub2apiSyncConfig {
31
44
  /** sub2api instance address, with or without a trailing `/v1`. */
32
45
  baseURL?: string;
@@ -56,6 +69,15 @@ export interface Sub2apiSyncConfig {
56
69
  deleteOrphanKeys?: boolean;
57
70
  /** Scheduled resync interval; absent = off. */
58
71
  syncIntervalMs?: number;
72
+ /** Retry policy overrides merged over the built-in policy every synced
73
+ * route carries (see `RetryPolicyOverride`). */
74
+ retryPolicy?: RetryPolicyOverride;
75
+ /** Route-level context-window default for models the metadata enrichment
76
+ * cannot match. Default 128000. */
77
+ defaultContextWindow?: number;
78
+ /** Route-level max-output-tokens default for models the metadata
79
+ * enrichment cannot match. Default 8192. */
80
+ defaultMaxTokens?: number;
59
81
  /** Escape hatch: allow action routes from private-network peers (§11). Default false. */
60
82
  allowRemoteActions?: boolean;
61
83
  }
@@ -63,7 +85,7 @@ export declare const Config: z<Sub2apiSyncConfig, any>;
63
85
  /** The default provider-name template — owned by `types.ts` (browser-safe), re-exported here. */
64
86
  export { DEFAULT_PROVIDER_NAME_TEMPLATE } from './types.ts';
65
87
  /**
66
- * One resolved volatile field:./types.jsreference whose `get()` yields the
88
+ * One resolved volatile field: a stable refe./types.jse `get()` yields the
67
89
  * current immutable snapshot. Structurally `cosmokit`'s `Volatile<T>`; kept
68
90
  * local so this package need not depend on it.
69
91
  */
package/lib/engine.d.ts CHANGED
@@ -108,8 +108,8 @@ export declare class Sub2apiSyncEngine {
108
108
  isRunning(): boolean;
109
109
  /**
110
110
  * Run one sync. At most one runs at a time — later callers queue behind
111
- * the previous one (the settings-card button, the tool, and the timer all
112
- * share this queue).
111
+ * the previous one (the settings-card button and the timer share this
112
+ * queue).
113
113
  */
114
114
  sync(config: Sub2apiSyncConfig, keyIds?: number[]): Promise<SyncReport>;
115
115
  private runSync;
@@ -125,5 +125,4 @@ export declare class Sub2apiSyncEngine {
125
125
  */
126
126
  private discoverModels;
127
127
  private readCurrentProviders;
128
- private revisionOf;
129
128
  }
package/lib/enrich.d.ts CHANGED
@@ -91,9 +91,9 @@ export declare function bundledIndex(): ModelMetaIndex;
91
91
  /**
92
92
  * Resolve the index for one sync run. With `enrich` on, a fresh fetch is
93
93
  * attempted and cached for 24h (failures degrade to the bundled snapshot and
94
- * are cached for the same TTL so a dead endpoint is not retried per sync);
95
- * with `enrich` off, only the bundled snapshot is used — deterministic and
96
- * offline.
94
+ * are negative-cached for 1h so a dead endpoint is not retried per sync but
95
+ * an outage self-heals within the hour); with `enrich` off, only the bundled
96
+ * snapshot is used — deterministic and offline.
97
97
  */
98
98
  export declare function loadModelIndex(options: {
99
99
  enrich: boolean;
package/lib/index.js CHANGED
@@ -211,6 +211,26 @@ function parseGroupId(input) {
211
211
  const id = Number(input);
212
212
  return Number.isSafeInteger(id) && id > 0 ? id : undefined;
213
213
  }
214
+ function isSettingsConflict(error) {
215
+ const shape = error;
216
+ return shape?.code === "SETTINGS_CONFLICT" || shape?.name === "SettingsConflictError";
217
+ }
218
+ async function mutateWithRetry(settings, ns, buildOps, options = {}) {
219
+ const maxAttempts = options.attempts ?? 3;
220
+ for (let attempt = 1;; attempt++) {
221
+ const ops = await buildOps();
222
+ const descriptor = settings.describe().find((candidate) => candidate.ns === ns);
223
+ if (descriptor === undefined) throw new Error(options.missingError ?? `sub2api-sync: 设置命名空间 "${ns}" 未注册`);
224
+ if (ops.length === 0) return;
225
+ try {
226
+ await settings.mutate(ns, ops, descriptor.revision);
227
+ return;
228
+ } catch (error) {
229
+ if (attempt < maxAttempts && isSettingsConflict(error)) continue;
230
+ throw error;
231
+ }
232
+ }
233
+ }
214
234
 
215
235
  //#endregion
216
236
  //#region src/protocols.ts
@@ -283,6 +303,16 @@ const THINKING_LEVELS = [
283
303
  function buildProviderProfile(input) {
284
304
  const compat = input.template?.compat;
285
305
  const reasoning = input.template?.reasoningEfforts;
306
+ const override = input.retryPolicy ?? {};
307
+ const retryPolicy = {
308
+ ...SUB2API_RETRY_POLICY,
309
+ ...override,
310
+ retryableCodes: [...override.retryableCodes ?? SUB2API_RETRY_POLICY.retryableCodes],
311
+ backoff: {
312
+ ...SUB2API_RETRY_POLICY.backoff,
313
+ ...override.backoff
314
+ }
315
+ };
286
316
  return {
287
317
  displayName: input.displayName.length > 0 ? input.displayName : `Group ${input.groupId}`,
288
318
  apiKeyEnv: input.apiKeyEnv,
@@ -298,14 +328,10 @@ function buildProviderProfile(input) {
298
328
  ...reasoning === false ? { reasoningEfforts: false } : reasoning !== undefined ? { reasoningEfforts: { ...reasoning } } : model.reasoningEfforts !== undefined ? { reasoningEfforts: { ...model.reasoningEfforts } } : {}
299
329
  })),
300
330
  ...input.template?.reasoning !== undefined ? { reasoning: input.template.reasoning } : {},
301
- defaultContextWindow: 128e3,
302
- defaultMaxTokens: 8192,
331
+ defaultContextWindow: input.defaultContextWindow ?? 128e3,
332
+ defaultMaxTokens: input.defaultMaxTokens ?? 8192,
303
333
  defaultInput: ["text"],
304
- retryPolicy: {
305
- ...SUB2API_RETRY_POLICY,
306
- retryableCodes: [...SUB2API_RETRY_POLICY.retryableCodes],
307
- backoff: { ...SUB2API_RETRY_POLICY.backoff }
308
- }
334
+ retryPolicy
309
335
  };
310
336
  }
311
337
 
@@ -342,6 +368,18 @@ const Config = z.object({
342
368
  keyExpiresInDays: z.number().step(1).volatile(),
343
369
  deleteOrphanKeys: z.boolean().default(false).volatile(),
344
370
  syncIntervalMs: z.number().step(1).volatile(),
371
+ retryPolicy: z.object({
372
+ mode: z.union([z.const("normal"), z.const("always")]),
373
+ maxRetries: z.number().step(1),
374
+ retryableCodes: z.array(z.string()),
375
+ backoff: z.object({
376
+ initialDelayMs: z.number(),
377
+ maxDelayMs: z.number(),
378
+ jitterRatio: z.number()
379
+ })
380
+ }).volatile(),
381
+ defaultContextWindow: z.number().step(1).volatile(),
382
+ defaultMaxTokens: z.number().step(1).volatile(),
345
383
  allowRemoteActions: z.boolean().default(false).volatile()
346
384
  });
347
385
  /** Unwrap one live cell into its current snapshot; plain values pass through. */
@@ -359,6 +397,22 @@ function validateConfig(config) {
359
397
  const template = config.providerNameTemplate;
360
398
  if (template !== undefined && template.length > 0 && !template.includes("{keyName}") && !template.includes("{siteName}")) throw new Error("sub2api-sync: providerNameTemplate 必须包含 {keyName} 或 {siteName} 占位符");
361
399
  for (const id of config.selectedKeys ?? []) if (!Number.isSafeInteger(id) || id <= 0) throw new Error(`sub2api-sync: selectedKeys 含非法 key id ${String(id)}`);
400
+ if (config.syncIntervalMs !== undefined && config.syncIntervalMs < 6e4) throw new Error("sub2api-sync: syncIntervalMs 定时同步间隔不能小于 60000ms(删除该字段即关闭定时同步)");
401
+ if (config.keyQuota !== undefined && config.keyQuota < 0) throw new Error("sub2api-sync: keyQuota 不能为负数");
402
+ if (config.keyExpiresInDays !== undefined && (!Number.isSafeInteger(config.keyExpiresInDays) || config.keyExpiresInDays < 1)) throw new Error("sub2api-sync: keyExpiresInDays 必须是 ≥1 的整数");
403
+ if (config.defaultContextWindow !== undefined && (!Number.isSafeInteger(config.defaultContextWindow) || config.defaultContextWindow <= 0)) throw new Error("sub2api-sync: defaultContextWindow 必须是正整数");
404
+ if (config.defaultMaxTokens !== undefined && (!Number.isSafeInteger(config.defaultMaxTokens) || config.defaultMaxTokens <= 0)) throw new Error("sub2api-sync: defaultMaxTokens 必须是正整数");
405
+ const retry = config.retryPolicy;
406
+ if (retry !== undefined) {
407
+ if (retry.mode !== undefined && retry.mode !== "normal" && retry.mode !== "always") throw new Error("sub2api-sync: retryPolicy.mode 必须是 normal 或 always");
408
+ if (retry.maxRetries !== undefined && (!Number.isSafeInteger(retry.maxRetries) || retry.maxRetries < 0)) throw new Error("sub2api-sync: retryPolicy.maxRetries 必须是非负整数");
409
+ const backoff = retry.backoff;
410
+ if (backoff !== undefined) {
411
+ if (backoff.initialDelayMs !== undefined && (!Number.isSafeInteger(backoff.initialDelayMs) || backoff.initialDelayMs < 0)) throw new Error("sub2api-sync: retryPolicy.backoff.initialDelayMs 必须是非负整数");
412
+ if (backoff.maxDelayMs !== undefined && (!Number.isSafeInteger(backoff.maxDelayMs) || backoff.maxDelayMs < 0)) throw new Error("sub2api-sync: retryPolicy.backoff.maxDelayMs 必须是非负整数");
413
+ if (backoff.jitterRatio !== undefined && (backoff.jitterRatio < 0 || backoff.jitterRatio > 1)) throw new Error("sub2api-sync: retryPolicy.backoff.jitterRatio 必须在 [0, 1] 之间");
414
+ }
415
+ }
362
416
  }
363
417
 
364
418
  //#endregion
@@ -433,7 +487,16 @@ function readModelList(payload) {
433
487
  //#region src/enrich.ts
434
488
  const SNAPSHOT_PATH = join(dirname(fileURLToPath(import.meta.url)), "../data/models-dev.snapshot.json");
435
489
  const SOURCE_URL = "https://models.dev/api.json";
490
+ /** How long a successful catalog fetch is trusted. */
436
491
  const TTL = 864e5;
492
+ /**
493
+ * How long a FAILED fetch defers the next attempt. Much shorter than the
494
+ * success TTL on purpose: a negative cache exists so an unreachable endpoint
495
+ * is not hammered per sync, but a models.dev outage (or a local network
496
+ * blip) should self-heal within the hour instead of freezing the catalog
497
+ * metadata for a full day.
498
+ */
499
+ const NEGATIVE_TTL = 36e5;
437
500
  const EFFORT_VOCABULARY = [
438
501
  "none",
439
502
  "minimal",
@@ -673,7 +736,7 @@ function bundledIndex() {
673
736
  }
674
737
  async function loadModelIndex(options) {
675
738
  const now = (options.now ?? Date.now)();
676
- if (networkCache !== undefined && now - networkCache.at < TTL) return networkCache.index;
739
+ if (networkCache !== undefined && now - networkCache.at < (networkCache.ok ? TTL : NEGATIVE_TTL)) return networkCache.index;
677
740
  if (options.enrich) try {
678
741
  const doFetch = options.fetchImpl ?? globalThis.fetch;
679
742
  if (doFetch === undefined) throw new Error("no fetch available");
@@ -683,14 +746,16 @@ async function loadModelIndex(options) {
683
746
  const index = parseCatalog(catalog);
684
747
  networkCache = {
685
748
  index,
686
- at: now
749
+ at: now,
750
+ ok: true
687
751
  };
688
752
  return index;
689
753
  } catch {}
690
754
  const bundled = bundledIndex();
691
755
  if (options.enrich) networkCache = {
692
756
  index: bundled,
693
- at: now
757
+ at: now,
758
+ ok: false
694
759
  };
695
760
  return bundled;
696
761
  }
@@ -721,10 +786,6 @@ var Sub2apiSyncError = class extends Error {
721
786
  this.name = "Sub2apiSyncError";
722
787
  }
723
788
  };
724
- function isSettingsConflict(error) {
725
- const shape = error;
726
- return shape?.code === "SETTINGS_CONFLICT" || shape?.name === "SettingsConflictError";
727
- }
728
789
  function messageOf(error) {
729
790
  return error instanceof Error ? error.message : String(error);
730
791
  }
@@ -786,8 +847,8 @@ var Sub2apiSyncEngine = class {
786
847
  }
787
848
  /**
788
849
  * 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).
850
+ * the previous one (the settings-card button and the timer share this
851
+ * queue).
791
852
  */
792
853
  sync(config, keyIds) {
793
854
  const run = this.queue.then(() => this.runSync(config, keyIds), () => this.runSync(config, keyIds));
@@ -807,6 +868,7 @@ var Sub2apiSyncEngine = class {
807
868
  if (!/^https?:\/\//i.test(instance)) throw new Sub2apiSyncError("sub2api-sync: baseURL 未配置或不是 http(s) 地址");
808
869
  const entries = [];
809
870
  const desired = new Map();
871
+ let keyWalkTruncated = false;
810
872
  const { groups, keyRows } = await this.gateway.use(async (panel, token) => {
811
873
  const groupList = await panel.listAvailableGroups(token);
812
874
  const rows = [];
@@ -816,6 +878,7 @@ var Sub2apiSyncEngine = class {
816
878
  pageSize: 100
817
879
  });
818
880
  rows.push(...result.items);
881
+ if (result.pages > 10) keyWalkTruncated = true;
819
882
  if (page >= result.pages || result.items.length === 0) break;
820
883
  }
821
884
  return {
@@ -830,6 +893,7 @@ var Sub2apiSyncEngine = class {
830
893
  }).catch(() => new Map());
831
894
  const site = await this.siteName?.().catch(() => undefined);
832
895
  const syncWarnings = [];
896
+ if (keyWalkTruncated) syncWarnings.push("面板 key 数量超过单次同步的读取上限(10 页 × 100 = 1000 把)——超出部分未参与同步,也不会被移除");
833
897
  const effective = {
834
898
  ...config,
835
899
  selectedKeys: selection ?? config.selectedKeys
@@ -875,7 +939,7 @@ var Sub2apiSyncEngine = class {
875
939
  const removed = [];
876
940
  let ops = [];
877
941
  let routeActions = new Map();
878
- for (;;) {
942
+ await mutateWithRetry(this.settings, PI_AI_NS, () => {
879
943
  attempts++;
880
944
  const now = this.readCurrentProviders();
881
945
  const plan = planOps(now, desired);
@@ -883,15 +947,8 @@ var Sub2apiSyncEngine = class {
883
947
  routeActions = plan.actions;
884
948
  removed.length = 0;
885
949
  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
- }
950
+ return ops;
951
+ });
895
952
  for (const entry of entries) {
896
953
  const action = routeActions.get(entry.route);
897
954
  if (action !== undefined) entry.action = action;
@@ -922,7 +979,7 @@ var Sub2apiSyncEngine = class {
922
979
  op: "unset",
923
980
  path: ["routeKeyIds", route]
924
981
  });
925
- if (mapOps.length > 0) await this.settings.mutate(OWN_NS, mapOps, this.revisionOf(OWN_NS));
982
+ if (mapOps.length > 0) await mutateWithRetry(this.settings, OWN_NS, () => mapOps, { missingError: "sub2api-sync: 自身 settings 命名空间未注册" });
926
983
  } catch {}
927
984
  return {
928
985
  entries,
@@ -1016,7 +1073,10 @@ var Sub2apiSyncEngine = class {
1016
1073
  instance,
1017
1074
  models: enriched,
1018
1075
  apiKeyEnv: ref,
1019
- template
1076
+ template,
1077
+ retryPolicy: config.retryPolicy,
1078
+ defaultContextWindow: config.defaultContextWindow,
1079
+ defaultMaxTokens: config.defaultMaxTokens
1020
1080
  }),
1021
1081
  warnings
1022
1082
  };
@@ -1048,11 +1108,6 @@ var Sub2apiSyncEngine = class {
1048
1108
  if (typeof providers !== "object" || Array.isArray(providers)) throw new Sub2apiSyncError("llm-pi-ai.providers 形状异常(不是对象)");
1049
1109
  return providers;
1050
1110
  }
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
1111
  };
1057
1112
  /** Attach the enriched facts (display name, capacity, efforts) to one discovered model. */
1058
1113
  function enrichModel(model, index) {
@@ -1414,6 +1469,7 @@ var OAuthAttemptStore = class {
1414
1469
  this.ttlMs = ttlMs;
1415
1470
  }
1416
1471
  create(input, now = Date.now()) {
1472
+ for (const [id, attempt$1] of this.attempts) if (this.expired(attempt$1, now)) this.drop(attempt$1);
1417
1473
  const attempt = {
1418
1474
  id: randomBase64url(24),
1419
1475
  state: randomBase64url(32),
@@ -1718,16 +1774,10 @@ var PanelClient = class {
1718
1774
  }
1719
1775
  /** Password login; classifies 2FA and captcha outcomes. */
1720
1776
  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
- }
1777
+ const payload = await this.call(undefined, "POST", "/auth/login", {
1778
+ email,
1779
+ password
1780
+ });
1731
1781
  const data = payload;
1732
1782
  if (data !== null && typeof data === "object" && data.requires_2fa === true) {
1733
1783
  const tempToken = data.temp_token;
@@ -2105,9 +2155,19 @@ function isPrivateAddress(address) {
2105
2155
  if (lowered === "::1") return true;
2106
2156
  return lowered.startsWith("fc") || lowered.startsWith("fd");
2107
2157
  }
2158
+ /** Request bodies here are tiny JSON documents; a larger stream is a broken
2159
+ * or hostile peer — refuse it before buffering (same posture as icon.ts's
2160
+ * MAX_BYTES). */
2161
+ const MAX_BODY_BYTES = 1e6;
2108
2162
  async function readJson(req) {
2109
2163
  const chunks = [];
2110
- for await (const chunk of req) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
2164
+ let total = 0;
2165
+ for await (const chunk of req) {
2166
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
2167
+ total += buffer.length;
2168
+ if (total > MAX_BODY_BYTES) throw new Error(`request body 超过 ${MAX_BODY_BYTES} 字节上限`);
2169
+ chunks.push(buffer);
2170
+ }
2111
2171
  const text = Buffer.concat(chunks).toString("utf8").trim();
2112
2172
  if (text.length === 0) return {};
2113
2173
  const value = JSON.parse(text);
@@ -2141,8 +2201,7 @@ function safeMessage(error) {
2141
2201
  return error instanceof Error ? error.message : String(error);
2142
2202
  }
2143
2203
  function conflictStatus(error) {
2144
- const shape = error;
2145
- return shape?.code === "SETTINGS_CONFLICT" || shape?.name === "SettingsConflictError" ? 409 : undefined;
2204
+ return isSettingsConflict(error) ? 409 : undefined;
2146
2205
  }
2147
2206
  function registerRoutes(ctx, deps) {
2148
2207
  ctx.inject(["webServer"], (webCtx) => {
@@ -2519,23 +2578,7 @@ function apply(ctx, config) {
2519
2578
  judge();
2520
2579
  /** Conflict-retried write into the llm-pi-ai section (purge path mirrors
2521
2580
  * 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
- };
2581
+ const mutatePiAi = (ops) => mutateWithRetry(ctx.settings, PI_AI_NS, () => ops, { missingError: "sub2api-sync: llm-pi-ai 设置条目未注册" });
2539
2582
  const credentials = {
2540
2583
  resolve: (ref) => ctx.credentials.resolve(credentialRef(ref)),
2541
2584
  set: (ref, value) => ctx.credentials.set(credentialRef(ref), value),
@@ -2581,25 +2624,9 @@ function apply(ctx, config) {
2581
2624
  gateway,
2582
2625
  siteName: () => instanceProbe.siteName(instance())
2583
2626
  });
2584
- const ownRevision = () => ctx.settings.describe().find((descriptor) => descriptor.ns === OWN_NS)?.revision;
2627
+ let pendingTypedPassword;
2585
2628
  /** 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
- };
2629
+ const mutateOwn = (ops) => mutateWithRetry(ctx.settings, OWN_NS, () => ops, { missingError: "sub2api-sync: 自身 settings 命名空间未注册" });
2603
2630
  const instance = () => source().baseURL?.trim() ?? "";
2604
2631
  const instanceProbe = createInstanceProbe();
2605
2632
  const oauthAttempts = new OAuthAttemptStore();
@@ -2607,27 +2634,37 @@ function apply(ctx, config) {
2607
2634
  instance,
2608
2635
  authState: () => gateway.authState(source().email),
2609
2636
  login: async (email, password) => {
2610
- let pwd = password;
2611
- if (pwd === undefined || pwd.length === 0) pwd = (await credentials.resolve(panelPasswordRef(instance())))?.value;
2637
+ const typed = password !== undefined && password.length > 0 ? password : undefined;
2638
+ let pwd = typed ?? (await credentials.resolve(panelPasswordRef(instance())))?.value;
2612
2639
  if (pwd === undefined || pwd.length === 0) throw new Error("请输入面板密码(首次登录后密码会保存到凭据存储,之后可免输入)");
2613
- if (password !== undefined && password.length > 0) await credentials.set(panelPasswordRef(instance()), password);
2614
2640
  await mutateOwn([{
2615
2641
  op: "set",
2616
2642
  path: ["email"],
2617
2643
  value: email
2618
2644
  }]).catch(() => undefined);
2619
2645
  const outcome = await gateway.login(email, pwd);
2620
- if (outcome.kind === "2fa") return {
2621
- ok: true,
2622
- kind: "2fa",
2623
- requires2FA: true
2624
- };
2646
+ if (outcome.kind === "2fa") {
2647
+ pendingTypedPassword = typed;
2648
+ return {
2649
+ ok: true,
2650
+ kind: "2fa",
2651
+ requires2FA: true
2652
+ };
2653
+ }
2654
+ if (outcome.kind === "ok" && typed !== undefined) await credentials.set(panelPasswordRef(instance()), typed).catch(() => undefined);
2655
+ pendingTypedPassword = undefined;
2625
2656
  return {
2626
2657
  ok: true,
2627
2658
  kind: "ok"
2628
2659
  };
2629
2660
  },
2630
- login2FA: (code) => gateway.login2FA(code),
2661
+ login2FA: async (code) => {
2662
+ await gateway.login2FA(code);
2663
+ if (pendingTypedPassword !== undefined) {
2664
+ await credentials.set(panelPasswordRef(instance()), pendingTypedPassword).catch(() => undefined);
2665
+ pendingTypedPassword = undefined;
2666
+ }
2667
+ },
2631
2668
  logout: async () => {
2632
2669
  await gateway.logout();
2633
2670
  await purgeSyncState().catch(() => undefined);
@@ -2641,8 +2678,7 @@ function apply(ctx, config) {
2641
2678
  status: group.status,
2642
2679
  rateMultiplier: group.rateMultiplier,
2643
2680
  isExclusive: group.isExclusive,
2644
- claudeCodeOnly: group.claudeCodeOnly,
2645
- enabled: false
2681
+ claudeCodeOnly: group.claudeCodeOnly
2646
2682
  }));
2647
2683
  },
2648
2684
  sync: async (keyIds) => {
@@ -114,6 +114,12 @@ export interface ProfileInput {
114
114
  models: readonly ProfileModelInput[];
115
115
  apiKeyEnv: string;
116
116
  template?: ModelTemplate;
117
+ /** Overrides over {@link SUB2API_RETRY_POLICY}; absent keys inherit it. */
118
+ retryPolicy?: Partial<PiAiRetryPolicy>;
119
+ /** Route-level defaults for models the enrichment could not match
120
+ * (Config `defaultContextWindow`/`defaultMaxTokens`). */
121
+ defaultContextWindow?: number;
122
+ defaultMaxTokens?: number;
117
123
  }
118
124
  /** Build one hand-declared llm-pi-ai provider profile for a group. */
119
125
  export declare function buildProviderProfile(input: ProfileInput): PiAiProviderProfile;
package/lib/routes.d.ts CHANGED
@@ -103,10 +103,6 @@ export interface GroupsView {
103
103
  rateMultiplier?: number;
104
104
  isExclusive?: boolean;
105
105
  claudeCodeOnly?: boolean;
106
- /** Whether the group is checked for sync (`enabledGroups`). */
107
- enabled: boolean;
108
- /** The stored key source (`groups[gid].keyId`), when one is pinned. */
109
- keyId?: number;
110
106
  }
111
107
  export interface GroupsResponse {
112
108
  ok: true;
@@ -190,12 +186,9 @@ export interface RouteDeps {
190
186
  login2FA(code: string): Promise<void>;
191
187
  /** Drop panel tokens. */
192
188
  logout(): Promise<void>;
193
- /** Live panel groups annotated with the card's checked/keyId state. */
189
+ /** Live panel groups as the panel reports them (the card's checked state
190
+ * rides the config's `selectedKeys`, not this response). */
194
191
  listGroups(): Promise<GroupsView[]>;
195
- /** One group's relay keys, metadata only (no plaintext). */
196
- /** Store or clear one group's pasted relay key (empty/absent clears). */
197
- /** Model preview for one group: pasted override wins over the stored key. */
198
- /** Best-effort subscription group-name probe for one group's key. */
199
192
  /** Run one sync; the implementation serializes and records the report. */
200
193
  sync(keyIds?: number[]): Promise<SyncReport>;
201
194
  lastReport(): SyncReport | undefined;
package/lib/types.d.ts CHANGED
@@ -128,3 +128,24 @@ export interface CredentialsLike {
128
128
  set(ref: string, value: string): Promise<void>;
129
129
  unset(ref: string): Promise<void>;
130
130
  }
131
+ /**
132
+ * The settings service's concurrent-writer error shape (`code` or `name`).
133
+ * One predicate for every conflict surface: the shared mutate helper below,
134
+ * the engine's plan loop, and the route layer's 409 projection.
135
+ */
136
+ export declare function isSettingsConflict(error: unknown): boolean;
137
+ /**
138
+ * Conflict-retried write into one settings namespace: build the ops, read
139
+ * the namespace's live revision, mutate, and on a concurrent-writer conflict
140
+ * rebuild + retry (bounded — 3 attempts total, matching the engine's policy).
141
+ *
142
+ * `buildOps` runs per attempt so a retry can recompute the plan against the
143
+ * just-re-read state; it may close over outer holders to surface the final
144
+ * ops/actions (the engine does exactly that for its sync report). An empty
145
+ * ops list skips the wire round trip entirely — but the namespace must still
146
+ * exist (the same loud failure the engine's revision read used to produce).
147
+ */
148
+ export declare function mutateWithRetry(settings: SettingsLike, ns: string, buildOps: () => SettingsPathOp[] | Promise<SettingsPathOp[]>, options?: {
149
+ missingError?: string;
150
+ attempts?: number;
151
+ }): Promise<void>;
package/locale/en.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Sub2API Sync",
4
- "description": "Sync one sub2api instance's groups, keys, and models into dsh-llm-pi-ai provider routes: panel-driven login (PAT / password / OAuth) first, one group one provider."
4
+ "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 one provider."
5
5
  }
6
6
  }
package/locale/zh.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Sub2API 同步",
4
- "description": "把一个 sub2api 实例的分组、key 与模型同步为 dsh-llm-pi-ai 供应商路由:先连接面板(PAT / 密码 / OAuth),一个分组一个供应商。"
4
+ "description": "把一个 sub2api 实例的分组、key 与模型同步为 dsh-llm-pi-ai 供应商路由:面板登录(密码 / OAuth),一把 key 一个供应商。"
5
5
  }
6
6
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "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) first, one group one provider.",
4
- "version": "0.1.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 relay key one provider.",
4
+ "version": "0.2.1",
5
5
  "license": "MIT",
6
6
  "author": "Shuai-S",
7
7
  "homepage": "https://github.com/Shuai-S/dsh-sub2api-sync#readme",