@gehennawu/dsh-service 0.22.0 → 0.24.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.en.md +3 -1
- package/README.md +3 -1
- package/client.js +224 -23
- package/index.js +577 -23
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -38,7 +38,7 @@ The plugin also appears under **Plugins → Plugin configuration**, with six hos
|
|
|
38
38
|
|
|
39
39
|
- 7-day stacked bar chart of input/output/cache tokens with blue/orange/teal legend
|
|
40
40
|
- Filter by project; hover for exact values
|
|
41
|
-
- Model breakdown as horizontal stacked bars (same legend colors),
|
|
41
|
+
- Model breakdown as horizontal stacked bars (same legend colors) with a "Today / Last 7 days / All time" toggle on the right of the list header (default: last 7 days): Today aggregates only the current day, Last 7 days ranks within the chart's window, and All time covers every date in the index (bounded by session retention); each row keeps `x times · Cache hit x% · Input xM token · Output xM token`
|
|
42
42
|
- Steps whose provider reports no token usage are excluded from the statistics
|
|
43
43
|
- Last-24-hour model/tool error statistics, collapsed by default
|
|
44
44
|
|
|
@@ -48,6 +48,8 @@ The plugin also appears under **Plugins → Plugin configuration**, with six hos
|
|
|
48
48
|
- A quota ring inside the conversation composer follows the provider selected by the current session and shows the tightest budget window as a percentage (green below 80%, amber at or above); clicking opens a panel headed by the provider name, with a bar and used percentage for each window and reset times on their own lines; on narrow viewports (phones) the panel switches to a viewport-centered floating layer — fully visible, scrolling internally when too tall — and rotates or resizing wider flips it back to the anchored placement above the ring
|
|
49
49
|
- A "Show a remote quota entry in the settings left navigation" switch lives in the Quota lookup tab (off by default), mirroring the Restart entry
|
|
50
50
|
- Built-in adaptations: **OpenCode Go** (`{baseURL}/usage`), **Zhipu GLM Coding Plan / zai-coding-cn** (official monitor `quota/limit` endpoint with three windows — 5-hour rolling tokens, weekly tokens, monthly MCP quota; an idle 5-hour window hides its reset time, matching the official console), **OpenRouter** (credits used %), **Kimi/Moonshot** and **SiliconFlow** (CNY balance text); dialects that natively report remaining percentage flip the panel word to "Remaining" and invert the warn threshold; transient network errors retry automatically and the Zhipu dual-domain candidate chain switches automatically; the provider-to-kind mapping lives in `DSH_HOME/dsh-service-quota.json`, and known services are auto-detected from their baseURL (e.g. opencode.ai, bigmodel.cn) with no manual picking; no upstream request is ever made for unadapted or disabled providers; disabling can be done via "Disable" in a card's footer or by writing `"<provider>": null` in the config file (both are equivalent). Zhipu reset cards have no API-key-queryable endpoint yet — you can add any number of them via each provider card's "Add reset card" button on the tab (name plus expiry time, down to the minute), and remove each one independently — the ring panel shows them too (stored in the same config file); expired cards are flagged automatically
|
|
51
|
+
- **CLIProxyAPI accounts (cliproxy)**: queries the **official remaining quota** of each OAuth upstream account inside a CLIProxyAPI (CPA) deployment (Codex 5-hour/weekly windows, per-model quotas for GeminiCLI and Antigravity) — not proxy-key usage. Prerequisites: CPA's `remote-management.secret-key` must be set (the whole management API is unavailable without it), remote access requires `allow-remote-management`, and the management secret goes into the DSH credential store as `CPA_MANAGEMENT_KEY` or an environment variable of the same name — it is independent of the proxy API key, and the plugin never sends the proxy key to the management plane. When you save the "CLIProxyAPI accounts" adaptation, the plugin pins the hostname of that provider's settings baseURL into the config file and afterwards only sends requests to that pinned domain (re-save the adaptation after changing baseURL); a single refresh queries at most 8 accounts (disabled accounts and account types without a supported quota endpoint are skipped automatically), and failures on some accounts do not affect the rest. Note that CPA's management plane has built-in key throttling — 5 wrong attempts ban your IP for 30 minutes — so make sure the secret is correct before testing
|
|
52
|
+
- Credential fill-in form: when an adapted provider shows "credential missing", its card offers an inline **"Set API credential"** form — the credential name comes from a host-derived whitelist per adaptation type (CLIProxyAPI only ever offers `CPA_MANAGEMENT_KEY`/`CLIPROXY_MANAGEMENT_KEY` — these are **alias slots for the same secret; storing one is enough**, discovery takes the first configured value in order; the primary name is marked and the form defaults to a configured slot. Never the proxy key); saving writes the value into the DSH credential provider (the `refs:` section of `$DSH_HOME/.credentials.yaml`, hot-effective without restarts) and then force-refreshes that provider; the form can also clear a stored file-layer credential in one click. If a process environment variable is currently shadowing that name, the host refuses the write (credential-store contract) — change the environment variable itself instead
|
|
51
53
|
- Anti-rate-limit pacing is enforced by the host: successful results are cached for 60 seconds (shared across tabs), failures back off exponentially (30 s doubling, capped at 15 minutes), upstream timeout is 15 s; the panel can set auto query to manual only / 1 / 2 / 5 / 10 minutes (manual only by default), paused automatically while the page is hidden
|
|
52
54
|
- API keys are resolved and used only inside the host process — the browser receives normalized percentages only; data flows through the plugin's own loopback RPC with no webServer routes exposed
|
|
53
55
|
|
package/README.md
CHANGED
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
|
|
39
39
|
- 近 7 天输入/输出/缓存 token 堆叠柱图,蓝/橙/青图例
|
|
40
40
|
- 按项目筛选,鼠标悬停显示精确数值
|
|
41
|
-
-
|
|
41
|
+
- 模型明细为横向堆叠柱形图(沿用主图图例配色),列表头部右侧提供「今日 / 近 7 天 / 累计」切换标签(默认近 7 天):今日只聚合当天,近 7 天按主图同窗口排序,累计覆盖索引内全部日期(受会话持久化留存范围限制);每行附「x次 · 缓存命中 x% · 输入 xM token · 输出 xM token」明细
|
|
42
42
|
- 提供方未上报 token 用量的模型步骤不纳入统计
|
|
43
43
|
- 最近 24 小时模型/工具报错统计,默认折叠
|
|
44
44
|
|
|
@@ -48,6 +48,8 @@
|
|
|
48
48
|
- 对话输入框内一枚额度圆环,跟随当前会话所选模型的供应商,显示最紧预算窗口的已用百分比(<80% 绿色、≥80% 黄色);点击弹出面板——头部标明供应商,各窗口带独立进度条与已用百分比、重置时间单独一行;手机等窄屏上面板自动切换为视口居中的浮层(完整可见、超高时内部滚动),旋转或拖宽窗口即时切回圆环上方锚定
|
|
49
49
|
- 可在「额度查询」标签开启「设置页左列显示入口」开关(默认关闭),开启后在设置页左侧标签列底部显示「额度查询」快捷入口(与「重启」入口同模式)
|
|
50
50
|
- 内置适配:**OpenCode Go**(`{baseURL}/usage`)、**智谱 GLM Coding Plan / zai-coding-cn**(官方监控端点 `quota/limit`,含 5 小时滚动 Token、每周 Token、MCP 月度配额三个窗口,5 小时窗口空闲时与官网一致地不显示重置时间)、**OpenRouter**(credits 已用%)、**Kimi/Moonshot** 与 **硅基流动**(人民币余额);原生报「剩余百分比」的方言会自动把面板头部切换为「剩余」并把预警阈值反向;上游瞬时网络错误自动重试,智谱双域候选链自动切换;供应商与适配类型的对应关系保存在 `DSH_HOME/dsh-service-quota.json`,已知服务商按 baseURL 自动识别适配(如 opencode.ai、bigmodel.cn),无需手选;未适配或已停用的供应商不会被发起任何上游请求,停用可在卡片脚部选「停用查询」或在配置文件写 `"<provider>": null`(两者等价)。智谱的重置卡暂无 API Key 可查的接口,可在「额度查询」标签内点各供应商卡片的「添加重置卡」填写名称与到期时间(可精确到分钟),可连续添加多条、每条独立移除;圆环面板同步显示(数据存入同一配置文件),过期自动标注
|
|
51
|
+
- **CLIProxyAPI 账号额度(cliproxy)**:查询 CLIProxyAPI(CPA)部署内各 OAuth 上游账号的**官方剩余额度**(Codex 的 5 小时/本周窗口、GeminiCLI 与 Antigravity 的各模型配额),而不是代理 key 的用量。前置条件:CPA 配置 `remote-management.secret-key` 非空(为空时管理面整体不可用)、远程访问需开启 `allow-remote-management`;管理密钥放入 DSH 凭据库 `CPA_MANAGEMENT_KEY` 或同名环境变量——它与代理用的 API key 相互独立,插件绝不会把代理 key 发往管理面。在「手动适配」中选择「CLIProxyAPI 账号额度」保存时,插件会把该供应商 settings baseURL 的域名钉进配置文件,此后外呼只发往这个域(修改 baseURL 后需重新保存适配);单次刷新最多查询 8 个账号(已禁用或无受支持配额端点的账号自动跳过),部分账号失败不影响其余账号的结果。请注意 CPA 管理面对错误密钥有内建封禁(连续错 5 次封 IP 30 分钟),务必填对再测
|
|
52
|
+
- 凭据填写窗口:已适配供应商因缺凭据显示「凭据未配置」时,卡片上会出现**「填写 API 密钥」**内联表单——凭据名称由宿主按适配类型派生白名单(CLIProxyAPI 只会出现 `CPA_MANAGEMENT_KEY`/`CLIPROXY_MANAGEMENT_KEY`——两者是同一密钥的**别名存放槽,二选一即可**,查找按顺序取第一个已配置的值;主名带「主名」标记且表单默认选中已配置的槽位。绝不混入代理 key),输入密钥值保存即写入 DSH 凭据提供方(`$DSH_HOME/.credentials.yaml` 的 `refs:` 分节,热生效无需重启),随后自动强制重拉该供应商;表单里还能一键清除已存的文件层凭据。进程环境变量正在遮蔽该名字时宿主会拒绝写入(凭据库契约),此时请改环境变量本身
|
|
51
53
|
- 防风控节律由宿主统一控制:成功结果缓存 60 秒(多标签共享)、失败指数退避(30 秒起 ×2、封顶 15 分钟)、上游超时 15 秒;面板可把自动查询调成仅手动 / 1 / 2 / 5 / 10 分钟(默认仅手动),页面不可见时自动暂停
|
|
52
54
|
- API key 只在宿主进程内解析使用,浏览器只会收到归一化后的百分比数字;数据走插件自有 loopback RPC,不在 webServer 上暴露任何路由
|
|
53
55
|
|
package/client.js
CHANGED
|
@@ -313,8 +313,15 @@ window.__ModuleLoader__.load({
|
|
|
313
313
|
'usage.axis': 'token 纵轴',
|
|
314
314
|
'usage.models.more': '展开其余 {count} 个模型',
|
|
315
315
|
'usage.models.less': '收起模型列表',
|
|
316
|
-
'usage.
|
|
317
|
-
'usage.
|
|
316
|
+
'usage.modelScope.today': '今日',
|
|
317
|
+
'usage.modelScope.week': '近 7 天',
|
|
318
|
+
'usage.modelScope.all': '累计',
|
|
319
|
+
'usage.modelSortHint.today': '按今日 token 从多到少排列',
|
|
320
|
+
'usage.modelSortHint.week': '按近 7 天 token 从多到少排列',
|
|
321
|
+
'usage.modelSortHint.all': '按累计 token 从多到少排列',
|
|
322
|
+
'usage.modelBar.today': '{model}:今日 {total} token',
|
|
323
|
+
'usage.modelBar.week': '{model}:近 7 天 {total} token',
|
|
324
|
+
'usage.modelBar.all': '{model}:累计 {total} token',
|
|
318
325
|
'usage.refresh': '刷新统计',
|
|
319
326
|
'usage.refreshing': '刷新中…',
|
|
320
327
|
'usage.empty': '尚未建立使用统计索引。点击刷新统计开始只读建立索引。',
|
|
@@ -374,6 +381,10 @@ window.__ModuleLoader__.load({
|
|
|
374
381
|
'quota.window.balance': '余额',
|
|
375
382
|
'quota.window.weekly': '本周',
|
|
376
383
|
'quota.window.monthly': '本月',
|
|
384
|
+
'quota.window.codex-5h': 'Codex 5 小时窗',
|
|
385
|
+
'quota.window.codex-day': 'Codex 日窗口',
|
|
386
|
+
'quota.window.codex-week': 'Codex 本周窗',
|
|
387
|
+
'quota.window.codex-month': 'Codex 本月窗口',
|
|
377
388
|
'quota.panel.title': '额度用量',
|
|
378
389
|
'quota.panel.used': '已用',
|
|
379
390
|
'quota.panel.remaining': '剩余',
|
|
@@ -399,6 +410,7 @@ window.__ModuleLoader__.load({
|
|
|
399
410
|
'quota.kind.openrouter': 'OpenRouter',
|
|
400
411
|
'quota.kind.kimi': 'Kimi / Moonshot',
|
|
401
412
|
'quota.kind.siliconflow': '硅基流动',
|
|
413
|
+
'quota.kind.cliproxy': 'CLIProxyAPI 账号额度',
|
|
402
414
|
'quota.kindAuto': '自动识别',
|
|
403
415
|
'quota.noAdapted': '暂无已适配的供应商,可在下方手动适配',
|
|
404
416
|
'quota.disable': '停用查询',
|
|
@@ -416,6 +428,22 @@ window.__ModuleLoader__.load({
|
|
|
416
428
|
'quota.error.network-transient': '网络不稳定(已自动重试)',
|
|
417
429
|
'quota.error.timeout': '请求超时',
|
|
418
430
|
'quota.error.bad-payload': '响应格式异常',
|
|
431
|
+
'quota.error.mgmt-disabled': 'CLIProxyAPI 管理面未启用(未设置 remote-management secret-key)',
|
|
432
|
+
'quota.error.host-not-pinned': 'baseURL 与适配时记录的域名不一致,请重新保存适配',
|
|
433
|
+
'quota.error.upstream-status': '上游官方接口返回错误状态',
|
|
434
|
+
'quota.credential.edit': '填写 API 密钥',
|
|
435
|
+
'quota.credential.nameLabel': '凭据名称',
|
|
436
|
+
'quota.credential.valueLabel': '密钥值',
|
|
437
|
+
'quota.credential.save': '保存',
|
|
438
|
+
'quota.credential.primary': '主名(别名存一即可)',
|
|
439
|
+
'quota.credential.clear': '清除已存',
|
|
440
|
+
'quota.credential.clearConfirm': '再次点击清除',
|
|
441
|
+
'quota.credential.configured': '已配置',
|
|
442
|
+
'quota.credential.notConfigured': '未配置',
|
|
443
|
+
'quota.credential.saveFailed': '凭据保存失败:{error}',
|
|
444
|
+
'quota.credential.unknown-hint': '未知凭据名',
|
|
445
|
+
'quota.credential.invalid-value': '密钥值不能为空',
|
|
446
|
+
'quota.credential.credentials-unavailable': '凭据服务不可用',
|
|
419
447
|
'quota.error.unknown': '未知错误',
|
|
420
448
|
'quota.unadapted': '该供应商未适配,请先在下方选择类型',
|
|
421
449
|
'quota.unit.day': '{count} 天',
|
|
@@ -727,8 +755,15 @@ window.__ModuleLoader__.load({
|
|
|
727
755
|
'usage.axis': 'token vertical axis',
|
|
728
756
|
'usage.models.more': 'Show {count} more models',
|
|
729
757
|
'usage.models.less': 'Collapse model list',
|
|
730
|
-
'usage.
|
|
731
|
-
'usage.
|
|
758
|
+
'usage.modelScope.today': 'Today',
|
|
759
|
+
'usage.modelScope.week': 'Last 7 days',
|
|
760
|
+
'usage.modelScope.all': 'All time',
|
|
761
|
+
'usage.modelSortHint.today': 'Sorted by tokens used today, largest first',
|
|
762
|
+
'usage.modelSortHint.week': 'Sorted by tokens in the last 7 days, largest first',
|
|
763
|
+
'usage.modelSortHint.all': 'Sorted by all-time tokens, largest first',
|
|
764
|
+
'usage.modelBar.today': '{model}: {total} tokens today',
|
|
765
|
+
'usage.modelBar.week': '{model}: {total} tokens in the last 7 days',
|
|
766
|
+
'usage.modelBar.all': '{model}: {total} tokens in total',
|
|
732
767
|
'usage.refresh': 'Refresh usage',
|
|
733
768
|
'usage.refreshing': 'Refreshing…',
|
|
734
769
|
'usage.empty': 'No usage index yet. Select Refresh usage to build the read-only index.',
|
|
@@ -788,6 +823,10 @@ window.__ModuleLoader__.load({
|
|
|
788
823
|
'quota.window.balance': 'Balance',
|
|
789
824
|
'quota.window.weekly': 'This week',
|
|
790
825
|
'quota.window.monthly': 'This month',
|
|
826
|
+
'quota.window.codex-5h': 'Codex 5-hour window',
|
|
827
|
+
'quota.window.codex-day': 'Codex daily window',
|
|
828
|
+
'quota.window.codex-week': 'Codex weekly window',
|
|
829
|
+
'quota.window.codex-month': 'Codex monthly window',
|
|
791
830
|
'quota.panel.title': 'Quota usage',
|
|
792
831
|
'quota.panel.used': 'Used',
|
|
793
832
|
'quota.panel.remaining': 'Remaining',
|
|
@@ -813,6 +852,7 @@ window.__ModuleLoader__.load({
|
|
|
813
852
|
'quota.kind.openrouter': 'OpenRouter',
|
|
814
853
|
'quota.kind.kimi': 'Kimi / Moonshot',
|
|
815
854
|
'quota.kind.siliconflow': 'SiliconFlow',
|
|
855
|
+
'quota.kind.cliproxy': 'CLIProxyAPI accounts',
|
|
816
856
|
'quota.kindAuto': 'Auto-detected',
|
|
817
857
|
'quota.noAdapted': 'No adapted providers yet — adapt manually below',
|
|
818
858
|
'quota.disable': 'Disable',
|
|
@@ -830,6 +870,22 @@ window.__ModuleLoader__.load({
|
|
|
830
870
|
'quota.error.network-transient': 'Unstable network (auto-retried)',
|
|
831
871
|
'quota.error.timeout': 'Request timed out',
|
|
832
872
|
'quota.error.bad-payload': 'Unexpected response format',
|
|
873
|
+
'quota.error.mgmt-disabled': 'CLIProxyAPI management API disabled (remote-management secret-key not set)',
|
|
874
|
+
'quota.error.host-not-pinned': 'baseURL differs from the domain recorded when adapting; save the adapter again',
|
|
875
|
+
'quota.error.upstream-status': 'Upstream official API returned an error status',
|
|
876
|
+
'quota.credential.edit': 'Set API credential',
|
|
877
|
+
'quota.credential.nameLabel': 'Credential name',
|
|
878
|
+
'quota.credential.valueLabel': 'Secret value',
|
|
879
|
+
'quota.credential.save': 'Save',
|
|
880
|
+
'quota.credential.primary': 'primary (one alias is enough)',
|
|
881
|
+
'quota.credential.clear': 'Clear stored',
|
|
882
|
+
'quota.credential.clearConfirm': 'Click again to clear',
|
|
883
|
+
'quota.credential.configured': 'configured',
|
|
884
|
+
'quota.credential.notConfigured': 'not set',
|
|
885
|
+
'quota.credential.saveFailed': 'Failed to save credential: {error}',
|
|
886
|
+
'quota.credential.unknown-hint': 'Unknown credential name',
|
|
887
|
+
'quota.credential.invalid-value': 'Secret value must not be empty',
|
|
888
|
+
'quota.credential.credentials-unavailable': 'Credential service unavailable',
|
|
833
889
|
'quota.error.unknown': 'Unknown error',
|
|
834
890
|
'quota.unadapted': 'Not adapted: pick a provider kind below first',
|
|
835
891
|
'quota.unit.day': '{count} d',
|
|
@@ -1615,7 +1671,7 @@ window.__ModuleLoader__.load({
|
|
|
1615
1671
|
const QUOTA_POLL_KEY = 'dsh-service-quota-poll'
|
|
1616
1672
|
const QUOTA_POLL_CHOICES = [0, 1, 2, 5, 10]
|
|
1617
1673
|
// 适配类型下拉选项:与宿主 QUOTA_KINDS 白名单保持一致(词典键 quota.kind.<kind>)。
|
|
1618
|
-
const QUOTA_KIND_OPTIONS = ['opencode-go', 'zai-coding-cn', 'openrouter', 'kimi', 'siliconflow']
|
|
1674
|
+
const QUOTA_KIND_OPTIONS = ['opencode-go', 'zai-coding-cn', 'openrouter', 'kimi', 'siliconflow', 'cliproxy']
|
|
1619
1675
|
function readQuotaPollMinutes() {
|
|
1620
1676
|
try {
|
|
1621
1677
|
const raw = Number.parseInt(localStorage.getItem(QUOTA_POLL_KEY), 10)
|
|
@@ -1777,6 +1833,25 @@ window.__ModuleLoader__.load({
|
|
|
1777
1833
|
const byType = translate(`quota.window.${prefix}`)
|
|
1778
1834
|
return byType === `quota.window.${prefix}` ? id : byType
|
|
1779
1835
|
}
|
|
1836
|
+
/** 窗口展示文案(v0.24 cliproxy 起支持两段式):宿主下发 label(账号标识,纯数据)与
|
|
1837
|
+
* kindKey(稳定代码),本地化拼接「<账号> · <窗口名>」在客户端完成——宿主不拼用户可见句子
|
|
1838
|
+
* (双语教义)。无 kindKey 的旧窗口走 quotaWindowLabel 原解析链;kindKey 未收录时裸用
|
|
1839
|
+
* 模型名兜底(比拼凑 id 可读)。 */
|
|
1840
|
+
function quotaWindowDisplayLabel(window, translate) {
|
|
1841
|
+
const hasKindKey = typeof window.kindKey === 'string' && window.kindKey !== ''
|
|
1842
|
+
const kindSource = hasKindKey ? window.kindKey : window.id
|
|
1843
|
+
let kindText = translate(`quota.window.${kindSource}`)
|
|
1844
|
+
if (kindText === `quota.window.${kindSource}`) {
|
|
1845
|
+
if (hasKindKey) {
|
|
1846
|
+
const byId = translate(`quota.window.${window.id}`)
|
|
1847
|
+
kindText = byId !== `quota.window.${window.id}` ? byId : kindSource
|
|
1848
|
+
} else {
|
|
1849
|
+
kindText = quotaWindowLabel(window.id, translate)
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
const account = typeof window.label === 'string' ? window.label.trim() : ''
|
|
1853
|
+
return account !== '' ? `${account} · ${kindText}` : kindText
|
|
1854
|
+
}
|
|
1780
1855
|
function humanizeDuration(ms, translate) {
|
|
1781
1856
|
// 官网口径:取最显着的两个非零单位(28 天 22 小时 / 4 小时 1 分钟),不足 1 分钟显示 0 分钟。
|
|
1782
1857
|
const totalMinutes = Math.max(0, Math.floor(ms / 60000))
|
|
@@ -1846,16 +1921,19 @@ window.__ModuleLoader__.load({
|
|
|
1846
1921
|
* testid 约定:面板 quota-text|quota-window-bar|quota-reset-<id>;卡片 quota-card-window|bar|text|reset-<provider>-<id>。 */
|
|
1847
1922
|
function renderQuotaWindowRow(window, translate, provider) {
|
|
1848
1923
|
const inCard = provider !== null && provider !== undefined
|
|
1924
|
+
// 标签可能很长(cliproxy 是「账号 · 窗口名」两段式):标签溢出省略,百分比整体不换行、不被挤压。
|
|
1925
|
+
const labelStyle = { color: 'var(--dsw-alias-label-secondary)', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }
|
|
1926
|
+
const valueStyle = { color: 'var(--dsw-alias-label-primary)', fontWeight: 500, fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap', flexShrink: 0 }
|
|
1849
1927
|
if (typeof window.text === 'string') {
|
|
1850
1928
|
return React.createElement('div', {
|
|
1851
1929
|
key: window.id,
|
|
1852
1930
|
...(inCard ? { 'data-testid': `quota-card-window-${provider}-${window.id}` } : {}),
|
|
1853
1931
|
style: { display: 'flex', justifyContent: 'space-between', gap: '10px', fontSize: '12px', lineHeight: '18px' },
|
|
1854
1932
|
},
|
|
1855
|
-
React.createElement('span', { style:
|
|
1933
|
+
React.createElement('span', { style: labelStyle }, quotaWindowDisplayLabel(window, translate)),
|
|
1856
1934
|
React.createElement('span', {
|
|
1857
1935
|
'data-testid': inCard ? `quota-card-text-${provider}-${window.id}` : `quota-text-${window.id}`,
|
|
1858
|
-
style:
|
|
1936
|
+
style: valueStyle,
|
|
1859
1937
|
}, window.text))
|
|
1860
1938
|
}
|
|
1861
1939
|
let resetNode = null
|
|
@@ -1873,8 +1951,8 @@ window.__ModuleLoader__.load({
|
|
|
1873
1951
|
...(inCard ? { 'data-testid': `quota-card-window-${provider}-${window.id}` } : {}),
|
|
1874
1952
|
},
|
|
1875
1953
|
React.createElement('div', { 'data-value': window.percent, style: { display: 'flex', justifyContent: 'space-between', gap: '10px', fontSize: '12px', lineHeight: '18px' } },
|
|
1876
|
-
React.createElement('span', { style:
|
|
1877
|
-
React.createElement('span', { style:
|
|
1954
|
+
React.createElement('span', { style: labelStyle }, quotaWindowDisplayLabel(window, translate)),
|
|
1955
|
+
React.createElement('span', { style: valueStyle }, `${window.percent}%`)),
|
|
1878
1956
|
quotaBar(inCard ? `quota-card-bar-${provider}-${window.id}` : `quota-window-bar-${window.id}`, window.percent, '4px', window.remaining === true),
|
|
1879
1957
|
resetNode)
|
|
1880
1958
|
}
|
|
@@ -2546,6 +2624,63 @@ window.__ModuleLoader__.load({
|
|
|
2546
2624
|
setCardEditor({ provider: row.provider })
|
|
2547
2625
|
setCardDraft({ expiresAt: '', label: '' })
|
|
2548
2626
|
}
|
|
2627
|
+
// 凭据填写窗口(v0.24):未配置行的「填写 API 密钥」内联表单;宿主写入凭据库后立即强制
|
|
2628
|
+
// 重拉该 provider(清退避闸),密钥值只随保存请求发出、绝不回显。
|
|
2629
|
+
const [credEditor, setCredEditor] = useState(null)
|
|
2630
|
+
const [credDraft, setCredDraft] = useState({ name: '', value: '' })
|
|
2631
|
+
// 清除已存凭据的两段式确认武装态(skill 开关同款):3 秒无第二击自动复位。
|
|
2632
|
+
const [credClearArmed, setCredClearArmed] = useState(false)
|
|
2633
|
+
useEffect(() => {
|
|
2634
|
+
if (!credClearArmed) return undefined
|
|
2635
|
+
const handle = setTimeout(() => setCredClearArmed(false), 3000)
|
|
2636
|
+
return () => clearTimeout(handle)
|
|
2637
|
+
}, [credClearArmed])
|
|
2638
|
+
const closeCredEditor = () => {
|
|
2639
|
+
setCredEditor(null)
|
|
2640
|
+
setCredDraft({ name: '', value: '' })
|
|
2641
|
+
setCredClearArmed(false)
|
|
2642
|
+
}
|
|
2643
|
+
const openCredEditor = (row) => {
|
|
2644
|
+
const hints = Array.isArray(row.credentialHints) ? row.credentialHints : []
|
|
2645
|
+
// 默认选中「已配置」的那个槽位(别名链里可能已有生效值);全空才落回主名。
|
|
2646
|
+
setCardEditor(null)
|
|
2647
|
+
setCredEditor({ provider: row.provider })
|
|
2648
|
+
setCredDraft({ name: (hints.find((hint) => hint.configured === true) ?? hints[0])?.name ?? '', value: '' })
|
|
2649
|
+
setCredClearArmed(false)
|
|
2650
|
+
}
|
|
2651
|
+
const credentialFailCopy = (res) => {
|
|
2652
|
+
const code = String(res?.error ?? '')
|
|
2653
|
+
const known = ['unknown-hint', 'invalid-value', 'credentials-unavailable']
|
|
2654
|
+
const reason = known.includes(code) ? translate(`quota.credential.${code}`) : `${code}${res?.detail ? ` (${res.detail})` : ''}`
|
|
2655
|
+
return translate('quota.credential.saveFailed', { error: reason })
|
|
2656
|
+
}
|
|
2657
|
+
const saveCredential = async (providerName) => {
|
|
2658
|
+
setConfigError('')
|
|
2659
|
+
try {
|
|
2660
|
+
const res = await ctx.connection.rpc.call('/dsh-service', 'quota-credential-set', { provider: providerName, name: credDraft.name, value: credDraft.value })
|
|
2661
|
+
if (res?.ok !== true) {
|
|
2662
|
+
setConfigError(credentialFailCopy(res))
|
|
2663
|
+
return
|
|
2664
|
+
}
|
|
2665
|
+
closeCredEditor()
|
|
2666
|
+
await refreshProvider(providerName)
|
|
2667
|
+
} catch (_) {
|
|
2668
|
+
setConfigError(translate('quota.saveFailed', { error: 'network' }))
|
|
2669
|
+
}
|
|
2670
|
+
}
|
|
2671
|
+
const clearCredential = async (providerName, name) => {
|
|
2672
|
+
setConfigError('')
|
|
2673
|
+
try {
|
|
2674
|
+
const res = await ctx.connection.rpc.call('/dsh-service', 'quota-credential-unset', { provider: providerName, name })
|
|
2675
|
+
if (res?.ok !== true) {
|
|
2676
|
+
setConfigError(credentialFailCopy(res))
|
|
2677
|
+
return
|
|
2678
|
+
}
|
|
2679
|
+
await refreshProvider(providerName)
|
|
2680
|
+
} catch (_) {
|
|
2681
|
+
setConfigError(translate('quota.saveFailed', { error: 'network' }))
|
|
2682
|
+
}
|
|
2683
|
+
}
|
|
2549
2684
|
const saveResetCard = async () => {
|
|
2550
2685
|
if (cardEditor === null) return
|
|
2551
2686
|
setConfigError('')
|
|
@@ -2727,6 +2862,49 @@ window.__ModuleLoader__.load({
|
|
|
2727
2862
|
? React.createElement('span', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-tertiary)' } }, translate('quota.updated', { time: formatClockTime(row.fetchedAt) }))
|
|
2728
2863
|
: null)),
|
|
2729
2864
|
body,
|
|
2865
|
+
...(row.status === 'unconfigured' && Array.isArray(row.credentialHints) && row.credentialHints.length > 0
|
|
2866
|
+
? (() => {
|
|
2867
|
+
const editingCred = credEditor !== null && credEditor.provider === row.provider
|
|
2868
|
+
const hints = row.credentialHints
|
|
2869
|
+
const selectedName = hints.some((hint) => hint.name === credDraft.name) ? credDraft.name : (hints[0]?.name ?? '')
|
|
2870
|
+
const selectedHint = hints.find((hint) => hint.name === selectedName)
|
|
2871
|
+
return [editingCred
|
|
2872
|
+
? React.createElement('div', { key: 'cred-editor', 'data-testid': `quota-cred-editor-${row.provider}`, style: { display: 'flex', flexWrap: 'wrap', gap: '10px', alignItems: 'flex-end', padding: '8px 10px', borderRadius: '8px', background: 'var(--dsw-alias-bg-layer-2)' } },
|
|
2873
|
+
hints.length > 1
|
|
2874
|
+
? React.createElement('label', { style: { display: 'flex', flexDirection: 'column', gap: '2px', fontSize: '11px', color: 'var(--dsw-alias-label-secondary)' } },
|
|
2875
|
+
translate('quota.credential.nameLabel'),
|
|
2876
|
+
React.createElement('select', {
|
|
2877
|
+
'data-testid': 'quota-cred-name-select',
|
|
2878
|
+
value: selectedName,
|
|
2879
|
+
onChange: (event) => { setCredDraft({ ...credDraft, name: event.target.value }); setCredClearArmed(false) },
|
|
2880
|
+
style: quotaSelectStyle,
|
|
2881
|
+
},
|
|
2882
|
+
// 别名链说明:多个名字是同一密钥的备用存放槽(发现按序取第一个已配置的值),主名带「主名」标记。
|
|
2883
|
+
hints.map((hint, hintIndex) => React.createElement('option', { key: hint.name, value: hint.name }, `${hint.name}${hintIndex === 0 ? ` · ${translate('quota.credential.primary')}` : ''} · ${hint.configured === true ? translate('quota.credential.configured') : translate('quota.credential.notConfigured')}`))))
|
|
2884
|
+
: React.createElement('span', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-tertiary)', alignSelf: 'center' } }, `${selectedName}${selectedHint?.configured === true ? ` · ${translate('quota.credential.configured')}` : ''}`),
|
|
2885
|
+
React.createElement('label', { style: { display: 'flex', flexDirection: 'column', gap: '2px', fontSize: '11px', color: 'var(--dsw-alias-label-secondary)' } },
|
|
2886
|
+
translate('quota.credential.valueLabel'),
|
|
2887
|
+
React.createElement('input', {
|
|
2888
|
+
type: 'password',
|
|
2889
|
+
'data-testid': 'quota-cred-input-value',
|
|
2890
|
+
value: credDraft.value,
|
|
2891
|
+
onChange: (event) => setCredDraft({ ...credDraft, value: event.target.value }),
|
|
2892
|
+
autoComplete: 'off',
|
|
2893
|
+
style: inputStyle,
|
|
2894
|
+
})),
|
|
2895
|
+
React.createElement('button', { type: 'button', 'data-testid': 'quota-cred-save', onClick: () => saveCredential(row.provider), disabled: credDraft.value.trim() === '', style: { minHeight: '28px', padding: '4px 12px', borderRadius: '7px', border: '1px solid var(--dsw-alias-brand-primary)', background: credDraft.value.trim() === '' ? 'transparent' : 'var(--dsw-alias-brand-primary)', color: credDraft.value.trim() === '' ? 'var(--dsw-alias-label-tertiary)' : '#fff', cursor: credDraft.value.trim() === '' ? 'default' : 'pointer', fontSize: '12px' } }, translate('quota.credential.save')),
|
|
2896
|
+
...(selectedHint?.configured === true && selectedHint?.writable !== false ? [React.createElement('button', { type: 'button', key: 'cred-clear', 'data-testid': 'quota-cred-clear', title: credClearArmed ? translate('quota.credential.clearConfirm') : undefined, onClick: () => { if (!credClearArmed) { setCredClearArmed(true); return } setCredClearArmed(false); void clearCredential(row.provider, selectedName) }, style: { minHeight: '28px', padding: '4px 12px', borderRadius: '7px', border: '1px solid var(--dsw-alias-state-error-primary)', background: credClearArmed ? 'var(--dsw-alias-state-error-primary)' : 'transparent', color: credClearArmed ? '#fff' : 'var(--dsw-alias-state-error-primary)', cursor: 'pointer', fontSize: '12px', whiteSpace: 'nowrap' } }, translate(credClearArmed ? 'quota.credential.clearConfirm' : 'quota.credential.clear'))] : []),
|
|
2897
|
+
React.createElement('button', { type: 'button', 'data-testid': 'quota-cred-cancel', onClick: closeCredEditor, style: { minHeight: '28px', padding: '4px 12px', borderRadius: '7px', border: '1px solid var(--dsw-alias-border-l2)', background: 'transparent', color: 'var(--dsw-alias-label-secondary)', cursor: 'pointer', fontSize: '12px' } }, translate('quota.resetCard.cancel')),
|
|
2898
|
+
)
|
|
2899
|
+
: React.createElement('div', { key: 'cred-entry', style: { display: 'flex' } },
|
|
2900
|
+
React.createElement('button', {
|
|
2901
|
+
type: 'button',
|
|
2902
|
+
'data-testid': `quota-cred-edit-${row.provider}`,
|
|
2903
|
+
onClick: () => openCredEditor(row),
|
|
2904
|
+
style: { fontSize: '12px', lineHeight: '20px', padding: '4px 14px', borderRadius: 999, border: '1px solid var(--dsw-alias-brand-primary)', background: 'transparent', color: 'var(--dsw-alias-brand-primary)', cursor: 'pointer', width: 'auto', minWidth: 0, overflow: 'visible', flex: '0 0 auto', whiteSpace: 'nowrap' },
|
|
2905
|
+
}, translate('quota.credential.edit')))]
|
|
2906
|
+
})()
|
|
2907
|
+
: []),
|
|
2730
2908
|
...resetCardNodes,
|
|
2731
2909
|
...(editingThis ? [React.createElement('div', { key: 'reset-editor', 'data-testid': `quota-reset-editor-${row.provider}`, style: { display: 'flex', flexWrap: 'wrap', gap: '10px', alignItems: 'flex-end', padding: '8px 10px', borderRadius: '8px', background: 'var(--dsw-alias-bg-layer-2)' } },
|
|
2732
2910
|
resetField(translate('quota.resetCard.dateLabel'), 'quota-reset-input-date', 'datetime-local', 'expiresAt'),
|
|
@@ -2820,6 +2998,8 @@ window.__ModuleLoader__.load({
|
|
|
2820
2998
|
const [modelErrorsOpen, setModelErrorsOpen] = useState(false)
|
|
2821
2999
|
const [toolErrorsOpen, setToolErrorsOpen] = useState(false)
|
|
2822
3000
|
const [modelsOpen, setModelsOpen] = useState(false)
|
|
3001
|
+
// 模型列表口径:today=仅今日 / week=近 7 天(默认,保持既有视图)/ all=宿主索引内全部日期累计。
|
|
3002
|
+
const [modelScope, setModelScope] = useState('week')
|
|
2823
3003
|
const [activeTab, setActiveTab] = useState('overview')
|
|
2824
3004
|
// 版本详情行内展开(不用浮层:弹层会被设置模态盖住)
|
|
2825
3005
|
const [channelOpen, setChannelOpen] = useState(false)
|
|
@@ -3152,6 +3332,8 @@ window.__ModuleLoader__.load({
|
|
|
3152
3332
|
const tabPanel = { padding: '14px 2px 2px', color: 'var(--dsw-alias-label-primary)' }
|
|
3153
3333
|
const inlineTab = { background: 'transparent', color: 'var(--dsw-alias-label-secondary)', border: 0, borderBottom: '2px solid transparent', padding: '8px 10px', cursor: 'pointer', fontSize: '13px', fontWeight: 550, transition: 'color 120ms, border-color 120ms' }
|
|
3154
3334
|
const inlineTabActive = { color: 'var(--dsw-alias-brand-primary)', borderBottom: '2px solid var(--dsw-alias-brand-primary)', fontWeight: 700 }
|
|
3335
|
+
// 模型列表头部的紧凑口径切换:沿用下划线标签语言,但按 11px 行高缩比。
|
|
3336
|
+
const compactTab = Object.assign({}, inlineTab, { padding: '3px 6px', fontSize: '11px' })
|
|
3155
3337
|
const sectionTitle = { fontSize: '14px', fontWeight: 700, margin: '0 0 8px', color: 'var(--dsw-alias-label-primary)' }
|
|
3156
3338
|
|
|
3157
3339
|
const formatSize = (bytes) => {
|
|
@@ -3242,23 +3424,34 @@ window.__ModuleLoader__.load({
|
|
|
3242
3424
|
const selectedProjects = usageProject === 'all'
|
|
3243
3425
|
? (usage?.projects || []).map((project) => project.id)
|
|
3244
3426
|
: [usageProject]
|
|
3245
|
-
const
|
|
3246
|
-
|
|
3247
|
-
const
|
|
3248
|
-
|
|
3427
|
+
const emptyModelTotals = (id) => ({ id, steps: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 })
|
|
3428
|
+
const accumulateModelTotals = (buckets, model) => {
|
|
3429
|
+
const existing = buckets.get(model.id) || emptyModelTotals(model.id)
|
|
3430
|
+
existing.steps += model.totals.steps || 0
|
|
3431
|
+
existing.inputTokens += model.totals.inputTokens || 0
|
|
3432
|
+
existing.outputTokens += model.totals.outputTokens || 0
|
|
3433
|
+
existing.cacheReadTokens += model.totals.cacheReadTokens || 0
|
|
3434
|
+
existing.cacheWriteTokens += model.totals.cacheWriteTokens || 0
|
|
3435
|
+
buckets.set(model.id, existing)
|
|
3436
|
+
}
|
|
3437
|
+
const modelTodayTotals = new Map()
|
|
3438
|
+
const modelWeekTotals = new Map()
|
|
3439
|
+
const modelAllTotals = new Map()
|
|
3440
|
+
const weekDayKeys = new Set(usageDays.map((day) => day.key))
|
|
3441
|
+
const todayDayKey = usageDays[usageDays.length - 1].key
|
|
3442
|
+
// 累计口径遍历宿主下发的全部日期键(可早于 7 天窗口);周/今日按窗口命中累积。
|
|
3443
|
+
for (const [dayKey, source] of Object.entries(usage?.days || {})) {
|
|
3444
|
+
const targets = [modelAllTotals]
|
|
3445
|
+
if (weekDayKeys.has(dayKey)) targets.push(modelWeekTotals)
|
|
3446
|
+
if (dayKey === todayDayKey) targets.push(modelTodayTotals)
|
|
3249
3447
|
for (const project of source.projects) {
|
|
3250
3448
|
if (!selectedProjects.includes(project.id)) continue
|
|
3251
3449
|
for (const model of project.models) {
|
|
3252
|
-
const
|
|
3253
|
-
existing.steps += model.totals.steps || 0
|
|
3254
|
-
existing.inputTokens += model.totals.inputTokens || 0
|
|
3255
|
-
existing.outputTokens += model.totals.outputTokens || 0
|
|
3256
|
-
existing.cacheReadTokens += model.totals.cacheReadTokens || 0
|
|
3257
|
-
existing.cacheWriteTokens += model.totals.cacheWriteTokens || 0
|
|
3258
|
-
modelTotals.set(model.id, existing)
|
|
3450
|
+
for (const buckets of targets) accumulateModelTotals(buckets, model)
|
|
3259
3451
|
}
|
|
3260
3452
|
}
|
|
3261
3453
|
}
|
|
3454
|
+
const scopedModelTotals = modelScope === 'today' ? modelTodayTotals : modelScope === 'all' ? modelAllTotals : modelWeekTotals
|
|
3262
3455
|
const diagnosticDetail = (check) => {
|
|
3263
3456
|
const detail = String(check.detail ?? '')
|
|
3264
3457
|
if (check.id === 'session-storage' && check.status === 'ok') return translate('health.detail.session-storage.ok', { count: detail })
|
|
@@ -3297,7 +3490,7 @@ window.__ModuleLoader__.load({
|
|
|
3297
3490
|
React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary)' } }, translate(label)),
|
|
3298
3491
|
React.createElement('span', { style: { fontWeight: 650 } }, value)))
|
|
3299
3492
|
)
|
|
3300
|
-
const sortedModels = [...
|
|
3493
|
+
const sortedModels = [...scopedModelTotals.values()].sort((a, b) => modelTotalTokens(b) - modelTotalTokens(a) || b.steps - a.steps || a.id.localeCompare(b.id))
|
|
3301
3494
|
const visibleModels = modelsOpen ? sortedModels : sortedModels.slice(0, 3)
|
|
3302
3495
|
const hiddenModelCount = Math.max(0, sortedModels.length - 3)
|
|
3303
3496
|
const maxModelTokens = Math.max(1, ...visibleModels.map((model) => modelTotalTokens(model)))
|
|
@@ -3360,7 +3553,15 @@ window.__ModuleLoader__.load({
|
|
|
3360
3553
|
summaryBlock('today', 'usage.today', todayTotals),
|
|
3361
3554
|
summaryBlock('seven', 'usage.sevenDays', sevenTotals)),
|
|
3362
3555
|
React.createElement('div', { 'data-testid': 'usage-model-list', style: { marginTop: '10px', padding: '8px 10px', borderRadius: '8px', background: 'var(--dsw-alias-bg-layer-2)', border: '1px solid var(--dsw-alias-border-l1)' } },
|
|
3363
|
-
React.createElement('div', { style: {
|
|
3556
|
+
React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px', flexWrap: 'wrap', marginBottom: '4px' } },
|
|
3557
|
+
React.createElement('div', { 'data-testid': 'usage-model-sort-hint', style: { fontSize: '11px', color: 'var(--dsw-alias-label-secondary)' } }, translate(`usage.modelSortHint.${modelScope}`)),
|
|
3558
|
+
React.createElement('div', { 'data-testid': 'usage-model-scope-tabs', style: { display: 'flex', gap: '2px' } },
|
|
3559
|
+
['today', 'week', 'all'].map((scopeId) => React.createElement('button', {
|
|
3560
|
+
key: scopeId,
|
|
3561
|
+
'data-testid': `usage-model-scope-${scopeId}`,
|
|
3562
|
+
style: Object.assign({}, compactTab, modelScope === scopeId ? inlineTabActive : { color: 'var(--dsw-alias-label-secondary)', borderBottom: '2px solid transparent' }),
|
|
3563
|
+
onClick: () => setModelScope(scopeId),
|
|
3564
|
+
}, translate(`usage.modelScope.${scopeId}`))))),
|
|
3364
3565
|
visibleModels.map((model, index) => {
|
|
3365
3566
|
const total = modelTotalTokens(model)
|
|
3366
3567
|
const fillWidth = `${Math.round(total / maxModelTokens * 10000) / 100}%`
|
|
@@ -3368,7 +3569,7 @@ window.__ModuleLoader__.load({
|
|
|
3368
3569
|
React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', gap: '12px', alignItems: 'baseline', fontSize: '12px' } },
|
|
3369
3570
|
React.createElement('span', { style: { overflowWrap: 'anywhere' } }, model.id),
|
|
3370
3571
|
React.createElement('span', { style: { fontWeight: 650, whiteSpace: 'nowrap' } }, formatTokenValue(total))),
|
|
3371
|
-
React.createElement('div', { 'data-testid': `usage-model-bar-${model.id}`, 'data-value': total, 'aria-label': translate(
|
|
3572
|
+
React.createElement('div', { 'data-testid': `usage-model-bar-${model.id}`, 'data-value': total, 'aria-label': translate(`usage.modelBar.${modelScope}`, { model: model.id, total: formatTokenValue(total) }), style: { height: '8px', borderRadius: '4px', marginTop: '6px', overflow: 'hidden', background: 'var(--dsw-alias-border-l1)' } },
|
|
3372
3573
|
React.createElement('div', { style: { display: 'flex', height: '100%', width: fillWidth } },
|
|
3373
3574
|
usageSegments.map(([metricName, , color]) => {
|
|
3374
3575
|
const value = modelSegmentValue(model, metricName)
|
package/index.js
CHANGED
|
@@ -107,6 +107,19 @@ const KIND_REGISTRY = {
|
|
|
107
107
|
keyHints: ['SILICONFLOW_API_KEY'],
|
|
108
108
|
hosts: ['siliconflow.cn'],
|
|
109
109
|
},
|
|
110
|
+
// CLIProxyAPI(router-for-me/CLIProxyAPI)管理面:查它托管的 OAuth 上游账号官方额度。
|
|
111
|
+
// 无固定 endpoints / 全局 hosts——每个部署一个域名,保存适配时把 settings baseURL 的
|
|
112
|
+
// hostname 钉进配置 allowedHosts[provider](见 quota-config 写入口),外呼前复验精确命中。
|
|
113
|
+
// managementPlane:true 表示凭据是 remote-management.secret-key(线索 CPA_MANAGEMENT_KEY),
|
|
114
|
+
// 绝不使用也不回退 settings 的 apiKeyEnv——那是代理 key,发去管理面等于拿错钥匙撞 fail2ban
|
|
115
|
+
// (CPA 内建:管理面错 key 满 5 次封 IP 30 分钟,源码核实)。
|
|
116
|
+
cliproxy: {
|
|
117
|
+
fetcher: fetchCliproxyUsage,
|
|
118
|
+
fetcherGuard: cliproxyFetchGuard,
|
|
119
|
+
keyHints: ['CPA_MANAGEMENT_KEY', 'CLIPROXY_MANAGEMENT_KEY'],
|
|
120
|
+
hosts: [],
|
|
121
|
+
managementPlane: true,
|
|
122
|
+
},
|
|
110
123
|
}
|
|
111
124
|
const QUOTA_KINDS = Object.keys(KIND_REGISTRY)
|
|
112
125
|
const QUOTA_UPSTREAM_TIMEOUT_MS = 15000
|
|
@@ -125,6 +138,12 @@ const MAX_QUOTA_PROVIDER_NAME = 128
|
|
|
125
138
|
const MAX_QUOTA_RESET_CARDS = 500
|
|
126
139
|
const MAX_QUOTA_RESET_CARDS_PER_PROVIDER = 10
|
|
127
140
|
const MAX_QUOTA_ERROR_DETAIL = 256
|
|
141
|
+
// CLIProxyAPI 编排参数(v0.24):账号数 / api-call 总次数(antigravity 候选 URL 也计入)/
|
|
142
|
+
// 并发 / 窗口总数上限。数值只在此处与 TODO.md 里程碑两处出现。
|
|
143
|
+
const MAX_QUOTA_CPA_ACCOUNTS = 8
|
|
144
|
+
const MAX_QUOTA_CPA_CALLS = 12
|
|
145
|
+
const QUOTA_CPA_CONCURRENCY = 3
|
|
146
|
+
const MAX_QUOTA_CPA_WINDOWS = 32
|
|
128
147
|
|
|
129
148
|
// 升级目标白名单:命令与包名全部来自宿主常量,浏览器不传任何输入。
|
|
130
149
|
// TARGET_RE 与 dsh-market 同源:只放行「包名@版本」这一种形状的字符集。
|
|
@@ -559,7 +578,45 @@ async function saveUsageIndex(dshHome, index) {
|
|
|
559
578
|
// ── 远端额度(v0.18):kind 映射存取、方言解析、节流状态机 ─────────────
|
|
560
579
|
|
|
561
580
|
function createEmptyQuotaConfig() {
|
|
562
|
-
return { version: QUOTA_CONFIG_VERSION, kinds: {}, resetCards: [] }
|
|
581
|
+
return { version: QUOTA_CONFIG_VERSION, kinds: {}, resetCards: [], allowedHosts: {} }
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** 钉住表主机名归一:小写、去尾点;拒绝 IP 字面量与非法形状。合法返回归一值,否则 undefined。 */
|
|
585
|
+
function normalizeQuotaHostname(value) {
|
|
586
|
+
if (typeof value !== 'string') return undefined
|
|
587
|
+
const host = value.trim().toLowerCase().replace(/\.$/, '')
|
|
588
|
+
if (host.length === 0 || host.length > 253) return undefined
|
|
589
|
+
if (host.includes(':')) return undefined
|
|
590
|
+
// IP 字面量的全部数值编码一并拒收:getaddrinfo 对十六进制(0x7f000001 / 0x7f.0.0.1)、纯整数
|
|
591
|
+
// (2130706433)、八进制(0177.0.0.1)标签都会解析成 IP——「每个标签都是纯数字或 0x 十六进制」
|
|
592
|
+
// 即按 IP 对待;数字标签混在真域名里(如 127.0.0.1.nip.io)不受影响。
|
|
593
|
+
if (host.split('.').every((label) => /^(0x[0-9a-f]+|\d+)$/.test(label))) return undefined
|
|
594
|
+
if (!/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/.test(host)) return undefined
|
|
595
|
+
return host
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** 解析磁盘上的 allowedHosts(cliproxy 等「每部署一个域名」kind 的钉住表):
|
|
599
|
+
* provider 键沿用 kinds 白名单形状,每家最多 4 条去重主机名;非法条目整键丢弃。 */
|
|
600
|
+
function normalizeQuotaAllowedHosts(raw) {
|
|
601
|
+
if (typeof raw !== 'object' || raw === null) return {}
|
|
602
|
+
const out = {}
|
|
603
|
+
let count = 0
|
|
604
|
+
for (const [provider, hosts] of Object.entries(raw)) {
|
|
605
|
+
if (count >= MAX_QUOTA_PROVIDERS) break
|
|
606
|
+
if (typeof provider !== 'string' || provider.length === 0 || provider.length > MAX_QUOTA_PROVIDER_NAME) continue
|
|
607
|
+
if (!Array.isArray(hosts)) continue
|
|
608
|
+
const normalized = []
|
|
609
|
+
for (const candidate of hosts) {
|
|
610
|
+
if (normalized.length >= 4) break
|
|
611
|
+
const host = normalizeQuotaHostname(candidate)
|
|
612
|
+
if (host !== undefined && !normalized.includes(host)) normalized.push(host)
|
|
613
|
+
}
|
|
614
|
+
if (normalized.length > 0) {
|
|
615
|
+
out[provider] = normalized
|
|
616
|
+
count += 1
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
return out
|
|
563
620
|
}
|
|
564
621
|
|
|
565
622
|
/**
|
|
@@ -607,7 +664,7 @@ function parseQuotaConfigText(text) {
|
|
|
607
664
|
kindCount += 1
|
|
608
665
|
}
|
|
609
666
|
}
|
|
610
|
-
return { version: QUOTA_CONFIG_VERSION, kinds, resetCards: normalizeResetCards(parsed.resetCards) }
|
|
667
|
+
return { version: QUOTA_CONFIG_VERSION, kinds, resetCards: normalizeResetCards(parsed.resetCards), allowedHosts: normalizeQuotaAllowedHosts(parsed.allowedHosts) }
|
|
611
668
|
}
|
|
612
669
|
|
|
613
670
|
async function loadQuotaConfig(dshHome) {
|
|
@@ -634,10 +691,15 @@ async function saveQuotaConfig(dshHome, config) {
|
|
|
634
691
|
}
|
|
635
692
|
|
|
636
693
|
function copyQuotaConfig(config) {
|
|
694
|
+
const allowedHosts = {}
|
|
695
|
+
for (const [provider, hosts] of Object.entries(config?.allowedHosts ?? {})) {
|
|
696
|
+
allowedHosts[provider] = Array.isArray(hosts) ? [...hosts] : []
|
|
697
|
+
}
|
|
637
698
|
return {
|
|
638
699
|
version: QUOTA_CONFIG_VERSION,
|
|
639
700
|
kinds: { ...(config?.kinds ?? {}) },
|
|
640
701
|
resetCards: Array.isArray(config?.resetCards) ? config.resetCards.map((card) => ({ ...card })) : [],
|
|
702
|
+
allowedHosts,
|
|
641
703
|
}
|
|
642
704
|
}
|
|
643
705
|
|
|
@@ -723,6 +785,41 @@ function inferQuotaKind(baseURL) {
|
|
|
723
785
|
return hits.length === 1 ? hits[0][0] : undefined
|
|
724
786
|
}
|
|
725
787
|
|
|
788
|
+
/** 保存适配时的钉住派生(服务端从 settings baseURL 取值,浏览器零输入):
|
|
789
|
+
* 仅接受 HTTPS、无 userinfo、默认端口、DNS 域名(拒 IP 字面量);不合格返回 undefined。 */
|
|
790
|
+
function cliproxyPinHostFromBaseURL(baseURL) {
|
|
791
|
+
let parsed
|
|
792
|
+
try { parsed = new URL(String(baseURL ?? '').trim()) } catch (_) { return undefined }
|
|
793
|
+
if (parsed.protocol !== 'https:' || parsed.username !== '' || parsed.password !== '') return undefined
|
|
794
|
+
if (parsed.port !== '' && parsed.port !== '443') return undefined
|
|
795
|
+
return normalizeQuotaHostname(parsed.hostname)
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* CLIProxyAPI 管理面 origin 守卫:baseURL 必须整体 HTTPS,且 hostname 与保存适配时
|
|
800
|
+
* 钉入 allowedHosts[provider] 的记录**精确相等**——不做子串/子域猜测,管理密钥只发往
|
|
801
|
+
* 用户钉过的那个域;settings 里改了 baseURL 就必须重新保存适配才恢复外呼。
|
|
802
|
+
* 返回规范化 origin(scheme://host[:443],无路径)或 undefined。
|
|
803
|
+
*/
|
|
804
|
+
function safeCliproxyOrigin(baseURL, pinnedHosts) {
|
|
805
|
+
if (!Array.isArray(pinnedHosts) || pinnedHosts.length === 0) return undefined
|
|
806
|
+
let parsed
|
|
807
|
+
try { parsed = new URL(String(baseURL ?? '').trim()) } catch (_) { return undefined }
|
|
808
|
+
if (parsed.protocol !== 'https:' || parsed.username !== '' || parsed.password !== '') return undefined
|
|
809
|
+
if (parsed.port !== '' && parsed.port !== '443') return undefined
|
|
810
|
+
const host = normalizeQuotaHostname(parsed.hostname)
|
|
811
|
+
if (host === undefined || !pinnedHosts.includes(host)) return undefined
|
|
812
|
+
return parsed.origin
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/** fetcher 类 kind 的外呼前置守卫:返回稳定错误码或 null(放行)。在解凭据之前跑,省一次凭据查找。 */
|
|
816
|
+
function cliproxyFetchGuard(profile, config) {
|
|
817
|
+
const base = String(profile?.baseURL ?? '').trim()
|
|
818
|
+
if (base === '') return 'no-base-url'
|
|
819
|
+
const pinned = Array.isArray(config?.allowedHosts?.[profile.name]) ? config.allowedHosts[profile.name] : []
|
|
820
|
+
return safeCliproxyOrigin(base, pinned) === undefined ? 'host-not-pinned' : null
|
|
821
|
+
}
|
|
822
|
+
|
|
726
823
|
/**
|
|
727
824
|
* 单个 provider 的 kind 解析(quota 与 quota-refresh 共用)。
|
|
728
825
|
* 优先序:配置显式 kind > 配置 null(手动停用,永不外呼)> baseURL 自动推断;未命中返回空对象。
|
|
@@ -758,6 +855,22 @@ function normalizeResetTimestamp(value) {
|
|
|
758
855
|
return new Date(n > 1e12 ? n : n * 1000).toISOString()
|
|
759
856
|
}
|
|
760
857
|
|
|
858
|
+
/**
|
|
859
|
+
* 凭据线索名清单(与发现链同一顺序):settings 声明的 apiKeyEnv 在前、kind keyHints 殿后,去重。
|
|
860
|
+
* 凭据填写窗口(quota-credential-set/unset)只接受这份清单里的名字——零输入拼接:浏览器传来的
|
|
861
|
+
* 名字必须命中宿主派生的白名单,顺带保证 CredentialRef 文法(POSIX shell 标识符)合法。
|
|
862
|
+
*/
|
|
863
|
+
function quotaCredentialHintNames(kind, profile) {
|
|
864
|
+
const names = []
|
|
865
|
+
// 管理面 kind(cliproxy)的凭据独立于模型代理 key:settings 的 apiKeyEnv 指向代理 key,
|
|
866
|
+
// 发去管理面等于拿错钥匙撞 fail2ban(CPA 错 5 次封 IP 30 分钟)——不尝试、不回退。
|
|
867
|
+
if (KIND_REGISTRY[kind]?.managementPlane !== true && profile.apiKeyEnv !== '') names.push(profile.apiKeyEnv)
|
|
868
|
+
for (const name of KIND_REGISTRY[kind]?.keyHints ?? []) {
|
|
869
|
+
if (!names.includes(name)) names.push(name)
|
|
870
|
+
}
|
|
871
|
+
return names
|
|
872
|
+
}
|
|
873
|
+
|
|
761
874
|
/**
|
|
762
875
|
* Key 发现链:settings 声明的 apiKeyEnv → DSH 凭据库按 kind 线索名 → 环境变量(含旧名兼容)。
|
|
763
876
|
* 全部落空返回 undefined(调用方转为 credential-missing);settings 显式声明了 apiKeyEnv
|
|
@@ -765,13 +878,7 @@ function normalizeResetTimestamp(value) {
|
|
|
765
878
|
* 与「从未配置」的 credential-missing 区分。
|
|
766
879
|
*/
|
|
767
880
|
async function discoverQuotaCredential(ctx, kind, profile) {
|
|
768
|
-
const
|
|
769
|
-
const attempted = []
|
|
770
|
-
if (profile.apiKeyEnv !== '') attempted.push(profile.apiKeyEnv)
|
|
771
|
-
for (const name of hints) {
|
|
772
|
-
if (attempted.includes(name)) continue
|
|
773
|
-
attempted.push(name)
|
|
774
|
-
}
|
|
881
|
+
const attempted = quotaCredentialHintNames(kind, profile)
|
|
775
882
|
const envHas = (name) => typeof process.env[name] === 'string' && process.env[name].trim() !== ''
|
|
776
883
|
const credentials = ctx.get('credentials')
|
|
777
884
|
if (credentials !== undefined && typeof credentials.resolve === 'function') {
|
|
@@ -859,6 +966,252 @@ function normalizeSiliconFlowInfo(payload) {
|
|
|
859
966
|
return { windows: [{ id: 'balance', text: `¥${(Math.round(n * 100) / 100).toFixed(2)}` }] }
|
|
860
967
|
}
|
|
861
968
|
|
|
969
|
+
// ─── CLIProxyAPI(cliproxy)管理面编排 ──────────────────────────────────────
|
|
970
|
+
// 上游官方额度端点注册表(经 CPA api-call 代调,header 的 $TOKEN$ 由 CPA 替换为对应账号凭据)。
|
|
971
|
+
// 形状来源:muyouzhi6/astrbot_plugin_cliproxy_stats + CPA main 分支源码(2026-08 核实);
|
|
972
|
+
// 全部折算成统一「已用 %」口径(remainingFraction 类字段做纯 clamp,不做 ≤1 启发式——教训见 KNOWLEDGE.md)。
|
|
973
|
+
const CLIPROXY_CODEX_USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage'
|
|
974
|
+
const CLIPROXY_GEMINI_QUOTA_URL = 'https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota'
|
|
975
|
+
const CLIPROXY_ANTIGRAVITY_QUOTA_URLS = [
|
|
976
|
+
'https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels',
|
|
977
|
+
'https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:fetchAvailableModels',
|
|
978
|
+
'https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels',
|
|
979
|
+
]
|
|
980
|
+
const CLIPROXY_SUPPORTED_ACCOUNT_KINDS = new Set(['codex', 'gemini', 'gemini-cli', 'antigravity'])
|
|
981
|
+
|
|
982
|
+
/** Codex 窗口长度 → 展示桶位代码:按上游下发的实际秒数判,绝不按 primary/secondary 位置猜
|
|
983
|
+
* (真实部署里主槽位完全可能就是周额度——2026-08-25 用户实测踩中)。 */
|
|
984
|
+
function codexWindowCode(key, window) {
|
|
985
|
+
const seconds = Number(window.limit_window_seconds)
|
|
986
|
+
if (Number.isFinite(seconds) && seconds > 0) {
|
|
987
|
+
if (seconds <= 6 * 3600) return 'codex-5h'
|
|
988
|
+
if (seconds <= 24 * 3600) return 'codex-day'
|
|
989
|
+
if (seconds <= 8 * 24 * 3600) return 'codex-week'
|
|
990
|
+
return 'codex-month'
|
|
991
|
+
}
|
|
992
|
+
// 上游没带窗口长度时的位置兜底:primary 常见为短周期、secondary 为长周期。
|
|
993
|
+
return key === 'primary_window' ? 'codex-primary' : 'codex-secondary'
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/** Codex wham/usage 的 rate_limit → 窗口(used_percent 已用口径;reset_at 为 unix 秒)。
|
|
997
|
+
* secondary_window 可为 null(真实上游行为);窗口名由 limit_window_seconds 推导。 */
|
|
998
|
+
function normalizeCodexRateLimit(rateLimit) {
|
|
999
|
+
const windows = []
|
|
1000
|
+
if (rateLimit === null || typeof rateLimit !== 'object') return windows
|
|
1001
|
+
for (const key of ['primary_window', 'secondary_window']) {
|
|
1002
|
+
const window = rateLimit[key]
|
|
1003
|
+
if (window === null || typeof window !== 'object') continue
|
|
1004
|
+
const percent = normalizePercentValue(window.used_percent)
|
|
1005
|
+
if (percent === null) continue
|
|
1006
|
+
const code = codexWindowCode(key, window)
|
|
1007
|
+
const resetsAt = normalizeResetTimestamp(window.reset_at)
|
|
1008
|
+
windows.push({ id: code, kindKey: code, percent, ...(resetsAt !== undefined ? { resetsAt } : {}) })
|
|
1009
|
+
}
|
|
1010
|
+
// 已用多的排前面(与 gemini/antigravity 解析器同一关注序)。
|
|
1011
|
+
return windows.sort((a, b) => b.percent - a.percent)
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/** GeminiCLI retrieveUserQuota 的 buckets → 窗口(remainingFraction∈[0,1] 折算为已用 %)。 */
|
|
1015
|
+
function normalizeGeminiBuckets(buckets) {
|
|
1016
|
+
const windows = []
|
|
1017
|
+
if (!Array.isArray(buckets)) return windows
|
|
1018
|
+
for (const bucket of buckets) {
|
|
1019
|
+
if (bucket === null || typeof bucket !== 'object') continue
|
|
1020
|
+
const modelId = typeof bucket.modelId === 'string' && bucket.modelId.trim() !== '' ? bucket.modelId.trim() : ''
|
|
1021
|
+
const remaining = Number(bucket.remainingFraction)
|
|
1022
|
+
if (modelId === '' || !Number.isFinite(remaining)) continue
|
|
1023
|
+
const fraction = Math.max(0, Math.min(1, remaining))
|
|
1024
|
+
const resetsAt = normalizeResetTimestamp(bucket.resetTime)
|
|
1025
|
+
windows.push({
|
|
1026
|
+
id: modelId.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'model',
|
|
1027
|
+
kindKey: modelId,
|
|
1028
|
+
percent: Math.round((1 - fraction) * 100),
|
|
1029
|
+
...(resetsAt !== undefined ? { resetsAt } : {}),
|
|
1030
|
+
})
|
|
1031
|
+
}
|
|
1032
|
+
// 剩得最少的排前面(最值得关注):已用 % 降序。
|
|
1033
|
+
return windows.sort((a, b) => b.percent - a.percent)
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/** Antigravity fetchAvailableModels 的 models{} → 窗口(quotaInfo.remainingFraction 折算已用 %)。 */
|
|
1037
|
+
function normalizeAntigravityModels(models) {
|
|
1038
|
+
const windows = []
|
|
1039
|
+
if (models === null || typeof models !== 'object') return windows
|
|
1040
|
+
for (const [modelId, entry] of Object.entries(models)) {
|
|
1041
|
+
if (entry === null || typeof entry !== 'object') continue
|
|
1042
|
+
const info = entry.quotaInfo !== null && typeof entry.quotaInfo === 'object' ? entry.quotaInfo
|
|
1043
|
+
: entry.quota_info !== null && typeof entry.quota_info === 'object' ? entry.quota_info : null
|
|
1044
|
+
if (info === null) continue
|
|
1045
|
+
const remaining = Number(info.remainingFraction ?? info.remaining_fraction)
|
|
1046
|
+
if (!Number.isFinite(remaining)) continue
|
|
1047
|
+
const fraction = Math.max(0, Math.min(1, remaining))
|
|
1048
|
+
const resetsAt = normalizeResetTimestamp(info.resetTime ?? info.reset_time)
|
|
1049
|
+
windows.push({
|
|
1050
|
+
id: String(modelId).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'model',
|
|
1051
|
+
kindKey: modelId,
|
|
1052
|
+
percent: Math.round((1 - fraction) * 100),
|
|
1053
|
+
...(resetsAt !== undefined ? { resetsAt } : {}),
|
|
1054
|
+
})
|
|
1055
|
+
}
|
|
1056
|
+
// 剩得最少的排前面(最值得关注):已用 % 降序。
|
|
1057
|
+
return windows.sort((a, b) => b.percent - a.percent)
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/** api-call 信封解包:{status_code:int, body:string|object} → {statusCode, payload}。 */
|
|
1061
|
+
function unwrapCliproxyApiCallEnvelope(envelope) {
|
|
1062
|
+
if (envelope === null || typeof envelope !== 'object') return { statusCode: 0, payload: null }
|
|
1063
|
+
const rawStatus = Number(envelope.status_code)
|
|
1064
|
+
let payload = envelope.body
|
|
1065
|
+
if (typeof payload === 'string') {
|
|
1066
|
+
try { payload = JSON.parse(payload) } catch (_) { payload = null }
|
|
1067
|
+
}
|
|
1068
|
+
if (payload === null || typeof payload !== 'object') payload = null
|
|
1069
|
+
return { statusCode: Number.isFinite(rawStatus) ? rawStatus : 0, payload }
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/** GeminiCLI project 提取:优先 auth-files 条目的 project_id 字段,回落文件名 gemini-{email}-{project}.json。 */
|
|
1073
|
+
function cliproxyProjectFor(entry) {
|
|
1074
|
+
const direct = typeof entry?.project_id === 'string' ? entry.project_id.trim() : ''
|
|
1075
|
+
if (direct !== '') return direct
|
|
1076
|
+
const name = typeof entry?.name === 'string' ? entry.name.trim().replace(/\.json$/i, '') : ''
|
|
1077
|
+
if (/^gemini-[^@]+@/.test(name)) {
|
|
1078
|
+
const afterAt = name.slice(name.lastIndexOf('@') + 1)
|
|
1079
|
+
const dash = afterAt.indexOf('-')
|
|
1080
|
+
if (dash > 0 && dash + 1 < afterAt.length) return afterAt.slice(dash + 1)
|
|
1081
|
+
}
|
|
1082
|
+
return ''
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/** auth-files 条目 → api-call 计划;不支持的类型或缺关键参数(如 project 推不出)返回 null 跳过。 */
|
|
1086
|
+
function buildCliproxyAccountPlan(entry) {
|
|
1087
|
+
const provider = String(entry?.provider ?? entry?.type ?? '').toLowerCase()
|
|
1088
|
+
if (!CLIPROXY_SUPPORTED_ACCOUNT_KINDS.has(provider)) return null
|
|
1089
|
+
const baseHeaders = { Authorization: 'Bearer $TOKEN$', 'Content-Type': 'application/json' }
|
|
1090
|
+
if (provider === 'codex') {
|
|
1091
|
+
return { provider, calls: [{ method: 'GET', url: CLIPROXY_CODEX_USAGE_URL, header: baseHeaders, data: '' }] }
|
|
1092
|
+
}
|
|
1093
|
+
if (provider === 'antigravity') {
|
|
1094
|
+
// 候选 URL 顺试到首个成功;每次尝试都计入 api-call 预算。
|
|
1095
|
+
return {
|
|
1096
|
+
provider,
|
|
1097
|
+
calls: CLIPROXY_ANTIGRAVITY_QUOTA_URLS.map((url) => ({
|
|
1098
|
+
method: 'POST',
|
|
1099
|
+
url,
|
|
1100
|
+
header: { ...baseHeaders, 'User-Agent': 'antigravity/1.11.5 windows/amd64' },
|
|
1101
|
+
data: '{}',
|
|
1102
|
+
})),
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
const project = cliproxyProjectFor(entry)
|
|
1106
|
+
if (project === '') return null
|
|
1107
|
+
return {
|
|
1108
|
+
provider,
|
|
1109
|
+
calls: [{ method: 'POST', url: CLIPROXY_GEMINI_QUOTA_URL, header: baseHeaders, data: JSON.stringify({ project }) }],
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/** 按账号类型分发上游 payload 解析。 */
|
|
1114
|
+
function parseCliproxyUpstream(provider, payload) {
|
|
1115
|
+
if (provider === 'codex') return normalizeCodexRateLimit(payload?.rate_limit)
|
|
1116
|
+
if (provider === 'antigravity') {
|
|
1117
|
+
const models = payload?.models
|
|
1118
|
+
return models !== null && typeof models === 'object' ? normalizeAntigravityModels(models) : []
|
|
1119
|
+
}
|
|
1120
|
+
return normalizeGeminiBuckets(payload?.buckets)
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/**
|
|
1124
|
+
* CLIProxyAPI 编排:auth-files 列账号 → 小并发池逐账号 api-call 代调上游官方额度 → 合并窗口。
|
|
1125
|
+
* 预算:账号 ≤8、api-call 总次数 ≤12、并发 3、窗口总数 ≤32;部分账号失败不拖垮整行
|
|
1126
|
+
* (有成功窗口即返回),全部失败抛首个稳定错误码。守卫与凭据由 kickQuotaRefresh 先行完成。
|
|
1127
|
+
*/
|
|
1128
|
+
async function fetchCliproxyUsage({ profile, config, authorization, signal }) {
|
|
1129
|
+
const pinned = Array.isArray(config?.allowedHosts?.[profile.name]) ? config.allowedHosts[profile.name] : []
|
|
1130
|
+
const origin = safeCliproxyOrigin(profile.baseURL, pinned)
|
|
1131
|
+
if (origin === undefined) throw new Error('host-not-pinned')
|
|
1132
|
+
let filesPayload
|
|
1133
|
+
try {
|
|
1134
|
+
filesPayload = await requestQuotaJson(`${origin}/v0/management/auth-files`, { authorization, signal })
|
|
1135
|
+
} catch (error) {
|
|
1136
|
+
// 管理路由未注册(secret-key 为空)时 CPA 整体 404——单独成码,别让用户猜。
|
|
1137
|
+
// 注意用完整 message 比较:quotaErrorCode 会把 http-status:404 截成 http-status。
|
|
1138
|
+
if (error?.message === 'http-status:404') throw new Error('mgmt-disabled')
|
|
1139
|
+
throw error
|
|
1140
|
+
}
|
|
1141
|
+
const files = Array.isArray(filesPayload?.files) ? filesPayload.files : []
|
|
1142
|
+
const accounts = []
|
|
1143
|
+
for (const entry of files) {
|
|
1144
|
+
if (entry === null || typeof entry !== 'object') continue
|
|
1145
|
+
if (entry.disabled === true || entry.unavailable === true) continue
|
|
1146
|
+
if (typeof entry.auth_index !== 'string' || entry.auth_index.trim() === '') continue
|
|
1147
|
+
const plan = buildCliproxyAccountPlan(entry)
|
|
1148
|
+
if (plan === null) continue
|
|
1149
|
+
const label = typeof entry.email === 'string' && entry.email.trim() !== '' ? entry.email.trim()
|
|
1150
|
+
: typeof entry.name === 'string' && entry.name.trim() !== '' ? entry.name.trim() : ''
|
|
1151
|
+
const slugSource = `${label !== '' ? label : plan.provider}-${accounts.length}`
|
|
1152
|
+
const slug = slugSource.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || `acct-${accounts.length}`
|
|
1153
|
+
accounts.push({ authIndex: entry.auth_index, provider: plan.provider, label, slug, calls: plan.calls })
|
|
1154
|
+
if (accounts.length >= MAX_QUOTA_CPA_ACCOUNTS) break
|
|
1155
|
+
}
|
|
1156
|
+
if (accounts.length === 0) return []
|
|
1157
|
+
|
|
1158
|
+
const windows = []
|
|
1159
|
+
const failures = []
|
|
1160
|
+
const seenWindowIds = new Set()
|
|
1161
|
+
let callBudget = MAX_QUOTA_CPA_CALLS
|
|
1162
|
+
const runAccount = async (account) => {
|
|
1163
|
+
for (const call of account.calls) {
|
|
1164
|
+
if (callBudget <= 0 || signal?.aborted === true) return
|
|
1165
|
+
callBudget -= 1
|
|
1166
|
+
let envelope
|
|
1167
|
+
try {
|
|
1168
|
+
envelope = await requestQuotaJson(`${origin}/v0/management/api-call`, {
|
|
1169
|
+
method: 'POST',
|
|
1170
|
+
authorization,
|
|
1171
|
+
signal,
|
|
1172
|
+
body: JSON.stringify({ auth_index: account.authIndex, method: call.method, url: call.url, header: call.header, data: call.data }),
|
|
1173
|
+
})
|
|
1174
|
+
} catch (error) {
|
|
1175
|
+
failures.push(quotaErrorCode(error))
|
|
1176
|
+
return
|
|
1177
|
+
}
|
|
1178
|
+
const { statusCode, payload } = unwrapCliproxyApiCallEnvelope(envelope)
|
|
1179
|
+
const parsed = statusCode === 200 && payload !== null ? parseCliproxyUpstream(account.provider, payload) : []
|
|
1180
|
+
if (parsed.length > 0) {
|
|
1181
|
+
for (const window of parsed) {
|
|
1182
|
+
if (windows.length >= MAX_QUOTA_CPA_WINDOWS) return
|
|
1183
|
+
// 窗口 id 必须全局唯一:同账号两窗撞码(如 codex primary/secondary 恰好同秒长度折算同桶)
|
|
1184
|
+
// 或不同模型名 slug 化撞名,都会复制出重复的 React key/testid——后缀 ~ 保持唯一;
|
|
1185
|
+
// kindKey 不动,展示名仍按窗口码本地化。
|
|
1186
|
+
let windowId = `${account.slug}-${window.id}`
|
|
1187
|
+
while (seenWindowIds.has(windowId)) windowId += '~'
|
|
1188
|
+
seenWindowIds.add(windowId)
|
|
1189
|
+
windows.push({
|
|
1190
|
+
id: windowId,
|
|
1191
|
+
kindKey: window.kindKey ?? window.id,
|
|
1192
|
+
...(account.label !== '' ? { label: account.label } : {}),
|
|
1193
|
+
percent: window.percent,
|
|
1194
|
+
...(window.resetsAt !== undefined ? { resetsAt: window.resetsAt } : {}),
|
|
1195
|
+
})
|
|
1196
|
+
}
|
|
1197
|
+
return
|
|
1198
|
+
}
|
|
1199
|
+
failures.push(statusCode === 200 ? 'bad-payload:shape' : `upstream-status:${statusCode}`)
|
|
1200
|
+
// antigravity 候选链:当前候选失败换下一个;其余单调用类型直接结束该账号。
|
|
1201
|
+
if (account.provider !== 'antigravity') return
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
const queue = [...accounts]
|
|
1205
|
+
await Promise.all(Array.from({ length: Math.min(QUOTA_CPA_CONCURRENCY, queue.length) }, async () => {
|
|
1206
|
+
while (queue.length > 0 && signal?.aborted !== true && windows.length < MAX_QUOTA_CPA_WINDOWS) {
|
|
1207
|
+
await runAccount(queue.shift())
|
|
1208
|
+
}
|
|
1209
|
+
}))
|
|
1210
|
+
if (signal?.aborted === true) throw new Error('cancelled')
|
|
1211
|
+
if (windows.length === 0 && failures.length > 0) throw new Error(failures[0])
|
|
1212
|
+
return windows
|
|
1213
|
+
}
|
|
1214
|
+
|
|
862
1215
|
/** 稳定错误码提取:fetchProviderUsage 抛错时 message 即错误码(可带 :detail)。 */
|
|
863
1216
|
function quotaErrorCode(error) {
|
|
864
1217
|
const raw = typeof error?.message === 'string' && error.message.length > 0 ? error.message : String(error ?? '')
|
|
@@ -958,13 +1311,13 @@ function abortableDelay(delay, signal) {
|
|
|
958
1311
|
})
|
|
959
1312
|
}
|
|
960
1313
|
|
|
961
|
-
/**
|
|
962
|
-
async function
|
|
1314
|
+
/** 瞬时错误退避重试骨架:共 3 次尝试、300/600ms 退避,仅 quotaTransient 错误续命;支持整体取消。 */
|
|
1315
|
+
async function retryQuotaTransient(attempt, signal) {
|
|
963
1316
|
let lastError
|
|
964
|
-
for (let
|
|
965
|
-
if (
|
|
1317
|
+
for (let attemptNo = 1; attemptNo <= 3; attemptNo++) {
|
|
1318
|
+
if (attemptNo > 1) await abortableDelay(300 * (attemptNo - 1), signal)
|
|
966
1319
|
try {
|
|
967
|
-
return await
|
|
1320
|
+
return await attempt()
|
|
968
1321
|
} catch (error) {
|
|
969
1322
|
lastError = error
|
|
970
1323
|
if (error?.quotaTransient !== true) break
|
|
@@ -973,6 +1326,85 @@ async function fetchProviderUsage(endpoint, authorization, options = {}) {
|
|
|
973
1326
|
throw lastError
|
|
974
1327
|
}
|
|
975
1328
|
|
|
1329
|
+
/** 带重试的上游 GET:仅瞬时网络错误退避重试(共 3 次尝试,300/600ms),支持整体取消。 */
|
|
1330
|
+
async function fetchProviderUsage(endpoint, authorization, options = {}) {
|
|
1331
|
+
return retryQuotaTransient(() => fetchProviderUsageOnce(endpoint, authorization, options), options.signal)
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
/**
|
|
1335
|
+
* 单次 JSON 请求(GET/POST,POST 带 JSON body):CLIProxyAPI 管理面专用(GET auth-files +
|
|
1336
|
+
* POST api-call)。与 GET 版同样的 15s 超时、64KB 上限、瞬时错误白名单;body 只接受已序列化字符串。
|
|
1337
|
+
*/
|
|
1338
|
+
function requestQuotaJsonOnce(endpoint, options = {}) {
|
|
1339
|
+
return new Promise((resolve, reject) => {
|
|
1340
|
+
let settled = false
|
|
1341
|
+
const fail = (code, transient = false) => {
|
|
1342
|
+
if (settled) return
|
|
1343
|
+
settled = true
|
|
1344
|
+
const error = new Error(code)
|
|
1345
|
+
error.quotaTransient = transient
|
|
1346
|
+
reject(error)
|
|
1347
|
+
}
|
|
1348
|
+
if (options.signal?.aborted === true) { fail('cancelled'); return }
|
|
1349
|
+
const body = typeof options.body === 'string' ? options.body : ''
|
|
1350
|
+
const request = https.request(endpoint, {
|
|
1351
|
+
method: options.method === 'POST' ? 'POST' : 'GET',
|
|
1352
|
+
timeout: QUOTA_UPSTREAM_TIMEOUT_MS,
|
|
1353
|
+
signal: options.signal,
|
|
1354
|
+
headers: {
|
|
1355
|
+
Accept: 'application/json',
|
|
1356
|
+
'user-agent': `dsh-service/${pluginVersion} (DeepSeek Harness plugin)`,
|
|
1357
|
+
...(body === '' ? {} : { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }),
|
|
1358
|
+
...(options.authorization === undefined || options.authorization === '' ? {} : { Authorization: options.authorization }),
|
|
1359
|
+
},
|
|
1360
|
+
}, (response) => {
|
|
1361
|
+
const status = response.statusCode || 0
|
|
1362
|
+
if (status < 200 || status >= 300) {
|
|
1363
|
+
response.resume()
|
|
1364
|
+
fail(`http-status:${status}`)
|
|
1365
|
+
return
|
|
1366
|
+
}
|
|
1367
|
+
let payload = ''
|
|
1368
|
+
let bytes = 0
|
|
1369
|
+
response.setEncoding('utf8')
|
|
1370
|
+
response.on('data', (chunk) => {
|
|
1371
|
+
bytes += Buffer.byteLength(chunk)
|
|
1372
|
+
if (bytes > MAX_QUOTA_RESPONSE_BYTES) {
|
|
1373
|
+
fail('bad-payload:oversize')
|
|
1374
|
+
request.destroy()
|
|
1375
|
+
return
|
|
1376
|
+
}
|
|
1377
|
+
payload += chunk
|
|
1378
|
+
})
|
|
1379
|
+
response.on('error', () => fail('network'))
|
|
1380
|
+
response.on('end', () => {
|
|
1381
|
+
try {
|
|
1382
|
+
resolve(JSON.parse(payload))
|
|
1383
|
+
} catch (_) {
|
|
1384
|
+
fail('bad-payload:json')
|
|
1385
|
+
}
|
|
1386
|
+
})
|
|
1387
|
+
})
|
|
1388
|
+
request.on('error', (error) => {
|
|
1389
|
+
const code = error?.cause?.code ?? error?.code
|
|
1390
|
+
if (options.signal?.aborted === true || code === 'ABORT_ERR') { fail('cancelled'); return }
|
|
1391
|
+
fail(typeof code === 'string' && QUOTA_TRANSIENT_CODES.has(code) ? 'network-transient' : 'network',
|
|
1392
|
+
typeof code === 'string' && QUOTA_TRANSIENT_CODES.has(code))
|
|
1393
|
+
})
|
|
1394
|
+
request.on('timeout', () => {
|
|
1395
|
+
request.destroy()
|
|
1396
|
+
fail('timeout', true)
|
|
1397
|
+
})
|
|
1398
|
+
if (body !== '') request.write(body)
|
|
1399
|
+
request.end()
|
|
1400
|
+
})
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
/** 带重试的 JSON 请求:与 GET 版同一套瞬时错误白名单退避。 */
|
|
1404
|
+
async function requestQuotaJson(endpoint, options = {}) {
|
|
1405
|
+
return retryQuotaTransient(() => requestQuotaJsonOnce(endpoint, options), options.signal)
|
|
1406
|
+
}
|
|
1407
|
+
|
|
976
1408
|
/**
|
|
977
1409
|
* 每 provider 节流状态机(内存态,重启清零)。一切来源共用同一判定,优先序:
|
|
978
1410
|
* 单飞去重 > 失败指数退避(30s ×2 封顶 15min)> 成功 TTL 60s > 最小上游间隔 15s——
|
|
@@ -2487,13 +2919,35 @@ function apply(ctx) {
|
|
|
2487
2919
|
return true
|
|
2488
2920
|
}
|
|
2489
2921
|
// 远端额度:后台补拉一次。是否真的发上游由节流器判定;跨 provider 经小型并发池调度。
|
|
2490
|
-
|
|
2922
|
+
// config 供 fetcher 类 kind(cliproxy)做钉住域守卫;经典 kind 忽略该参数。
|
|
2923
|
+
const kickQuotaRefresh = (profile, kind, config) => {
|
|
2491
2924
|
const decision = quotaThrottle.attempt(profile.name)
|
|
2492
2925
|
if (!decision.ok) return
|
|
2493
|
-
const
|
|
2926
|
+
const registered = KIND_REGISTRY[kind]
|
|
2494
2927
|
const queued = enqueueQuotaWork(async () => {
|
|
2495
2928
|
try {
|
|
2496
|
-
if (parser === undefined) throw new Error('bad-payload:kind')
|
|
2929
|
+
if (registered === undefined || (registered.parser === undefined && registered.fetcher === undefined)) throw new Error('bad-payload:kind')
|
|
2930
|
+
if (registered.fetcher !== undefined) {
|
|
2931
|
+
// 编排类 kind(cliproxy):端点守卫 → 凭据 → fetcher 合并多账号窗口,无候选链。
|
|
2932
|
+
const guard = registered.fetcherGuard?.(profile, config) ?? null
|
|
2933
|
+
if (guard !== null) throw new Error(guard)
|
|
2934
|
+
const authorization = await discoverQuotaCredential(ctx, kind, profile)
|
|
2935
|
+
if (authorization === undefined) throw new Error('credential-missing')
|
|
2936
|
+
const controller = new AbortController()
|
|
2937
|
+
quotaAbortControllers.add(controller)
|
|
2938
|
+
const deadline = setTimeout(() => controller.abort(), QUOTA_PROVIDER_DEADLINE_MS)
|
|
2939
|
+
let produced
|
|
2940
|
+
try {
|
|
2941
|
+
produced = await registered.fetcher({ profile, config, authorization, signal: controller.signal })
|
|
2942
|
+
} finally {
|
|
2943
|
+
clearTimeout(deadline)
|
|
2944
|
+
quotaAbortControllers.delete(controller)
|
|
2945
|
+
}
|
|
2946
|
+
if (!Array.isArray(produced)) throw new Error('bad-payload:shape')
|
|
2947
|
+
if (!quotaDisposed) quotaThrottle.settle(profile.name, { ok: true, windows: produced })
|
|
2948
|
+
return
|
|
2949
|
+
}
|
|
2950
|
+
const parser = registered.parser
|
|
2497
2951
|
// 端点候选链先于凭据解析:baseURL 缺失是更明确的配置错误(也省一次凭据查找)。
|
|
2498
2952
|
const candidates = quotaEndpointFor(kind, profile.baseURL)
|
|
2499
2953
|
if (candidates.length === 0) throw new Error('no-base-url')
|
|
@@ -3080,11 +3534,34 @@ function apply(ctx) {
|
|
|
3080
3534
|
rows.push({ provider: profile.name, displayName: profile.displayName, adapted: false })
|
|
3081
3535
|
continue
|
|
3082
3536
|
}
|
|
3083
|
-
if (refreshAll || requestedProviders.has(profile.name)) kickQuotaRefresh(profile, kind)
|
|
3537
|
+
if (refreshAll || requestedProviders.has(profile.name)) kickQuotaRefresh(profile, kind, config)
|
|
3084
3538
|
const view = quotaThrottle.view(profile.name)
|
|
3085
3539
|
const windows = Array.isArray(view.windows) ? view.windows : []
|
|
3086
3540
|
const credentialClass = view.lastError === 'credential-missing' || view.lastError === 'no-base-url' || view.lastError === 'credentials-unavailable'
|
|
3087
3541
|
const providerResetCards = resetCardsByProvider.get(profile.name) ?? []
|
|
3542
|
+
// 凭据填写窗口的数据源:仅对「缺凭据」的未配置行附带候选线索名的配置状态(describe 只回
|
|
3543
|
+
// 配置与否/来源/可写,绝不带值);凭据服务缺席时省略字段——客户端隐藏窗口退回文案指引。
|
|
3544
|
+
let credentialHints
|
|
3545
|
+
if (credentialClass && !view.refreshing && (view.lastError === 'credential-missing' || view.lastError === 'credentials-unavailable')) {
|
|
3546
|
+
const credentials = ctx.get('credentials')
|
|
3547
|
+
if (credentials !== undefined && typeof credentials.describe === 'function') {
|
|
3548
|
+
const described = []
|
|
3549
|
+
for (const name of quotaCredentialHintNames(kind, profile)) {
|
|
3550
|
+
try {
|
|
3551
|
+
const info = await Promise.resolve(credentials.describe(name))
|
|
3552
|
+
described.push({
|
|
3553
|
+
name,
|
|
3554
|
+
configured: info?.configured === true,
|
|
3555
|
+
...(typeof info?.source === 'string' ? { source: info.source } : {}),
|
|
3556
|
+
...(info?.writable === false ? { writable: false } : {}),
|
|
3557
|
+
})
|
|
3558
|
+
} catch (_) {
|
|
3559
|
+
described.push({ name, configured: false })
|
|
3560
|
+
}
|
|
3561
|
+
}
|
|
3562
|
+
credentialHints = described
|
|
3563
|
+
}
|
|
3564
|
+
}
|
|
3088
3565
|
rows.push({
|
|
3089
3566
|
provider: profile.name,
|
|
3090
3567
|
displayName: profile.displayName,
|
|
@@ -3098,6 +3575,7 @@ function apply(ctx) {
|
|
|
3098
3575
|
...(view.lastErrorDetail !== undefined ? { errorDetail: view.lastErrorDetail } : {}),
|
|
3099
3576
|
nextAllowedAt: view.nextAllowedAt,
|
|
3100
3577
|
...(providerResetCards.length > 0 ? { resetCards: providerResetCards } : {}),
|
|
3578
|
+
...(credentialHints !== undefined ? { credentialHints } : {}),
|
|
3101
3579
|
})
|
|
3102
3580
|
}
|
|
3103
3581
|
return { ok: true, value: { providers: rows, serverTime: Date.now() } }
|
|
@@ -3122,7 +3600,7 @@ function apply(ctx) {
|
|
|
3122
3600
|
if (forced.reason === 'inflight') return { ok: true }
|
|
3123
3601
|
return { ok: false, error: forced.reason === 'cooldown' ? 'refresh-cooldown' : 'refresh-backoff', nextAllowedAt: forced.nextAllowedAt }
|
|
3124
3602
|
}
|
|
3125
|
-
kickQuotaRefresh(profile, kind)
|
|
3603
|
+
kickQuotaRefresh(profile, kind, config)
|
|
3126
3604
|
return { ok: true }
|
|
3127
3605
|
} catch (error) {
|
|
3128
3606
|
return { ok: false, error: error?.message || String(error) }
|
|
@@ -3140,11 +3618,24 @@ function apply(ctx) {
|
|
|
3140
3618
|
return await serializeQuotaConfigWrite(async (config) => {
|
|
3141
3619
|
if (payload?.clear === true) {
|
|
3142
3620
|
delete config.kinds[providerName]
|
|
3621
|
+
delete config.allowedHosts[providerName]
|
|
3143
3622
|
} else {
|
|
3144
3623
|
const kind = payload?.kind
|
|
3145
3624
|
if (kind !== null && !QUOTA_KINDS.includes(kind)) return { save: false, value: { ok: false, error: 'unknown-kind' } }
|
|
3146
|
-
if (kind !== null &&
|
|
3147
|
-
|
|
3625
|
+
if (kind !== null && KIND_REGISTRY[kind]?.managementPlane === true) {
|
|
3626
|
+
// 管理面 kind:把 settings baseURL 的 host 钉进本条目(服务端派生,浏览器零输入)。
|
|
3627
|
+
// 外呼时复验同一张表——之后改 baseURL 必须重新保存适配才恢复外呼。
|
|
3628
|
+
const host = cliproxyPinHostFromBaseURL(profileForProvider.baseURL)
|
|
3629
|
+
if (host === undefined) {
|
|
3630
|
+
return { save: false, value: { ok: false, error: profileForProvider.baseURL === '' ? 'no-base-url' : 'unsafe-provider-endpoint' } }
|
|
3631
|
+
}
|
|
3632
|
+
config.allowedHosts[providerName] = [host]
|
|
3633
|
+
} else {
|
|
3634
|
+
if (kind !== null && Array.isArray(KIND_REGISTRY[kind]?.endpoints) === false && quotaEndpointFor(kind, profileForProvider.baseURL).length === 0) {
|
|
3635
|
+
return { save: false, value: { ok: false, error: 'unsafe-provider-endpoint' } }
|
|
3636
|
+
}
|
|
3637
|
+
// 非管理面 kind(含显式停用/清除)不保留钉住记录,避免陈旧域残留。
|
|
3638
|
+
delete config.allowedHosts[providerName]
|
|
3148
3639
|
}
|
|
3149
3640
|
config.kinds[providerName] = kind
|
|
3150
3641
|
}
|
|
@@ -3155,6 +3646,47 @@ function apply(ctx) {
|
|
|
3155
3646
|
}
|
|
3156
3647
|
}
|
|
3157
3648
|
|
|
3649
|
+
// 凭据填写窗口(v0.24):面板输入密钥 → 宿主写进凭据提供方(dsh-credentials-local 即
|
|
3650
|
+
// $DSH_HOME/.credentials.yaml 的 refs 分节,热生效无需重启)。名字只收宿主派生的线索白名单,
|
|
3651
|
+
// 值只在写入方向流动——describe/快照永远不回传密钥内容。unset 供清除文件层存量。
|
|
3652
|
+
if (endpoint === 'quota-credential-set' || endpoint === 'quota-credential-unset') {
|
|
3653
|
+
if (!featureEnabled('quotaLookup')) return { ok: false, error: 'feature-disabled' }
|
|
3654
|
+
try {
|
|
3655
|
+
const providerName = typeof payload?.provider === 'string' ? payload.provider : ''
|
|
3656
|
+
const profileForProvider = readLlmProviders(ctx.get('settings')).find((candidate) => candidate.name === providerName)
|
|
3657
|
+
if (profileForProvider === undefined) return { ok: false, error: 'unknown-provider' }
|
|
3658
|
+
const config = await refreshQuotaConfigCache()
|
|
3659
|
+
const { kind } = resolveQuotaKind(config, profileForProvider)
|
|
3660
|
+
if (kind === undefined) return { ok: false, error: 'not-adapted' }
|
|
3661
|
+
const name = typeof payload?.name === 'string' ? payload.name : ''
|
|
3662
|
+
if (!quotaCredentialHintNames(kind, profileForProvider).includes(name)) return { ok: false, error: 'unknown-hint' }
|
|
3663
|
+
const credentials = ctx.get('credentials')
|
|
3664
|
+
if (credentials === undefined) return { ok: false, error: 'credentials-unavailable' }
|
|
3665
|
+
if (endpoint === 'quota-credential-set') {
|
|
3666
|
+
if (typeof credentials.set !== 'function') return { ok: false, error: 'credentials-unavailable' }
|
|
3667
|
+
// 去掉粘贴带入的首尾空白;空值与超长值在宿主侧拦下(凭据库本身也拒绝空串)。
|
|
3668
|
+
const value = typeof payload?.value === 'string' ? payload.value.trim() : ''
|
|
3669
|
+
if (value === '' || value.length > 4096) return { ok: false, error: 'invalid-value' }
|
|
3670
|
+
try {
|
|
3671
|
+
await credentials.set(name, value)
|
|
3672
|
+
} catch (error) {
|
|
3673
|
+
// 典型拒绝:进程环境层正在遮蔽该名字(describe().writable=false)——seam 契约的显式报错。
|
|
3674
|
+
return { ok: false, error: 'credential-write-failed', detail: sanitizeQuotaErrorDetail(error?.message) }
|
|
3675
|
+
}
|
|
3676
|
+
return { ok: true }
|
|
3677
|
+
}
|
|
3678
|
+
if (typeof credentials.unset !== 'function') return { ok: false, error: 'credentials-unavailable' }
|
|
3679
|
+
try {
|
|
3680
|
+
await credentials.unset(name)
|
|
3681
|
+
} catch (error) {
|
|
3682
|
+
return { ok: false, error: 'credential-write-failed', detail: sanitizeQuotaErrorDetail(error?.message) }
|
|
3683
|
+
}
|
|
3684
|
+
return { ok: true }
|
|
3685
|
+
} catch (error) {
|
|
3686
|
+
return { ok: false, error: error?.message || String(error) }
|
|
3687
|
+
}
|
|
3688
|
+
}
|
|
3689
|
+
|
|
3158
3690
|
if (endpoint === 'quota-reset-card') {
|
|
3159
3691
|
if (!featureEnabled('quotaLookup')) return { ok: false, error: 'feature-disabled' }
|
|
3160
3692
|
try {
|
|
@@ -3209,60 +3741,82 @@ function apply(ctx) {
|
|
|
3209
3741
|
export {
|
|
3210
3742
|
SKILL_SOURCE_RANK,
|
|
3211
3743
|
apply,
|
|
3744
|
+
buildCliproxyAccountPlan,
|
|
3745
|
+
cliproxyFetchGuard,
|
|
3746
|
+
cliproxyPinHostFromBaseURL,
|
|
3747
|
+
cliproxyProjectFor,
|
|
3212
3748
|
createQuotaThrottle,
|
|
3213
3749
|
detectRuntimeEnv,
|
|
3214
3750
|
evaluateSkillFile,
|
|
3215
3751
|
extractSkillDraftJson,
|
|
3752
|
+
fetchCliproxyUsage,
|
|
3216
3753
|
fetchProviderUsage,
|
|
3217
3754
|
fixLegacySkillInvocationKeys,
|
|
3218
3755
|
inferQuotaKind,
|
|
3219
3756
|
inject,
|
|
3220
3757
|
locateSkillFrontmatter,
|
|
3221
3758
|
name,
|
|
3759
|
+
normalizeAntigravityModels,
|
|
3760
|
+
normalizeCodexRateLimit,
|
|
3761
|
+
normalizeGeminiBuckets,
|
|
3222
3762
|
normalizeKimiBalance,
|
|
3223
|
-
normalizeOpenRouterCredits,
|
|
3224
3763
|
normalizeOpencodeUsage,
|
|
3764
|
+
normalizeOpenRouterCredits,
|
|
3225
3765
|
normalizeSiliconFlowInfo,
|
|
3226
3766
|
normalizeZaiCodingUsage,
|
|
3227
3767
|
parseQuotaConfigText,
|
|
3228
3768
|
parseSkillFrontmatterData,
|
|
3769
|
+
quotaCredentialHintNames,
|
|
3229
3770
|
quotaEndpointFor,
|
|
3230
3771
|
quotaErrorCode,
|
|
3231
3772
|
readLlmProviders,
|
|
3232
3773
|
resolveSkillInvocationState,
|
|
3233
3774
|
runtimeEnvCheck,
|
|
3775
|
+
safeCliproxyOrigin,
|
|
3234
3776
|
sanitizeSkillDraftText,
|
|
3235
3777
|
selectSkillBatchCandidates,
|
|
3236
3778
|
setSkillInvocationKey,
|
|
3237
3779
|
skillIdFor,
|
|
3780
|
+
unwrapCliproxyApiCallEnvelope,
|
|
3238
3781
|
}
|
|
3239
3782
|
export default {
|
|
3240
3783
|
SKILL_SOURCE_RANK,
|
|
3241
3784
|
apply,
|
|
3785
|
+
buildCliproxyAccountPlan,
|
|
3786
|
+
cliproxyFetchGuard,
|
|
3787
|
+
cliproxyPinHostFromBaseURL,
|
|
3788
|
+
cliproxyProjectFor,
|
|
3242
3789
|
createQuotaThrottle,
|
|
3243
3790
|
detectRuntimeEnv,
|
|
3244
3791
|
evaluateSkillFile,
|
|
3245
3792
|
extractSkillDraftJson,
|
|
3793
|
+
fetchCliproxyUsage,
|
|
3246
3794
|
fetchProviderUsage,
|
|
3247
3795
|
fixLegacySkillInvocationKeys,
|
|
3248
3796
|
inferQuotaKind,
|
|
3249
3797
|
inject,
|
|
3250
3798
|
locateSkillFrontmatter,
|
|
3251
3799
|
name,
|
|
3800
|
+
normalizeAntigravityModels,
|
|
3801
|
+
normalizeCodexRateLimit,
|
|
3802
|
+
normalizeGeminiBuckets,
|
|
3252
3803
|
normalizeKimiBalance,
|
|
3253
|
-
normalizeOpenRouterCredits,
|
|
3254
3804
|
normalizeOpencodeUsage,
|
|
3805
|
+
normalizeOpenRouterCredits,
|
|
3255
3806
|
normalizeSiliconFlowInfo,
|
|
3256
3807
|
normalizeZaiCodingUsage,
|
|
3257
3808
|
parseQuotaConfigText,
|
|
3258
3809
|
parseSkillFrontmatterData,
|
|
3810
|
+
quotaCredentialHintNames,
|
|
3259
3811
|
quotaEndpointFor,
|
|
3260
3812
|
quotaErrorCode,
|
|
3261
3813
|
readLlmProviders,
|
|
3262
3814
|
resolveSkillInvocationState,
|
|
3263
3815
|
runtimeEnvCheck,
|
|
3816
|
+
safeCliproxyOrigin,
|
|
3264
3817
|
sanitizeSkillDraftText,
|
|
3265
3818
|
selectSkillBatchCandidates,
|
|
3266
3819
|
setSkillInvocationKey,
|
|
3267
3820
|
skillIdFor,
|
|
3821
|
+
unwrapCliproxyApiCallEnvelope,
|
|
3268
3822
|
}
|