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 +21 -0
- package/README.md +3 -9
- package/README.zh.md +2 -6
- package/lib/client.cjs +1 -1
- package/lib/client.js +1 -1
- package/lib/config.d.ts +25 -3
- package/lib/engine.d.ts +2 -3
- package/lib/enrich.d.ts +3 -3
- package/lib/index.js +128 -92
- package/lib/protocols.d.ts +6 -0
- package/lib/routes.d.ts +2 -9
- package/lib/types.d.ts +21 -0
- package/package.json +1 -1
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
|
|
29
|
-
latest release
|
|
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 (
|
|
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 →
|
|
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 =
|
|
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 =
|
|
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.
|
|
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
|
|
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
|
|
112
|
-
*
|
|
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
|
|
95
|
-
* with `enrich` off, only the bundled
|
|
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
|
|
790
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
887
|
-
|
|
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
|
|
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
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
2627
|
+
let pendingTypedPassword;
|
|
2585
2628
|
/** Conflict-retried write into this plugin's own section (best effort). */
|
|
2586
|
-
const mutateOwn =
|
|
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
|
-
|
|
2611
|
-
|
|
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")
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
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) =>
|
|
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) => {
|
package/lib/protocols.d.ts
CHANGED
|
@@ -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
|
|
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": "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.
|
|
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",
|