@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 CHANGED
@@ -1,40 +1,48 @@
1
1
  # @omdp/dsh-key-fallback
2
2
 
3
- A minimal API-key fallback plugin for DeepSeek Harness (DSH). When a provider request fails with a retryable error, it silently retries the same call with the next key in the pool instead of committing a partial message.
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
- ## How it works
36
+ # 2) restart DSH
26
37
 
27
- - `agent/request`: picks the next live key (round-robin) and writes it into `process.env[<env>]` before the request goes out.
28
- - `agent/request-error`: on a retryable error (`RATE_LIMIT`, `AUTH`, `QUOTA_EXCEEDED`, `TIMEOUT`, `TRANSPORT`, any 4xx/5xx, or a message matching rate/limit/quota/timeout/auth/key…), marks the current key as cooling and returns `{ kind: 'retry' }` so the loop retries and picks the next key.
29
-
30
- ## Boundaries
38
+ # 3) open Settings → API Key 回退
39
+ ```
31
40
 
32
- - The plugin **overrides** `process.env[<env>]` for every provider you list under `keyFallback.providers`. Put your original key in `keys` so it stays in rotation.
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
- Settings → 插件 shows a read-only card with the key count, cursor, and cooling/healthy keys.
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
@@ -1,5 +1,5 @@
1
1
  - insert:
2
2
  - id: key-fallback
3
3
  name: '@omdp/dsh-key-fallback'
4
- inject: [llm, settings, webServer]
4
+ inject: [llm, settings, webServer, credentials]
5
5
  config: {}
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
- // Hand-written in the lazy-CJS bundle protocol (window.__ModuleLoader__.load
5
- // with a factory receiving `require`) — the same zero-dependency stance as the
6
- // host half. The registered id MUST be the package name (@omdp/dsh-key-fallback):
7
- // dsh-client-modules rejects any bundle whose __ModuleLoader__.load id does not
8
- // match the package it was loaded for.
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
- function fetchJson(url, signal) {
18
- return fetch(url, signal ? { signal: signal } : undefined).then(function (r) { return r.json() })
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
- // 重新可见 → 立即刷新并恢复每 2s 轮询,数据最多晚 2s,正常及时。
24
- function KeyFallbackCard() {
25
- var s = React.useState({ loading: true, data: null })
26
- var state = s[0]
27
- var setState = s[1]
28
- var rootRef = React.useRef(null)
29
- var ioRef = React.useRef(null)
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
- function onVisibility() {
67
- pageVisible = !document.hidden
68
- sync()
69
- }
70
-
71
- document.addEventListener('visibilitychange', onVisibility)
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
- refresh()
87
- start()
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
- return function () {
90
- alive = false
91
- stop()
92
- document.removeEventListener('visibilitychange', onVisibility)
93
- if (ioRef.current) { ioRef.current.disconnect(); ioRef.current = null }
94
- ioRef.current = null
95
- }
96
- }, [refresh])
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
- if (state.loading) {
99
- return React.createElement('div', { ref: attachRoot, style: { fontSize: '12px', opacity: 0.6 } }, '加载中…')
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
- var data = state.data || {}
102
- var providers = Object.keys(data)
103
- if (providers.length === 0) {
104
- return React.createElement('p', { ref: attachRoot, style: { fontSize: '12px', opacity: 0.6, margin: 0 } },
105
- '未在 settings.yaml 配置 keyFallback.providers')
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
- return React.createElement('div', { ref: attachRoot, style: { fontSize: '12px', display: 'flex', flexDirection: 'column', gap: '8px' } },
108
- providers.map(function (p) {
109
- var pd = data[p]
110
- return React.createElement('div', { key: p, style: { padding: '6px 0', borderTop: '1px solid var(--dsw-alias-border-l2, #eee)' } },
111
- React.createElement('div', { style: { fontWeight: 600 } }, p + ' (' + pd.env + ')'),
112
- React.createElement('div', { style: { opacity: 0.8 } }, 'key 数: ' + pd.keyCount + ',当前游标: ' + pd.currentIndex),
113
- pd.cooling.length > 0
114
- ? React.createElement('div', { style: { color: '#c62828' } }, '冷却中: ' + pd.cooling.join(', '))
115
- : null,
116
- pd.healthy.length > 0
117
- ? React.createElement('div', { style: { color: '#2e7d32' } }, '健康: ' + pd.healthy.join(', '))
118
- : null,
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
- function FallbackSection() {
125
- // 同一个 KeyFallbackCard 样式,只是放在 section 页面容器中。
126
- return React.createElement('div', { style: { padding: '12px 0' } },
127
- React.createElement('h3', { style: { margin: '0 0 8px 0', fontSize: '14px', fontWeight: 600 } }, 'API Key 回退 — 池状态'),
128
- React.createElement(KeyFallbackCard))
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
- }, FallbackSection)
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
+ })