@omdp/dsh-key-fallback 1.0.6 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -26
- package/cordis.patch.yml +1 -1
- package/lib/client.js +366 -127
- package/lib/index.js +460 -148
- package/package.json +15 -6
package/README.md
CHANGED
|
@@ -1,40 +1,48 @@
|
|
|
1
1
|
# @omdp/dsh-key-fallback
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Multi-key API key pool with automatic rotation for DeepSeek Harness. Sits between the LLM adapter and the credential store:
|
|
4
|
+
the plugin picks a key from the per-provider pool and writes it into the provider's credential reference before each request;
|
|
5
|
+
on a retryable error (`AUTH` / `QUOTA_EXCEEDED` / `RATE_LIMIT` by default), it advances to the next key in the chain and returns `{ kind: 'retry' }` so the agent loop retries with the rotated key.
|
|
6
|
+
|
|
7
|
+
This is **v3** (work in progress; not yet feature-frozen). v3 supersedes v1 (which stored keys in plaintext under `~/.dsh/settings.yaml#keyFallback.providers`);
|
|
8
|
+
v1 still ships in this profile (it registers the `key-fallback` settings namespace), so v3 deliberately uses a different namespace (`key-fallback-pools`) to avoid the registration conflict.
|
|
9
|
+
|
|
10
|
+
## What's in v3
|
|
11
|
+
|
|
12
|
+
- **Independent settings page**: **Settings → API Key 回退** — a top-level section, not a card under plugin config.
|
|
13
|
+
- **Provider dropdown** (the chooser merges `ctx.llm.listProviders()` and `ctx.llm.listConfigurableProviders()`).
|
|
14
|
+
- **Add / remove keys via the UI** (write-only; the value is never read back into the page).
|
|
15
|
+
- **Per-provider enabled switch** (disable = the pool is bypassed entirely).
|
|
16
|
+
- **Environment key in the pool**: the runtime composes `effectiveKeys = [envKey, ...userKeys]`. The env key is the first entry, marked `[环境来源]` in the UI, and cannot be edited or deleted; the environment remains the source of truth for it.
|
|
17
|
+
- **Manual key selector** (pool-level dropdown "当前使用") — pick any key (including the env one) to lock the pool to that key. Failures do **not** auto-rotate while a key is locked; select "自动轮换" to return to cursor-based rotation.
|
|
18
|
+
- **Rotation on retryable errors** (`agent/request-error` waterfall) with cooldown tracking per key.
|
|
19
|
+
- **Pre-write on every request** (`agent/request` waterfall) so the LLM adapter always reads the pool key, not the original env value.
|
|
20
|
+
- **v1 → v3 one-shot migration** on first launch: reads `~/.dsh/settings.yaml#keyFallback.providers`, writes the plaintext keys into the credential store, copies the pool metadata into the new namespace, and marks `keyFallbackMigratedToV3: true` to prevent re-running. The v1 plaintext block is **left in place** (deleting it would touch a file DSH is actively using; verify the v3 UI looks right, then delete manually).
|
|
21
|
+
|
|
22
|
+
## What's still in flight / not done
|
|
23
|
+
|
|
24
|
+
- **Cooldown / failure status rendering in the UI**: host returns `failCount / cooldownUntil / cooldownRemainingMs / lastErrorAt / lastErrorMsg / status: 'cooling' | 'healthy'` per key and a pool-level `cursor`; the client currently shows the basic list and the env-key badge but does not yet render the cooldown progress bars. Pending — I stopped here for a stable checkpoint after you said "收尾" (wrap up).
|
|
25
|
+
- **v1 and v3 running side by side**: v1's host still loads alongside v3 and both touch the same `~/.dsh/.credentials.yaml`. This is the root cause of the credential-file lock contention you hit while editing model config. To eliminate it: uninstall v1 (remove the dep from `~/.dsh/profiles/web/package.json` and delete `node_modules/@omdp/dsh-key-fallback` so only v3 remains). **Until you do, behavior is undefined under concurrent writes.**
|
|
26
|
+
- **DSH-side bundle caching**: the browser keeps old client bundles by `rev=` hash. The settings-page fix you saw (label-as-function contract) only lands after a DSH restart that re-hashes the plugin. Use Ctrl+Shift+R if a page shows stale UI.
|
|
4
27
|
|
|
5
28
|
## Install
|
|
6
29
|
|
|
7
30
|
```sh
|
|
31
|
+
# 1) from a local checkout (no publish required)
|
|
32
|
+
dsh plugin --profile web add link:D:/WorkSpace/omdp/dsh-key-fallback
|
|
33
|
+
# or from npm
|
|
8
34
|
dsh plugin --profile web add @omdp/dsh-key-fallback
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
-
Restart DSH, then add a config block to `~/.dsh/settings.yaml`:
|
|
12
|
-
|
|
13
|
-
```yaml
|
|
14
|
-
keyFallback:
|
|
15
|
-
providers:
|
|
16
|
-
ninerouter:
|
|
17
|
-
env: NINEROUTER_API_KEY # optional; defaults to ${PROVIDER}_API_KEY
|
|
18
|
-
cooldownMs: 30000 # optional; cooldown after a failure
|
|
19
|
-
keys:
|
|
20
|
-
- <your original key>
|
|
21
|
-
- <alternate key 1>
|
|
22
|
-
- <alternate key 2>
|
|
23
|
-
```
|
|
24
35
|
|
|
25
|
-
|
|
36
|
+
# 2) restart DSH
|
|
26
37
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
## Boundaries
|
|
38
|
+
# 3) open Settings → API Key 回退
|
|
39
|
+
```
|
|
31
40
|
|
|
32
|
-
|
|
33
|
-
- Providers you don't list are left untouched.
|
|
34
|
-
- With a single key, a retryable error surfaces directly (no pointless retry, no crash).
|
|
35
|
-
- With no configured keys for a provider, the plugin does nothing.
|
|
41
|
+
## Diagnostics (built in for v3, not for v1)
|
|
36
42
|
|
|
37
|
-
|
|
43
|
+
- The client wires `window.addEventListener('error' / 'unhandledrejection')` to POST every runtime exception to `POST /dsh-key-fallback/diag`.
|
|
44
|
+
- The host keeps the last 50 reports in memory.
|
|
45
|
+
- `GET /dsh-key-fallback/diag` returns the buffer as JSON. Use this to see what's actually breaking in the browser (instead of reading minified DSH bundles).
|
|
38
46
|
|
|
39
47
|
## License
|
|
40
48
|
|
package/cordis.patch.yml
CHANGED
package/lib/client.js
CHANGED
|
@@ -1,11 +1,30 @@
|
|
|
1
|
-
// @omdp/dsh-key-fallback — Client half
|
|
2
|
-
// Settings → 插件 里的只读状态卡:显示每个 provider 的 key 数 / 游标 / 冷却。
|
|
1
|
+
// @omdp/dsh-key-fallback — Client half (v3)
|
|
3
2
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
3
|
+
// 独立设置页「设置 → API Key 回退」:
|
|
4
|
+
// 1) provider 下拉(来自 /providers:live + dormant)→ 「启用」创建池
|
|
5
|
+
// 2) 每个池一张卡片:enabled 开关 / env / cooldownMs / rotateOn chips /
|
|
6
|
+
// key 链(只写输入,保存后不回读,只显示 [hidden] 占位 + 删除)
|
|
7
|
+
// 3) 顶部刷新按钮 + 冷却重置
|
|
8
|
+
//
|
|
9
|
+
// 走 fetch 跟 host 通信(/dsh-key-fallback/*),视觉用 --dsw-alias-* 变量。
|
|
10
|
+
//
|
|
11
|
+
// ── 诊断:把渲染/运行时错误上报到 host 的 /dsh-key-fallback/diag ──
|
|
12
|
+
function __kfReportDiag(kind, message, stack) {
|
|
13
|
+
try {
|
|
14
|
+
fetch('/dsh-key-fallback/diag', {
|
|
15
|
+
method: 'POST',
|
|
16
|
+
headers: { 'Content-Type': 'application/json' },
|
|
17
|
+
body: JSON.stringify({ kind: kind, message: String(message || '').slice(0, 500), stack: String(stack || '').slice(0, 1500), at: Date.now() }),
|
|
18
|
+
}).catch(function () {})
|
|
19
|
+
} catch (e) {}
|
|
20
|
+
}
|
|
21
|
+
window.addEventListener('error', function (e) {
|
|
22
|
+
__kfReportDiag('error', e && e.message, e && e.error && e.error.stack)
|
|
23
|
+
})
|
|
24
|
+
window.addEventListener('unhandledrejection', function (e) {
|
|
25
|
+
var r = e && e.reason
|
|
26
|
+
__kfReportDiag('unhandledrejection', r && r.message || String(r), r && r.stack)
|
|
27
|
+
})
|
|
9
28
|
window.__ModuleLoader__.load({
|
|
10
29
|
id: '@omdp/dsh-key-fallback',
|
|
11
30
|
factory: function (require) {
|
|
@@ -13,154 +32,374 @@ window.__ModuleLoader__.load({
|
|
|
13
32
|
var exports = module.exports
|
|
14
33
|
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
15
34
|
var React = require('react')
|
|
35
|
+
var ce = React.createElement
|
|
16
36
|
|
|
17
|
-
|
|
18
|
-
|
|
37
|
+
// ── CSS(一次注入)──
|
|
38
|
+
var CSS = [
|
|
39
|
+
'.kf_root{display:flex;flex-direction:column;gap:16px;padding:0 24px 28px}',
|
|
40
|
+
'.kf_head{display:flex;align-items:flex-end;justify-content:space-between;gap:12px;flex-wrap:wrap;border-bottom:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.22));padding-bottom:12px}',
|
|
41
|
+
'.kf_title{font-size:17px;font-weight:600;color:var(--dsw-alias-label-primary,#eee);margin:0}',
|
|
42
|
+
'.kf_sub{font-size:12.5px;color:var(--dsw-alias-label-secondary,#9aa);margin:4px 0 0;line-height:1.55}',
|
|
43
|
+
'.kf_card{background:var(--dsw-alias-bg-layer-1,rgba(255,255,255,.035));border:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.22));border-radius:12px;padding:14px 16px;display:flex;flex-direction:column;gap:12px}',
|
|
44
|
+
'.kf_cardHead{display:flex;align-items:center;gap:10px;flex-wrap:wrap}',
|
|
45
|
+
'.kf_name{font-weight:600;font-size:14.5px;color:var(--dsw-alias-label-primary,#eee);display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
|
|
46
|
+
'.kf_badge{display:inline-flex;align-items:center;gap:4px;font-size:11px;padding:2px 9px;border-radius:999px;border:1px solid currentColor;line-height:1.6;white-space:nowrap}',
|
|
47
|
+
'.kf_badge.muted{color:var(--dsw-alias-label-secondary,#9aa)}',
|
|
48
|
+
'.kf_badge.ok{color:var(--dsw-alias-state-success-primary,#4ade80)}',
|
|
49
|
+
'.kf_badge.warn{color:var(--dsw-alias-state-warn-primary,#fbbf24)}',
|
|
50
|
+
'.kf_badge.brand{color:var(--dsw-alias-brand-primary,#7aa2ff);border-color:var(--dsw-alias-brand-primary,#7aa2ff)}',
|
|
51
|
+
'.kf_badge.err{color:var(--dsw-alias-state-error-primary,#f87171)}',
|
|
52
|
+
'.kf_switch{position:relative;width:34px;height:20px;border-radius:999px;background:var(--dsw-alias-bg-base,rgba(0,0,0,.3));border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.4));cursor:pointer;transition:background .15s;flex:none}',
|
|
53
|
+
'.kf_switch::after{content:"";position:absolute;top:1px;left:1px;width:16px;height:16px;border-radius:50%;background:var(--dsw-alias-label-secondary,#9aa);transition:transform .15s,background .15s}',
|
|
54
|
+
'.kf_switch.on{background:color-mix(in srgb,var(--dsw-alias-brand-primary,#5b8cff) 50%,transparent);border-color:var(--dsw-alias-brand-primary,#5b8cff)}',
|
|
55
|
+
'.kf_switch.on::after{transform:translateX(14px);background:var(--dsw-alias-brand-primary,#7aa2ff)}',
|
|
56
|
+
'.kf_btn{cursor:pointer;font:inherit;font-size:12px;padding:5px 12px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.4));background:transparent;color:inherit;transition:background .12s;flex:none}',
|
|
57
|
+
'.kf_btn:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2,rgba(128,128,128,.14))}',
|
|
58
|
+
'.kf_btn:disabled{opacity:.45;cursor:default}',
|
|
59
|
+
'.kf_btn.primary{border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary,#5b8cff) 55%,transparent);background:color-mix(in srgb,var(--dsw-alias-brand-primary,#5b8cff) 15%,transparent);color:var(--dsw-alias-brand-primary,#7aa2ff)}',
|
|
60
|
+
'.kf_btn.primary:hover:not(:disabled){background:color-mix(in srgb,var(--dsw-alias-brand-primary,#5b8cff) 26%,transparent)}',
|
|
61
|
+
'.kf_btn.danger{color:var(--dsw-alias-state-error-primary,#f87171);border-color:currentColor}',
|
|
62
|
+
'.kf_btn.ghost{border-style:dashed}',
|
|
63
|
+
'.kf_form{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:10px}',
|
|
64
|
+
'.kf_form label{display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--dsw-alias-label-secondary,#9aa)}',
|
|
65
|
+
'.kf_form input,.kf_form select,.kf_chips select{font:inherit;font-size:13px;padding:6px 9px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.35));background:var(--dsw-alias-bg-base,rgba(0,0,0,.18));color:inherit}',
|
|
66
|
+
'.kf_form input:focus,.kf_form select:focus{outline:2px solid var(--dsw-alias-brand-primary,rgba(91,140,255,.5));outline-offset:0;border-color:transparent}',
|
|
67
|
+
'.kf_keys{display:flex;flex-direction:column;gap:6px}',
|
|
68
|
+
'.kf_keyRow{display:flex;align-items:center;gap:10px;padding:8px 10px;border:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.18));border-radius:8px;background:var(--dsw-alias-bg-base,rgba(0,0,0,.16));flex-wrap:wrap}',
|
|
69
|
+
'.kf_keyLabel{font-weight:500;font-size:12.5px;color:var(--dsw-alias-label-primary,#eee);min-width:120px;flex:0 0 auto;display:flex;gap:8px;align-items:center}',
|
|
70
|
+
'.kf_keyRef{font-size:11px;color:var(--dsw-alias-label-secondary,#9aa);font-family:ui-monospace,Menlo,Consolas,monospace;opacity:.7}',
|
|
71
|
+
'.kf_inputSecret{flex:1;min-width:200px;font:inherit;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12.5px;padding:7px 10px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.35));background:var(--dsw-alias-bg-base,rgba(0,0,0,.25));color:inherit}',
|
|
72
|
+
'.kf_inputSecret:focus{outline:2px solid var(--dsw-alias-brand-primary,rgba(91,140,255,.5));border-color:transparent}',
|
|
73
|
+
'.kf_hiddenBadge{font-size:11.5px;color:var(--dsw-alias-label-secondary,#9aa);font-family:ui-monospace,Menlo,Consolas,monospace;padding:7px 10px;border:1px dashed var(--dsw-alias-border-l2,rgba(128,128,128,.3));border-radius:8px;flex:1;min-width:200px;text-align:center}',
|
|
74
|
+
'.kf_chips{display:flex;gap:6px;flex-wrap:wrap}',
|
|
75
|
+
'.kf_chip{cursor:pointer;font:inherit;font-size:11.5px;padding:3px 11px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.35));background:transparent;color:var(--dsw-alias-label-secondary,#9aa);transition:all .12s}',
|
|
76
|
+
'.kf_chip:hover{color:var(--dsw-alias-label-primary,#ddd);border-color:var(--dsw-alias-brand-primary,#5b8cff)}',
|
|
77
|
+
'.kf_chip.active{background:color-mix(in srgb,var(--dsw-alias-brand-primary,#5b8cff) 26%,transparent);color:var(--dsw-alias-brand-primary,#7aa2ff);border-color:color-mix(in srgb,var(--dsw-alias-brand-primary,#5b8cff) 60%,transparent)}',
|
|
78
|
+
'.kf_err{color:var(--dsw-alias-state-error-primary,#f87171);font-size:12.5px;line-height:1.5}',
|
|
79
|
+
'.kf_status{font-size:11px;padding:1px 8px;border-radius:999px;line-height:1.7;white-space:nowrap;flex:none}',
|
|
80
|
+
'.kf_status.live{color:var(--dsw-alias-state-success-primary,#4ade80)}',
|
|
81
|
+
'.kf_status.cooling{color:var(--dsw-alias-state-warn-primary,#fbbf24)}',
|
|
82
|
+
'.kf_status.cooldown{color:var(--dsw-alias-state-error-primary,#f87171)}',
|
|
83
|
+
'.kf_nextRow{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--dsw-alias-label-secondary,#9aa)}',
|
|
84
|
+
'.kf_nextSel{font:inherit;font-size:12px;background:transparent;color:inherit;border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.4));border-radius:6px;padding:2px 6px}',
|
|
85
|
+
'.kf_ok{color:var(--dsw-alias-state-success-primary,#4ade80);font-size:12.5px;line-height:1.5}',
|
|
86
|
+
'.kf_empty{font-size:13px;color:var(--dsw-alias-label-secondary,#888);padding:16px 4px;text-align:center}',
|
|
87
|
+
'.kf_addrow{display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap;background:var(--dsw-alias-bg-layer-1,rgba(255,255,255,.035));border:1px dashed var(--dsw-alias-border-l2,rgba(128,128,128,.4));border-radius:12px;padding:12px 14px}',
|
|
88
|
+
'.kf_addrow label{display:flex;flex-direction:column;gap:5px;font-size:12px;color:var(--dsw-alias-label-secondary,#9aa);min-width:240px;flex:1}',
|
|
89
|
+
'.kf_spinner{display:inline-block;width:12px;height:12px;border:2px solid var(--dsw-alias-border-l2,rgba(128,128,128,.4));border-top-color:var(--dsw-alias-brand-primary,#5b8cff);border-radius:50%;animation:kf_spin .7s linear infinite;vertical-align:-2px;margin-right:6px}',
|
|
90
|
+
'@keyframes kf_spin{to{transform:rotate(360deg)}}',
|
|
91
|
+
].join('')
|
|
92
|
+
if (typeof document !== 'undefined' && !document.querySelector('style[data-plugin-css="@omdp/dsh-key-fallback"]')) {
|
|
93
|
+
var tag = document.createElement('style')
|
|
94
|
+
tag.setAttribute('data-plugin-css', '@omdp/dsh-key-fallback')
|
|
95
|
+
tag.textContent = CSS
|
|
96
|
+
document.head.appendChild(tag)
|
|
19
97
|
}
|
|
20
98
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
// 回调 ref:卡片根元素变化(loading ↔ 列表)时先解除旧元素再观察新元素,
|
|
32
|
-
// 避免旧元素因被移除触发 isIntersecting:false 而错误停止轮询。
|
|
33
|
-
var attachRoot = React.useCallback(function (el) {
|
|
34
|
-
var prev = rootRef.current
|
|
35
|
-
var io = ioRef.current
|
|
36
|
-
if (io && prev && prev !== el) io.unobserve(prev)
|
|
37
|
-
rootRef.current = el
|
|
38
|
-
if (io && el) io.observe(el)
|
|
39
|
-
}, [])
|
|
40
|
-
|
|
41
|
-
var refresh = React.useCallback(function () {
|
|
42
|
-
fetchJson('/dsh-key-fallback/pools')
|
|
43
|
-
.then(function (data) { setState({ loading: false, data: (data && data.pools) || {} }) })
|
|
44
|
-
.catch(function () { setState({ loading: false, data: null }) })
|
|
45
|
-
}, [])
|
|
46
|
-
|
|
47
|
-
React.useEffect(function () {
|
|
48
|
-
var alive = true
|
|
49
|
-
var timer = null
|
|
50
|
-
var pageVisible = !document.hidden
|
|
51
|
-
var cardVisible = true
|
|
52
|
-
|
|
53
|
-
function stop() {
|
|
54
|
-
if (timer !== null) { clearInterval(timer); timer = null }
|
|
55
|
-
}
|
|
56
|
-
function start() {
|
|
57
|
-
if (!alive || timer !== null || !pageVisible || !cardVisible) return
|
|
58
|
-
refresh()
|
|
59
|
-
timer = setInterval(refresh, 2000)
|
|
60
|
-
}
|
|
61
|
-
function sync() {
|
|
62
|
-
if (!alive) return
|
|
63
|
-
if (pageVisible && cardVisible) start()
|
|
64
|
-
else stop()
|
|
99
|
+
function api(path, options) {
|
|
100
|
+
// 先进入 Promise 微任务:JSON.stringify、AbortController、fetch 的同步异常
|
|
101
|
+
// 也必须落到统一错误结果,不能让按钮卡在"保存中"。
|
|
102
|
+
return Promise.resolve().then(function () {
|
|
103
|
+
var url = '/dsh-key-fallback' + path
|
|
104
|
+
var opts = Object.assign({ method: 'GET' }, options || {})
|
|
105
|
+
if (opts.body && typeof opts.body !== 'string') {
|
|
106
|
+
opts.body = JSON.stringify(opts.body)
|
|
107
|
+
opts.headers = Object.assign({ 'Content-Type': 'application/json' }, opts.headers || {})
|
|
65
108
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
if (typeof IntersectionObserver === 'function') {
|
|
74
|
-
var io = new IntersectionObserver(function (entries) {
|
|
75
|
-
if (!alive) return
|
|
76
|
-
for (var i = 0; i < entries.length; i++) {
|
|
77
|
-
cardVisible = entries[i].isIntersecting
|
|
78
|
-
}
|
|
79
|
-
sync()
|
|
80
|
-
}, { threshold: 0.01 })
|
|
81
|
-
ioRef.current = io
|
|
82
|
-
var el = rootRef.current
|
|
83
|
-
if (el) io.observe(el)
|
|
109
|
+
// 8s 超时:避免请求挂起导致 UI 一直"保存中"
|
|
110
|
+
var ctrl = (typeof AbortController === 'function') ? new AbortController() : null
|
|
111
|
+
var timer = null
|
|
112
|
+
if (ctrl) {
|
|
113
|
+
opts.signal = ctrl.signal
|
|
114
|
+
timer = setTimeout(function () { ctrl.abort() }, 8000)
|
|
84
115
|
}
|
|
116
|
+
// Promise.race:即使宿主 fetch 忽略 AbortSignal,也保证 8s 内 settle,
|
|
117
|
+
// 避免保存/删除永久卡在“保存中”。
|
|
118
|
+
var timeoutP = new Promise(function (_, reject) {
|
|
119
|
+
var t = setTimeout(function () { reject(new Error('请求超时')) }, 8000)
|
|
120
|
+
if (typeof t === 'object' && t && typeof t.unref === 'function') t.unref()
|
|
121
|
+
})
|
|
122
|
+
return Promise.race([fetch(url, opts), timeoutP])
|
|
123
|
+
}).then(function (resp) {
|
|
124
|
+
return resp.json().then(function (body) { return { ok: resp.ok, status: resp.status, body: body } }).catch(function () { return { ok: resp.ok, status: resp.status, body: {} } })
|
|
125
|
+
}).catch(function (e) {
|
|
126
|
+
return { ok: false, status: 0, body: { error: '请求超时或失败: ' + (e && e.message || e) } }
|
|
127
|
+
})
|
|
128
|
+
}
|
|
85
129
|
|
|
86
|
-
|
|
87
|
-
|
|
130
|
+
// ── 单个 key 行:编辑态显示 password input,未编辑显示 [hidden] 占位 ──
|
|
131
|
+
function KeyRow(props) {
|
|
132
|
+
var k = props.entry
|
|
133
|
+
var editing = React.useState(false)
|
|
134
|
+
var isEditing = editing[0]
|
|
135
|
+
var setEditing = editing[1]
|
|
136
|
+
var val = React.useState('')
|
|
137
|
+
var value = val[0]
|
|
138
|
+
var setValue = val[1]
|
|
139
|
+
var busy = React.useState(false)
|
|
140
|
+
var isBusy = busy[0]
|
|
141
|
+
var setBusy = busy[1]
|
|
142
|
+
var err = React.useState('')
|
|
143
|
+
var setErr = err[1]
|
|
144
|
+
function save() {
|
|
145
|
+
if (!value) { setErr('请输入 key'); return }
|
|
146
|
+
setBusy(true); setErr('')
|
|
147
|
+
try {
|
|
148
|
+
__kfReportDiag('save', 'PATCH start provider=' + props.provider + ' ref=')
|
|
149
|
+
} catch (e) {}
|
|
150
|
+
// 额外包一层 Promise:即使 api 被宿主替换或请求构造同步抛错,
|
|
151
|
+
// 也一定会进入 catch,按钮不会永久停在"保存中"。
|
|
152
|
+
Promise.resolve().then(function () {
|
|
153
|
+
return api('/keys', { method: 'PATCH', body: { provider: props.provider, ref: k.ref, value: value, label: k.label || '' } })
|
|
154
|
+
})
|
|
155
|
+
.then(function (r) {
|
|
156
|
+
if (!r.ok) { setErr((r.body && r.body.error) || ('HTTP ' + r.status)); return }
|
|
157
|
+
setValue(''); setEditing(false); props.onChanged()
|
|
158
|
+
})
|
|
159
|
+
.catch(function (e) {
|
|
160
|
+
setErr((e && e.message) || String(e))
|
|
161
|
+
})
|
|
162
|
+
.then(function () {
|
|
163
|
+
// 成功、HTTP 错误、请求异常全部收口 busy 状态。
|
|
164
|
+
setBusy(false)
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
var isEnv = props.source === 'env'
|
|
168
|
+
var st = k.status || (isEnv ? 'live' : '')
|
|
169
|
+
var nowMs = Date.now()
|
|
170
|
+
var cooling = k.cooldownRemainingMs > 0
|
|
171
|
+
var stLabel = isEnv ? '' : (st === 'cooling' || cooling) ? ('冷却中 ' + Math.ceil((k.cooldownRemainingMs || 0) / 1000) + 's') : (st === 'recovered' ? '已恢复' : (st === 'cooldown' ? '冷却' : '正常'))
|
|
172
|
+
return ce('div', { className: 'kf_keyRow' },
|
|
173
|
+
ce('div', { className: 'kf_keyLabel' },
|
|
174
|
+
k.label ? k.label : (k.ref || '').slice(-8),
|
|
175
|
+
isEnv ? ce('span', { className: 'kf_badge brand' }, '[环境来源]') : null,
|
|
176
|
+
isEnv || !stLabel ? null : ce('span', { className: 'kf_status ' + ((st === 'cooling' || cooling) ? 'cooling' : 'live') }, stLabel),
|
|
177
|
+
ce('span', { className: 'kf_keyRef' }, k.ref)
|
|
178
|
+
),
|
|
179
|
+
isEnv
|
|
180
|
+
? ce('span', { className: 'kf_hiddenBadge', style: { flex: 1 } }, '来自环境配置(不可编辑/删除;可设为当前、参与轮换)')
|
|
181
|
+
: isEditing
|
|
182
|
+
? ce('div', null,
|
|
183
|
+
ce('input', { className: 'kf_inputSecret', type: 'text', autoComplete: 'off', spellCheck: false, placeholder: '粘贴新 key(只写,不回读)', value: value, onChange: function (e) { setValue(e.target.value) } }),
|
|
184
|
+
ce('button', { className: 'kf_btn primary', disabled: isBusy, onClick: save }, isBusy ? '保存…' : '保存'),
|
|
185
|
+
ce('button', { className: 'kf_btn', onClick: function () { setEditing(false); setValue(''); setErr('') } }, '取消'),
|
|
186
|
+
)
|
|
187
|
+
: ce('div', null,
|
|
188
|
+
ce('span', { className: 'kf_hiddenBadge' }, '[hidden] 已保存(点击「更新」可改)'),
|
|
189
|
+
ce('button', { className: 'kf_btn', onClick: function () { setEditing(true) } }, '更新'),
|
|
190
|
+
),
|
|
191
|
+
ce('label', { className: 'kf_nextRow' },
|
|
192
|
+
'失败后切换到',
|
|
193
|
+
ce('select', { className: 'kf_nextSel', value: k.nextRef || '', onChange: function (e) { api('/keys', { method: 'PATCH', body: { provider: props.provider, ref: k.ref, nextRef: e.target.value } }).then(function () { props.onChanged() }).catch(function () {}) } },
|
|
194
|
+
ce('option', { value: '' }, '自动(顺序循环)'),
|
|
195
|
+
(props.allRefs || []).filter(function (r) { return r !== k.ref }).map(function (r) { return ce('option', { key: r, value: r }, r.slice(-12)) }),
|
|
196
|
+
),
|
|
197
|
+
),
|
|
198
|
+
props.currentRef === k.ref ? null : ce('button', { className: 'kf_btn primary', onClick: function () {
|
|
199
|
+
api('/keys', { method: 'PATCH', body: { provider: props.provider, ref: k.ref, useKeyRef: k.ref } })
|
|
200
|
+
.then(function () { props.onChanged() }).catch(function (e) { setErr((e && e.message) || String(e)) })
|
|
201
|
+
} }, '设为当前'),
|
|
202
|
+
isEnv ? null : ce('button', { className: 'kf_btn danger', onClick: function () { props.onDelete() } }, '删除'),
|
|
203
|
+
isEnv || !k.lastErrorMsg ? null : ce('span', { className: 'kf_err', title: k.lastErrorMsg }, '上次错误: ' + k.lastErrorMsg.slice(0, 60)),
|
|
204
|
+
err ? ce('span', { className: 'kf_err' }, err) : null,
|
|
205
|
+
)
|
|
206
|
+
}
|
|
88
207
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
208
|
+
// ── 单个 provider 池卡片 ──
|
|
209
|
+
function PoolCard(props) {
|
|
210
|
+
var pool = props.pool
|
|
211
|
+
var busySet = React.useState(false)
|
|
212
|
+
var busy = busySet[0]
|
|
213
|
+
var setBusy = busySet[1]
|
|
214
|
+
var errSet = React.useState('')
|
|
215
|
+
var err = errSet[0]
|
|
216
|
+
var setErr = errSet[1]
|
|
217
|
+
var okSet = React.useState('')
|
|
218
|
+
var ok = okSet[0]
|
|
219
|
+
var setOk = okSet[1]
|
|
220
|
+
var newKey = React.useState('')
|
|
221
|
+
var newKeyVal = newKey[0]
|
|
222
|
+
var setNewKey = newKey[1]
|
|
223
|
+
var addBusy = React.useState(false)
|
|
224
|
+
var isAddBusy = addBusy[0]
|
|
225
|
+
var setAddBusy = addBusy[1]
|
|
97
226
|
|
|
98
|
-
|
|
99
|
-
|
|
227
|
+
function toggle() {
|
|
228
|
+
if (busy) return
|
|
229
|
+
setBusy(true); setErr('')
|
|
230
|
+
api('/pools', { method: 'POST', body: { provider: pool.provider, enabled: !pool.enabled, env: pool.env, cooldownMs: pool.cooldownMs, rotateOn: pool.rotateOn } })
|
|
231
|
+
.then(function (r) { setBusy(false); if (!r.ok) { setErr((r.body && r.body.error) || ('HTTP ' + r.status)); return } props.onChanged() })
|
|
232
|
+
.catch(function (e) { setBusy(false); setErr(String(e && e.message || e)) })
|
|
233
|
+
}
|
|
234
|
+
function addKey() {
|
|
235
|
+
if (!newKeyVal) { setErr('请粘贴 key'); return }
|
|
236
|
+
setAddBusy(true); setErr('')
|
|
237
|
+
api('/keys', { method: 'POST', body: { provider: pool.provider, label: '', value: newKeyVal } })
|
|
238
|
+
.then(function (r) {
|
|
239
|
+
setAddBusy(false)
|
|
240
|
+
if (!r.ok) { setErr((r.body && r.body.error) || ('HTTP ' + r.status)); return }
|
|
241
|
+
setNewKey(''); props.onChanged()
|
|
242
|
+
})
|
|
243
|
+
.catch(function (e) { setAddBusy(false); setErr(String(e && e.message || e)) })
|
|
100
244
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
245
|
+
function deleteKey(ref) {
|
|
246
|
+
if (!confirm('确认删除这个 key?')) return
|
|
247
|
+
api('/keys?provider=' + encodeURIComponent(pool.provider) + '&ref=' + encodeURIComponent(ref), { method: 'DELETE' })
|
|
248
|
+
.then(function (r) { if (r.ok) props.onChanged(); else setErr((r.body && r.body.error) || ('HTTP ' + r.status)) })
|
|
249
|
+
.catch(function (e) { setErr(String(e && e.message || e)) })
|
|
106
250
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
251
|
+
function setUseKey(ref) {
|
|
252
|
+
api('/pools', { method: 'POST', body: { provider: pool.provider, enabled: pool.enabled, env: pool.env, cooldownMs: pool.cooldownMs, rotateOn: pool.rotateOn, useKeyRef: ref } })
|
|
253
|
+
.then(function (r) { if (!r.ok) { setErr((r.body && r.body.error) || ('HTTP ' + r.status)); return } props.onChanged() })
|
|
254
|
+
.catch(function (e) { setErr(String(e && e.message || e)) })
|
|
255
|
+
}
|
|
256
|
+
function deletePool() {
|
|
257
|
+
if (!confirm('确认删除「' + pool.provider + '」池(会清掉所有 key)?')) return
|
|
258
|
+
api('/pools?provider=' + encodeURIComponent(pool.provider), { method: 'DELETE' })
|
|
259
|
+
.then(function (r) { if (r.ok) props.onChanged(); else setErr((r.body && r.body.error) || ('HTTP ' + r.status)) })
|
|
260
|
+
.catch(function (e) { setErr(String(e && e.message || e)) })
|
|
261
|
+
}
|
|
262
|
+
return ce('div', { className: 'kf_card' },
|
|
263
|
+
ce('div', { className: 'kf_cardHead' },
|
|
264
|
+
ce('div', { className: 'kf_name' },
|
|
265
|
+
pool.displayName || pool.provider,
|
|
266
|
+
ce('span', { className: 'kf_badge ' + (pool.enabled ? 'ok' : 'muted') }, pool.enabled ? '启用' : '停用'),
|
|
267
|
+
ce('span', { className: 'kf_badge muted' }, 'env: ' + pool.env),
|
|
268
|
+
),
|
|
269
|
+
ce('div', { style: { flex: 1 } }),
|
|
270
|
+
ce('div', { className: 'kf_switch' + (pool.enabled ? ' on' : ''), onClick: toggle, title: pool.enabled ? '停用' : '启用' }),
|
|
271
|
+
ce('button', { className: 'kf_btn danger', disabled: busy, onClick: deletePool }, '删除池'),
|
|
272
|
+
),
|
|
273
|
+
ce('div', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary,#9aa)', display: 'flex', gap: 14, flexWrap: 'wrap', alignItems: 'center' } },
|
|
274
|
+
ce('span', null, 'key 数: ' + (Array.isArray(pool.keys) ? pool.keys.length : 0)),
|
|
275
|
+
pool.currentRef ? ce('span', { style: { color: 'var(--dsw-alias-brand-primary,#7aa2ff)' } }, '当前用: ' + pool.currentRef.slice(-12)) : null,
|
|
276
|
+
ce('span', null, 'cooldown: ' + (pool.cooldownMs || 0) + ' ms'),
|
|
277
|
+
ce('label', { style: { display: 'flex', alignItems: 'center', gap: 6 } },
|
|
278
|
+
ce('span', null, '当前使用:'),
|
|
279
|
+
ce('select', { value: pool.useKeyRef || '_auto', onChange: function (e) { var v = e.target.value; if (v === '_auto') setUseKey(''); else setUseKey(v) } },
|
|
280
|
+
ce('option', { value: '_auto' }, '自动轮换'),
|
|
281
|
+
(Array.isArray(pool.keys) ? pool.keys : []).map(function (k) {
|
|
282
|
+
return ce('option', { value: k.ref, key: k.ref }, (k.ref === pool.env ? '[环境] ' : '') + (k.label || k.ref.slice(-8)))
|
|
283
|
+
}),
|
|
284
|
+
),
|
|
285
|
+
),
|
|
286
|
+
),
|
|
287
|
+
ce('div', { className: 'kf_chips' },
|
|
288
|
+
ce('span', { style: { fontSize: 11.5, color: 'var(--dsw-alias-label-secondary,#9aa)', alignSelf: 'center' } }, '轮换触发码:'),
|
|
289
|
+
['QUOTA_EXCEEDED', 'AUTH', 'RATE_LIMIT'].map(function (code) {
|
|
290
|
+
return ce('span', { key: code, className: 'kf_chip' + ((pool.rotateOn || []).includes(code) ? ' active' : '') }, code)
|
|
291
|
+
}),
|
|
292
|
+
),
|
|
293
|
+
((Array.isArray(pool.keys) ? pool.keys : [])).length === 0
|
|
294
|
+
? ce('div', { className: 'kf_empty' }, '还没有 key。在下方粘贴第一个。')
|
|
295
|
+
: ce('div', { className: 'kf_keys' }, (Array.isArray(pool.keys) ? pool.keys : []).map(function (k) {
|
|
296
|
+
return ce(KeyRow, { key: k.ref, entry: k, provider: pool.provider, allRefs: (Array.isArray(pool.keys) ? pool.keys : []).map(function (x) { return x.ref }), onDelete: (k.ref === pool.env) ? null : function () { deleteKey(k.ref) }, onChanged: props.onChanged, source: (k.ref === pool.env) ? 'env' : 'user', currentRef: pool.currentRef || '' })
|
|
297
|
+
}),
|
|
298
|
+
),
|
|
299
|
+
ce('div', { style: { display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' } },
|
|
300
|
+
ce('input', { className: 'kf_inputSecret', style: { flex: 1, minWidth: 220 }, type: 'text', autoComplete: 'off', spellCheck: false, placeholder: '粘贴新 API key 后点添加(只写,保存后不回读)', value: newKeyVal, onChange: function (e) { setNewKey(e.target.value) } }),
|
|
301
|
+
ce('button', { className: 'kf_btn primary', disabled: isAddBusy, onClick: addKey }, isAddBusy ? '添加中…' : '添加 key'),
|
|
302
|
+
),
|
|
303
|
+
err ? ce('div', { className: 'kf_err' }, err) : null,
|
|
304
|
+
ok ? ce('div', { className: 'kf_ok' }, ok) : null,
|
|
121
305
|
)
|
|
122
306
|
}
|
|
123
307
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
308
|
+
// ── 顶级 Tab ──
|
|
309
|
+
function FallbackTab() {
|
|
310
|
+
var poolsSet = React.useState(null)
|
|
311
|
+
var pools = poolsSet[0]
|
|
312
|
+
var setPools = poolsSet[1]
|
|
313
|
+
var dirSet = React.useState(null)
|
|
314
|
+
var dir = dirSet[0]
|
|
315
|
+
var setDir = dirSet[1]
|
|
316
|
+
var errSet = React.useState('')
|
|
317
|
+
var err = errSet[0]
|
|
318
|
+
var setErr = errSet[1]
|
|
319
|
+
var pickSet = React.useState('')
|
|
320
|
+
var pick = pickSet[0]
|
|
321
|
+
var setPick = pickSet[1]
|
|
322
|
+
var addingSet = React.useState(false)
|
|
323
|
+
var adding = addingSet[0]
|
|
324
|
+
var setAdding = addingSet[1]
|
|
325
|
+
|
|
326
|
+
function refresh() {
|
|
327
|
+
Promise.all([api('/pools'), api('/providers')])
|
|
328
|
+
.then(function (rs) {
|
|
329
|
+
if (!rs[0].ok) { setErr((rs[0].body && rs[0].body.error) || ('HTTP ' + rs[0].status)); return }
|
|
330
|
+
setPools(rs[0].body.pools || [])
|
|
331
|
+
var d = rs[1] && rs[1].body || {}; setDir((d && typeof d === 'object') ? d : {})
|
|
332
|
+
setErr('')
|
|
333
|
+
})
|
|
334
|
+
.catch(function (e) { setErr(String(e && e.message || e)) })
|
|
335
|
+
}
|
|
336
|
+
React.useEffect(function () { refresh() }, [])
|
|
337
|
+
|
|
338
|
+
function addPool() {
|
|
339
|
+
if (!pick) { setErr('请选择 provider'); return }
|
|
340
|
+
api('/pools', { method: 'POST', body: { provider: pick, enabled: true } })
|
|
341
|
+
.then(function (r) {
|
|
342
|
+
if (!r.ok) { setErr((r.body && r.body.error) || ('HTTP ' + r.status)); return }
|
|
343
|
+
setPick(''); setAdding(false); refresh()
|
|
344
|
+
})
|
|
345
|
+
.catch(function (e) { setErr(String(e && e.message || e)) })
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
var existing = new Set((pools || []).map(function (p) { return p.provider }))
|
|
349
|
+
var all = []
|
|
350
|
+
if (dir) {
|
|
351
|
+
var dl = Array.isArray(dir.live || dir.providers) ? dir.live : []
|
|
352
|
+
var dd = Array.isArray(dir.dormant) ? dir.dormant : []
|
|
353
|
+
dl.forEach(function (p) { if (!existing.has(p.id)) all.push({ id: p.id, name: p.name, hint: '运行中' }) })
|
|
354
|
+
dd.forEach(function (p) { if (!existing.has(p.id)) all.push({ id: p.id, name: p.name, hint: '休眠' }) })
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return ce('div', { className: 'kf_root' },
|
|
358
|
+
ce('div', { className: 'kf_head' },
|
|
359
|
+
ce('div', null,
|
|
360
|
+
ce('h2', { className: 'kf_title' }, 'API Key 回退'),
|
|
361
|
+
ce('p', { className: 'kf_sub' }, '为 LLM provider 维护多个 API key,请求失败时自动换下一个 key 重试。key 只写入本机 .credentials.yaml,不进设置文件。'),
|
|
362
|
+
),
|
|
363
|
+
(pools || []).length === 0 ? ce('span', { className: 'kf_badge warn' }, '尚未启用') : ce('span', { className: 'kf_badge ok' }, (pools || []).length + ' 个池'),
|
|
364
|
+
),
|
|
365
|
+
ce('div', { className: 'kf_addrow' },
|
|
366
|
+
adding
|
|
367
|
+
? ce('div', null,
|
|
368
|
+
ce('label', null,
|
|
369
|
+
ce('span', null, '选择 LLM provider'),
|
|
370
|
+
ce('select', { value: pick, onChange: function (e) { setPick(e.target.value) } },
|
|
371
|
+
ce('option', { value: '' }, '— 选择 —'),
|
|
372
|
+
all.map(function (p) { return ce('option', { value: p.id, key: p.id }, p.name + '(' + p.id + ' · ' + p.hint + ')') }),
|
|
373
|
+
),
|
|
374
|
+
),
|
|
375
|
+
ce('button', { className: 'kf_btn primary', onClick: addPool }, '启用'),
|
|
376
|
+
ce('button', { className: 'kf_btn', onClick: function () { setAdding(false); setPick('') } }, '取消'),
|
|
377
|
+
)
|
|
378
|
+
: ce('button', { className: 'kf_btn primary', onClick: function () { setAdding(true) } }, '+ 启用新 provider 池'),
|
|
379
|
+
),
|
|
380
|
+
err ? ce('div', { className: 'kf_err' }, err) : null,
|
|
381
|
+
!pools
|
|
382
|
+
? ce('div', { className: 'kf_empty' }, ce('span', { className: 'kf_spinner' }), '加载中…')
|
|
383
|
+
: pools.length === 0 ? ce('div', { className: 'kf_empty' }, '还没有任何池,点上方「启用新 provider 池」开始。')
|
|
384
|
+
: pools.map(function (p) { return ce(PoolCard, { key: p.provider, pool: p, onChanged: refresh }) }),
|
|
385
|
+
)
|
|
129
386
|
}
|
|
130
387
|
|
|
131
388
|
function apply(ctx) {
|
|
132
389
|
var slots = ctx.get('slots')
|
|
133
390
|
if (slots === undefined) return
|
|
134
|
-
// 1) 独立设置页:左侧菜单出现“API Key 回退”项(永远可见,不依赖 served)。
|
|
135
|
-
// 与 dshmarket/connector 的 settings.section 同构。
|
|
136
391
|
slots.inject('settings.section', function () {
|
|
137
392
|
return slots.register({
|
|
138
393
|
name: 'settings.section',
|
|
139
394
|
id: 'key-fallback',
|
|
140
395
|
order: 62,
|
|
141
396
|
label: 'API Key 回退',
|
|
142
|
-
},
|
|
397
|
+
}, FallbackTab)
|
|
143
398
|
})
|
|
144
|
-
// 2) 插件配置标签下的状态卡(served 时显示;上述独立页的补充)。
|
|
145
|
-
// 我们的 host 已用 installSettingsSection 注册 key-fallback 命名空间使它可 serve;
|
|
146
|
-
// 这里复用同一 KeyFallbackCard。
|
|
147
|
-
var scopedInject = ctx.inject
|
|
148
|
-
if (typeof scopedInject === 'function') {
|
|
149
|
-
scopedInject(['settingsScope'], function (scoped) {
|
|
150
|
-
var scopedSlots = scoped.slots
|
|
151
|
-
if (!scopedSlots) return
|
|
152
|
-
scopedSlots.inject('settings.plugin.item', function () {
|
|
153
|
-
return scopedSlots.register({
|
|
154
|
-
name: 'settings.plugin.item',
|
|
155
|
-
key: 'key-fallback',
|
|
156
|
-
}, KeyFallbackCard)
|
|
157
|
-
})
|
|
158
|
-
})
|
|
159
|
-
}
|
|
160
399
|
}
|
|
161
400
|
|
|
162
401
|
exports.apply = apply
|
|
163
402
|
exports.inject = ['slots']
|
|
164
403
|
return module.exports
|
|
165
404
|
},
|
|
166
|
-
})
|
|
405
|
+
})
|