@mangoc/dsh-sub2api-sync 0.1.0 → 0.2.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/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
@@ -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;
package/lib/client.js CHANGED
@@ -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;
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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mangoc/dsh-sub2api-sync",
3
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",
4
+ "version": "0.2.0",
5
5
  "license": "MIT",
6
6
  "author": "Shuai-S",
7
7
  "homepage": "https://github.com/Shuai-S/dsh-sub2api-sync#readme",